(feature): add secrets cli and migrate tests to xunit v3

This commit is contained in:
Maksym Sadovnychyy 2026-08-21 13:16:07 +02:00
parent 434b569b3a
commit f79aa8f3c2
31 changed files with 2037 additions and 288 deletions

View File

@ -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/),
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
### Changed

View File

@ -26,11 +26,25 @@ dotnet build MaksIT.Core.slnx
### 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
cd src
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
This project uses the following commit message format:
@ -119,8 +133,8 @@ Orchestration lives in **`utils/`** (from [maksit-repoutils](https://github.com/
### Workflow
1. Bump `<Version>` in `src/MaksIT.Core/MaksIT.Core.csproj` and **CHANGELOG.md**
2. Commit, tag `vX.Y.Z` on `main`
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 `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`
Dry-run: `pwsh -File utils\engines\release\Invoke-ReleasePackage.ps1 -DryRun`

701
README.md
View File

@ -1,11 +1,30 @@
# MaksIT.Core Library Documentation
![Line Coverage](https://img.shields.io/badge/Line%20Coverage-60.1%25-green)
![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-49.9%25-yellowgreen)
![Method Coverage](https://img.shields.io/badge/Method%20Coverage-69.2%25-green)
![Line Coverage](https://img.shields.io/badge/Line%20Coverage-38.5%25-yellow)
![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-28.4%25-yellow)
![Method Coverage](https://img.shields.io/badge/Method%20Coverage-37.9%25-yellow)
**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
- [CLI (secrets toolkit)](#cli-secrets-toolkit)
- [Abstractions](#abstractions)
- [Base Classes](#base-classes)
- [Enumeration](#enumeration)
@ -14,12 +33,15 @@
- [DateTime Extensions](#datetime-extensions)
- [String Extensions](#string-extensions)
- [Object Extensions](#object-extensions)
- [Exception Extensions](#exception-extensions)
- [Formats Extensions](#formats-extensions)
- [DataTable Extensions](#datatable-extensions)
- [Guid Extensions](#guid-extensions)
- [Enum Extensions](#enum-extensions)
- [Logging](#logging)
- [File Logger](#file-logger)
- [JSON File Logger](#json-file-logger)
- [Console Loggers](#console-loggers)
- [Logger Prefix](#logger-prefix)
- [Threading](#threading)
- [Lock Manager](#lock-manager)
@ -29,17 +51,20 @@
- [Security](#security)
- [AES-GCM Utility](#aes-gcm-utility)
- [Base32 Encoder](#base32-encoder)
- [Base64Url Utility](#base64url-utility)
- [Checksum Utility](#checksum-utility)
- [Password Hasher](#password-hasher)
- [JWT Generator](#jwt-generator)
- [JWK Generator](#jwk-generator)
- [JWK Thumbprint Utility](#jwk-thumbprint-utility)
- [JWS Generator](#jws-generator)
- [JWK Thumbprint Utility](#jwk-thumbprint-utility)
- [TOTP Generator](#totp-generator)
- [Web API](#web-api)
- [Paged Request](#paged-request)
- [Paged Response](#paged-response)
- [Patch Operation](#patch-operation)
- [Error Handling Middleware](#error-handling-middleware)
- [Trace ID Logging Scope Middleware](#trace-id-logging-scope-middleware)
- [Sagas](#sagas)
- [CombGuidGenerator](#combguidgenerator)
- [Others](#others)
@ -48,11 +73,60 @@
- [File System](#file-system)
- [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
### 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
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
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.
---
#### 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
### 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.
@ -427,13 +428,13 @@ The `ExpressionExtensions` class provides utility methods for combining and mani
#### Features
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**:
- Negate an expression using the `Not` method.
- Negate a predicate with `Not`.
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;
var combined = isEven.AndAlso(isPositive);
var either = isEven.OrElse(isPositive);
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.
@ -466,13 +468,13 @@ The `DateTimeExtensions` class provides methods for manipulating and querying `D
#### Features
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**:
- 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**:
- 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
```csharp
public sealed class NoHolidays : IHolidayCalendar {
public bool Contains(DateTime date) => false;
}
DateTime today = DateTime.Today;
DateTime futureDate = today.AddWorkdays(5);
DateTime futureDate = today.AddWorkdays(5, new NoHolidays());
```
##### 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.
@ -501,13 +507,16 @@ The `StringExtensions` class provides a wide range of methods for string manipul
#### Features
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**:
- Extract substrings from the left, right, or middle of a string.
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"
```
##### 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.
@ -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.
@ -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.
@ -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
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
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
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
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();
}
```
@ -915,6 +1043,7 @@ The `AESGCMUtility` class provides methods for encrypting and decrypting data us
```csharp
var key = AESGCMUtility.GenerateKeyBase64();
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
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
1. **Checksum Calculation**:
- Calculate CRC32 checksums for data.
- Calculate CRC32 checksums for in-memory data, files, or files in chunks.
2. **Checksum Verification**:
- Verify data integrity using CRC32 checksums.
@ -965,6 +1119,7 @@ The `ChecksumUtility` class provides methods for calculating and verifying CRC32
##### Calculating a Checksum
```csharp
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
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
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**:
- 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
```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
- Only supports RSA public keys.
- 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.
---
@ -1437,7 +1613,7 @@ var response = new PagedResponse<UserDto>(items, totalCount, pageNumber, pageSiz
### 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
@ -1452,28 +1628,166 @@ The `PatchOperation` enum in the `MaksIT.Core.Webapi.Models` namespace defines o
```csharp
public class UserPatchRequest : PatchRequestModelBase {
public PatchOperation Operation { get; set; }
public string PropertyName { get; set; }
public object? Value { get; set; }
public string? Name { get; set; }
public List<string>? Roles { get; set; }
}
// Example: Set a field
var patch = new UserPatchRequest {
Operation = PatchOperation.SetField,
PropertyName = "Name",
Value = "New Name"
Name = "New Name",
Operations = new Dictionary<string, PatchOperation> {
["Name"] = PatchOperation.SetField,
["Roles"] = PatchOperation.AddToCollection
}
};
// Example: Add to collection
var patch = new UserPatchRequest {
Operation = PatchOperation.AddToCollection,
PropertyName = "Roles",
Value = "Admin"
};
if (patch.TryGetOperation(nameof(UserPatchRequest.Name), out var operation)) {
// operation == PatchOperation.SetField
}
```
---
### 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
### Culture
@ -1519,6 +1833,8 @@ The `EnvVar` class provides methods for managing environment variables.
##### Adding to PATH
```csharp
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
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**:
- 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.
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
If you have any questions or need further assistance, feel free to reach out:

View 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());
}

View 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);
}
}

View 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);
}
}

View 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>

View 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);
}
}

View 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();
}
}

View 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();
}

View 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
};
}

View 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;
}
}

View 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>

View 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();
}
}

View 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);
}

View File

@ -7,20 +7,14 @@
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<OutputType>Exe</OutputType>
<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.MTP" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.3.12" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.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" />
<PackageReference Include="xunit.v3" Version="4.0.0" />
</ItemGroup>
<ItemGroup>

View File

@ -1,4 +1,6 @@
<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/MaksIT.Core.csproj" />
</Solution>

View File

@ -12,7 +12,7 @@
<!-- NuGet package metadata -->
<PackageId>MaksIT.Core</PackageId>
<Version>1.6.9</Version>
<Version>1.6.10</Version>
<Authors>Maksym Sadovnychyy</Authors>
<Company>MAKS-IT</Company>
<Product>MaksIT.Core</Product>

5
src/global.json Normal file
View File

@ -0,0 +1,5 @@
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}

View File

@ -15,7 +15,10 @@
"name": "DotNetTest",
"stageLabel": "test",
"enabled": true,
"project": "..\\..\\..\\src\\MaksIT.Core.Tests",
"projects": [
"..\\..\\..\\src\\MaksIT.Core.Tests",
"..\\..\\..\\src\\MaksIT.Core.Cli.Tests"
],
"resultsDir": "..\\..\\..\\testResults"
},
{
@ -37,6 +40,15 @@
],
"artifactsDir": "..\\..\\..\\releases"
},
{
"name": "DotNetPublish",
"stageLabel": "build",
"enabled": true,
"projectFiles": [
"..\\..\\..\\src\\MaksIT.Core.Cli\\MaksIT.Core.Cli.csproj"
],
"artifactsDir": "..\\..\\..\\releases"
},
{
"name": "DotNetCreateArchive",
"stageLabel": "build",

View File

@ -8,7 +8,8 @@
"stageLabel": "test",
"enabled": true,
"projects": [
"..\\..\\..\\src\\MaksIT.Core.Tests"
"..\\..\\..\\src\\MaksIT.Core.Tests",
"..\\..\\..\\src\\MaksIT.Core.Cli.Tests"
],
"resultsDir": "..\\..\\..\\test-results"
},

View File

@ -6,16 +6,65 @@
Keep a Changelog header parsing and section extraction.
.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
## [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 {
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 {
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 {
@ -53,4 +102,4 @@ function Get-ChangelogReleaseNotesSection {
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

View File

@ -7,7 +7,7 @@
.DESCRIPTION
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
Author: MaksIT
@ -65,6 +65,20 @@ function Write-TestRunnerLogInternal {
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 {
<#
.SYNOPSIS
@ -155,7 +169,7 @@ function Invoke-TestsWithCoverage {
New-Item -ItemType Directory -Path $ResultsDir -Force | Out-Null
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) {
Write-TestRunnerLogInternal -Level "INFO" -Message "Test Project: $d"
}
@ -164,11 +178,15 @@ function Invoke-TestsWithCoverage {
foreach ($TestProjectDir in $resolvedProjectDirs) {
Push-Location $TestProjectDir
try {
$projectName = [System.IO.Path]::GetFileName($TestProjectDir)
$dotnetArgs = @(
"test"
"--collect:XPlat Code Coverage"
"--results-directory", $ResultsDir
"--verbosity", $(if ($Silent) { "quiet" } else { "normal" })
"--coverlet"
"--coverlet-output-format", "cobertura"
"--coverlet-file-prefix", $projectName
"--coverlet-include", "[MaksIT.*]*"
)
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) {
return [PSCustomObject]@{
@ -455,7 +473,7 @@ function Get-DotNetCoverageFromResultsDirectory {
[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) {
return [PSCustomObject]@{
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'
$hasNpm = Test-Path -LiteralPath $jestSummary -PathType Leaf

View File

@ -6,9 +6,10 @@
.NET publish plugin for producing application release artifacts.
.DESCRIPTION
This plugin publishes the configured .NET project into a release output
directory and exposes that published directory to the shared release
context so later release-stage plugins can archive and publish it.
This plugin publishes configured .NET projects into the artifacts directory
and appends those publish folders to shared archive inputs so later plugins
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)) {
@ -29,47 +30,83 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact"
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$projectFiles = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'projectFiles' -LegacyProperty @('projectFiles')
$artifactsDirectory = $sharedSettings.artifactsDirectory
$publishProjectPath = $null
$scriptDir = $sharedSettings.scriptDir
$projectFiles = @()
Assert-Command dotnet
if ($null -eq $projectFiles -or @($projectFiles).Count -eq 0) {
throw "DotNetPublish plugin requires project files in the shared context."
if ($pluginSettings.PSObject.Properties['projectFiles'] -and $null -ne $pluginSettings.projectFiles) {
$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)) {
New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null
}
# The first configured project remains the canonical release artifact source.
$publishProjectPath = $projectFiles[0]
$publishDir = Join-Path $artifactsDirectory ([System.IO.Path]::GetFileNameWithoutExtension($publishProjectPath))
if (Test-Path $publishDir) {
Remove-Item -Path $publishDir -Recurse -Force
$existing = Get-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -LegacyProperty @('releaseArchiveInputs')
$archiveInputs = [System.Collections.Generic.List[object]]::new()
if ($null -ne $existing) {
foreach ($item in @($existing)) {
if ($null -ne $item) {
$archiveInputs.Add($item)
}
}
}
Write-Log -Level "STEP" -Message "Publishing release artifact..."
dotnet publish $publishProjectPath -c Release -o $publishDir --nologo
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed for $publishProjectPath."
foreach ($publishProjectPath in $projectFiles) {
$publishDir = Join-Path $artifactsDirectory ([System.IO.Path]::GetFileNameWithoutExtension($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)
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'
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value @($archiveInputs) -Overwrite Replace -LegacyProperty 'releaseArchiveInputs'
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -7,9 +7,11 @@
.DESCRIPTION
Dedicated version-loading plugin. Reads <Version> from the first configured
projectFiles entry and writes it (plus the resolved projectFiles) to the
shared runtime context. Declares providesVersion = $true so the engine can
discover it as the single release version source.
projectFiles .csproj, or from the nearest Directory.Build.props when the
csproj omits it. Accepts SemVer prerelease (0.1.0-alpha.1 / beta / rc). Writes
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)) {
@ -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 {
param(
[Parameter(Mandatory = $true)]
@ -36,7 +54,33 @@ function Get-CsprojPropertyValueInternal {
Select-Object -First 1
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
@ -58,12 +102,16 @@ function Get-CsprojVersionInternal {
[xml]$csproj = Get-Content $ProjectPath
$version = Get-CsprojPropertyValueInternal -Csproj $csproj -PropertyName "Version"
if ([string]::IsNullOrWhiteSpace([string]$version)) {
throw "DotNetReleaseVersion: <Version> not found in '$ProjectPath'."
if (-not [string]::IsNullOrWhiteSpace([string]$version)) {
return [string]$version
}
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 {
@ -87,6 +135,10 @@ function Invoke-Plugin {
Write-Log -Level "INFO" -Message "Reading version from SDK-style project file (projectFiles)..."
$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-EngineFact -Context $shared -Namespace 'dotnet' -Name 'projectFiles' -Value $projectFiles -Overwrite Replace -LegacyProperty 'projectFiles'

View File

@ -7,12 +7,15 @@
.DESCRIPTION
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`,
method counts, `testResultsDirectory`, `coverageCoberturaPaths`. Quality gates read
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
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)) {

View File

@ -28,6 +28,7 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-ReleaseSemverPrereleaseLabel"
$pluginSettings = $Settings
$shared = $Settings.context
@ -62,6 +63,14 @@ function Invoke-Plugin {
[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 = @()
if ($pluginSettings.publishOrder) {
if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) {
@ -84,7 +93,8 @@ function Invoke-Plugin {
if ($dryRun) {
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
}
@ -112,13 +122,20 @@ registry=$registry
foreach ($packageName in $publishOrder) {
Write-Log -Level "STEP" -Message "Publishing npm package '$packageName'..."
$publishArgs = @('publish')
if ($useWorkspaces) {
npm publish -w $packageName --access $access --userconfig $tempNpmRcPath
$publishArgs += @('-w', $packageName)
}
else {
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) {
throw "Failed to publish npm package '$packageName'."

View File

@ -35,8 +35,9 @@ function Get-PackageJsonVersionInternal {
throw "NpmReleaseVersion: 'version' is missing in '$PackageJsonPath'."
}
if ($version -notmatch '^\d+\.\d+\.\d+') {
throw "NpmReleaseVersion: version '$version' in '$PackageJsonPath' is not a valid semver."
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
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
@ -69,6 +70,7 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
$pluginSettings = $Settings
$shared = $Settings.context

View File

@ -7,9 +7,10 @@
.DESCRIPTION
Reads a single-line semver from the configured versionFilePath (default
repo-root VERSION). Useful for repositories without .csproj or package.json
version metadata. Declares providesVersion = $true so the engine can
discover it as the single release version source.
repo-root VERSION), including optional prerelease (0.1.0-alpha.1). Useful for
repositories without .csproj or package.json version metadata. Declares
providesVersion = $true so the engine can discover it as the single release
version source.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
@ -36,8 +37,9 @@ function Get-VersionFileSemverInternal {
}
$version = $version -replace '^[vV]', ''
if ($version -notmatch '^\d+\.\d+\.\d+') {
throw "FileReleaseVersion: version '$version' in '$VersionFilePath' is not a valid semver."
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
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
@ -55,6 +57,7 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
$shared = $Settings.context
$versionFileSetting = if ($Settings.versionFilePath) {

View File

@ -10,7 +10,8 @@
repository, and creates the configured GitHub release using the
shared release artifacts and release notes from CHANGELOG.md.
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)) {
@ -95,6 +96,7 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-LatestChangelogVersion"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemverPrerelease"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Get-EngineFact"
$pluginSettings = $Settings
@ -128,6 +130,9 @@ function Invoke-Plugin {
}
$releaseName = $releaseTitlePattern -replace '\{version\}', $version
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
}
@ -261,6 +266,10 @@ function Invoke-Plugin {
"--title", $releaseName,
"--notes-file", $notesFilePath
)
if (Test-ReleaseSemverPrerelease -Version ([string]$version)) {
$createReleaseArgs += '--prerelease'
}
& gh @createReleaseArgs
if ($LASTEXITCODE -ne 0) {

View File

@ -11,8 +11,9 @@
when they do not (whenRequirementsNotMet: skip). Publish plugins no longer use per-plugin
branch lists; put allowed branches here instead.
Typical checks: allowed branches, optional clean working tree, exact semver tag on HEAD,
tag version vs DotNetReleaseVersion, optional push tag to remote.
Typical checks: allowed branches, optional clean working tree, exact semver tag on HEAD
(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
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 "Test-RemoteTagExists"
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Push-TagToRemote"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-ChangelogSemverPattern"
$pluginSettings = $Settings
$shared = $Settings.context
@ -123,8 +125,9 @@ function Invoke-Plugin {
return
}
if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') {
Invoke-NotMetInternal -Shared $shared -When $when -Reason "tag '$tag' must match vX.Y.Z."
$tagPattern = '^v(' + (Get-ChangelogSemverPattern) + ')$'
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
}