diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fdafed..54257cc 100644 --- a/CHANGELOG.md +++ b/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/), 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3d8d63c..45b6366 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 `` in `src/MaksIT.Core/MaksIT.Core.csproj` and **CHANGELOG.md** -2. Commit, tag `vX.Y.Z` on `main` +1. Bump `` 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` diff --git a/README.md b/README.md index 63101ed..4162a16 100644 --- a/README.md +++ b/README.md @@ -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? Roles { get; set; } +} + +var patch = new UserPatchRequest { + Name = "New Name", + Operations = new Dictionary { + ["Name"] = PatchOperation.SetField, + ["Roles"] = PatchOperation.AddToCollection + } +}; + +if (patch.TryGetOperation(nameof(UserPatchRequest.Name), out var operation)) { + // operation == PatchOperation.SetField +} +``` + +--- + +##### 8. **`QueryResultBase`** + +###### 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 { + 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` into smaller lists with `Batch`. --- @@ -445,6 +446,7 @@ Expression> isEven = x => x % 2 == 0; Expression> 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` deserializes JSON). + +4. **JSON Deserialization**: + - `ToObject()` / `ToObject(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(); +``` + --- -#### 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` 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(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? 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 { + ["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(); +``` + +--- + +### 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(); +``` + +--- + +## 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` 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` 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("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: diff --git a/src/MaksIT.Core.Cli.Tests/CliActionsTests.cs b/src/MaksIT.Core.Cli.Tests/CliActionsTests.cs new file mode 100644 index 0000000..3581763 --- /dev/null +++ b/src/MaksIT.Core.Cli.Tests/CliActionsTests.cs @@ -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()); +} diff --git a/src/MaksIT.Core.Cli.Tests/CommandFactoryTests.cs b/src/MaksIT.Core.Cli.Tests/CommandFactoryTests.cs new file mode 100644 index 0000000..2e9a8f4 --- /dev/null +++ b/src/MaksIT.Core.Cli.Tests/CommandFactoryTests.cs @@ -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); + } +} diff --git a/src/MaksIT.Core.Cli.Tests/InputParsersTests.cs b/src/MaksIT.Core.Cli.Tests/InputParsersTests.cs new file mode 100644 index 0000000..af24043 --- /dev/null +++ b/src/MaksIT.Core.Cli.Tests/InputParsersTests.cs @@ -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); + } +} diff --git a/src/MaksIT.Core.Cli.Tests/MaksIT.Core.Cli.Tests.csproj b/src/MaksIT.Core.Cli.Tests/MaksIT.Core.Cli.Tests.csproj new file mode 100644 index 0000000..777a9dd --- /dev/null +++ b/src/MaksIT.Core.Cli.Tests/MaksIT.Core.Cli.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + + false + true + Exe + true + + + + + + + + + + + + + + + + diff --git a/src/MaksIT.Core.Cli.Tests/SecretOperationsTests.cs b/src/MaksIT.Core.Cli.Tests/SecretOperationsTests.cs new file mode 100644 index 0000000..4878b95 --- /dev/null +++ b/src/MaksIT.Core.Cli.Tests/SecretOperationsTests.cs @@ -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); + } +} diff --git a/src/MaksIT.Core.Cli/Application.cs b/src/MaksIT.Core.Cli/Application.cs new file mode 100644 index 0000000..060eedf --- /dev/null +++ b/src/MaksIT.Core.Cli/Application.cs @@ -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; + +/// +/// Interactive numbered menu for generating MaksIT.Core secrets. +/// +public sealed class Application { + /// + /// Runs the main menu until the user exits. + /// + public void Run() { + Console.OutputEncoding = Encoding.UTF8; + var version = typeof(Application).Assembly + .GetCustomAttribute()? + .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(); + } +} diff --git a/src/MaksIT.Core.Cli/CliActions.cs b/src/MaksIT.Core.Cli/CliActions.cs new file mode 100644 index 0000000..79379cc --- /dev/null +++ b/src/MaksIT.Core.Cli/CliActions.cs @@ -0,0 +1,170 @@ +using MaksIT.Core.Extensions; +using MaksIT.Core.Security.JWT; + + +namespace MaksIT.Core.Cli; + +/// +/// Non-interactive command handlers: values on stdout, errors on stderr, exit 0/1. +/// +public static class CliActions { + /// + /// Writes an error to stderr and returns exit code 1. + /// + public static int Fail(string errorMessage) { + Console.Error.WriteLine(errorMessage); + return 1; + } + + /// + /// Generates a Base64 secret. + /// + 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; + } + + /// + /// Generates an opaque refresh token. + /// + public static int GenerateRefreshToken() { + Console.WriteLine(SecretOperations.GenerateRefreshToken()); + return 0; + } + + /// + /// Generates a Base64 AES-256 key. + /// + public static int GenerateAesKey() { + Console.WriteLine(SecretOperations.GenerateAesKey()); + return 0; + } + + /// + /// Generates a COMB GUID. + /// + 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; + } + + /// + /// Signs an access JWT. + /// + 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; + } + + /// + /// Validates an access JWT and writes claims JSON. + /// + 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; + } + + /// + /// Generates a Base32 TOTP secret. + /// + public static int GenerateTotpSecret() { + if (!SecretOperations.TryGenerateTotpSecret(out var secret, out var errorMessage)) + return Fail(errorMessage); + + Console.WriteLine(secret); + return 0; + } + + /// + /// Generates TOTP recovery codes (one per line). + /// + 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; + } + + /// + /// Builds an otpauth URI. + /// + 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; + } + + /// + /// Validates a TOTP code. Exit 1 when the code is invalid. + /// + 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; + } + + /// + /// Creates a salted password hash as JSON. + /// + 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(); +} diff --git a/src/MaksIT.Core.Cli/CommandFactory.cs b/src/MaksIT.Core.Cli/CommandFactory.cs new file mode 100644 index 0000000..83f16d2 --- /dev/null +++ b/src/MaksIT.Core.Cli/CommandFactory.cs @@ -0,0 +1,277 @@ +using System.CommandLine; + + +namespace MaksIT.Core.Cli; + +/// +/// Builds the agent-facing command tree. No arguments runs the interactive menu. +/// +public static class CommandFactory { + /// + /// Creates the root command with secret, jwt, aes, totp, password, and guid subcommands. + /// + 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("--expiration") { + Description = "Lifetime in minutes", + DefaultValueFactory = _ => 60 + }; + var userIdOption = new Option("--user-id") { + Description = "Optional user id claim" + }; + var usernameOption = new Option("--username") { + Description = "Optional username claim" + }; + var rolesOption = new Option("--roles") { + Description = "Optional comma-separated roles" + }; + var aclOption = new Option("--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("--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("--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("--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 BytesOption() => + new("--bytes") { + Description = "Random key size in bytes", + DefaultValueFactory = _ => 32 + }; + + private static Option RequiredString(string name, string description) => + new(name) { + Description = description, + Required = true + }; +} diff --git a/src/MaksIT.Core.Cli/InputParsers.cs b/src/MaksIT.Core.Cli/InputParsers.cs new file mode 100644 index 0000000..1a477b4 --- /dev/null +++ b/src/MaksIT.Core.Cli/InputParsers.cs @@ -0,0 +1,76 @@ +using MaksIT.Core.Comb; + + +namespace MaksIT.Core.Cli; + +/// +/// Parses interactive menu input for the Core CLI. +/// +public static class InputParsers { + /// + /// Parses a positive integer, using when input is blank. + /// + 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; + } + + /// + /// Parses a COMB GUID type, defaulting to when input is blank. + /// + 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; + } + + /// + /// Splits a comma-separated list; returns null when input is blank. + /// + public static List? 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; + } +} diff --git a/src/MaksIT.Core.Cli/MaksIT.Core.Cli.csproj b/src/MaksIT.Core.Cli/MaksIT.Core.Cli.csproj new file mode 100644 index 0000000..5b42409 --- /dev/null +++ b/src/MaksIT.Core.Cli/MaksIT.Core.Cli.csproj @@ -0,0 +1,27 @@ + + + + Exe + net10.0 + enable + enable + $(MSBuildProjectName.Replace(" ", "_")) + false + + 1.6.10 + Maksym Sadovnychyy + MAKS-IT + MaksIT.Core.Cli + Copyright © Maksym Sadovnychyy (MAKS-IT) + Interactive and flag-based console toolkit for generating MaksIT.Core secrets (JWT, AES-GCM, TOTP, password hashes, COMB GUIDs). + + + + + + + + + + + diff --git a/src/MaksIT.Core.Cli/Program.cs b/src/MaksIT.Core.Cli/Program.cs new file mode 100644 index 0000000..9c2040f --- /dev/null +++ b/src/MaksIT.Core.Cli/Program.cs @@ -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(); + } +} diff --git a/src/MaksIT.Core.Cli/SecretOperations.cs b/src/MaksIT.Core.Cli/SecretOperations.cs new file mode 100644 index 0000000..f0bbd4a --- /dev/null +++ b/src/MaksIT.Core.Cli/SecretOperations.cs @@ -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; + +/// +/// Thin wrappers around MaksIT.Core secret and token helpers. +/// +public static class SecretOperations { + /// + /// Generates a Base64 secret suitable for JWT signing or a password pepper. + /// + public static string GenerateSecret(int keySize = 32) => + JwtGenerator.GenerateSecret(keySize); + + /// + /// Generates an opaque refresh token. + /// + public static string GenerateRefreshToken() => + JwtGenerator.GenerateRefreshToken(); + + /// + /// Generates a Base64 AES-256 key. + /// + public static string GenerateAesKey() => + AESGCMUtility.GenerateKeyBase64(); + + /// + /// Generates a COMB GUID for the given layout. + /// + public static Guid GenerateCombGuid(CombGuidType type) => + CombGuidGenerator.CreateCombGuid(DateTime.UtcNow, type); + + /// + /// Signs an access JWT. + /// + 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; + } + + /// + /// Validates an access JWT and returns its claims. + /// + 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); + + /// + /// Generates a Base32 TOTP shared secret. + /// + public static bool TryGenerateTotpSecret( + [NotNullWhen(true)] out string? secret, + [NotNullWhen(false)] out string? errorMessage + ) => + TotpGenerator.TryGenerateSecret(out secret, out errorMessage); + + /// + /// Generates TOTP recovery codes. + /// + public static bool TryGenerateRecoveryCodes( + int count, + [NotNullWhen(true)] out List? codes, + [NotNullWhen(false)] out string? errorMessage + ) => + TotpGenerator.TryGenerateRecoveryCodes(count, out codes, out errorMessage); + + /// + /// Builds an otpauth:// URI for authenticator apps. + /// + 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 + ); + + /// + /// Validates a TOTP code against a Base32 secret. + /// + 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); + + /// + /// Creates a salted password hash with the given pepper. + /// + 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); +} diff --git a/src/MaksIT.Core.Tests/MaksIT.Core.Tests.csproj b/src/MaksIT.Core.Tests/MaksIT.Core.Tests.csproj index c0120d1..9619251 100644 --- a/src/MaksIT.Core.Tests/MaksIT.Core.Tests.csproj +++ b/src/MaksIT.Core.Tests/MaksIT.Core.Tests.csproj @@ -7,20 +7,14 @@ false true + Exe + true - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - + diff --git a/src/MaksIT.Core.slnx b/src/MaksIT.Core.slnx index 6114519..da56ba6 100644 --- a/src/MaksIT.Core.slnx +++ b/src/MaksIT.Core.slnx @@ -1,4 +1,6 @@ + + diff --git a/src/MaksIT.Core/MaksIT.Core.csproj b/src/MaksIT.Core/MaksIT.Core.csproj index ca1e04c..6dd8568 100644 --- a/src/MaksIT.Core/MaksIT.Core.csproj +++ b/src/MaksIT.Core/MaksIT.Core.csproj @@ -12,7 +12,7 @@ MaksIT.Core - 1.6.9 + 1.6.10 Maksym Sadovnychyy MAKS-IT MaksIT.Core diff --git a/src/global.json b/src/global.json new file mode 100644 index 0000000..3140116 --- /dev/null +++ b/src/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/utils/engines/release/scriptSettings.json b/utils/engines/release/scriptSettings.json index aac6e66..f0cc6fa 100644 --- a/utils/engines/release/scriptSettings.json +++ b/utils/engines/release/scriptSettings.json @@ -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", diff --git a/utils/engines/test/scriptSettings.json b/utils/engines/test/scriptSettings.json index a5a4f44..4652b07 100644 --- a/utils/engines/test/scriptSettings.json +++ b/utils/engines/test/scriptSettings.json @@ -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" }, diff --git a/utils/modules/ChangelogSupport.psm1 b/utils/modules/ChangelogSupport.psm1 index feb7afa..211c83a 100644 --- a/utils/modules/ChangelogSupport.psm1 +++ b/utils/modules/ChangelogSupport.psm1 @@ -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 diff --git a/utils/modules/TestRunner.psm1 b/utils/modules/TestRunner.psm1 index aac3308..d1e2eea 100644 --- a/utils/modules/TestRunner.psm1 +++ b/utils/modules/TestRunner.psm1 @@ -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 diff --git a/utils/plugins/DotNet/DotNetPublish.psm1 b/utils/plugins/DotNet/DotNetPublish.psm1 index d2dacba..cf9a090 100644 --- a/utils/plugins/DotNet/DotNetPublish.psm1 +++ b/utils/plugins/DotNet/DotNetPublish.psm1 @@ -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 diff --git a/utils/plugins/DotNet/DotNetReleaseVersion.psm1 b/utils/plugins/DotNet/DotNetReleaseVersion.psm1 index 3cb01fd..98a857f 100644 --- a/utils/plugins/DotNet/DotNetReleaseVersion.psm1 +++ b/utils/plugins/DotNet/DotNetReleaseVersion.psm1 @@ -7,9 +7,11 @@ .DESCRIPTION Dedicated version-loading plugin. Reads 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: 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: 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' diff --git a/utils/plugins/DotNet/DotNetTest.psm1 b/utils/plugins/DotNet/DotNetTest.psm1 index b8bd09d..3aa8db9 100644 --- a/utils/plugins/DotNet/DotNetTest.psm1 +++ b/utils/plugins/DotNet/DotNetTest.psm1 @@ -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)) { diff --git a/utils/plugins/Npm/NpmPublish.psm1 b/utils/plugins/Npm/NpmPublish.psm1 index db6b0ec..7357ffe 100644 --- a/utils/plugins/Npm/NpmPublish.psm1 +++ b/utils/plugins/Npm/NpmPublish.psm1 @@ -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'." diff --git a/utils/plugins/Npm/NpmReleaseVersion.psm1 b/utils/plugins/Npm/NpmReleaseVersion.psm1 index ad48e46..31f300a 100644 --- a/utils/plugins/Npm/NpmReleaseVersion.psm1 +++ b/utils/plugins/Npm/NpmReleaseVersion.psm1 @@ -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 diff --git a/utils/plugins/Platform/FileReleaseVersion.psm1 b/utils/plugins/Platform/FileReleaseVersion.psm1 index c7bb3bd..6095289 100644 --- a/utils/plugins/Platform/FileReleaseVersion.psm1 +++ b/utils/plugins/Platform/FileReleaseVersion.psm1 @@ -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) { diff --git a/utils/plugins/Platform/GitHub.psm1 b/utils/plugins/Platform/GitHub.psm1 index 61773e0..5528635 100644 --- a/utils/plugins/Platform/GitHub.psm1 +++ b/utils/plugins/Platform/GitHub.psm1 @@ -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) { diff --git a/utils/plugins/Platform/ReleasePublishGuard.psm1 b/utils/plugins/Platform/ReleasePublishGuard.psm1 index 43b6dfe..56153f6 100644 --- a/utils/plugins/Platform/ReleasePublishGuard.psm1 +++ b/utils/plugins/Platform/ReleasePublishGuard.psm1 @@ -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 }