(feature): add secrets cli and migrate tests to xunit v3
This commit is contained in:
parent
434b569b3a
commit
f79aa8f3c2
12
CHANGELOG.md
12
CHANGELOG.md
@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.6.10] - 2026-08-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **CLI:** `MaksIT.Core.Cli` for generating JWT/pepper secrets, AES-256 keys, TOTP material, password hashes, and COMB GUIDs. Interactive numbered menu when run with no arguments; flag-based commands (`secret`, `jwt`, `aes`, `totp`, `password`, `guid`) for scripts and agents. Shipped in the GitHub release zip next to the library nupkg; not pushed to nuget.org.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Tests:** migrate to **xunit.v3** **4.0** + **Microsoft Testing Platform** only (`src/global.json` `test.runner` next to the `.slnx`; no VSTest / **coverlet.collector** / **Microsoft.NET.Test.Sdk** / **xunit.runner.visualstudio**). Use **coverlet.MTP**; **TestRunner** always uses `--coverlet` (scoped to **`[MaksIT.*]*`**).
|
||||||
|
- RepoUtils `DotNetPublish` now publishes listed CLI projects alongside `DotNetPack` without replacing the library NuGet artifact.
|
||||||
|
- **README:** aligned the table of contents with the body heading hierarchy and mapped remaining public APIs (console loggers, Web API middleware, Base64Url, CRC32, exception/formats extensions, `QueryResultBase`, `PatchRequestModelBase`). Corrected saga, JWT, DateTime, PATCH, and network-share examples to match current signatures.
|
||||||
|
|
||||||
## [1.6.9] - 2026-08-14
|
## [1.6.9] - 2026-08-14
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@ -26,11 +26,25 @@ dotnet build MaksIT.Core.slnx
|
|||||||
|
|
||||||
### Running Tests
|
### Running Tests
|
||||||
|
|
||||||
|
Preferred: `utils\Invoke-TestEngine.bat` (DotNetTest → QualityGate → CoverageBadges). Tests run under **Microsoft Testing Platform** (`src/global.json` `test.runner` next to the `.slnx`) with **xunit.v3** and **coverlet.MTP**.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd src
|
cd src
|
||||||
dotnet test MaksIT.Core.Tests
|
dotnet test MaksIT.Core.Tests
|
||||||
|
dotnet test MaksIT.Core.Cli.Tests
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Running the secrets CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
dotnet run --project MaksIT.Core.Cli
|
||||||
|
dotnet run --project MaksIT.Core.Cli -- secret
|
||||||
|
dotnet run --project MaksIT.Core.Cli -- --help
|
||||||
|
```
|
||||||
|
|
||||||
|
No arguments opens the interactive numbered menu. Commands/flags are for scripts and agents (values on stdout, errors on stderr). It is not a `dotnet tool` and is not published to NuGet.
|
||||||
|
|
||||||
## Commit Message Format
|
## Commit Message Format
|
||||||
|
|
||||||
This project uses the following commit message format:
|
This project uses the following commit message format:
|
||||||
@ -119,8 +133,8 @@ Orchestration lives in **`utils/`** (from [maksit-repoutils](https://github.com/
|
|||||||
|
|
||||||
### Workflow
|
### Workflow
|
||||||
|
|
||||||
1. Bump `<Version>` in `src/MaksIT.Core/MaksIT.Core.csproj` and **CHANGELOG.md**
|
1. Bump `<Version>` in `src/MaksIT.Core/MaksIT.Core.csproj` and `src/MaksIT.Core.Cli/MaksIT.Core.Cli.csproj` (keep them aligned) and **CHANGELOG.md**
|
||||||
2. Commit, tag `vX.Y.Z` on `main`
|
2. Commit, tag `v{version}` on `main` (`v1.2.3` or SemVer prerelease such as `v0.1.0-alpha.1`, `v0.1.0-beta.1`, `v0.1.0-rc.1`). GitHub marks hyphenated versions as prerelease.
|
||||||
3. Set `$env:GitHub`, `$env:NuGet`, run `utils\Invoke-ReleasePackage-Single.bat`
|
3. Set `$env:GitHub`, `$env:NuGet`, run `utils\Invoke-ReleasePackage-Single.bat`
|
||||||
|
|
||||||
Dry-run: `pwsh -File utils\engines\release\Invoke-ReleasePackage.ps1 -DryRun`
|
Dry-run: `pwsh -File utils\engines\release\Invoke-ReleasePackage.ps1 -DryRun`
|
||||||
|
|||||||
701
README.md
701
README.md
@ -1,11 +1,30 @@
|
|||||||
# MaksIT.Core Library Documentation
|
# MaksIT.Core Library Documentation
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
|
**MaksIT.Core** is a .NET 10 library of shared helpers used across MaksIT products: domain/DTO/Web API bases, strongly-typed enumerations, extensions, logging, security (JWT, JWK, JWS, TOTP, AES-GCM), sagas, COMB GUIDs, and Web API pagination.
|
||||||
|
|
||||||
|
Install from NuGet:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet add package MaksIT.Core
|
||||||
|
```
|
||||||
|
|
||||||
|
The secrets CLI (`MaksIT.Core.Cli`) is **not** published to NuGet. It ships in the GitHub release zip next to `MaksIT.Core.*.nupkg`.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| Tests / coverage badges | `utils\Invoke-TestEngine.bat` |
|
||||||
|
| Release (pack, NuGet, GitHub) | `utils\Invoke-ReleasePackage.bat` |
|
||||||
|
| Contributing | [CONTRIBUTING.md](CONTRIBUTING.md) |
|
||||||
|
| Changelog | [CHANGELOG.md](CHANGELOG.md) |
|
||||||
|
| License | [LICENSE.md](LICENSE.md) |
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
|
- [CLI (secrets toolkit)](#cli-secrets-toolkit)
|
||||||
- [Abstractions](#abstractions)
|
- [Abstractions](#abstractions)
|
||||||
- [Base Classes](#base-classes)
|
- [Base Classes](#base-classes)
|
||||||
- [Enumeration](#enumeration)
|
- [Enumeration](#enumeration)
|
||||||
@ -14,12 +33,15 @@
|
|||||||
- [DateTime Extensions](#datetime-extensions)
|
- [DateTime Extensions](#datetime-extensions)
|
||||||
- [String Extensions](#string-extensions)
|
- [String Extensions](#string-extensions)
|
||||||
- [Object Extensions](#object-extensions)
|
- [Object Extensions](#object-extensions)
|
||||||
|
- [Exception Extensions](#exception-extensions)
|
||||||
|
- [Formats Extensions](#formats-extensions)
|
||||||
- [DataTable Extensions](#datatable-extensions)
|
- [DataTable Extensions](#datatable-extensions)
|
||||||
- [Guid Extensions](#guid-extensions)
|
- [Guid Extensions](#guid-extensions)
|
||||||
- [Enum Extensions](#enum-extensions)
|
- [Enum Extensions](#enum-extensions)
|
||||||
- [Logging](#logging)
|
- [Logging](#logging)
|
||||||
- [File Logger](#file-logger)
|
- [File Logger](#file-logger)
|
||||||
- [JSON File Logger](#json-file-logger)
|
- [JSON File Logger](#json-file-logger)
|
||||||
|
- [Console Loggers](#console-loggers)
|
||||||
- [Logger Prefix](#logger-prefix)
|
- [Logger Prefix](#logger-prefix)
|
||||||
- [Threading](#threading)
|
- [Threading](#threading)
|
||||||
- [Lock Manager](#lock-manager)
|
- [Lock Manager](#lock-manager)
|
||||||
@ -29,17 +51,20 @@
|
|||||||
- [Security](#security)
|
- [Security](#security)
|
||||||
- [AES-GCM Utility](#aes-gcm-utility)
|
- [AES-GCM Utility](#aes-gcm-utility)
|
||||||
- [Base32 Encoder](#base32-encoder)
|
- [Base32 Encoder](#base32-encoder)
|
||||||
|
- [Base64Url Utility](#base64url-utility)
|
||||||
- [Checksum Utility](#checksum-utility)
|
- [Checksum Utility](#checksum-utility)
|
||||||
- [Password Hasher](#password-hasher)
|
- [Password Hasher](#password-hasher)
|
||||||
- [JWT Generator](#jwt-generator)
|
- [JWT Generator](#jwt-generator)
|
||||||
- [JWK Generator](#jwk-generator)
|
- [JWK Generator](#jwk-generator)
|
||||||
- [JWK Thumbprint Utility](#jwk-thumbprint-utility)
|
|
||||||
- [JWS Generator](#jws-generator)
|
- [JWS Generator](#jws-generator)
|
||||||
|
- [JWK Thumbprint Utility](#jwk-thumbprint-utility)
|
||||||
- [TOTP Generator](#totp-generator)
|
- [TOTP Generator](#totp-generator)
|
||||||
- [Web API](#web-api)
|
- [Web API](#web-api)
|
||||||
- [Paged Request](#paged-request)
|
- [Paged Request](#paged-request)
|
||||||
- [Paged Response](#paged-response)
|
- [Paged Response](#paged-response)
|
||||||
- [Patch Operation](#patch-operation)
|
- [Patch Operation](#patch-operation)
|
||||||
|
- [Error Handling Middleware](#error-handling-middleware)
|
||||||
|
- [Trace ID Logging Scope Middleware](#trace-id-logging-scope-middleware)
|
||||||
- [Sagas](#sagas)
|
- [Sagas](#sagas)
|
||||||
- [CombGuidGenerator](#combguidgenerator)
|
- [CombGuidGenerator](#combguidgenerator)
|
||||||
- [Others](#others)
|
- [Others](#others)
|
||||||
@ -48,11 +73,60 @@
|
|||||||
- [File System](#file-system)
|
- [File System](#file-system)
|
||||||
- [Processes](#processes)
|
- [Processes](#processes)
|
||||||
|
|
||||||
|
## CLI (secrets toolkit)
|
||||||
|
|
||||||
|
Generates the same secrets the library uses at runtime (JWT signing keys, password pepper, AES-256 keys, TOTP material, COMB GUIDs). It is **not** published to NuGet; the exe ships in the GitHub release zip next to `MaksIT.Core.*.nupkg`.
|
||||||
|
|
||||||
|
- **No arguments** — interactive numbered menu.
|
||||||
|
- **With commands** — non-interactive flags for scripts and agents. Values go to stdout; errors to stderr; exit `0`/`1`.
|
||||||
|
|
||||||
|
### Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
dotnet run --project MaksIT.Core.Cli
|
||||||
|
dotnet run --project MaksIT.Core.Cli -- --help
|
||||||
|
dotnet run --project MaksIT.Core.Cli -- secret
|
||||||
|
```
|
||||||
|
|
||||||
|
From an unpacked release zip:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MaksIT.Core.Cli/MaksIT.Core.Cli
|
||||||
|
MaksIT.Core.Cli/MaksIT.Core.Cli secret --bytes 32
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agent commands
|
||||||
|
|
||||||
|
| Command | Output |
|
||||||
|
|---------|--------|
|
||||||
|
| `secret [--bytes 32]` | Base64 secret (JWT signing / pepper) |
|
||||||
|
| `jwt secret [--bytes 32]` | Same as `secret` |
|
||||||
|
| `jwt refresh` | Opaque refresh token |
|
||||||
|
| `jwt generate --secret S --issuer I --audience A [--expiration 60] [--user-id] [--username] [--roles] [--acl]` | Access JWT |
|
||||||
|
| `jwt validate --secret S --issuer I --audience A --token T` | Claims JSON |
|
||||||
|
| `aes key` | Base64 AES-256 key |
|
||||||
|
| `totp secret` | Base32 TOTP secret |
|
||||||
|
| `totp recovery [--count 10]` | Recovery codes (one per line) |
|
||||||
|
| `totp link --label L --username U --secret S --issuer I` | `otpauth://` URI |
|
||||||
|
| `totp validate --secret S --code C [--tolerance 1]` | `valid` / `invalid` (exit 1 if invalid) |
|
||||||
|
| `password hash --pepper P --password PWD` | JSON `{ salt, hash }` |
|
||||||
|
| `guid comb [--type PostgreSql]` | COMB GUID (`SqlServer` also accepted) |
|
||||||
|
|
||||||
|
Typical `appsecrets.json` values:
|
||||||
|
|
||||||
|
| Menu / command | Writes |
|
||||||
|
|----------------|--------|
|
||||||
|
| Generate secret / `secret` | `JwtSettings` signing secret or `PasswordPepper` |
|
||||||
|
| AES-GCM key / `aes key` | host encryption key |
|
||||||
|
| TOTP / 2FA / `totp secret` | authenticator shared key / recovery codes |
|
||||||
|
| JWT generate | debug access tokens against a known secret |
|
||||||
|
|
||||||
## Abstractions
|
## Abstractions
|
||||||
|
|
||||||
### Base Classes
|
### Base Classes
|
||||||
|
|
||||||
The following base classes in the `MaksIT.Core.Abstractions` namespace provide a foundation for implementing domain, DTO, and Web API models, ensuring consistency and maintainability in application design.
|
The following base classes in the `MaksIT.Core.Abstractions` namespaces (`Domain`, `Dto`, `Webapi`, `Query`) provide a foundation for implementing domain, DTO, query, and Web API models, ensuring consistency and maintainability in application design.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -152,6 +226,55 @@ public class UserResponse : ResponseModelBase {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
##### 7. **`PatchRequestModelBase`**
|
||||||
|
|
||||||
|
###### Summary
|
||||||
|
Represents the base class for Web API PATCH request models.
|
||||||
|
|
||||||
|
###### Purpose
|
||||||
|
- Extends `RequestModelBase` with a dictionary of property names to `PatchOperation` values.
|
||||||
|
- Validates that each operation is a defined `PatchOperation` enum value.
|
||||||
|
- Provides `TryGetOperation` for case-insensitive lookup by property name.
|
||||||
|
|
||||||
|
###### Example Usage
|
||||||
|
```csharp
|
||||||
|
public class UserPatchRequest : PatchRequestModelBase {
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public List<string>? Roles { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
var patch = new UserPatchRequest {
|
||||||
|
Name = "New Name",
|
||||||
|
Operations = new Dictionary<string, PatchOperation> {
|
||||||
|
["Name"] = PatchOperation.SetField,
|
||||||
|
["Roles"] = PatchOperation.AddToCollection
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (patch.TryGetOperation(nameof(UserPatchRequest.Name), out var operation)) {
|
||||||
|
// operation == PatchOperation.SetField
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
##### 8. **`QueryResultBase<T>`**
|
||||||
|
|
||||||
|
###### Summary
|
||||||
|
Represents a base class for query-layer results with a unique identifier (`MaksIT.Core.Abstractions.Query`).
|
||||||
|
|
||||||
|
###### Purpose
|
||||||
|
- Provides a common `Id` property for read-model / query results.
|
||||||
|
|
||||||
|
###### Example Usage
|
||||||
|
```csharp
|
||||||
|
public class UserQueryResult : QueryResultBase<Guid> {
|
||||||
|
public required string Name { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
#### Features and Benefits
|
#### Features and Benefits
|
||||||
|
|
||||||
1. **Consistency**:
|
1. **Consistency**:
|
||||||
@ -213,69 +336,6 @@ This structure promotes clean code principles, reducing redundancy and improving
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### CombGuidGenerator
|
|
||||||
|
|
||||||
The `CombGuidGenerator` class in the `MaksIT.Core.Comb` namespace provides methods for generating and extracting COMB GUIDs (GUIDs with embedded timestamps). COMB GUIDs improve index locality by combining randomness with a sortable timestamp.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Features
|
|
||||||
|
|
||||||
1. **Generate COMB GUIDs**:
|
|
||||||
- Create GUIDs with embedded timestamps for improved database indexing.
|
|
||||||
|
|
||||||
2. **Extract Timestamps**:
|
|
||||||
- Retrieve the embedded timestamp from a COMB GUID.
|
|
||||||
|
|
||||||
3. **Support for Multiple Formats**:
|
|
||||||
- Generate COMB GUIDs compatible with SQL Server and PostgreSQL.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Example Usage
|
|
||||||
|
|
||||||
##### Generating a COMB GUID
|
|
||||||
```csharp
|
|
||||||
var baseGuid = Guid.NewGuid();
|
|
||||||
var timestamp = DateTime.UtcNow;
|
|
||||||
|
|
||||||
// Generate a COMB GUID for SQL Server
|
|
||||||
var combGuid = CombGuidGenerator.CreateCombGuid(baseGuid, timestamp, CombGuidType.SqlServer);
|
|
||||||
|
|
||||||
// Generate a COMB GUID for PostgreSQL
|
|
||||||
var combGuidPostgres = CombGuidGenerator.CreateCombGuid(baseGuid, timestamp, CombGuidType.PostgreSql);
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Extracting a Timestamp
|
|
||||||
```csharp
|
|
||||||
var extractedTimestamp = CombGuidGenerator.ExtractTimestamp(combGuid, CombGuidType.SqlServer);
|
|
||||||
Console.WriteLine($"Extracted Timestamp: {extractedTimestamp}");
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Generating a COMB GUID with Current Timestamp
|
|
||||||
```csharp
|
|
||||||
var combGuidWithCurrentTimestamp = CombGuidGenerator.CreateCombGuid(Guid.NewGuid(), CombGuidType.SqlServer);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Best Practices
|
|
||||||
|
|
||||||
1. **Use COMB GUIDs for Indexing**:
|
|
||||||
- COMB GUIDs are ideal for database indexing as they improve index locality.
|
|
||||||
|
|
||||||
2. **Choose the Correct Format**:
|
|
||||||
- Use `CombGuidType.SqlServer` for SQL Server and `CombGuidType.PostgreSql` for PostgreSQL.
|
|
||||||
|
|
||||||
3. **Ensure UTC Timestamps**:
|
|
||||||
- Always use UTC timestamps to ensure consistency across systems.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
The `CombGuidGenerator` class simplifies the creation and management of COMB GUIDs, making it easier to work with GUIDs in database applications.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Enumeration
|
### Enumeration
|
||||||
|
|
||||||
The `Enumeration` class in the `MaksIT.Core.Abstractions` namespace provides a base class for creating strongly-typed enumerations. It enables you to define enumerable constants with additional functionality, such as methods for querying, comparing, and parsing enumerations.
|
The `Enumeration` class in the `MaksIT.Core.Abstractions` namespace provides a base class for creating strongly-typed enumerations. It enables you to define enumerable constants with additional functionality, such as methods for querying, comparing, and parsing enumerations.
|
||||||
@ -353,72 +413,13 @@ values.Sort(); // Orders by ID
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
The `Enumeration` class provides a powerful alternative to traditional enums, offering flexibility and functionality for scenarios requiring additional metadata or logic.
|
The `Enumeration` class provides a powerful alternative to traditional enums, offering flexibility and functionality for scenarios requiring additional metadata or logic. In-library examples include `LoggerPrefix`, `CustomClaims`, `JwkKeyType`, `JwkCurve`, and `JwkAlgorithm`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Sagas
|
## Extensions
|
||||||
|
|
||||||
The `Sagas` namespace in the `MaksIT.Core` project provides a framework for managing distributed transactions or workflows. It includes classes for defining saga steps, contexts, and builders.
|
### Expression Extensions
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Features
|
|
||||||
|
|
||||||
1. **Saga Context**:
|
|
||||||
- Manage the state and data of a saga.
|
|
||||||
|
|
||||||
2. **Saga Steps**:
|
|
||||||
- Define individual steps in a saga workflow.
|
|
||||||
|
|
||||||
3. **Saga Builder**:
|
|
||||||
- Build and execute sagas dynamically.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Example Usage
|
|
||||||
|
|
||||||
##### Defining a Saga Step
|
|
||||||
```csharp
|
|
||||||
public class MySagaStep : LocalSagaStep {
|
|
||||||
public override Task ExecuteAsync(LocalSagaContext context) {
|
|
||||||
// Perform step logic here
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Building a Saga
|
|
||||||
```csharp
|
|
||||||
var saga = new LocalSagaBuilder()
|
|
||||||
.AddStep(new MySagaStep())
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
await saga.ExecuteAsync();
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Best Practices
|
|
||||||
|
|
||||||
1. **Idempotency**:
|
|
||||||
- Ensure saga steps are idempotent to handle retries gracefully.
|
|
||||||
|
|
||||||
2. **Error Handling**:
|
|
||||||
- Implement robust error handling and compensation logic for failed steps.
|
|
||||||
|
|
||||||
3. **State Management**:
|
|
||||||
- Use the saga context to manage state and pass data between steps.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
The `Sagas` namespace simplifies the implementation of distributed workflows, making it easier to manage complex transactions and processes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Extensions
|
|
||||||
|
|
||||||
#### Expression Extensions
|
|
||||||
|
|
||||||
The `ExpressionExtensions` class provides utility methods for combining and manipulating LINQ expressions. These methods are particularly useful for building dynamic queries in a type-safe manner.
|
The `ExpressionExtensions` class provides utility methods for combining and manipulating LINQ expressions. These methods are particularly useful for building dynamic queries in a type-safe manner.
|
||||||
|
|
||||||
@ -427,13 +428,13 @@ The `ExpressionExtensions` class provides utility methods for combining and mani
|
|||||||
#### Features
|
#### Features
|
||||||
|
|
||||||
1. **Combine Expressions**:
|
1. **Combine Expressions**:
|
||||||
- Combine two expressions using logical operators like `AndAlso` and `OrElse`.
|
- Combine two predicates with `AndAlso` and `OrElse` using parameter replacement (no `Expression.Invoke`), so the result is safe for `IQueryable` and EF Core.
|
||||||
|
|
||||||
2. **Negate Expressions**:
|
2. **Negate Expressions**:
|
||||||
- Negate an expression using the `Not` method.
|
- Negate a predicate with `Not`.
|
||||||
|
|
||||||
3. **Batch Processing**:
|
3. **Batch Processing**:
|
||||||
- Divide a collection into smaller batches for processing.
|
- Split an `IEnumerable<T>` into smaller lists with `Batch`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -445,6 +446,7 @@ Expression<Func<int, bool>> isEven = x => x % 2 == 0;
|
|||||||
Expression<Func<int, bool>> isPositive = x => x > 0;
|
Expression<Func<int, bool>> isPositive = x => x > 0;
|
||||||
|
|
||||||
var combined = isEven.AndAlso(isPositive);
|
var combined = isEven.AndAlso(isPositive);
|
||||||
|
var either = isEven.OrElse(isPositive);
|
||||||
var result = combined.Compile()(4); // True
|
var result = combined.Compile()(4); // True
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -457,7 +459,7 @@ var result = notEven.Compile()(3); // True
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### DateTime Extensions
|
### DateTime Extensions
|
||||||
|
|
||||||
The `DateTimeExtensions` class provides methods for manipulating and querying `DateTime` objects. These methods simplify common date-related operations.
|
The `DateTimeExtensions` class provides methods for manipulating and querying `DateTime` objects. These methods simplify common date-related operations.
|
||||||
|
|
||||||
@ -466,13 +468,13 @@ The `DateTimeExtensions` class provides methods for manipulating and querying `D
|
|||||||
#### Features
|
#### Features
|
||||||
|
|
||||||
1. **Add Workdays**:
|
1. **Add Workdays**:
|
||||||
- Add a specified number of workdays to a date, excluding weekends and holidays.
|
- Add a specified number of workdays to a date, skipping weekends and dates in an `IHolidayCalendar`.
|
||||||
|
|
||||||
2. **Find Specific Dates**:
|
2. **Find Specific Dates**:
|
||||||
- Find the next occurrence of a specific day of the week.
|
- Find the next occurrence of a specific day of the week (`NextWeekday`, `ToNextWeekday`).
|
||||||
|
|
||||||
3. **Month and Year Boundaries**:
|
3. **Month and Year Boundaries**:
|
||||||
- Get the start or end of the current month or year.
|
- Get the start or end of the current month or year, and test those boundaries.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -480,8 +482,12 @@ The `DateTimeExtensions` class provides methods for manipulating and querying `D
|
|||||||
|
|
||||||
##### Adding Workdays
|
##### Adding Workdays
|
||||||
```csharp
|
```csharp
|
||||||
|
public sealed class NoHolidays : IHolidayCalendar {
|
||||||
|
public bool Contains(DateTime date) => false;
|
||||||
|
}
|
||||||
|
|
||||||
DateTime today = DateTime.Today;
|
DateTime today = DateTime.Today;
|
||||||
DateTime futureDate = today.AddWorkdays(5);
|
DateTime futureDate = today.AddWorkdays(5, new NoHolidays());
|
||||||
```
|
```
|
||||||
|
|
||||||
##### Finding the Next Monday
|
##### Finding the Next Monday
|
||||||
@ -492,7 +498,7 @@ DateTime nextMonday = today.NextWeekday(DayOfWeek.Monday);
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### String Extensions
|
### String Extensions
|
||||||
|
|
||||||
The `StringExtensions` class provides a wide range of methods for string manipulation, validation, and conversion.
|
The `StringExtensions` class provides a wide range of methods for string manipulation, validation, and conversion.
|
||||||
|
|
||||||
@ -501,13 +507,16 @@ The `StringExtensions` class provides a wide range of methods for string manipul
|
|||||||
#### Features
|
#### Features
|
||||||
|
|
||||||
1. **Pattern Matching**:
|
1. **Pattern Matching**:
|
||||||
- Check if a string matches a pattern using SQL-like wildcards.
|
- Check if a string matches a pattern using SQL-like wildcards (`Like`).
|
||||||
|
|
||||||
2. **Substring Extraction**:
|
2. **Substring Extraction**:
|
||||||
- Extract substrings from the left, right, or middle of a string.
|
- Extract substrings from the left, right, or middle of a string.
|
||||||
|
|
||||||
3. **Type Conversion**:
|
3. **Type Conversion**:
|
||||||
- Convert strings to various types, such as integers, booleans, and enums.
|
- Convert strings to integers, booleans, dates, GUIDs, and enums (`ToObject<T>` deserializes JSON).
|
||||||
|
|
||||||
|
4. **JSON Deserialization**:
|
||||||
|
- `ToObject<T>()` / `ToObject<T>(converters)` using `System.Text.Json`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -523,9 +532,14 @@ bool matches = "example".Like("exa*e"); // True
|
|||||||
string result = "example".Left(3); // "exa"
|
string result = "example".Left(3); // "exa"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
##### JSON Deserialization
|
||||||
|
```csharp
|
||||||
|
var person = json.ToObject<Person>();
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### Object Extensions
|
### Object Extensions
|
||||||
|
|
||||||
The `ObjectExtensions` class provides advanced methods for working with objects, including serialization, deep cloning, and structural equality comparison.
|
The `ObjectExtensions` class provides advanced methods for working with objects, including serialization, deep cloning, and structural equality comparison.
|
||||||
|
|
||||||
@ -598,7 +612,56 @@ current.RevertFrom(snapshot);
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### DataTable Extensions
|
### Exception Extensions
|
||||||
|
|
||||||
|
The `ExceptionExtensions` class in the `MaksIT.Core.Extensions` namespace walks an exception chain and collects messages from the exception and every inner exception.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Features
|
||||||
|
|
||||||
|
1. **Extract Messages**:
|
||||||
|
- `ExtractMessages()` returns a `List<string>` from the exception and its `InnerException` chain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Example Usage
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
try {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
catch (Exception ex) {
|
||||||
|
var messages = ex.ExtractMessages();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Formats Extensions
|
||||||
|
|
||||||
|
The `FormatsExtensions` class in the `MaksIT.Core.Extensions` namespace creates a Pax TAR archive from a directory tree.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Features
|
||||||
|
|
||||||
|
1. **Create TAR Archives**:
|
||||||
|
- `TryCreateTarFromDirectory(sourceDirectory, outputTarPath)` writes all files under the source directory into a TAR file. Returns `false` if the source is missing, empty, or the output path cannot be created.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Example Usage
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (FormatsExtensions.TryCreateTarFromDirectory(@"C:\data", @"C:\out\archive.tar")) {
|
||||||
|
Console.WriteLine("TAR created");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DataTable Extensions
|
||||||
|
|
||||||
The `DataTableExtensions` class provides methods for working with `DataTable` objects, such as counting duplicate rows and retrieving distinct records.
|
The `DataTableExtensions` class provides methods for working with `DataTable` objects, such as counting duplicate rows and retrieving distinct records.
|
||||||
|
|
||||||
@ -628,7 +691,7 @@ DataTable distinctTable = table.DistinctRecords(new[] { "Name", "Age" });
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### Guid Extensions
|
### Guid Extensions
|
||||||
|
|
||||||
The `GuidExtensions` class provides methods for working with `Guid` values, including converting them to nullable types.
|
The `GuidExtensions` class provides methods for working with `Guid` values, including converting them to nullable types.
|
||||||
|
|
||||||
@ -651,6 +714,47 @@ Guid? nullableId = id.ToNullable();
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Enum Extensions
|
||||||
|
|
||||||
|
The `EnumExtensions` class provides utility methods for working with enum types, specifically for retrieving display names defined via the `DisplayAttribute`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Features
|
||||||
|
|
||||||
|
1. **Get Display Name**:
|
||||||
|
- Retrieve the value of the `DisplayAttribute.Name` property for an enum value, or fall back to the enum's name if the attribute is not present.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Example Usage
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using MaksIT.Core.Extensions;
|
||||||
|
|
||||||
|
public enum Status {
|
||||||
|
[Display(Name = "In Progress")]
|
||||||
|
InProgress,
|
||||||
|
Completed
|
||||||
|
}
|
||||||
|
|
||||||
|
var status = Status.InProgress;
|
||||||
|
string displayName = status.GetDisplayName(); // "In Progress"
|
||||||
|
|
||||||
|
var completed = Status.Completed;
|
||||||
|
string completedName = completed.GetDisplayName(); // "Completed"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Best Practices
|
||||||
|
|
||||||
|
- Use the `Display` attribute on enum members to provide user-friendly names for UI or logging.
|
||||||
|
- Use `GetDisplayName()` to consistently retrieve display names for enums throughout your application.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
The `Logging` namespace provides a custom file-based logging implementation that integrates with the `Microsoft.Extensions.Logging` framework.
|
The `Logging` namespace provides a custom file-based logging implementation that integrates with the `Microsoft.Extensions.Logging` framework.
|
||||||
@ -717,6 +821,30 @@ logger.LogInformation("Logging to JSON file!");
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Console Loggers
|
||||||
|
|
||||||
|
`LoggingBuilderExtensions` in the `MaksIT.Core.Logging` namespace also registers console logging used by MaksIT hosts (`builder.Logging.AddConsoleLogger()`).
|
||||||
|
|
||||||
|
#### Methods
|
||||||
|
|
||||||
|
| Method | Behavior |
|
||||||
|
|--------|----------|
|
||||||
|
| `AddSimpleConsoleLogger()` | Adds a timestamped simple console logger. Does not clear existing providers. |
|
||||||
|
| `AddConsoleLogger(fileLoggerPath?)` | Clears providers, adds simple console, and optionally `AddFileLogger` when a folder path is passed. |
|
||||||
|
| `AddJsonConsoleLogger(fileLoggerPath)` | Clears providers, adds JSON console, and optionally `AddJsonFileLogger` when a folder path is passed. |
|
||||||
|
|
||||||
|
Timestamps use `yyyy-MM-ddTHH:mm:ss.fffZ` and scopes are included.
|
||||||
|
|
||||||
|
#### Example Usage
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Logging.AddConsoleLogger();
|
||||||
|
builder.Logging.AddConsoleLogger("logs");
|
||||||
|
builder.Logging.AddJsonConsoleLogger("logs");
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Logger Prefix
|
### Logger Prefix
|
||||||
|
|
||||||
The `LoggerPrefix` class in the `MaksIT.Core.Logging` namespace provides a type-safe way to specify logger categories with special prefixes. It extends the `Enumeration` base class and enables organizing logs into subfolders or applying custom categorization without using magic strings.
|
The `LoggerPrefix` class in the `MaksIT.Core.Logging` namespace provides a type-safe way to specify logger categories with special prefixes. It extends the `Enumeration` base class and enables organizing logs into subfolders or applying custom categorization without using magic strings.
|
||||||
@ -839,7 +967,7 @@ lockManager.Dispose();
|
|||||||
|
|
||||||
### Network Connection
|
### Network Connection
|
||||||
|
|
||||||
The `NetworkConnection` class provides methods for managing connections to network shares on Windows.
|
The `NetworkConnection` class in the `MaksIT.Core.Networking.Windows` namespace provides methods for managing connections to network shares on Windows.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -857,7 +985,7 @@ The `NetworkConnection` class provides methods for managing connections to netwo
|
|||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
var credentials = new NetworkCredential("username", "password");
|
var credentials = new NetworkCredential("username", "password");
|
||||||
if (NetworkConnection.TryCreate(logger, "\\server\share", credentials, out var connection, out var error)) {
|
if (NetworkConnection.TryCreate(logger, @"\\server\share", credentials, out var connection, out var error)) {
|
||||||
connection.Dispose();
|
connection.Dispose();
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -915,6 +1043,7 @@ The `AESGCMUtility` class provides methods for encrypting and decrypting data us
|
|||||||
```csharp
|
```csharp
|
||||||
var key = AESGCMUtility.GenerateKeyBase64();
|
var key = AESGCMUtility.GenerateKeyBase64();
|
||||||
AESGCMUtility.TryEncryptData(data, key, out var encryptedData, out var error);
|
AESGCMUtility.TryEncryptData(data, key, out var encryptedData, out var error);
|
||||||
|
AESGCMUtility.TryDecryptData(encryptedData, key, out var decrypted, out var decryptError);
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -944,16 +1073,41 @@ Base32Encoder.TryEncode(data, out var encoded, out var error);
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Base64Url Utility
|
||||||
|
|
||||||
|
The `Base64UrlUtility` class in the `MaksIT.Core.Security` namespace provides RFC 4648 §5 Base64Url encoding and decoding (used by JWK/JWS).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Features
|
||||||
|
|
||||||
|
1. **Encode**:
|
||||||
|
- Encode a UTF-8 string or byte array to a Base64Url string (no padding; `+`/`/` replaced with `-`/`_`).
|
||||||
|
|
||||||
|
2. **Decode**:
|
||||||
|
- Decode a Base64Url string to bytes (`Decode`) or a UTF-8 string (`DecodeToString`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Example Usage
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var encoded = Base64UrlUtility.Encode("hello");
|
||||||
|
var decoded = Base64UrlUtility.DecodeToString(encoded);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Checksum Utility
|
### Checksum Utility
|
||||||
|
|
||||||
The `ChecksumUtility` class provides methods for calculating and verifying CRC32 checksums.
|
The `ChecksumUtility` class provides methods for calculating and verifying CRC32 checksums. `Crc32` is a public `HashAlgorithm` implementation used by these helpers.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### Features
|
#### Features
|
||||||
|
|
||||||
1. **Checksum Calculation**:
|
1. **Checksum Calculation**:
|
||||||
- Calculate CRC32 checksums for data.
|
- Calculate CRC32 checksums for in-memory data, files, or files in chunks.
|
||||||
|
|
||||||
2. **Checksum Verification**:
|
2. **Checksum Verification**:
|
||||||
- Verify data integrity using CRC32 checksums.
|
- Verify data integrity using CRC32 checksums.
|
||||||
@ -965,6 +1119,7 @@ The `ChecksumUtility` class provides methods for calculating and verifying CRC32
|
|||||||
##### Calculating a Checksum
|
##### Calculating a Checksum
|
||||||
```csharp
|
```csharp
|
||||||
ChecksumUtility.TryCalculateCRC32Checksum(data, out var checksum, out var error);
|
ChecksumUtility.TryCalculateCRC32Checksum(data, out var checksum, out var error);
|
||||||
|
ChecksumUtility.TryCalculateCRC32ChecksumFromFile(path, out var fileChecksum, out var fileError);
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -1044,17 +1199,20 @@ public static bool TryValidateHash(
|
|||||||
|
|
||||||
### JWT Generator
|
### JWT Generator
|
||||||
|
|
||||||
The `JwtGenerator` class provides methods for generating and validating JSON Web Tokens (JWTs).
|
The `JwtGenerator` class in the `MaksIT.Core.Security.JWT` namespace provides methods for generating and validating JSON Web Tokens (JWTs). ACL entries are stored with the `CustomClaims.AclEntry` claim type (`acl_entry`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### Features
|
#### Features
|
||||||
|
|
||||||
1. **Token Generation**:
|
1. **Token Generation**:
|
||||||
- Generate JWTs with claims and metadata.
|
- Generate JWTs from a `JWTTokenGenerateRequest` (secret, issuer, audience, expiration, optional user id, username, roles, ACL entries).
|
||||||
|
|
||||||
2. **Token Validation**:
|
2. **Token Validation**:
|
||||||
- Validate JWTs against a secret.
|
- Validate JWTs against a secret, issuer, and audience; returns `JWTTokenClaims`.
|
||||||
|
|
||||||
|
3. **Secrets**:
|
||||||
|
- `GenerateSecret(keySize)` and `GenerateRefreshToken()` produce Base64 random values.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -1062,7 +1220,24 @@ The `JwtGenerator` class provides methods for generating and validating JSON Web
|
|||||||
|
|
||||||
##### Generating a Token
|
##### Generating a Token
|
||||||
```csharp
|
```csharp
|
||||||
JwtGenerator.TryGenerateToken(secret, issuer, audience, 60, "user", roles, out var token, out var error);
|
var request = new JWTTokenGenerateRequest {
|
||||||
|
Secret = secret,
|
||||||
|
Issuer = issuer,
|
||||||
|
Audience = audience,
|
||||||
|
Expiration = 60,
|
||||||
|
UserId = "user-1",
|
||||||
|
Username = "jane",
|
||||||
|
Roles = ["Admin"],
|
||||||
|
AclEntries = ["vault:read"]
|
||||||
|
};
|
||||||
|
|
||||||
|
if (JwtGenerator.TryGenerateToken(request, out var tokenData, out var error)) {
|
||||||
|
var (token, claims) = tokenData.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (JwtGenerator.TryValidateToken(secret, issuer, audience, token, out var validated, out var validateError)) {
|
||||||
|
// validated.UserId, validated.Roles, validated.AclEntries
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -1119,6 +1294,7 @@ public static bool TryGenerateFromRSA(
|
|||||||
#### Notes
|
#### Notes
|
||||||
- Only supports RSA public keys.
|
- Only supports RSA public keys.
|
||||||
- The generated JWK includes only the public exponent and modulus.
|
- The generated JWK includes only the public exponent and modulus.
|
||||||
|
- `JwkKeyType`, `JwkCurve`, and `JwkAlgorithm` are `Enumeration` types for JWK metadata.
|
||||||
- Returns `false` and an error message if the RSA parameters are missing or invalid.
|
- Returns `false` and an error message if the RSA parameters are missing or invalid.
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -1437,7 +1613,7 @@ var response = new PagedResponse<UserDto>(items, totalCount, pageNumber, pageSiz
|
|||||||
|
|
||||||
### Patch Operation
|
### Patch Operation
|
||||||
|
|
||||||
The `PatchOperation` enum in the `MaksIT.Core.Webapi.Models` namespace defines operations for partial updates (PATCH requests).
|
The `PatchOperation` enum in the `MaksIT.Core.Webapi.Models` namespace defines operations for partial updates (PATCH requests). Pair it with `PatchRequestModelBase` (`Operations` dictionary + `TryGetOperation`).
|
||||||
|
|
||||||
#### Values
|
#### Values
|
||||||
|
|
||||||
@ -1452,28 +1628,166 @@ The `PatchOperation` enum in the `MaksIT.Core.Webapi.Models` namespace defines o
|
|||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public class UserPatchRequest : PatchRequestModelBase {
|
public class UserPatchRequest : PatchRequestModelBase {
|
||||||
public PatchOperation Operation { get; set; }
|
public string? Name { get; set; }
|
||||||
public string PropertyName { get; set; }
|
public List<string>? Roles { get; set; }
|
||||||
public object? Value { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Example: Set a field
|
|
||||||
var patch = new UserPatchRequest {
|
var patch = new UserPatchRequest {
|
||||||
Operation = PatchOperation.SetField,
|
Name = "New Name",
|
||||||
PropertyName = "Name",
|
Operations = new Dictionary<string, PatchOperation> {
|
||||||
Value = "New Name"
|
["Name"] = PatchOperation.SetField,
|
||||||
|
["Roles"] = PatchOperation.AddToCollection
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Example: Add to collection
|
if (patch.TryGetOperation(nameof(UserPatchRequest.Name), out var operation)) {
|
||||||
var patch = new UserPatchRequest {
|
// operation == PatchOperation.SetField
|
||||||
Operation = PatchOperation.AddToCollection,
|
}
|
||||||
PropertyName = "Roles",
|
|
||||||
Value = "Admin"
|
|
||||||
};
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Error Handling Middleware
|
||||||
|
|
||||||
|
The `ErrorHandlingMiddleware` class in the `MaksIT.Core.Webapi.Middlewares` namespace catches unhandled exceptions, logs them, and returns HTTP 500 with a JSON body `{ error, details }`. Register it early in the ASP.NET pipeline.
|
||||||
|
|
||||||
|
#### Example Usage
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
app.UseMiddleware<ErrorHandlingMiddleware>();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Trace ID Logging Scope Middleware
|
||||||
|
|
||||||
|
The `TraceIdLoggingScopeMiddleware` class in the `MaksIT.Core.Webapi.Middlewares` namespace adds a `TraceId` logging scope from `Activity.Current` or `HttpContext.TraceIdentifier`.
|
||||||
|
|
||||||
|
#### Example Usage
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
app.UseMiddleware<TraceIdLoggingScopeMiddleware>();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sagas
|
||||||
|
|
||||||
|
The `MaksIT.Core.Sagas` namespace provides a local saga runner with LIFO compensation on failure. Steps are registered on `LocalSagaBuilder` (an `ILogger` is required). `LocalSagaStep<T>` is internal; use `AddAction` / `AddStep` / `AddActionIf` / `AddStepIf`. Share state through `LocalSagaContext`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Features
|
||||||
|
|
||||||
|
1. **Saga Context**:
|
||||||
|
- `Get` / `Set` / `Contains` for passing values between steps.
|
||||||
|
|
||||||
|
2. **Actions and Steps**:
|
||||||
|
- `AddAction` for side effects; `AddStep<T>` to store a result under `outputKey`. Conditional variants skip when the predicate is false.
|
||||||
|
|
||||||
|
3. **Compensation**:
|
||||||
|
- Optional compensate callbacks run in reverse order when a later step throws.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Example Usage
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var saga = new LocalSagaBuilder(logger)
|
||||||
|
.AddAction("Reserve", async (ctx, ct) => {
|
||||||
|
ctx.Set("orderId", "123");
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}, compensate: async (ctx, ct) => {
|
||||||
|
await Task.CompletedTask;
|
||||||
|
})
|
||||||
|
.AddStep<int>("Charge", async (ctx, ct) => 42, outputKey: "amount")
|
||||||
|
.AddActionIf(ctx => ctx.Contains("amount"), "Notify", async (ctx, ct) => {
|
||||||
|
await Task.CompletedTask;
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
await saga.ExecuteAsync();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Best Practices
|
||||||
|
|
||||||
|
1. **Idempotency**:
|
||||||
|
- Ensure saga steps are idempotent to handle retries gracefully.
|
||||||
|
|
||||||
|
2. **Error Handling**:
|
||||||
|
- Implement compensation for steps that mutate external state.
|
||||||
|
|
||||||
|
3. **State Management**:
|
||||||
|
- Use `LocalSagaContext` to pass data between steps; back up values you need to restore on compensate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
The `Sagas` namespace simplifies in-process workflows with compensation, not distributed two-phase commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CombGuidGenerator
|
||||||
|
|
||||||
|
The `CombGuidGenerator` class in the `MaksIT.Core.Comb` namespace provides methods for generating and extracting COMB GUIDs (GUIDs with embedded timestamps). COMB GUIDs improve index locality by combining randomness with a sortable timestamp.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Features
|
||||||
|
|
||||||
|
1. **Generate COMB GUIDs**:
|
||||||
|
- Create GUIDs with embedded timestamps for improved database indexing.
|
||||||
|
|
||||||
|
2. **Extract Timestamps**:
|
||||||
|
- Retrieve the embedded timestamp from a COMB GUID.
|
||||||
|
|
||||||
|
3. **Support for Multiple Formats**:
|
||||||
|
- Generate COMB GUIDs compatible with SQL Server and PostgreSQL.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Example Usage
|
||||||
|
|
||||||
|
##### Generating a COMB GUID
|
||||||
|
```csharp
|
||||||
|
var baseGuid = Guid.NewGuid();
|
||||||
|
var timestamp = DateTime.UtcNow;
|
||||||
|
|
||||||
|
var combGuid = CombGuidGenerator.CreateCombGuid(baseGuid, timestamp, CombGuidType.SqlServer);
|
||||||
|
var combGuidPostgres = CombGuidGenerator.CreateCombGuid(baseGuid, timestamp, CombGuidType.PostgreSql);
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Extracting a Timestamp
|
||||||
|
```csharp
|
||||||
|
var extractedTimestamp = CombGuidGenerator.ExtractTimestamp(combGuid, CombGuidType.SqlServer);
|
||||||
|
Console.WriteLine($"Extracted Timestamp: {extractedTimestamp}");
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Generating a COMB GUID with Current Timestamp
|
||||||
|
```csharp
|
||||||
|
var combGuidWithCurrentTimestamp = CombGuidGenerator.CreateCombGuid(Guid.NewGuid(), CombGuidType.SqlServer);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Best Practices
|
||||||
|
|
||||||
|
1. **Use COMB GUIDs for Indexing**:
|
||||||
|
- COMB GUIDs are ideal for database indexing as they improve index locality.
|
||||||
|
|
||||||
|
2. **Choose the Correct Format**:
|
||||||
|
- Use `CombGuidType.SqlServer` for SQL Server and `CombGuidType.PostgreSql` for PostgreSQL.
|
||||||
|
|
||||||
|
3. **Ensure UTC Timestamps**:
|
||||||
|
- Always use UTC timestamps to ensure consistency across systems.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
The `CombGuidGenerator` class simplifies the creation and management of COMB GUIDs, making it easier to work with GUIDs in database applications.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Others
|
## Others
|
||||||
|
|
||||||
### Culture
|
### Culture
|
||||||
@ -1519,6 +1833,8 @@ The `EnvVar` class provides methods for managing environment variables.
|
|||||||
##### Adding to PATH
|
##### Adding to PATH
|
||||||
```csharp
|
```csharp
|
||||||
EnvVar.TryAddToPath("/usr/local/bin", out var error);
|
EnvVar.TryAddToPath("/usr/local/bin", out var error);
|
||||||
|
EnvVar.TrySet("MY_VAR", "value", "process", out var setError);
|
||||||
|
EnvVar.TryUnSet("MY_VAR", "process", out var unsetError);
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -1532,10 +1848,16 @@ The `FileSystem` class provides methods for working with files and directories.
|
|||||||
#### Features
|
#### Features
|
||||||
|
|
||||||
1. **Copy Files and Folders**:
|
1. **Copy Files and Folders**:
|
||||||
- Copy files or directories to a target location.
|
- Copy files or directories to a target location (`TryCopyToFolder`).
|
||||||
|
|
||||||
2. **Delete Files and Folders**:
|
2. **Delete Files and Folders**:
|
||||||
- Delete files or directories.
|
- Delete files or directories (`TryDeleteFileOrDirectory`).
|
||||||
|
|
||||||
|
3. **Wildcard Paths**:
|
||||||
|
- `ResolveWildcardedPath` expands `*` / `?` path segments (including `?:` for drives on Windows).
|
||||||
|
|
||||||
|
4. **Duplicate File Names**:
|
||||||
|
- `DuplicateFileNameCheck` returns a non-colliding path (`file(1).ext`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -1560,7 +1882,7 @@ The `Processes` class provides methods for managing system processes.
|
|||||||
- Start new processes with optional arguments.
|
- Start new processes with optional arguments.
|
||||||
|
|
||||||
2. **Kill Processes**:
|
2. **Kill Processes**:
|
||||||
- Terminate processes by name.
|
- Terminate processes by name (`TryKill` accepts `*` / `?` wildcards).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -1573,47 +1895,6 @@ Processes.TryStart("notepad.exe", "", 0, false, out var error);
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Enum Extensions
|
|
||||||
|
|
||||||
The `EnumExtensions` class provides utility methods for working with enum types, specifically for retrieving display names defined via the `DisplayAttribute`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Features
|
|
||||||
|
|
||||||
1. **Get Display Name**:
|
|
||||||
- Retrieve the value of the `DisplayAttribute.Name` property for an enum value, or fall back to the enum's name if the attribute is not present.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Example Usage
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using MaksIT.Core.Extensions;
|
|
||||||
|
|
||||||
public enum Status {
|
|
||||||
[Display(Name = "In Progress")]
|
|
||||||
InProgress,
|
|
||||||
Completed
|
|
||||||
}
|
|
||||||
|
|
||||||
var status = Status.InProgress;
|
|
||||||
string displayName = status.GetDisplayName(); // "In Progress"
|
|
||||||
|
|
||||||
var completed = Status.Completed;
|
|
||||||
string completedName = completed.GetDisplayName(); // "Completed"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Best Practices
|
|
||||||
|
|
||||||
- Use the `Display` attribute on enum members to provide user-friendly names for UI or logging.
|
|
||||||
- Use `GetDisplayName()` to consistently retrieve display names for enums throughout your application.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contact
|
## Contact
|
||||||
|
|
||||||
If you have any questions or need further assistance, feel free to reach out:
|
If you have any questions or need further assistance, feel free to reach out:
|
||||||
|
|||||||
38
src/MaksIT.Core.Cli.Tests/CliActionsTests.cs
Normal file
38
src/MaksIT.Core.Cli.Tests/CliActionsTests.cs
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
using MaksIT.Core.Cli;
|
||||||
|
|
||||||
|
|
||||||
|
namespace MaksIT.Core.Cli.Tests;
|
||||||
|
|
||||||
|
public class CliActionsTests {
|
||||||
|
[Fact]
|
||||||
|
public void GenerateSecret_InvalidBytes_ReturnsOne() {
|
||||||
|
var exit = CliActions.GenerateSecret(0);
|
||||||
|
|
||||||
|
Assert.Equal(1, exit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GenerateCombGuid_InvalidType_ReturnsOne() {
|
||||||
|
var exit = CliActions.GenerateCombGuid("rsa");
|
||||||
|
|
||||||
|
Assert.Equal(1, exit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GenerateCombGuid_PostgreSql_ReturnsZero() {
|
||||||
|
var exit = CliActions.GenerateCombGuid(null);
|
||||||
|
|
||||||
|
Assert.Equal(0, exit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HashPassword_EmptyPassword_ReturnsOne() {
|
||||||
|
var exit = CliActions.HashPassword("pepper", "");
|
||||||
|
|
||||||
|
Assert.Equal(1, exit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GenerateAesKey_ReturnsZero() =>
|
||||||
|
Assert.Equal(0, CliActions.GenerateAesKey());
|
||||||
|
}
|
||||||
43
src/MaksIT.Core.Cli.Tests/CommandFactoryTests.cs
Normal file
43
src/MaksIT.Core.Cli.Tests/CommandFactoryTests.cs
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
using MaksIT.Core.Cli;
|
||||||
|
|
||||||
|
|
||||||
|
namespace MaksIT.Core.Cli.Tests;
|
||||||
|
|
||||||
|
public class CommandFactoryTests {
|
||||||
|
[Fact]
|
||||||
|
public void Secret_HasNoParseErrors() {
|
||||||
|
var result = CommandFactory.CreateRootCommand().Parse(["secret"]);
|
||||||
|
|
||||||
|
Assert.Empty(result.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JwtGenerate_MissingSecret_HasParseError() {
|
||||||
|
var result = CommandFactory.CreateRootCommand().Parse([
|
||||||
|
"jwt", "generate", "--issuer", "i", "--audience", "a"
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.NotEmpty(result.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JwtGenerate_RequiredOptions_HasNoParseErrors() {
|
||||||
|
var result = CommandFactory.CreateRootCommand().Parse([
|
||||||
|
"jwt", "generate",
|
||||||
|
"--secret", "s",
|
||||||
|
"--issuer", "i",
|
||||||
|
"--audience", "a"
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.Empty(result.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TotpValidate_RequiredOptions_HasNoParseErrors() {
|
||||||
|
var result = CommandFactory.CreateRootCommand().Parse([
|
||||||
|
"totp", "validate", "--secret", "s", "--code", "123456"
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.Empty(result.Errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/MaksIT.Core.Cli.Tests/InputParsersTests.cs
Normal file
60
src/MaksIT.Core.Cli.Tests/InputParsersTests.cs
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
using MaksIT.Core.Cli;
|
||||||
|
using MaksIT.Core.Comb;
|
||||||
|
|
||||||
|
|
||||||
|
namespace MaksIT.Core.Cli.Tests;
|
||||||
|
|
||||||
|
public class InputParsersTests {
|
||||||
|
[Fact]
|
||||||
|
public void TryParsePositiveInt_Blank_ReturnsDefault() {
|
||||||
|
var result = InputParsers.TryParsePositiveInt(" ", 32, out var value, out var errorMessage);
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
Assert.Equal(32, value);
|
||||||
|
Assert.Null(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParsePositiveInt_ValidNumber_ReturnsValue() {
|
||||||
|
var result = InputParsers.TryParsePositiveInt("64", 32, out var value, out var errorMessage);
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
Assert.Equal(64, value);
|
||||||
|
Assert.Null(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParsePositiveInt_Invalid_ReturnsError() {
|
||||||
|
var result = InputParsers.TryParsePositiveInt("abc", 32, out var value, out var errorMessage);
|
||||||
|
|
||||||
|
Assert.False(result);
|
||||||
|
Assert.Equal(0, value);
|
||||||
|
Assert.NotNull(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParseCombGuidType_Blank_ReturnsPostgreSql() {
|
||||||
|
var result = InputParsers.TryParseCombGuidType(null, out var type, out var errorMessage);
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
Assert.Equal(CombGuidType.PostgreSql, type);
|
||||||
|
Assert.Null(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParseCombGuidType_SqlServer_ParsesIgnoreCase() {
|
||||||
|
var result = InputParsers.TryParseCombGuidType("sqlserver", out var type, out var errorMessage);
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
Assert.Equal(CombGuidType.SqlServer, type);
|
||||||
|
Assert.Null(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseOptionalList_SplitsAndTrims() {
|
||||||
|
var items = InputParsers.ParseOptionalList(" Admin, User , ");
|
||||||
|
|
||||||
|
Assert.NotNull(items);
|
||||||
|
Assert.Equal(["Admin", "User"], items);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/MaksIT.Core.Cli.Tests/MaksIT.Core.Cli.Tests.csproj
Normal file
27
src/MaksIT.Core.Cli.Tests/MaksIT.Core.Cli.Tests.csproj
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.MTP" Version="10.0.1" />
|
||||||
|
<PackageReference Include="xunit.v3" Version="4.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MaksIT.Core.Cli\MaksIT.Core.Cli.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
60
src/MaksIT.Core.Cli.Tests/SecretOperationsTests.cs
Normal file
60
src/MaksIT.Core.Cli.Tests/SecretOperationsTests.cs
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
using MaksIT.Core.Cli;
|
||||||
|
using MaksIT.Core.Comb;
|
||||||
|
using MaksIT.Core.Security.JWT;
|
||||||
|
|
||||||
|
|
||||||
|
namespace MaksIT.Core.Cli.Tests;
|
||||||
|
|
||||||
|
public class SecretOperationsTests {
|
||||||
|
[Fact]
|
||||||
|
public void GenerateSecret_ReturnsUniqueNonEmptyValues() {
|
||||||
|
var secret1 = SecretOperations.GenerateSecret();
|
||||||
|
var secret2 = SecretOperations.GenerateSecret();
|
||||||
|
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(secret1));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(secret2));
|
||||||
|
Assert.NotEqual(secret1, secret2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GenerateAesKey_ReturnsNonEmptyValue() =>
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(SecretOperations.GenerateAesKey()));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GenerateCombGuid_PostgreSql_ReturnsNonEmptyGuid() {
|
||||||
|
var guid = SecretOperations.GenerateCombGuid(CombGuidType.PostgreSql);
|
||||||
|
|
||||||
|
Assert.NotEqual(Guid.Empty, guid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGenerateJwt_ThenValidate_Succeeds() {
|
||||||
|
var secret = SecretOperations.GenerateSecret();
|
||||||
|
var request = new JWTTokenGenerateRequest {
|
||||||
|
Secret = secret,
|
||||||
|
Issuer = "cli-tests",
|
||||||
|
Audience = "cli-tests",
|
||||||
|
Expiration = 5,
|
||||||
|
Username = "tester"
|
||||||
|
};
|
||||||
|
|
||||||
|
var generated = SecretOperations.TryGenerateJwt(request, out var token, out var generateError);
|
||||||
|
|
||||||
|
Assert.True(generated);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(token));
|
||||||
|
Assert.Null(generateError);
|
||||||
|
|
||||||
|
var validated = SecretOperations.TryValidateJwt(
|
||||||
|
secret,
|
||||||
|
request.Issuer,
|
||||||
|
request.Audience,
|
||||||
|
token!,
|
||||||
|
out var claims,
|
||||||
|
out var validateError
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.True(validated);
|
||||||
|
Assert.Equal("tester", claims?.Username);
|
||||||
|
Assert.Null(validateError);
|
||||||
|
}
|
||||||
|
}
|
||||||
317
src/MaksIT.Core.Cli/Application.cs
Normal file
317
src/MaksIT.Core.Cli/Application.cs
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Reflection;
|
||||||
|
using MaksIT.Core.Comb;
|
||||||
|
using MaksIT.Core.Extensions;
|
||||||
|
using MaksIT.Core.Security.JWT;
|
||||||
|
|
||||||
|
|
||||||
|
namespace MaksIT.Core.Cli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interactive numbered menu for generating MaksIT.Core secrets.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Application {
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the main menu until the user exits.
|
||||||
|
/// </summary>
|
||||||
|
public void Run() {
|
||||||
|
Console.OutputEncoding = Encoding.UTF8;
|
||||||
|
var version = typeof(Application).Assembly
|
||||||
|
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||||
|
.InformationalVersion?
|
||||||
|
.Split('+')[0]
|
||||||
|
?? "0.0.0";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
Console.WriteLine($"MaksIT.Core.Cli v{version}");
|
||||||
|
Console.WriteLine("© Maksym Sadovnychyy (MAKS-IT) 2026");
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("1. Generate secret (JWT / pepper)");
|
||||||
|
Console.WriteLine("2. JWT");
|
||||||
|
Console.WriteLine("3. AES-GCM key");
|
||||||
|
Console.WriteLine("4. TOTP / 2FA");
|
||||||
|
Console.WriteLine("5. Password hash");
|
||||||
|
Console.WriteLine("6. COMB GUID");
|
||||||
|
Console.WriteLine("0. Exit");
|
||||||
|
Console.Write("Enter your choice: ");
|
||||||
|
|
||||||
|
var choice = Console.ReadLine();
|
||||||
|
try {
|
||||||
|
switch (choice) {
|
||||||
|
case "1":
|
||||||
|
GenerateSecret();
|
||||||
|
Pause();
|
||||||
|
break;
|
||||||
|
case "2":
|
||||||
|
RunJwtMenu();
|
||||||
|
break;
|
||||||
|
case "3":
|
||||||
|
WriteLabeled("AES-256 key", SecretOperations.GenerateAesKey());
|
||||||
|
Pause();
|
||||||
|
break;
|
||||||
|
case "4":
|
||||||
|
RunTotpMenu();
|
||||||
|
break;
|
||||||
|
case "5":
|
||||||
|
HashPassword();
|
||||||
|
Pause();
|
||||||
|
break;
|
||||||
|
case "6":
|
||||||
|
GenerateCombGuid();
|
||||||
|
Pause();
|
||||||
|
break;
|
||||||
|
case "0":
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
Console.WriteLine("Invalid option.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) {
|
||||||
|
Console.WriteLine($"Error: {ex.Message}");
|
||||||
|
Pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RunJwtMenu() {
|
||||||
|
while (true) {
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("JWT");
|
||||||
|
Console.WriteLine("1. Generate signing secret");
|
||||||
|
Console.WriteLine("2. Generate refresh token");
|
||||||
|
Console.WriteLine("3. Generate access token");
|
||||||
|
Console.WriteLine("4. Validate token");
|
||||||
|
Console.WriteLine("0. Back");
|
||||||
|
Console.Write("Enter your choice: ");
|
||||||
|
|
||||||
|
var choice = Console.ReadLine();
|
||||||
|
try {
|
||||||
|
switch (choice) {
|
||||||
|
case "1":
|
||||||
|
GenerateSecret();
|
||||||
|
Pause();
|
||||||
|
break;
|
||||||
|
case "2":
|
||||||
|
WriteLabeled("Refresh token", SecretOperations.GenerateRefreshToken());
|
||||||
|
Pause();
|
||||||
|
break;
|
||||||
|
case "3":
|
||||||
|
GenerateAccessToken();
|
||||||
|
Pause();
|
||||||
|
break;
|
||||||
|
case "4":
|
||||||
|
ValidateAccessToken();
|
||||||
|
Pause();
|
||||||
|
break;
|
||||||
|
case "0":
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
Console.WriteLine("Invalid option.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) {
|
||||||
|
Console.WriteLine($"Error: {ex.Message}");
|
||||||
|
Pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RunTotpMenu() {
|
||||||
|
while (true) {
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("TOTP / 2FA");
|
||||||
|
Console.WriteLine("1. Generate secret");
|
||||||
|
Console.WriteLine("2. Generate recovery codes");
|
||||||
|
Console.WriteLine("3. Generate otpauth link");
|
||||||
|
Console.WriteLine("4. Validate code");
|
||||||
|
Console.WriteLine("0. Back");
|
||||||
|
Console.Write("Enter your choice: ");
|
||||||
|
|
||||||
|
var choice = Console.ReadLine();
|
||||||
|
try {
|
||||||
|
switch (choice) {
|
||||||
|
case "1":
|
||||||
|
if (!SecretOperations.TryGenerateTotpSecret(out var secret, out var secretError))
|
||||||
|
throw new InvalidOperationException(secretError);
|
||||||
|
|
||||||
|
WriteLabeled("TOTP secret", secret);
|
||||||
|
Pause();
|
||||||
|
break;
|
||||||
|
case "2":
|
||||||
|
GenerateRecoveryCodes();
|
||||||
|
Pause();
|
||||||
|
break;
|
||||||
|
case "3":
|
||||||
|
GenerateTotpAuthLink();
|
||||||
|
Pause();
|
||||||
|
break;
|
||||||
|
case "4":
|
||||||
|
ValidateTotp();
|
||||||
|
Pause();
|
||||||
|
break;
|
||||||
|
case "0":
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
Console.WriteLine("Invalid option.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) {
|
||||||
|
Console.WriteLine($"Error: {ex.Message}");
|
||||||
|
Pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void GenerateSecret() {
|
||||||
|
var bytes = ReadPositiveInt("Key size in bytes", 32);
|
||||||
|
WriteLabeled("Secret", SecretOperations.GenerateSecret(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void GenerateAccessToken() {
|
||||||
|
var request = new JWTTokenGenerateRequest {
|
||||||
|
Secret = ReadRequired("Secret"),
|
||||||
|
Issuer = ReadRequired("Issuer"),
|
||||||
|
Audience = ReadRequired("Audience"),
|
||||||
|
Expiration = ReadPositiveInt("Expiration (minutes)", 60),
|
||||||
|
UserId = ReadOptional("User id"),
|
||||||
|
Username = ReadOptional("Username"),
|
||||||
|
Roles = InputParsers.ParseOptionalList(ReadOptional("Roles (comma-separated)")),
|
||||||
|
AclEntries = InputParsers.ParseOptionalList(ReadOptional("ACL entries (comma-separated)"))
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!SecretOperations.TryGenerateJwt(request, out var token, out var errorMessage))
|
||||||
|
throw new InvalidOperationException(errorMessage);
|
||||||
|
|
||||||
|
WriteLabeled("Access token", token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateAccessToken() {
|
||||||
|
var secret = ReadRequired("Secret");
|
||||||
|
var issuer = ReadRequired("Issuer");
|
||||||
|
var audience = ReadRequired("Audience");
|
||||||
|
var token = ReadRequired("Token");
|
||||||
|
|
||||||
|
if (!SecretOperations.TryValidateJwt(secret, issuer, audience, token, out var claims, out var errorMessage))
|
||||||
|
throw new InvalidOperationException(errorMessage);
|
||||||
|
|
||||||
|
WriteLabeled("Claims", claims.ToJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void GenerateRecoveryCodes() {
|
||||||
|
var count = ReadPositiveInt("Number of codes", 10);
|
||||||
|
if (!SecretOperations.TryGenerateRecoveryCodes(count, out var codes, out var errorMessage))
|
||||||
|
throw new InvalidOperationException(errorMessage);
|
||||||
|
|
||||||
|
Console.WriteLine("Recovery codes:");
|
||||||
|
foreach (var code in codes)
|
||||||
|
Console.WriteLine(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void GenerateTotpAuthLink() {
|
||||||
|
var label = ReadRequired("Label");
|
||||||
|
var username = ReadRequired("Username");
|
||||||
|
var secret = ReadRequired("TOTP secret");
|
||||||
|
var issuer = ReadRequired("Issuer");
|
||||||
|
|
||||||
|
if (!SecretOperations.TryGenerateTotpAuthLink(label, username, secret, issuer, out var authLink, out var errorMessage))
|
||||||
|
throw new InvalidOperationException(errorMessage);
|
||||||
|
|
||||||
|
WriteLabeled("otpauth link", authLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateTotp() {
|
||||||
|
var secret = ReadRequired("TOTP secret");
|
||||||
|
var code = ReadRequired("Code");
|
||||||
|
var tolerance = ReadPositiveInt("Time-step tolerance", 1);
|
||||||
|
|
||||||
|
if (!SecretOperations.TryValidateTotp(code, secret, tolerance, out var isValid, out var errorMessage))
|
||||||
|
throw new InvalidOperationException(errorMessage);
|
||||||
|
|
||||||
|
Console.WriteLine(isValid ? "Valid." : "Invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void HashPassword() {
|
||||||
|
var pepper = ReadRequired("Pepper");
|
||||||
|
var password = ReadSecret("Password");
|
||||||
|
if (string.IsNullOrEmpty(password))
|
||||||
|
throw new InvalidOperationException("Password is required.");
|
||||||
|
|
||||||
|
if (!SecretOperations.TryHashPassword(password, pepper, out var saltedHash, out var errorMessage))
|
||||||
|
throw new InvalidOperationException(errorMessage);
|
||||||
|
|
||||||
|
Console.WriteLine($"Salt: {saltedHash.Value.Salt}");
|
||||||
|
Console.WriteLine($"Hash: {saltedHash.Value.Hash}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void GenerateCombGuid() {
|
||||||
|
Console.Write("COMB type (PostgreSql/SqlServer) [PostgreSql]: ");
|
||||||
|
if (!InputParsers.TryParseCombGuidType(Console.ReadLine(), out var type, out var errorMessage))
|
||||||
|
throw new InvalidOperationException(errorMessage);
|
||||||
|
|
||||||
|
WriteLabeled($"COMB GUID ({type})", SecretOperations.GenerateCombGuid(type).ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ReadPositiveInt(string prompt, int defaultValue) {
|
||||||
|
Console.Write($"{prompt} [{defaultValue}]: ");
|
||||||
|
if (!InputParsers.TryParsePositiveInt(Console.ReadLine(), defaultValue, out var value, out var errorMessage))
|
||||||
|
throw new InvalidOperationException(errorMessage);
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadRequired(string prompt) {
|
||||||
|
Console.Write($"{prompt}: ");
|
||||||
|
var value = Console.ReadLine();
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
throw new InvalidOperationException($"{prompt} is required.");
|
||||||
|
|
||||||
|
return value.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ReadOptional(string prompt) {
|
||||||
|
Console.Write($"{prompt}: ");
|
||||||
|
var value = Console.ReadLine();
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return value.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadSecret(string prompt) {
|
||||||
|
Console.Write($"{prompt}: ");
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
while (true) {
|
||||||
|
var key = Console.ReadKey(intercept: true);
|
||||||
|
if (key.Key == ConsoleKey.Enter) {
|
||||||
|
Console.WriteLine();
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.Key == ConsoleKey.Backspace) {
|
||||||
|
if (builder.Length > 0)
|
||||||
|
builder.Length--;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!char.IsControl(key.KeyChar))
|
||||||
|
builder.Append(key.KeyChar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteLabeled(string label, string value) {
|
||||||
|
Console.WriteLine($"{label}:");
|
||||||
|
Console.WriteLine(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Pause() {
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.Write("Press Enter to continue...");
|
||||||
|
Console.ReadLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
170
src/MaksIT.Core.Cli/CliActions.cs
Normal file
170
src/MaksIT.Core.Cli/CliActions.cs
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
using MaksIT.Core.Extensions;
|
||||||
|
using MaksIT.Core.Security.JWT;
|
||||||
|
|
||||||
|
|
||||||
|
namespace MaksIT.Core.Cli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Non-interactive command handlers: values on stdout, errors on stderr, exit 0/1.
|
||||||
|
/// </summary>
|
||||||
|
public static class CliActions {
|
||||||
|
/// <summary>
|
||||||
|
/// Writes an error to stderr and returns exit code 1.
|
||||||
|
/// </summary>
|
||||||
|
public static int Fail(string errorMessage) {
|
||||||
|
Console.Error.WriteLine(errorMessage);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a Base64 secret.
|
||||||
|
/// </summary>
|
||||||
|
public static int GenerateSecret(int bytes) {
|
||||||
|
if (!InputParsers.TryParsePositiveInt(bytes.ToString(), 32, out var keySize, out var errorMessage))
|
||||||
|
return Fail(errorMessage!);
|
||||||
|
|
||||||
|
Console.WriteLine(SecretOperations.GenerateSecret(keySize));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates an opaque refresh token.
|
||||||
|
/// </summary>
|
||||||
|
public static int GenerateRefreshToken() {
|
||||||
|
Console.WriteLine(SecretOperations.GenerateRefreshToken());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a Base64 AES-256 key.
|
||||||
|
/// </summary>
|
||||||
|
public static int GenerateAesKey() {
|
||||||
|
Console.WriteLine(SecretOperations.GenerateAesKey());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a COMB GUID.
|
||||||
|
/// </summary>
|
||||||
|
public static int GenerateCombGuid(string? typeName) {
|
||||||
|
if (!InputParsers.TryParseCombGuidType(typeName, out var type, out var errorMessage))
|
||||||
|
return Fail(errorMessage!);
|
||||||
|
|
||||||
|
Console.WriteLine(SecretOperations.GenerateCombGuid(type));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Signs an access JWT.
|
||||||
|
/// </summary>
|
||||||
|
public static int GenerateJwt(
|
||||||
|
string secret,
|
||||||
|
string issuer,
|
||||||
|
string audience,
|
||||||
|
int expiration,
|
||||||
|
string? userId,
|
||||||
|
string? username,
|
||||||
|
string? roles,
|
||||||
|
string? aclEntries
|
||||||
|
) {
|
||||||
|
if (!InputParsers.TryParsePositiveInt(expiration.ToString(), 60, out var minutes, out var errorMessage))
|
||||||
|
return Fail(errorMessage!);
|
||||||
|
|
||||||
|
var request = new JWTTokenGenerateRequest {
|
||||||
|
Secret = secret,
|
||||||
|
Issuer = issuer,
|
||||||
|
Audience = audience,
|
||||||
|
Expiration = minutes,
|
||||||
|
UserId = EmptyToNull(userId),
|
||||||
|
Username = EmptyToNull(username),
|
||||||
|
Roles = InputParsers.ParseOptionalList(roles),
|
||||||
|
AclEntries = InputParsers.ParseOptionalList(aclEntries)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!SecretOperations.TryGenerateJwt(request, out var token, out var generateError))
|
||||||
|
return Fail(generateError);
|
||||||
|
|
||||||
|
Console.WriteLine(token);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates an access JWT and writes claims JSON.
|
||||||
|
/// </summary>
|
||||||
|
public static int ValidateJwt(string secret, string issuer, string audience, string token) {
|
||||||
|
if (!SecretOperations.TryValidateJwt(secret, issuer, audience, token, out var claims, out var errorMessage))
|
||||||
|
return Fail(errorMessage);
|
||||||
|
|
||||||
|
Console.WriteLine(claims.ToJson());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a Base32 TOTP secret.
|
||||||
|
/// </summary>
|
||||||
|
public static int GenerateTotpSecret() {
|
||||||
|
if (!SecretOperations.TryGenerateTotpSecret(out var secret, out var errorMessage))
|
||||||
|
return Fail(errorMessage);
|
||||||
|
|
||||||
|
Console.WriteLine(secret);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates TOTP recovery codes (one per line).
|
||||||
|
/// </summary>
|
||||||
|
public static int GenerateRecoveryCodes(int count) {
|
||||||
|
if (!InputParsers.TryParsePositiveInt(count.ToString(), 10, out var codeCount, out var errorMessage))
|
||||||
|
return Fail(errorMessage!);
|
||||||
|
|
||||||
|
if (!SecretOperations.TryGenerateRecoveryCodes(codeCount, out var codes, out var generateError))
|
||||||
|
return Fail(generateError);
|
||||||
|
|
||||||
|
foreach (var code in codes)
|
||||||
|
Console.WriteLine(code);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an otpauth URI.
|
||||||
|
/// </summary>
|
||||||
|
public static int GenerateTotpAuthLink(string label, string username, string secret, string issuer) {
|
||||||
|
if (!SecretOperations.TryGenerateTotpAuthLink(label, username, secret, issuer, out var authLink, out var errorMessage))
|
||||||
|
return Fail(errorMessage);
|
||||||
|
|
||||||
|
Console.WriteLine(authLink);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a TOTP code. Exit 1 when the code is invalid.
|
||||||
|
/// </summary>
|
||||||
|
public static int ValidateTotp(string secret, string code, int tolerance) {
|
||||||
|
if (!InputParsers.TryParsePositiveInt(tolerance.ToString(), 1, out var timeTolerance, out var errorMessage))
|
||||||
|
return Fail(errorMessage!);
|
||||||
|
|
||||||
|
if (!SecretOperations.TryValidateTotp(code, secret, timeTolerance, out var isValid, out var validateError))
|
||||||
|
return Fail(validateError);
|
||||||
|
|
||||||
|
Console.WriteLine(isValid ? "valid" : "invalid");
|
||||||
|
return isValid ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a salted password hash as JSON.
|
||||||
|
/// </summary>
|
||||||
|
public static int HashPassword(string pepper, string password) {
|
||||||
|
if (string.IsNullOrEmpty(password))
|
||||||
|
return Fail("Password is required.");
|
||||||
|
|
||||||
|
if (!SecretOperations.TryHashPassword(password, pepper, out var saltedHash, out var errorMessage))
|
||||||
|
return Fail(errorMessage);
|
||||||
|
|
||||||
|
Console.WriteLine(new { salt = saltedHash.Value.Salt, hash = saltedHash.Value.Hash }.ToJson());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? EmptyToNull(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
}
|
||||||
277
src/MaksIT.Core.Cli/CommandFactory.cs
Normal file
277
src/MaksIT.Core.Cli/CommandFactory.cs
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
using System.CommandLine;
|
||||||
|
|
||||||
|
|
||||||
|
namespace MaksIT.Core.Cli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the agent-facing command tree. No arguments runs the interactive menu.
|
||||||
|
/// </summary>
|
||||||
|
public static class CommandFactory {
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the root command with secret, jwt, aes, totp, password, and guid subcommands.
|
||||||
|
/// </summary>
|
||||||
|
public static RootCommand CreateRootCommand() {
|
||||||
|
var root = new RootCommand("Generate MaksIT.Core secrets. No arguments opens the interactive menu.") {
|
||||||
|
CreateSecretCommand(),
|
||||||
|
CreateJwtCommand(),
|
||||||
|
CreateAesCommand(),
|
||||||
|
CreateTotpCommand(),
|
||||||
|
CreatePasswordCommand(),
|
||||||
|
CreateGuidCommand()
|
||||||
|
};
|
||||||
|
|
||||||
|
root.SetAction(_ => {
|
||||||
|
new Application().Run();
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreateSecretCommand() {
|
||||||
|
var bytesOption = BytesOption();
|
||||||
|
var command = new Command("secret", "Generate a Base64 secret (JWT signing key or password pepper)") {
|
||||||
|
bytesOption
|
||||||
|
};
|
||||||
|
|
||||||
|
command.SetAction(parseResult =>
|
||||||
|
CliActions.GenerateSecret(parseResult.GetValue(bytesOption)));
|
||||||
|
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreateJwtCommand() {
|
||||||
|
var jwt = new Command("jwt", "JWT signing secrets, tokens, and validation");
|
||||||
|
jwt.Subcommands.Add(CreateJwtSecretCommand());
|
||||||
|
jwt.Subcommands.Add(CreateJwtRefreshCommand());
|
||||||
|
jwt.Subcommands.Add(CreateJwtGenerateCommand());
|
||||||
|
jwt.Subcommands.Add(CreateJwtValidateCommand());
|
||||||
|
return jwt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreateJwtSecretCommand() {
|
||||||
|
var bytesOption = BytesOption();
|
||||||
|
var command = new Command("secret", "Generate a JWT signing secret") {
|
||||||
|
bytesOption
|
||||||
|
};
|
||||||
|
|
||||||
|
command.SetAction(parseResult =>
|
||||||
|
CliActions.GenerateSecret(parseResult.GetValue(bytesOption)));
|
||||||
|
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreateJwtRefreshCommand() {
|
||||||
|
var command = new Command("refresh", "Generate an opaque refresh token");
|
||||||
|
command.SetAction(_ => CliActions.GenerateRefreshToken());
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreateJwtGenerateCommand() {
|
||||||
|
var secretOption = RequiredString("--secret", "Signing secret");
|
||||||
|
var issuerOption = RequiredString("--issuer", "Token issuer");
|
||||||
|
var audienceOption = RequiredString("--audience", "Token audience");
|
||||||
|
var expirationOption = new Option<int>("--expiration") {
|
||||||
|
Description = "Lifetime in minutes",
|
||||||
|
DefaultValueFactory = _ => 60
|
||||||
|
};
|
||||||
|
var userIdOption = new Option<string?>("--user-id") {
|
||||||
|
Description = "Optional user id claim"
|
||||||
|
};
|
||||||
|
var usernameOption = new Option<string?>("--username") {
|
||||||
|
Description = "Optional username claim"
|
||||||
|
};
|
||||||
|
var rolesOption = new Option<string?>("--roles") {
|
||||||
|
Description = "Optional comma-separated roles"
|
||||||
|
};
|
||||||
|
var aclOption = new Option<string?>("--acl") {
|
||||||
|
Description = "Optional comma-separated ACL entries"
|
||||||
|
};
|
||||||
|
|
||||||
|
var command = new Command("generate", "Sign an access JWT") {
|
||||||
|
secretOption,
|
||||||
|
issuerOption,
|
||||||
|
audienceOption,
|
||||||
|
expirationOption,
|
||||||
|
userIdOption,
|
||||||
|
usernameOption,
|
||||||
|
rolesOption,
|
||||||
|
aclOption
|
||||||
|
};
|
||||||
|
|
||||||
|
command.SetAction(parseResult =>
|
||||||
|
CliActions.GenerateJwt(
|
||||||
|
parseResult.GetValue(secretOption)!,
|
||||||
|
parseResult.GetValue(issuerOption)!,
|
||||||
|
parseResult.GetValue(audienceOption)!,
|
||||||
|
parseResult.GetValue(expirationOption),
|
||||||
|
parseResult.GetValue(userIdOption),
|
||||||
|
parseResult.GetValue(usernameOption),
|
||||||
|
parseResult.GetValue(rolesOption),
|
||||||
|
parseResult.GetValue(aclOption)
|
||||||
|
));
|
||||||
|
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreateJwtValidateCommand() {
|
||||||
|
var secretOption = RequiredString("--secret", "Signing secret");
|
||||||
|
var issuerOption = RequiredString("--issuer", "Token issuer");
|
||||||
|
var audienceOption = RequiredString("--audience", "Token audience");
|
||||||
|
var tokenOption = RequiredString("--token", "JWT to validate");
|
||||||
|
|
||||||
|
var command = new Command("validate", "Validate an access JWT and print claims JSON") {
|
||||||
|
secretOption,
|
||||||
|
issuerOption,
|
||||||
|
audienceOption,
|
||||||
|
tokenOption
|
||||||
|
};
|
||||||
|
|
||||||
|
command.SetAction(parseResult =>
|
||||||
|
CliActions.ValidateJwt(
|
||||||
|
parseResult.GetValue(secretOption)!,
|
||||||
|
parseResult.GetValue(issuerOption)!,
|
||||||
|
parseResult.GetValue(audienceOption)!,
|
||||||
|
parseResult.GetValue(tokenOption)!
|
||||||
|
));
|
||||||
|
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreateAesCommand() {
|
||||||
|
var aes = new Command("aes", "AES-GCM keys");
|
||||||
|
var key = new Command("key", "Generate a Base64 AES-256 key");
|
||||||
|
key.SetAction(_ => CliActions.GenerateAesKey());
|
||||||
|
aes.Subcommands.Add(key);
|
||||||
|
return aes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreateTotpCommand() {
|
||||||
|
var totp = new Command("totp", "TOTP / 2FA secrets, recovery codes, and validation");
|
||||||
|
totp.Subcommands.Add(CreateTotpSecretCommand());
|
||||||
|
totp.Subcommands.Add(CreateTotpRecoveryCommand());
|
||||||
|
totp.Subcommands.Add(CreateTotpLinkCommand());
|
||||||
|
totp.Subcommands.Add(CreateTotpValidateCommand());
|
||||||
|
return totp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreateTotpSecretCommand() {
|
||||||
|
var command = new Command("secret", "Generate a Base32 TOTP shared secret");
|
||||||
|
command.SetAction(_ => CliActions.GenerateTotpSecret());
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreateTotpRecoveryCommand() {
|
||||||
|
var countOption = new Option<int>("--count") {
|
||||||
|
Description = "Number of recovery codes",
|
||||||
|
DefaultValueFactory = _ => 10
|
||||||
|
};
|
||||||
|
|
||||||
|
var command = new Command("recovery", "Generate TOTP recovery codes (one per line)") {
|
||||||
|
countOption
|
||||||
|
};
|
||||||
|
|
||||||
|
command.SetAction(parseResult =>
|
||||||
|
CliActions.GenerateRecoveryCodes(parseResult.GetValue(countOption)));
|
||||||
|
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreateTotpLinkCommand() {
|
||||||
|
var labelOption = RequiredString("--label", "Authenticator label");
|
||||||
|
var usernameOption = RequiredString("--username", "Account username");
|
||||||
|
var secretOption = RequiredString("--secret", "Base32 TOTP secret");
|
||||||
|
var issuerOption = RequiredString("--issuer", "Issuer name");
|
||||||
|
|
||||||
|
var command = new Command("link", "Build an otpauth:// URI") {
|
||||||
|
labelOption,
|
||||||
|
usernameOption,
|
||||||
|
secretOption,
|
||||||
|
issuerOption
|
||||||
|
};
|
||||||
|
|
||||||
|
command.SetAction(parseResult =>
|
||||||
|
CliActions.GenerateTotpAuthLink(
|
||||||
|
parseResult.GetValue(labelOption)!,
|
||||||
|
parseResult.GetValue(usernameOption)!,
|
||||||
|
parseResult.GetValue(secretOption)!,
|
||||||
|
parseResult.GetValue(issuerOption)!
|
||||||
|
));
|
||||||
|
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreateTotpValidateCommand() {
|
||||||
|
var secretOption = RequiredString("--secret", "Base32 TOTP secret");
|
||||||
|
var codeOption = RequiredString("--code", "Six-digit TOTP code");
|
||||||
|
var toleranceOption = new Option<int>("--tolerance") {
|
||||||
|
Description = "Time-step windows to accept on each side",
|
||||||
|
DefaultValueFactory = _ => 1
|
||||||
|
};
|
||||||
|
|
||||||
|
var command = new Command("validate", "Validate a TOTP code (prints valid/invalid)") {
|
||||||
|
secretOption,
|
||||||
|
codeOption,
|
||||||
|
toleranceOption
|
||||||
|
};
|
||||||
|
|
||||||
|
command.SetAction(parseResult =>
|
||||||
|
CliActions.ValidateTotp(
|
||||||
|
parseResult.GetValue(secretOption)!,
|
||||||
|
parseResult.GetValue(codeOption)!,
|
||||||
|
parseResult.GetValue(toleranceOption)
|
||||||
|
));
|
||||||
|
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreatePasswordCommand() {
|
||||||
|
var password = new Command("password", "Password hashing");
|
||||||
|
var pepperOption = RequiredString("--pepper", "Application pepper");
|
||||||
|
var passwordOption = RequiredString("--password", "Password to hash");
|
||||||
|
|
||||||
|
var hash = new Command("hash", "Create a salted hash (JSON with salt and hash)") {
|
||||||
|
pepperOption,
|
||||||
|
passwordOption
|
||||||
|
};
|
||||||
|
|
||||||
|
hash.SetAction(parseResult =>
|
||||||
|
CliActions.HashPassword(
|
||||||
|
parseResult.GetValue(pepperOption)!,
|
||||||
|
parseResult.GetValue(passwordOption)!
|
||||||
|
));
|
||||||
|
|
||||||
|
password.Subcommands.Add(hash);
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreateGuidCommand() {
|
||||||
|
var guid = new Command("guid", "COMB GUID generation");
|
||||||
|
var typeOption = new Option<string>("--type") {
|
||||||
|
Description = "PostgreSql or SqlServer",
|
||||||
|
DefaultValueFactory = _ => "PostgreSql"
|
||||||
|
};
|
||||||
|
|
||||||
|
var comb = new Command("comb", "Generate a COMB GUID") {
|
||||||
|
typeOption
|
||||||
|
};
|
||||||
|
|
||||||
|
comb.SetAction(parseResult =>
|
||||||
|
CliActions.GenerateCombGuid(parseResult.GetValue(typeOption)));
|
||||||
|
|
||||||
|
guid.Subcommands.Add(comb);
|
||||||
|
return guid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Option<int> BytesOption() =>
|
||||||
|
new("--bytes") {
|
||||||
|
Description = "Random key size in bytes",
|
||||||
|
DefaultValueFactory = _ => 32
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Option<string> RequiredString(string name, string description) =>
|
||||||
|
new(name) {
|
||||||
|
Description = description,
|
||||||
|
Required = true
|
||||||
|
};
|
||||||
|
}
|
||||||
76
src/MaksIT.Core.Cli/InputParsers.cs
Normal file
76
src/MaksIT.Core.Cli/InputParsers.cs
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
using MaksIT.Core.Comb;
|
||||||
|
|
||||||
|
|
||||||
|
namespace MaksIT.Core.Cli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses interactive menu input for the Core CLI.
|
||||||
|
/// </summary>
|
||||||
|
public static class InputParsers {
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a positive integer, using <paramref name="defaultValue"/> when input is blank.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryParsePositiveInt(
|
||||||
|
string? input,
|
||||||
|
int defaultValue,
|
||||||
|
out int value,
|
||||||
|
out string? errorMessage
|
||||||
|
) {
|
||||||
|
if (string.IsNullOrWhiteSpace(input)) {
|
||||||
|
value = defaultValue;
|
||||||
|
errorMessage = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!int.TryParse(input.Trim(), out value) || value <= 0) {
|
||||||
|
value = 0;
|
||||||
|
errorMessage = "Value must be a positive integer.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorMessage = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a COMB GUID type, defaulting to <see cref="CombGuidType.PostgreSql"/> when input is blank.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryParseCombGuidType(
|
||||||
|
string? input,
|
||||||
|
out CombGuidType type,
|
||||||
|
out string? errorMessage
|
||||||
|
) {
|
||||||
|
if (string.IsNullOrWhiteSpace(input)) {
|
||||||
|
type = CombGuidType.PostgreSql;
|
||||||
|
errorMessage = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Enum.TryParse(input.Trim(), ignoreCase: true, out type)
|
||||||
|
&& Enum.IsDefined(type)) {
|
||||||
|
errorMessage = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
type = default;
|
||||||
|
errorMessage = "Type must be PostgreSql or SqlServer.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Splits a comma-separated list; returns <c>null</c> when input is blank.
|
||||||
|
/// </summary>
|
||||||
|
public static List<string>? ParseOptionalList(string? input) {
|
||||||
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var items = input
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (items.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/MaksIT.Core.Cli/MaksIT.Core.Cli.csproj
Normal file
27
src/MaksIT.Core.Cli/MaksIT.Core.Cli.csproj
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
|
||||||
|
<Version>1.6.10</Version>
|
||||||
|
<Authors>Maksym Sadovnychyy</Authors>
|
||||||
|
<Company>MAKS-IT</Company>
|
||||||
|
<Product>MaksIT.Core.Cli</Product>
|
||||||
|
<Copyright>Copyright © Maksym Sadovnychyy (MAKS-IT)</Copyright>
|
||||||
|
<Description>Interactive and flag-based console toolkit for generating MaksIT.Core secrets (JWT, AES-GCM, TOTP, password hashes, COMB GUIDs).</Description>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MaksIT.Core\MaksIT.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="System.CommandLine" Version="2.0.11" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
9
src/MaksIT.Core.Cli/Program.cs
Normal file
9
src/MaksIT.Core.Cli/Program.cs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
namespace MaksIT.Core.Cli;
|
||||||
|
|
||||||
|
|
||||||
|
public static class Program {
|
||||||
|
public static int Main(string[] args) {
|
||||||
|
Console.OutputEncoding = System.Text.Encoding.UTF8;
|
||||||
|
return CommandFactory.CreateRootCommand().Parse(args).Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/MaksIT.Core.Cli/SecretOperations.cs
Normal file
131
src/MaksIT.Core.Cli/SecretOperations.cs
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using MaksIT.Core.Comb;
|
||||||
|
using MaksIT.Core.Security;
|
||||||
|
using MaksIT.Core.Security.JWT;
|
||||||
|
|
||||||
|
|
||||||
|
namespace MaksIT.Core.Cli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thin wrappers around MaksIT.Core secret and token helpers.
|
||||||
|
/// </summary>
|
||||||
|
public static class SecretOperations {
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a Base64 secret suitable for JWT signing or a password pepper.
|
||||||
|
/// </summary>
|
||||||
|
public static string GenerateSecret(int keySize = 32) =>
|
||||||
|
JwtGenerator.GenerateSecret(keySize);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates an opaque refresh token.
|
||||||
|
/// </summary>
|
||||||
|
public static string GenerateRefreshToken() =>
|
||||||
|
JwtGenerator.GenerateRefreshToken();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a Base64 AES-256 key.
|
||||||
|
/// </summary>
|
||||||
|
public static string GenerateAesKey() =>
|
||||||
|
AESGCMUtility.GenerateKeyBase64();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a COMB GUID for the given layout.
|
||||||
|
/// </summary>
|
||||||
|
public static Guid GenerateCombGuid(CombGuidType type) =>
|
||||||
|
CombGuidGenerator.CreateCombGuid(DateTime.UtcNow, type);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Signs an access JWT.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryGenerateJwt(
|
||||||
|
JWTTokenGenerateRequest request,
|
||||||
|
[NotNullWhen(true)] out string? token,
|
||||||
|
[NotNullWhen(false)] out string? errorMessage
|
||||||
|
) {
|
||||||
|
if (!JwtGenerator.TryGenerateToken(request, out var tokenData, out errorMessage)) {
|
||||||
|
token = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
token = tokenData.Value.Item1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates an access JWT and returns its claims.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryValidateJwt(
|
||||||
|
string secret,
|
||||||
|
string issuer,
|
||||||
|
string audience,
|
||||||
|
string token,
|
||||||
|
out JWTTokenClaims? claims,
|
||||||
|
[NotNullWhen(false)] out string? errorMessage
|
||||||
|
) =>
|
||||||
|
JwtGenerator.TryValidateToken(secret, issuer, audience, token, out claims, out errorMessage);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a Base32 TOTP shared secret.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryGenerateTotpSecret(
|
||||||
|
[NotNullWhen(true)] out string? secret,
|
||||||
|
[NotNullWhen(false)] out string? errorMessage
|
||||||
|
) =>
|
||||||
|
TotpGenerator.TryGenerateSecret(out secret, out errorMessage);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates TOTP recovery codes.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryGenerateRecoveryCodes(
|
||||||
|
int count,
|
||||||
|
[NotNullWhen(true)] out List<string>? codes,
|
||||||
|
[NotNullWhen(false)] out string? errorMessage
|
||||||
|
) =>
|
||||||
|
TotpGenerator.TryGenerateRecoveryCodes(count, out codes, out errorMessage);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an <c>otpauth://</c> URI for authenticator apps.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryGenerateTotpAuthLink(
|
||||||
|
string label,
|
||||||
|
string username,
|
||||||
|
string secret,
|
||||||
|
string issuer,
|
||||||
|
[NotNullWhen(true)] out string? authLink,
|
||||||
|
[NotNullWhen(false)] out string? errorMessage
|
||||||
|
) =>
|
||||||
|
TotpGenerator.TryGenerateTotpAuthLink(
|
||||||
|
label,
|
||||||
|
username,
|
||||||
|
secret,
|
||||||
|
issuer,
|
||||||
|
algorithm: null,
|
||||||
|
digits: null,
|
||||||
|
period: null,
|
||||||
|
out authLink,
|
||||||
|
out errorMessage
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a TOTP code against a Base32 secret.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryValidateTotp(
|
||||||
|
string totpCode,
|
||||||
|
string base32Secret,
|
||||||
|
int timeTolerance,
|
||||||
|
out bool isValid,
|
||||||
|
[NotNullWhen(false)] out string? errorMessage
|
||||||
|
) =>
|
||||||
|
TotpGenerator.TryValidate(totpCode, base32Secret, timeTolerance, out isValid, out errorMessage);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a salted password hash with the given pepper.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryHashPassword(
|
||||||
|
string password,
|
||||||
|
string pepper,
|
||||||
|
[NotNullWhen(true)] out (string Salt, string Hash)? saltedHash,
|
||||||
|
[NotNullWhen(false)] out string? errorMessage
|
||||||
|
) =>
|
||||||
|
PasswordHasher.TryCreateSaltedHash(password, pepper, out saltedHash, out errorMessage);
|
||||||
|
}
|
||||||
@ -7,20 +7,14 @@
|
|||||||
|
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<IsTestProject>true</IsTestProject>
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" Version="10.0.1">
|
<PackageReference Include="coverlet.MTP" Version="10.0.1" />
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
</PackageReference>
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.3.12" />
|
<PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.3.12" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
|
<PackageReference Include="xunit.v3" Version="4.0.0" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
</PackageReference>
|
|
||||||
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
|
<Project Path="MaksIT.Core.Cli.Tests/MaksIT.Core.Cli.Tests.csproj" />
|
||||||
|
<Project Path="MaksIT.Core.Cli/MaksIT.Core.Cli.csproj" />
|
||||||
<Project Path="MaksIT.Core.Tests/MaksIT.Core.Tests.csproj" />
|
<Project Path="MaksIT.Core.Tests/MaksIT.Core.Tests.csproj" />
|
||||||
<Project Path="MaksIT.Core/MaksIT.Core.csproj" />
|
<Project Path="MaksIT.Core/MaksIT.Core.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
<!-- NuGet package metadata -->
|
<!-- NuGet package metadata -->
|
||||||
<PackageId>MaksIT.Core</PackageId>
|
<PackageId>MaksIT.Core</PackageId>
|
||||||
<Version>1.6.9</Version>
|
<Version>1.6.10</Version>
|
||||||
<Authors>Maksym Sadovnychyy</Authors>
|
<Authors>Maksym Sadovnychyy</Authors>
|
||||||
<Company>MAKS-IT</Company>
|
<Company>MAKS-IT</Company>
|
||||||
<Product>MaksIT.Core</Product>
|
<Product>MaksIT.Core</Product>
|
||||||
|
|||||||
5
src/global.json
Normal file
5
src/global.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"test": {
|
||||||
|
"runner": "Microsoft.Testing.Platform"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,7 +15,10 @@
|
|||||||
"name": "DotNetTest",
|
"name": "DotNetTest",
|
||||||
"stageLabel": "test",
|
"stageLabel": "test",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"project": "..\\..\\..\\src\\MaksIT.Core.Tests",
|
"projects": [
|
||||||
|
"..\\..\\..\\src\\MaksIT.Core.Tests",
|
||||||
|
"..\\..\\..\\src\\MaksIT.Core.Cli.Tests"
|
||||||
|
],
|
||||||
"resultsDir": "..\\..\\..\\testResults"
|
"resultsDir": "..\\..\\..\\testResults"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -37,6 +40,15 @@
|
|||||||
],
|
],
|
||||||
"artifactsDir": "..\\..\\..\\releases"
|
"artifactsDir": "..\\..\\..\\releases"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "DotNetPublish",
|
||||||
|
"stageLabel": "build",
|
||||||
|
"enabled": true,
|
||||||
|
"projectFiles": [
|
||||||
|
"..\\..\\..\\src\\MaksIT.Core.Cli\\MaksIT.Core.Cli.csproj"
|
||||||
|
],
|
||||||
|
"artifactsDir": "..\\..\\..\\releases"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "DotNetCreateArchive",
|
"name": "DotNetCreateArchive",
|
||||||
"stageLabel": "build",
|
"stageLabel": "build",
|
||||||
|
|||||||
@ -8,7 +8,8 @@
|
|||||||
"stageLabel": "test",
|
"stageLabel": "test",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"projects": [
|
"projects": [
|
||||||
"..\\..\\..\\src\\MaksIT.Core.Tests"
|
"..\\..\\..\\src\\MaksIT.Core.Tests",
|
||||||
|
"..\\..\\..\\src\\MaksIT.Core.Cli.Tests"
|
||||||
],
|
],
|
||||||
"resultsDir": "..\\..\\..\\test-results"
|
"resultsDir": "..\\..\\..\\test-results"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -6,16 +6,65 @@
|
|||||||
Keep a Changelog header parsing and section extraction.
|
Keep a Changelog header parsing and section extraction.
|
||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Supports only the standard Keep a Changelog version line:
|
Supports Keep a Changelog version lines and shared SemVer checks, including prerelease:
|
||||||
## [1.0.0] - 2026-05-24
|
## [1.0.0] - 2026-05-24
|
||||||
|
## [0.1.0-alpha.1] - 2026-08-21
|
||||||
|
## [0.1.0-beta.1] - 2026-08-21
|
||||||
|
## [0.1.0-rc.1] - 2026-08-21
|
||||||
#>
|
#>
|
||||||
|
|
||||||
|
function Get-ChangelogSemverPattern {
|
||||||
|
return '\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-ReleaseSemver {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[AllowEmptyString()]
|
||||||
|
[string]$Version
|
||||||
|
)
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Version)) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
return [bool]($Version -match ('^' + (Get-ChangelogSemverPattern) + '$'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-ReleaseSemverPrerelease {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[AllowEmptyString()]
|
||||||
|
[string]$Version
|
||||||
|
)
|
||||||
|
|
||||||
|
return (Test-ReleaseSemver -Version $Version) -and ($Version -match '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ReleaseSemverPrereleaseLabel {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[AllowEmptyString()]
|
||||||
|
[string]$Version
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not (Test-ReleaseSemverPrerelease -Version $Version)) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Version -match '^\d+\.\d+\.\d+-([A-Za-z][0-9A-Za-z]*)') {
|
||||||
|
return $Matches[1].ToLowerInvariant()
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'next'
|
||||||
|
}
|
||||||
|
|
||||||
function Get-ChangelogVersionHeaderPattern {
|
function Get-ChangelogVersionHeaderPattern {
|
||||||
return '(?m)^##\s+\[(\d+\.\d+\.\d+)\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
|
return '(?m)^##\s+\[(' + (Get-ChangelogSemverPattern) + ')\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-ChangelogNextVersionHeaderPattern {
|
function Get-ChangelogNextVersionHeaderPattern {
|
||||||
return '(?m)^##\s+\[\d+\.\d+\.\d+\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
|
return '(?m)^##\s+\[' + (Get-ChangelogSemverPattern) + '\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-LatestChangelogVersion {
|
function Get-LatestChangelogVersion {
|
||||||
@ -53,4 +102,4 @@ function Get-ChangelogReleaseNotesSection {
|
|||||||
return $match.Value.Trim()
|
return $match.Value.Trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
Export-ModuleMember -Function Get-ChangelogVersionHeaderPattern, Get-ChangelogNextVersionHeaderPattern, Get-LatestChangelogVersion, Get-ChangelogReleaseNotesSection
|
Export-ModuleMember -Function Get-ChangelogSemverPattern, Test-ReleaseSemver, Test-ReleaseSemverPrerelease, Get-ReleaseSemverPrereleaseLabel, Get-ChangelogVersionHeaderPattern, Get-ChangelogNextVersionHeaderPattern, Get-LatestChangelogVersion, Get-ChangelogReleaseNotesSection
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Provides the Invoke-TestsWithCoverage function for running .NET tests
|
Provides the Invoke-TestsWithCoverage function for running .NET tests
|
||||||
with Coverlet code coverage collection and parsing results.
|
with Microsoft.Testing.Platform and coverlet.MTP code coverage.
|
||||||
|
|
||||||
.NOTES
|
.NOTES
|
||||||
Author: MaksIT
|
Author: MaksIT
|
||||||
@ -65,6 +65,20 @@ function Write-TestRunnerLogInternal {
|
|||||||
Write-Host $Message -ForegroundColor Gray
|
Write-Host $Message -ForegroundColor Gray
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Get-CoberturaCoverageFiles {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$ResultsDirectory
|
||||||
|
)
|
||||||
|
|
||||||
|
# coverlet.MTP: [prefix.]coverage.cobertura[.timestamp].xml
|
||||||
|
$files = @(
|
||||||
|
Get-ChildItem -Path $ResultsDirectory -Recurse -File -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.Name -like '*coverage.cobertura*.xml' }
|
||||||
|
)
|
||||||
|
return @($files | Sort-Object FullName -Unique)
|
||||||
|
}
|
||||||
|
|
||||||
function Invoke-TestsWithCoverage {
|
function Invoke-TestsWithCoverage {
|
||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
@ -155,7 +169,7 @@ function Invoke-TestsWithCoverage {
|
|||||||
New-Item -ItemType Directory -Path $ResultsDir -Force | Out-Null
|
New-Item -ItemType Directory -Path $ResultsDir -Force | Out-Null
|
||||||
|
|
||||||
if (-not $Silent) {
|
if (-not $Silent) {
|
||||||
Write-TestRunnerLogInternal -Level "STEP" -Message "Running tests with code coverage..."
|
Write-TestRunnerLogInternal -Level "STEP" -Message "Running tests with code coverage (Microsoft.Testing.Platform / coverlet.MTP)..."
|
||||||
foreach ($d in $resolvedProjectDirs) {
|
foreach ($d in $resolvedProjectDirs) {
|
||||||
Write-TestRunnerLogInternal -Level "INFO" -Message "Test Project: $d"
|
Write-TestRunnerLogInternal -Level "INFO" -Message "Test Project: $d"
|
||||||
}
|
}
|
||||||
@ -164,11 +178,15 @@ function Invoke-TestsWithCoverage {
|
|||||||
foreach ($TestProjectDir in $resolvedProjectDirs) {
|
foreach ($TestProjectDir in $resolvedProjectDirs) {
|
||||||
Push-Location $TestProjectDir
|
Push-Location $TestProjectDir
|
||||||
try {
|
try {
|
||||||
|
$projectName = [System.IO.Path]::GetFileName($TestProjectDir)
|
||||||
$dotnetArgs = @(
|
$dotnetArgs = @(
|
||||||
"test"
|
"test"
|
||||||
"--collect:XPlat Code Coverage"
|
|
||||||
"--results-directory", $ResultsDir
|
"--results-directory", $ResultsDir
|
||||||
"--verbosity", $(if ($Silent) { "quiet" } else { "normal" })
|
"--verbosity", $(if ($Silent) { "quiet" } else { "normal" })
|
||||||
|
"--coverlet"
|
||||||
|
"--coverlet-output-format", "cobertura"
|
||||||
|
"--coverlet-file-prefix", $projectName
|
||||||
|
"--coverlet-include", "[MaksIT.*]*"
|
||||||
)
|
)
|
||||||
|
|
||||||
Import-ExternalCommandSupportInternal
|
Import-ExternalCommandSupportInternal
|
||||||
@ -192,7 +210,7 @@ function Invoke-TestsWithCoverage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$coverageFiles = @(Get-ChildItem -Path $ResultsDir -Filter "coverage.cobertura.xml" -Recurse | Sort-Object FullName)
|
$coverageFiles = @(Get-CoberturaCoverageFiles -ResultsDirectory $ResultsDir)
|
||||||
|
|
||||||
if ($coverageFiles.Count -eq 0) {
|
if ($coverageFiles.Count -eq 0) {
|
||||||
return [PSCustomObject]@{
|
return [PSCustomObject]@{
|
||||||
@ -455,7 +473,7 @@ function Get-DotNetCoverageFromResultsDirectory {
|
|||||||
[switch]$Silent
|
[switch]$Silent
|
||||||
)
|
)
|
||||||
|
|
||||||
$coverageFiles = @(Get-ChildItem -Path $ResultsDirectory -Filter 'coverage.cobertura.xml' -Recurse -ErrorAction SilentlyContinue | Sort-Object FullName)
|
$coverageFiles = @(Get-CoberturaCoverageFiles -ResultsDirectory $ResultsDirectory)
|
||||||
if ($coverageFiles.Count -eq 0) {
|
if ($coverageFiles.Count -eq 0) {
|
||||||
return [PSCustomObject]@{
|
return [PSCustomObject]@{
|
||||||
Success = $false
|
Success = $false
|
||||||
@ -566,7 +584,7 @@ function Get-CoverageFromResultsDirectory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$hasDotNet = @(Get-ChildItem -Path $resolvedDirectory -Filter 'coverage.cobertura.xml' -Recurse -ErrorAction SilentlyContinue).Count -gt 0
|
$hasDotNet = @(Get-CoberturaCoverageFiles -ResultsDirectory $resolvedDirectory).Count -gt 0
|
||||||
$jestSummary = Join-Path $resolvedDirectory 'coverage-summary.json'
|
$jestSummary = Join-Path $resolvedDirectory 'coverage-summary.json'
|
||||||
$hasNpm = Test-Path -LiteralPath $jestSummary -PathType Leaf
|
$hasNpm = Test-Path -LiteralPath $jestSummary -PathType Leaf
|
||||||
|
|
||||||
|
|||||||
@ -6,9 +6,10 @@
|
|||||||
.NET publish plugin for producing application release artifacts.
|
.NET publish plugin for producing application release artifacts.
|
||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
This plugin publishes the configured .NET project into a release output
|
This plugin publishes configured .NET projects into the artifacts directory
|
||||||
directory and exposes that published directory to the shared release
|
and appends those publish folders to shared archive inputs so later plugins
|
||||||
context so later release-stage plugins can archive and publish it.
|
can zip them next to any earlier pack outputs. Existing NuGet package facts
|
||||||
|
(packageFile) are left unchanged.
|
||||||
#>
|
#>
|
||||||
|
|
||||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||||
@ -29,47 +30,83 @@ function Invoke-Plugin {
|
|||||||
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
|
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
|
||||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact"
|
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact"
|
||||||
|
|
||||||
|
$pluginSettings = $Settings
|
||||||
$sharedSettings = $Settings.context
|
$sharedSettings = $Settings.context
|
||||||
$projectFiles = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'projectFiles' -LegacyProperty @('projectFiles')
|
$scriptDir = $sharedSettings.scriptDir
|
||||||
$artifactsDirectory = $sharedSettings.artifactsDirectory
|
$projectFiles = @()
|
||||||
$publishProjectPath = $null
|
|
||||||
|
|
||||||
Assert-Command dotnet
|
Assert-Command dotnet
|
||||||
|
|
||||||
if ($null -eq $projectFiles -or @($projectFiles).Count -eq 0) {
|
if ($pluginSettings.PSObject.Properties['projectFiles'] -and $null -ne $pluginSettings.projectFiles) {
|
||||||
throw "DotNetPublish plugin requires project files in the shared context."
|
$projectFiles = @(Resolve-RelativePaths -Value $pluginSettings.projectFiles -BasePath $scriptDir)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$fromFact = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'projectFiles' -LegacyProperty @('projectFiles')
|
||||||
|
if ($null -ne $fromFact) {
|
||||||
|
$projectFiles = @($fromFact)
|
||||||
|
}
|
||||||
|
elseif ($sharedSettings.PSObject.Properties['projectFiles'] -and $null -ne $sharedSettings.projectFiles) {
|
||||||
|
$projectFiles = @($sharedSettings.projectFiles)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$projectFiles = @($projectFiles)
|
if ($projectFiles.Count -eq 0) {
|
||||||
|
throw "DotNetPublish plugin requires projectFiles in plugin settings or projectFiles on shared context."
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pluginSettings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.artifactsDir)) {
|
||||||
|
$artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$pluginSettings.artifactsDir)))
|
||||||
|
Set-EngineState -Context $sharedSettings -Name 'artifactsDirectory' -Value $artifactsDirectory
|
||||||
|
Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$artifactsDirectory = $sharedSettings.artifactsDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace([string]$artifactsDirectory)) {
|
||||||
|
throw "DotNetPublish plugin requires artifactsDir in plugin settings or artifactsDirectory on shared context."
|
||||||
|
}
|
||||||
|
|
||||||
if (!(Test-Path $artifactsDirectory)) {
|
if (!(Test-Path $artifactsDirectory)) {
|
||||||
New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null
|
New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null
|
||||||
}
|
}
|
||||||
|
|
||||||
# The first configured project remains the canonical release artifact source.
|
$existing = Get-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -LegacyProperty @('releaseArchiveInputs')
|
||||||
$publishProjectPath = $projectFiles[0]
|
$archiveInputs = [System.Collections.Generic.List[object]]::new()
|
||||||
$publishDir = Join-Path $artifactsDirectory ([System.IO.Path]::GetFileNameWithoutExtension($publishProjectPath))
|
if ($null -ne $existing) {
|
||||||
|
foreach ($item in @($existing)) {
|
||||||
if (Test-Path $publishDir) {
|
if ($null -ne $item) {
|
||||||
Remove-Item -Path $publishDir -Recurse -Force
|
$archiveInputs.Add($item)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Log -Level "STEP" -Message "Publishing release artifact..."
|
foreach ($publishProjectPath in $projectFiles) {
|
||||||
dotnet publish $publishProjectPath -c Release -o $publishDir --nologo
|
$publishDir = Join-Path $artifactsDirectory ([System.IO.Path]::GetFileNameWithoutExtension($publishProjectPath))
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
throw "dotnet publish failed for $publishProjectPath."
|
if (Test-Path $publishDir) {
|
||||||
|
Remove-Item -Path $publishDir -Recurse -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Log -Level "STEP" -Message "Publishing release artifact..."
|
||||||
|
$dotnetPublishArguments = @(
|
||||||
|
'publish', $publishProjectPath, '-c', 'Release', '-o', $publishDir, '--nologo'
|
||||||
|
)
|
||||||
|
& dotnet @dotnetPublishArguments
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "dotnet publish failed for $publishProjectPath."
|
||||||
|
}
|
||||||
|
|
||||||
|
$publishedItems = @(Get-ChildItem -Path $publishDir -Force -ErrorAction SilentlyContinue)
|
||||||
|
if ($publishedItems.Count -eq 0) {
|
||||||
|
throw "dotnet publish completed, but no files were produced in: $publishDir"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Log -Level "OK" -Message " Published artifact ready: $publishDir"
|
||||||
|
$archiveInputs.Add($publishDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
$publishedItems = @(Get-ChildItem -Path $publishDir -Force -ErrorAction SilentlyContinue)
|
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value @($archiveInputs) -Overwrite Replace -LegacyProperty 'releaseArchiveInputs'
|
||||||
if ($publishedItems.Count -eq 0) {
|
|
||||||
throw "dotnet publish completed, but no files were produced in: $publishDir"
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Log -Level "OK" -Message " Published artifact ready: $publishDir"
|
|
||||||
|
|
||||||
Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -Value $null -Overwrite Replace -LegacyProperty 'packageFile'
|
|
||||||
Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -Value $null -Overwrite Replace -LegacyProperty 'symbolsPackageFile'
|
|
||||||
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value @($publishDir) -Overwrite Replace -LegacyProperty 'releaseArchiveInputs'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Export-ModuleMember -Function Invoke-Plugin
|
Export-ModuleMember -Function Invoke-Plugin
|
||||||
|
|||||||
@ -7,9 +7,11 @@
|
|||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Dedicated version-loading plugin. Reads <Version> from the first configured
|
Dedicated version-loading plugin. Reads <Version> from the first configured
|
||||||
projectFiles entry and writes it (plus the resolved projectFiles) to the
|
projectFiles .csproj, or from the nearest Directory.Build.props when the
|
||||||
shared runtime context. Declares providesVersion = $true so the engine can
|
csproj omits it. Accepts SemVer prerelease (0.1.0-alpha.1 / beta / rc). Writes
|
||||||
discover it as the single release version source.
|
version plus the resolved projectFiles (csproj paths for later pack/publish)
|
||||||
|
to the shared runtime context. Declares providesVersion = $true so the engine
|
||||||
|
can discover it as the single release version source.
|
||||||
#>
|
#>
|
||||||
|
|
||||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||||
@ -20,6 +22,22 @@ if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ConvertTo-MsbuildPropertyStringInternal {
|
||||||
|
param(
|
||||||
|
$Value
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($null -eq $Value) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Value -is [System.Xml.XmlElement]) {
|
||||||
|
return [string]$Value.InnerText
|
||||||
|
}
|
||||||
|
|
||||||
|
return [string]$Value
|
||||||
|
}
|
||||||
|
|
||||||
function Get-CsprojPropertyValueInternal {
|
function Get-CsprojPropertyValueInternal {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
@ -36,7 +54,33 @@ function Get-CsprojPropertyValueInternal {
|
|||||||
Select-Object -First 1
|
Select-Object -First 1
|
||||||
|
|
||||||
if ($propNode) {
|
if ($propNode) {
|
||||||
return $propNode.$PropertyName
|
return ConvertTo-MsbuildPropertyStringInternal -Value $propNode.$PropertyName
|
||||||
|
}
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-DirectoryBuildPropsVersionInternal {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$ProjectPath
|
||||||
|
)
|
||||||
|
|
||||||
|
# MSBuild uses the first Directory.Build.props found walking up from the project directory.
|
||||||
|
$dir = [System.IO.Path]::GetDirectoryName((Resolve-Path -LiteralPath $ProjectPath))
|
||||||
|
while (-not [string]::IsNullOrWhiteSpace($dir)) {
|
||||||
|
$propsPath = Join-Path $dir 'Directory.Build.props'
|
||||||
|
if (Test-Path -LiteralPath $propsPath -PathType Leaf) {
|
||||||
|
[xml]$props = Get-Content -LiteralPath $propsPath
|
||||||
|
return Get-CsprojPropertyValueInternal -Csproj $props -PropertyName 'Version'
|
||||||
|
}
|
||||||
|
|
||||||
|
$parent = [System.IO.Directory]::GetParent($dir)
|
||||||
|
if ($null -eq $parent) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
$dir = $parent.FullName
|
||||||
}
|
}
|
||||||
|
|
||||||
return $null
|
return $null
|
||||||
@ -58,12 +102,16 @@ function Get-CsprojVersionInternal {
|
|||||||
|
|
||||||
[xml]$csproj = Get-Content $ProjectPath
|
[xml]$csproj = Get-Content $ProjectPath
|
||||||
$version = Get-CsprojPropertyValueInternal -Csproj $csproj -PropertyName "Version"
|
$version = Get-CsprojPropertyValueInternal -Csproj $csproj -PropertyName "Version"
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace([string]$version)) {
|
||||||
if ([string]::IsNullOrWhiteSpace([string]$version)) {
|
return [string]$version
|
||||||
throw "DotNetReleaseVersion: <Version> not found in '$ProjectPath'."
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [string]$version
|
$version = Get-DirectoryBuildPropsVersionInternal -ProjectPath $ProjectPath
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace([string]$version)) {
|
||||||
|
return [string]$version
|
||||||
|
}
|
||||||
|
|
||||||
|
throw "DotNetReleaseVersion: <Version> not found in '$ProjectPath' or a parent Directory.Build.props."
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-PluginMetadata {
|
function Get-PluginMetadata {
|
||||||
@ -87,6 +135,10 @@ function Invoke-Plugin {
|
|||||||
|
|
||||||
Write-Log -Level "INFO" -Message "Reading version from SDK-style project file (projectFiles)..."
|
Write-Log -Level "INFO" -Message "Reading version from SDK-style project file (projectFiles)..."
|
||||||
$version = Get-CsprojVersionInternal -ProjectPath $projectFiles[0]
|
$version = Get-CsprojVersionInternal -ProjectPath $projectFiles[0]
|
||||||
|
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
|
||||||
|
if (-not (Test-ReleaseSemver -Version $version)) {
|
||||||
|
throw "DotNetReleaseVersion: version '$version' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)."
|
||||||
|
}
|
||||||
|
|
||||||
Set-EngineState -Context $shared -Name 'version' -Value $version
|
Set-EngineState -Context $shared -Name 'version' -Value $version
|
||||||
Set-EngineFact -Context $shared -Namespace 'dotnet' -Name 'projectFiles' -Value $projectFiles -Overwrite Replace -LegacyProperty 'projectFiles'
|
Set-EngineFact -Context $shared -Namespace 'dotnet' -Name 'projectFiles' -Value $projectFiles -Overwrite Replace -LegacyProperty 'projectFiles'
|
||||||
|
|||||||
@ -7,12 +7,15 @@
|
|||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Resolves one or more .NET test projects (`project` or `projects`), runs tests once
|
Resolves one or more .NET test projects (`project` or `projects`), runs tests once
|
||||||
via TestRunner, then publishes metrics on the shared engine context for any later
|
via TestRunner (Microsoft Testing Platform + coverlet.MTP only — no VSTest /
|
||||||
|
coverlet.collector), then publishes metrics on the shared engine context for any later
|
||||||
plugin: `qualityLineCoverage`, `testResult`, `coverageLineRate` / `coverageBranchRate` / `coverageMethodRate`,
|
plugin: `qualityLineCoverage`, `testResult`, `coverageLineRate` / `coverageBranchRate` / `coverageMethodRate`,
|
||||||
method counts, `testResultsDirectory`, `coverageCoberturaPaths`. Quality gates read
|
method counts, `testResultsDirectory`, `coverageCoberturaPaths`. Quality gates read
|
||||||
those keys generically (not tied to this plugin by name). When `resultsDir` (or the
|
those keys generically (not tied to this plugin by name). When `resultsDir` (or the
|
||||||
multi-project default TestResults folder) is used, Cobertura output is kept on disk
|
multi-project default TestResults folder) is used, Cobertura output is kept on disk
|
||||||
via TestRunner `-KeepResults` so repo-root `test-results/` persists after the run.
|
via TestRunner `-KeepResults` so repo-root `test-results/` persists after the run.
|
||||||
|
Product solutions must place `src/global.json` with `test.runner` =
|
||||||
|
Microsoft.Testing.Platform next to the `.sln`/`.slnx`.
|
||||||
#>
|
#>
|
||||||
|
|
||||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||||
|
|||||||
@ -28,6 +28,7 @@ function Invoke-Plugin {
|
|||||||
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
||||||
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
|
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
|
||||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
|
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
|
||||||
|
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-ReleaseSemverPrereleaseLabel"
|
||||||
|
|
||||||
$pluginSettings = $Settings
|
$pluginSettings = $Settings
|
||||||
$shared = $Settings.context
|
$shared = $Settings.context
|
||||||
@ -62,6 +63,14 @@ function Invoke-Plugin {
|
|||||||
[string]$pluginSettings.access
|
[string]$pluginSettings.access
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$npmDistTag = $null
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.npmDistTag)) {
|
||||||
|
$npmDistTag = [string]$pluginSettings.npmDistTag
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$npmDistTag = Get-ReleaseSemverPrereleaseLabel -Version ([string]$shared.version)
|
||||||
|
}
|
||||||
|
|
||||||
$publishOrder = @()
|
$publishOrder = @()
|
||||||
if ($pluginSettings.publishOrder) {
|
if ($pluginSettings.publishOrder) {
|
||||||
if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) {
|
if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) {
|
||||||
@ -84,7 +93,8 @@ function Invoke-Plugin {
|
|||||||
|
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
foreach ($packageName in $publishOrder) {
|
foreach ($packageName in $publishOrder) {
|
||||||
Write-Log -Level "INFO" -Message "Dry run: would publish npm package '$packageName' to $registry"
|
$tagNote = if ([string]::IsNullOrWhiteSpace($npmDistTag)) { 'latest' } else { $npmDistTag }
|
||||||
|
Write-Log -Level "INFO" -Message "Dry run: would publish npm package '$packageName' to $registry (dist-tag $tagNote)"
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -112,13 +122,20 @@ registry=$registry
|
|||||||
|
|
||||||
foreach ($packageName in $publishOrder) {
|
foreach ($packageName in $publishOrder) {
|
||||||
Write-Log -Level "STEP" -Message "Publishing npm package '$packageName'..."
|
Write-Log -Level "STEP" -Message "Publishing npm package '$packageName'..."
|
||||||
|
$publishArgs = @('publish')
|
||||||
if ($useWorkspaces) {
|
if ($useWorkspaces) {
|
||||||
npm publish -w $packageName --access $access --userconfig $tempNpmRcPath
|
$publishArgs += @('-w', $packageName)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName
|
Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName
|
||||||
npm publish --access $access --userconfig $tempNpmRcPath
|
|
||||||
}
|
}
|
||||||
|
$publishArgs += @('--access', $access, '--userconfig', $tempNpmRcPath)
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($npmDistTag)) {
|
||||||
|
$publishArgs += @('--tag', $npmDistTag)
|
||||||
|
Write-Log -Level "INFO" -Message " Using npm dist-tag '$npmDistTag' (prerelease)."
|
||||||
|
}
|
||||||
|
|
||||||
|
npm @publishArgs
|
||||||
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
throw "Failed to publish npm package '$packageName'."
|
throw "Failed to publish npm package '$packageName'."
|
||||||
|
|||||||
@ -35,8 +35,9 @@ function Get-PackageJsonVersionInternal {
|
|||||||
throw "NpmReleaseVersion: 'version' is missing in '$PackageJsonPath'."
|
throw "NpmReleaseVersion: 'version' is missing in '$PackageJsonPath'."
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($version -notmatch '^\d+\.\d+\.\d+') {
|
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
|
||||||
throw "NpmReleaseVersion: version '$version' in '$PackageJsonPath' is not a valid semver."
|
if (-not (Test-ReleaseSemver -Version $version)) {
|
||||||
|
throw "NpmReleaseVersion: version '$version' in '$PackageJsonPath' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)."
|
||||||
}
|
}
|
||||||
|
|
||||||
return $version
|
return $version
|
||||||
@ -69,6 +70,7 @@ function Invoke-Plugin {
|
|||||||
|
|
||||||
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
||||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState"
|
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState"
|
||||||
|
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
|
||||||
|
|
||||||
$pluginSettings = $Settings
|
$pluginSettings = $Settings
|
||||||
$shared = $Settings.context
|
$shared = $Settings.context
|
||||||
|
|||||||
@ -7,9 +7,10 @@
|
|||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Reads a single-line semver from the configured versionFilePath (default
|
Reads a single-line semver from the configured versionFilePath (default
|
||||||
repo-root VERSION). Useful for repositories without .csproj or package.json
|
repo-root VERSION), including optional prerelease (0.1.0-alpha.1). Useful for
|
||||||
version metadata. Declares providesVersion = $true so the engine can
|
repositories without .csproj or package.json version metadata. Declares
|
||||||
discover it as the single release version source.
|
providesVersion = $true so the engine can discover it as the single release
|
||||||
|
version source.
|
||||||
#>
|
#>
|
||||||
|
|
||||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||||
@ -36,8 +37,9 @@ function Get-VersionFileSemverInternal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$version = $version -replace '^[vV]', ''
|
$version = $version -replace '^[vV]', ''
|
||||||
if ($version -notmatch '^\d+\.\d+\.\d+') {
|
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
|
||||||
throw "FileReleaseVersion: version '$version' in '$VersionFilePath' is not a valid semver."
|
if (-not (Test-ReleaseSemver -Version $version)) {
|
||||||
|
throw "FileReleaseVersion: version '$version' in '$VersionFilePath' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)."
|
||||||
}
|
}
|
||||||
|
|
||||||
return $version
|
return $version
|
||||||
@ -55,6 +57,7 @@ function Invoke-Plugin {
|
|||||||
|
|
||||||
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
||||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState"
|
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState"
|
||||||
|
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
|
||||||
|
|
||||||
$shared = $Settings.context
|
$shared = $Settings.context
|
||||||
$versionFileSetting = if ($Settings.versionFilePath) {
|
$versionFileSetting = if ($Settings.versionFilePath) {
|
||||||
|
|||||||
@ -10,7 +10,8 @@
|
|||||||
repository, and creates the configured GitHub release using the
|
repository, and creates the configured GitHub release using the
|
||||||
shared release artifacts and release notes from CHANGELOG.md.
|
shared release artifacts and release notes from CHANGELOG.md.
|
||||||
Release notes must use Keep a Changelog headers: ## [semver] - YYYY-MM-DD
|
Release notes must use Keep a Changelog headers: ## [semver] - YYYY-MM-DD
|
||||||
(see ChangelogSupport.psm1).
|
(including optional SemVer prerelease, e.g. ## [0.1.0-alpha.1] / [0.1.0-beta.1] / [0.1.0-rc.1];
|
||||||
|
see ChangelogSupport.psm1). Hyphenated versions are created with gh --prerelease.
|
||||||
#>
|
#>
|
||||||
|
|
||||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||||
@ -95,6 +96,7 @@ function Invoke-Plugin {
|
|||||||
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
||||||
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
|
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
|
||||||
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-LatestChangelogVersion"
|
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-LatestChangelogVersion"
|
||||||
|
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemverPrerelease"
|
||||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Get-EngineFact"
|
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Get-EngineFact"
|
||||||
|
|
||||||
$pluginSettings = $Settings
|
$pluginSettings = $Settings
|
||||||
@ -128,6 +130,9 @@ function Invoke-Plugin {
|
|||||||
}
|
}
|
||||||
$releaseName = $releaseTitlePattern -replace '\{version\}', $version
|
$releaseName = $releaseTitlePattern -replace '\{version\}', $version
|
||||||
Write-Log -Level "INFO" -Message "Dry run: would create GitHub release '$releaseName' ($tag) on $repo"
|
Write-Log -Level "INFO" -Message "Dry run: would create GitHub release '$releaseName' ($tag) on $repo"
|
||||||
|
if (Test-ReleaseSemverPrerelease -Version ([string]$version)) {
|
||||||
|
Write-Log -Level "INFO" -Message "Dry run: release would be marked prerelease."
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -261,6 +266,10 @@ function Invoke-Plugin {
|
|||||||
"--title", $releaseName,
|
"--title", $releaseName,
|
||||||
"--notes-file", $notesFilePath
|
"--notes-file", $notesFilePath
|
||||||
)
|
)
|
||||||
|
if (Test-ReleaseSemverPrerelease -Version ([string]$version)) {
|
||||||
|
$createReleaseArgs += '--prerelease'
|
||||||
|
}
|
||||||
|
|
||||||
& gh @createReleaseArgs
|
& gh @createReleaseArgs
|
||||||
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
|||||||
@ -11,8 +11,9 @@
|
|||||||
when they do not (whenRequirementsNotMet: skip). Publish plugins no longer use per-plugin
|
when they do not (whenRequirementsNotMet: skip). Publish plugins no longer use per-plugin
|
||||||
branch lists; put allowed branches here instead.
|
branch lists; put allowed branches here instead.
|
||||||
|
|
||||||
Typical checks: allowed branches, optional clean working tree, exact semver tag on HEAD,
|
Typical checks: allowed branches, optional clean working tree, exact semver tag on HEAD
|
||||||
tag version vs DotNetReleaseVersion, optional push tag to remote.
|
(vX.Y.Z or vX.Y.Z-prerelease such as v0.1.0-alpha.1 / v0.1.0-beta.1 / v0.1.0-rc.1),
|
||||||
|
tag version vs release version, optional push tag to remote.
|
||||||
|
|
||||||
The engine preflight no longer reads git tags; this plugin sets context.tag from the
|
The engine preflight no longer reads git tags; this plugin sets context.tag from the
|
||||||
git tag on HEAD when required. Shared context version always remains from DotNetReleaseVersion.
|
git tag on HEAD when required. Shared context version always remains from DotNetReleaseVersion.
|
||||||
@ -77,6 +78,7 @@ function Invoke-Plugin {
|
|||||||
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Get-GitStatusShort"
|
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Get-GitStatusShort"
|
||||||
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Test-RemoteTagExists"
|
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Test-RemoteTagExists"
|
||||||
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Push-TagToRemote"
|
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Push-TagToRemote"
|
||||||
|
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-ChangelogSemverPattern"
|
||||||
|
|
||||||
$pluginSettings = $Settings
|
$pluginSettings = $Settings
|
||||||
$shared = $Settings.context
|
$shared = $Settings.context
|
||||||
@ -123,8 +125,9 @@ function Invoke-Plugin {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') {
|
$tagPattern = '^v(' + (Get-ChangelogSemverPattern) + ')$'
|
||||||
Invoke-NotMetInternal -Shared $shared -When $when -Reason "tag '$tag' must match vX.Y.Z."
|
if ($tag -notmatch $tagPattern) {
|
||||||
|
Invoke-NotMetInternal -Shared $shared -When $when -Reason "tag '$tag' must match vX.Y.Z or vX.Y.Z-prerelease (e.g. v0.1.0-alpha.1, v0.1.0-beta.1, v0.1.0-rc.1)."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user