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

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

View File

@ -5,6 +5,15 @@ 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).
## [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.
## [1.6.9] - 2026-08-14
### Changed

View File

@ -26,11 +26,25 @@ dotnet build MaksIT.Core.slnx
### Running Tests
Preferred: `utils\Invoke-TestEngine.bat` (DotNetTest → QualityGate → CoverageBadges). Tests run under **Microsoft Testing Platform** (`src/global.json` `test.runner` next to the `.slnx`) with **xunit.v3** and **coverlet.MTP**.
```bash
cd src
dotnet test MaksIT.Core.Tests
dotnet test MaksIT.Core.Cli.Tests
```
### Running the secrets CLI
```bash
cd src
dotnet run --project MaksIT.Core.Cli
dotnet run --project MaksIT.Core.Cli -- secret
dotnet run --project MaksIT.Core.Cli -- --help
```
No arguments opens the interactive numbered menu. Commands/flags are for scripts and agents (values on stdout, errors on stderr). It is not a `dotnet tool` and is not published to NuGet.
## Commit Message Format
This project uses the following commit message format:
@ -119,8 +133,8 @@ Orchestration lives in **`utils/`** (from [maksit-repoutils](https://github.com/
### Workflow
1. Bump `<Version>` in `src/MaksIT.Core/MaksIT.Core.csproj` and **CHANGELOG.md**
2. Commit, tag `vX.Y.Z` on `main`
1. Bump `<Version>` in `src/MaksIT.Core/MaksIT.Core.csproj` and `src/MaksIT.Core.Cli/MaksIT.Core.Cli.csproj` (keep them aligned) and **CHANGELOG.md**
2. Commit, tag `v{version}` on `main` (`v1.2.3` or SemVer prerelease such as `v0.1.0-alpha.1`, `v0.1.0-beta.1`, `v0.1.0-rc.1`). GitHub marks hyphenated versions as prerelease.
3. Set `$env:GitHub`, `$env:NuGet`, run `utils\Invoke-ReleasePackage-Single.bat`
Dry-run: `pwsh -File utils\engines\release\Invoke-ReleasePackage.ps1 -DryRun`

View File

@ -6,6 +6,7 @@
## Table of Contents
- [CLI (secrets toolkit)](#cli-secrets-toolkit)
- [Abstractions](#abstractions)
- [Base Classes](#base-classes)
- [Enumeration](#enumeration)
@ -48,6 +49,55 @@
- [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

View File

@ -0,0 +1,38 @@
using MaksIT.Core.Cli;
namespace MaksIT.Core.Cli.Tests;
public class CliActionsTests {
[Fact]
public void GenerateSecret_InvalidBytes_ReturnsOne() {
var exit = CliActions.GenerateSecret(0);
Assert.Equal(1, exit);
}
[Fact]
public void GenerateCombGuid_InvalidType_ReturnsOne() {
var exit = CliActions.GenerateCombGuid("rsa");
Assert.Equal(1, exit);
}
[Fact]
public void GenerateCombGuid_PostgreSql_ReturnsZero() {
var exit = CliActions.GenerateCombGuid(null);
Assert.Equal(0, exit);
}
[Fact]
public void HashPassword_EmptyPassword_ReturnsOne() {
var exit = CliActions.HashPassword("pepper", "");
Assert.Equal(1, exit);
}
[Fact]
public void GenerateAesKey_ReturnsZero() =>
Assert.Equal(0, CliActions.GenerateAesKey());
}

View File

@ -0,0 +1,43 @@
using MaksIT.Core.Cli;
namespace MaksIT.Core.Cli.Tests;
public class CommandFactoryTests {
[Fact]
public void Secret_HasNoParseErrors() {
var result = CommandFactory.CreateRootCommand().Parse(["secret"]);
Assert.Empty(result.Errors);
}
[Fact]
public void JwtGenerate_MissingSecret_HasParseError() {
var result = CommandFactory.CreateRootCommand().Parse([
"jwt", "generate", "--issuer", "i", "--audience", "a"
]);
Assert.NotEmpty(result.Errors);
}
[Fact]
public void JwtGenerate_RequiredOptions_HasNoParseErrors() {
var result = CommandFactory.CreateRootCommand().Parse([
"jwt", "generate",
"--secret", "s",
"--issuer", "i",
"--audience", "a"
]);
Assert.Empty(result.Errors);
}
[Fact]
public void TotpValidate_RequiredOptions_HasNoParseErrors() {
var result = CommandFactory.CreateRootCommand().Parse([
"totp", "validate", "--secret", "s", "--code", "123456"
]);
Assert.Empty(result.Errors);
}
}

View File

@ -0,0 +1,60 @@
using MaksIT.Core.Cli;
using MaksIT.Core.Comb;
namespace MaksIT.Core.Cli.Tests;
public class InputParsersTests {
[Fact]
public void TryParsePositiveInt_Blank_ReturnsDefault() {
var result = InputParsers.TryParsePositiveInt(" ", 32, out var value, out var errorMessage);
Assert.True(result);
Assert.Equal(32, value);
Assert.Null(errorMessage);
}
[Fact]
public void TryParsePositiveInt_ValidNumber_ReturnsValue() {
var result = InputParsers.TryParsePositiveInt("64", 32, out var value, out var errorMessage);
Assert.True(result);
Assert.Equal(64, value);
Assert.Null(errorMessage);
}
[Fact]
public void TryParsePositiveInt_Invalid_ReturnsError() {
var result = InputParsers.TryParsePositiveInt("abc", 32, out var value, out var errorMessage);
Assert.False(result);
Assert.Equal(0, value);
Assert.NotNull(errorMessage);
}
[Fact]
public void TryParseCombGuidType_Blank_ReturnsPostgreSql() {
var result = InputParsers.TryParseCombGuidType(null, out var type, out var errorMessage);
Assert.True(result);
Assert.Equal(CombGuidType.PostgreSql, type);
Assert.Null(errorMessage);
}
[Fact]
public void TryParseCombGuidType_SqlServer_ParsesIgnoreCase() {
var result = InputParsers.TryParseCombGuidType("sqlserver", out var type, out var errorMessage);
Assert.True(result);
Assert.Equal(CombGuidType.SqlServer, type);
Assert.Null(errorMessage);
}
[Fact]
public void ParseOptionalList_SplitsAndTrims() {
var items = InputParsers.ParseOptionalList(" Admin, User , ");
Assert.NotNull(items);
Assert.Equal(["Admin", "User"], items);
}
}

View File

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<OutputType>Exe</OutputType>
<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.MTP" Version="10.0.1" />
<PackageReference Include="xunit.v3" Version="4.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MaksIT.Core.Cli\MaksIT.Core.Cli.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,60 @@
using MaksIT.Core.Cli;
using MaksIT.Core.Comb;
using MaksIT.Core.Security.JWT;
namespace MaksIT.Core.Cli.Tests;
public class SecretOperationsTests {
[Fact]
public void GenerateSecret_ReturnsUniqueNonEmptyValues() {
var secret1 = SecretOperations.GenerateSecret();
var secret2 = SecretOperations.GenerateSecret();
Assert.False(string.IsNullOrWhiteSpace(secret1));
Assert.False(string.IsNullOrWhiteSpace(secret2));
Assert.NotEqual(secret1, secret2);
}
[Fact]
public void GenerateAesKey_ReturnsNonEmptyValue() =>
Assert.False(string.IsNullOrWhiteSpace(SecretOperations.GenerateAesKey()));
[Fact]
public void GenerateCombGuid_PostgreSql_ReturnsNonEmptyGuid() {
var guid = SecretOperations.GenerateCombGuid(CombGuidType.PostgreSql);
Assert.NotEqual(Guid.Empty, guid);
}
[Fact]
public void TryGenerateJwt_ThenValidate_Succeeds() {
var secret = SecretOperations.GenerateSecret();
var request = new JWTTokenGenerateRequest {
Secret = secret,
Issuer = "cli-tests",
Audience = "cli-tests",
Expiration = 5,
Username = "tester"
};
var generated = SecretOperations.TryGenerateJwt(request, out var token, out var generateError);
Assert.True(generated);
Assert.False(string.IsNullOrWhiteSpace(token));
Assert.Null(generateError);
var validated = SecretOperations.TryValidateJwt(
secret,
request.Issuer,
request.Audience,
token!,
out var claims,
out var validateError
);
Assert.True(validated);
Assert.Equal("tester", claims?.Username);
Assert.Null(validateError);
}
}

View File

@ -0,0 +1,317 @@
using System.Text;
using System.Reflection;
using MaksIT.Core.Comb;
using MaksIT.Core.Extensions;
using MaksIT.Core.Security.JWT;
namespace MaksIT.Core.Cli;
/// <summary>
/// Interactive numbered menu for generating MaksIT.Core secrets.
/// </summary>
public sealed class Application {
/// <summary>
/// Runs the main menu until the user exits.
/// </summary>
public void Run() {
Console.OutputEncoding = Encoding.UTF8;
var version = typeof(Application).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion?
.Split('+')[0]
?? "0.0.0";
while (true) {
Console.WriteLine($"MaksIT.Core.Cli v{version}");
Console.WriteLine("© Maksym Sadovnychyy (MAKS-IT) 2026");
Console.WriteLine();
Console.WriteLine("1. Generate secret (JWT / pepper)");
Console.WriteLine("2. JWT");
Console.WriteLine("3. AES-GCM key");
Console.WriteLine("4. TOTP / 2FA");
Console.WriteLine("5. Password hash");
Console.WriteLine("6. COMB GUID");
Console.WriteLine("0. Exit");
Console.Write("Enter your choice: ");
var choice = Console.ReadLine();
try {
switch (choice) {
case "1":
GenerateSecret();
Pause();
break;
case "2":
RunJwtMenu();
break;
case "3":
WriteLabeled("AES-256 key", SecretOperations.GenerateAesKey());
Pause();
break;
case "4":
RunTotpMenu();
break;
case "5":
HashPassword();
Pause();
break;
case "6":
GenerateCombGuid();
Pause();
break;
case "0":
return;
default:
Console.WriteLine("Invalid option.");
break;
}
}
catch (Exception ex) {
Console.WriteLine($"Error: {ex.Message}");
Pause();
}
Console.WriteLine();
}
}
private static void RunJwtMenu() {
while (true) {
Console.WriteLine();
Console.WriteLine("JWT");
Console.WriteLine("1. Generate signing secret");
Console.WriteLine("2. Generate refresh token");
Console.WriteLine("3. Generate access token");
Console.WriteLine("4. Validate token");
Console.WriteLine("0. Back");
Console.Write("Enter your choice: ");
var choice = Console.ReadLine();
try {
switch (choice) {
case "1":
GenerateSecret();
Pause();
break;
case "2":
WriteLabeled("Refresh token", SecretOperations.GenerateRefreshToken());
Pause();
break;
case "3":
GenerateAccessToken();
Pause();
break;
case "4":
ValidateAccessToken();
Pause();
break;
case "0":
return;
default:
Console.WriteLine("Invalid option.");
break;
}
}
catch (Exception ex) {
Console.WriteLine($"Error: {ex.Message}");
Pause();
}
}
}
private static void RunTotpMenu() {
while (true) {
Console.WriteLine();
Console.WriteLine("TOTP / 2FA");
Console.WriteLine("1. Generate secret");
Console.WriteLine("2. Generate recovery codes");
Console.WriteLine("3. Generate otpauth link");
Console.WriteLine("4. Validate code");
Console.WriteLine("0. Back");
Console.Write("Enter your choice: ");
var choice = Console.ReadLine();
try {
switch (choice) {
case "1":
if (!SecretOperations.TryGenerateTotpSecret(out var secret, out var secretError))
throw new InvalidOperationException(secretError);
WriteLabeled("TOTP secret", secret);
Pause();
break;
case "2":
GenerateRecoveryCodes();
Pause();
break;
case "3":
GenerateTotpAuthLink();
Pause();
break;
case "4":
ValidateTotp();
Pause();
break;
case "0":
return;
default:
Console.WriteLine("Invalid option.");
break;
}
}
catch (Exception ex) {
Console.WriteLine($"Error: {ex.Message}");
Pause();
}
}
}
private static void GenerateSecret() {
var bytes = ReadPositiveInt("Key size in bytes", 32);
WriteLabeled("Secret", SecretOperations.GenerateSecret(bytes));
}
private static void GenerateAccessToken() {
var request = new JWTTokenGenerateRequest {
Secret = ReadRequired("Secret"),
Issuer = ReadRequired("Issuer"),
Audience = ReadRequired("Audience"),
Expiration = ReadPositiveInt("Expiration (minutes)", 60),
UserId = ReadOptional("User id"),
Username = ReadOptional("Username"),
Roles = InputParsers.ParseOptionalList(ReadOptional("Roles (comma-separated)")),
AclEntries = InputParsers.ParseOptionalList(ReadOptional("ACL entries (comma-separated)"))
};
if (!SecretOperations.TryGenerateJwt(request, out var token, out var errorMessage))
throw new InvalidOperationException(errorMessage);
WriteLabeled("Access token", token);
}
private static void ValidateAccessToken() {
var secret = ReadRequired("Secret");
var issuer = ReadRequired("Issuer");
var audience = ReadRequired("Audience");
var token = ReadRequired("Token");
if (!SecretOperations.TryValidateJwt(secret, issuer, audience, token, out var claims, out var errorMessage))
throw new InvalidOperationException(errorMessage);
WriteLabeled("Claims", claims.ToJson());
}
private static void GenerateRecoveryCodes() {
var count = ReadPositiveInt("Number of codes", 10);
if (!SecretOperations.TryGenerateRecoveryCodes(count, out var codes, out var errorMessage))
throw new InvalidOperationException(errorMessage);
Console.WriteLine("Recovery codes:");
foreach (var code in codes)
Console.WriteLine(code);
}
private static void GenerateTotpAuthLink() {
var label = ReadRequired("Label");
var username = ReadRequired("Username");
var secret = ReadRequired("TOTP secret");
var issuer = ReadRequired("Issuer");
if (!SecretOperations.TryGenerateTotpAuthLink(label, username, secret, issuer, out var authLink, out var errorMessage))
throw new InvalidOperationException(errorMessage);
WriteLabeled("otpauth link", authLink);
}
private static void ValidateTotp() {
var secret = ReadRequired("TOTP secret");
var code = ReadRequired("Code");
var tolerance = ReadPositiveInt("Time-step tolerance", 1);
if (!SecretOperations.TryValidateTotp(code, secret, tolerance, out var isValid, out var errorMessage))
throw new InvalidOperationException(errorMessage);
Console.WriteLine(isValid ? "Valid." : "Invalid.");
}
private static void HashPassword() {
var pepper = ReadRequired("Pepper");
var password = ReadSecret("Password");
if (string.IsNullOrEmpty(password))
throw new InvalidOperationException("Password is required.");
if (!SecretOperations.TryHashPassword(password, pepper, out var saltedHash, out var errorMessage))
throw new InvalidOperationException(errorMessage);
Console.WriteLine($"Salt: {saltedHash.Value.Salt}");
Console.WriteLine($"Hash: {saltedHash.Value.Hash}");
}
private static void GenerateCombGuid() {
Console.Write("COMB type (PostgreSql/SqlServer) [PostgreSql]: ");
if (!InputParsers.TryParseCombGuidType(Console.ReadLine(), out var type, out var errorMessage))
throw new InvalidOperationException(errorMessage);
WriteLabeled($"COMB GUID ({type})", SecretOperations.GenerateCombGuid(type).ToString());
}
private static int ReadPositiveInt(string prompt, int defaultValue) {
Console.Write($"{prompt} [{defaultValue}]: ");
if (!InputParsers.TryParsePositiveInt(Console.ReadLine(), defaultValue, out var value, out var errorMessage))
throw new InvalidOperationException(errorMessage);
return value;
}
private static string ReadRequired(string prompt) {
Console.Write($"{prompt}: ");
var value = Console.ReadLine();
if (string.IsNullOrWhiteSpace(value))
throw new InvalidOperationException($"{prompt} is required.");
return value.Trim();
}
private static string? ReadOptional(string prompt) {
Console.Write($"{prompt}: ");
var value = Console.ReadLine();
if (string.IsNullOrWhiteSpace(value))
return null;
return value.Trim();
}
private static string ReadSecret(string prompt) {
Console.Write($"{prompt}: ");
var builder = new StringBuilder();
while (true) {
var key = Console.ReadKey(intercept: true);
if (key.Key == ConsoleKey.Enter) {
Console.WriteLine();
return builder.ToString();
}
if (key.Key == ConsoleKey.Backspace) {
if (builder.Length > 0)
builder.Length--;
continue;
}
if (!char.IsControl(key.KeyChar))
builder.Append(key.KeyChar);
}
}
private static void WriteLabeled(string label, string value) {
Console.WriteLine($"{label}:");
Console.WriteLine(value);
}
private static void Pause() {
Console.WriteLine();
Console.Write("Press Enter to continue...");
Console.ReadLine();
}
}

View File

@ -0,0 +1,170 @@
using MaksIT.Core.Extensions;
using MaksIT.Core.Security.JWT;
namespace MaksIT.Core.Cli;
/// <summary>
/// Non-interactive command handlers: values on stdout, errors on stderr, exit 0/1.
/// </summary>
public static class CliActions {
/// <summary>
/// Writes an error to stderr and returns exit code 1.
/// </summary>
public static int Fail(string errorMessage) {
Console.Error.WriteLine(errorMessage);
return 1;
}
/// <summary>
/// Generates a Base64 secret.
/// </summary>
public static int GenerateSecret(int bytes) {
if (!InputParsers.TryParsePositiveInt(bytes.ToString(), 32, out var keySize, out var errorMessage))
return Fail(errorMessage!);
Console.WriteLine(SecretOperations.GenerateSecret(keySize));
return 0;
}
/// <summary>
/// Generates an opaque refresh token.
/// </summary>
public static int GenerateRefreshToken() {
Console.WriteLine(SecretOperations.GenerateRefreshToken());
return 0;
}
/// <summary>
/// Generates a Base64 AES-256 key.
/// </summary>
public static int GenerateAesKey() {
Console.WriteLine(SecretOperations.GenerateAesKey());
return 0;
}
/// <summary>
/// Generates a COMB GUID.
/// </summary>
public static int GenerateCombGuid(string? typeName) {
if (!InputParsers.TryParseCombGuidType(typeName, out var type, out var errorMessage))
return Fail(errorMessage!);
Console.WriteLine(SecretOperations.GenerateCombGuid(type));
return 0;
}
/// <summary>
/// Signs an access JWT.
/// </summary>
public static int GenerateJwt(
string secret,
string issuer,
string audience,
int expiration,
string? userId,
string? username,
string? roles,
string? aclEntries
) {
if (!InputParsers.TryParsePositiveInt(expiration.ToString(), 60, out var minutes, out var errorMessage))
return Fail(errorMessage!);
var request = new JWTTokenGenerateRequest {
Secret = secret,
Issuer = issuer,
Audience = audience,
Expiration = minutes,
UserId = EmptyToNull(userId),
Username = EmptyToNull(username),
Roles = InputParsers.ParseOptionalList(roles),
AclEntries = InputParsers.ParseOptionalList(aclEntries)
};
if (!SecretOperations.TryGenerateJwt(request, out var token, out var generateError))
return Fail(generateError);
Console.WriteLine(token);
return 0;
}
/// <summary>
/// Validates an access JWT and writes claims JSON.
/// </summary>
public static int ValidateJwt(string secret, string issuer, string audience, string token) {
if (!SecretOperations.TryValidateJwt(secret, issuer, audience, token, out var claims, out var errorMessage))
return Fail(errorMessage);
Console.WriteLine(claims.ToJson());
return 0;
}
/// <summary>
/// Generates a Base32 TOTP secret.
/// </summary>
public static int GenerateTotpSecret() {
if (!SecretOperations.TryGenerateTotpSecret(out var secret, out var errorMessage))
return Fail(errorMessage);
Console.WriteLine(secret);
return 0;
}
/// <summary>
/// Generates TOTP recovery codes (one per line).
/// </summary>
public static int GenerateRecoveryCodes(int count) {
if (!InputParsers.TryParsePositiveInt(count.ToString(), 10, out var codeCount, out var errorMessage))
return Fail(errorMessage!);
if (!SecretOperations.TryGenerateRecoveryCodes(codeCount, out var codes, out var generateError))
return Fail(generateError);
foreach (var code in codes)
Console.WriteLine(code);
return 0;
}
/// <summary>
/// Builds an otpauth URI.
/// </summary>
public static int GenerateTotpAuthLink(string label, string username, string secret, string issuer) {
if (!SecretOperations.TryGenerateTotpAuthLink(label, username, secret, issuer, out var authLink, out var errorMessage))
return Fail(errorMessage);
Console.WriteLine(authLink);
return 0;
}
/// <summary>
/// Validates a TOTP code. Exit 1 when the code is invalid.
/// </summary>
public static int ValidateTotp(string secret, string code, int tolerance) {
if (!InputParsers.TryParsePositiveInt(tolerance.ToString(), 1, out var timeTolerance, out var errorMessage))
return Fail(errorMessage!);
if (!SecretOperations.TryValidateTotp(code, secret, timeTolerance, out var isValid, out var validateError))
return Fail(validateError);
Console.WriteLine(isValid ? "valid" : "invalid");
return isValid ? 0 : 1;
}
/// <summary>
/// Creates a salted password hash as JSON.
/// </summary>
public static int HashPassword(string pepper, string password) {
if (string.IsNullOrEmpty(password))
return Fail("Password is required.");
if (!SecretOperations.TryHashPassword(password, pepper, out var saltedHash, out var errorMessage))
return Fail(errorMessage);
Console.WriteLine(new { salt = saltedHash.Value.Salt, hash = saltedHash.Value.Hash }.ToJson());
return 0;
}
private static string? EmptyToNull(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

View File

@ -0,0 +1,277 @@
using System.CommandLine;
namespace MaksIT.Core.Cli;
/// <summary>
/// Builds the agent-facing command tree. No arguments runs the interactive menu.
/// </summary>
public static class CommandFactory {
/// <summary>
/// Creates the root command with secret, jwt, aes, totp, password, and guid subcommands.
/// </summary>
public static RootCommand CreateRootCommand() {
var root = new RootCommand("Generate MaksIT.Core secrets. No arguments opens the interactive menu.") {
CreateSecretCommand(),
CreateJwtCommand(),
CreateAesCommand(),
CreateTotpCommand(),
CreatePasswordCommand(),
CreateGuidCommand()
};
root.SetAction(_ => {
new Application().Run();
return 0;
});
return root;
}
private static Command CreateSecretCommand() {
var bytesOption = BytesOption();
var command = new Command("secret", "Generate a Base64 secret (JWT signing key or password pepper)") {
bytesOption
};
command.SetAction(parseResult =>
CliActions.GenerateSecret(parseResult.GetValue(bytesOption)));
return command;
}
private static Command CreateJwtCommand() {
var jwt = new Command("jwt", "JWT signing secrets, tokens, and validation");
jwt.Subcommands.Add(CreateJwtSecretCommand());
jwt.Subcommands.Add(CreateJwtRefreshCommand());
jwt.Subcommands.Add(CreateJwtGenerateCommand());
jwt.Subcommands.Add(CreateJwtValidateCommand());
return jwt;
}
private static Command CreateJwtSecretCommand() {
var bytesOption = BytesOption();
var command = new Command("secret", "Generate a JWT signing secret") {
bytesOption
};
command.SetAction(parseResult =>
CliActions.GenerateSecret(parseResult.GetValue(bytesOption)));
return command;
}
private static Command CreateJwtRefreshCommand() {
var command = new Command("refresh", "Generate an opaque refresh token");
command.SetAction(_ => CliActions.GenerateRefreshToken());
return command;
}
private static Command CreateJwtGenerateCommand() {
var secretOption = RequiredString("--secret", "Signing secret");
var issuerOption = RequiredString("--issuer", "Token issuer");
var audienceOption = RequiredString("--audience", "Token audience");
var expirationOption = new Option<int>("--expiration") {
Description = "Lifetime in minutes",
DefaultValueFactory = _ => 60
};
var userIdOption = new Option<string?>("--user-id") {
Description = "Optional user id claim"
};
var usernameOption = new Option<string?>("--username") {
Description = "Optional username claim"
};
var rolesOption = new Option<string?>("--roles") {
Description = "Optional comma-separated roles"
};
var aclOption = new Option<string?>("--acl") {
Description = "Optional comma-separated ACL entries"
};
var command = new Command("generate", "Sign an access JWT") {
secretOption,
issuerOption,
audienceOption,
expirationOption,
userIdOption,
usernameOption,
rolesOption,
aclOption
};
command.SetAction(parseResult =>
CliActions.GenerateJwt(
parseResult.GetValue(secretOption)!,
parseResult.GetValue(issuerOption)!,
parseResult.GetValue(audienceOption)!,
parseResult.GetValue(expirationOption),
parseResult.GetValue(userIdOption),
parseResult.GetValue(usernameOption),
parseResult.GetValue(rolesOption),
parseResult.GetValue(aclOption)
));
return command;
}
private static Command CreateJwtValidateCommand() {
var secretOption = RequiredString("--secret", "Signing secret");
var issuerOption = RequiredString("--issuer", "Token issuer");
var audienceOption = RequiredString("--audience", "Token audience");
var tokenOption = RequiredString("--token", "JWT to validate");
var command = new Command("validate", "Validate an access JWT and print claims JSON") {
secretOption,
issuerOption,
audienceOption,
tokenOption
};
command.SetAction(parseResult =>
CliActions.ValidateJwt(
parseResult.GetValue(secretOption)!,
parseResult.GetValue(issuerOption)!,
parseResult.GetValue(audienceOption)!,
parseResult.GetValue(tokenOption)!
));
return command;
}
private static Command CreateAesCommand() {
var aes = new Command("aes", "AES-GCM keys");
var key = new Command("key", "Generate a Base64 AES-256 key");
key.SetAction(_ => CliActions.GenerateAesKey());
aes.Subcommands.Add(key);
return aes;
}
private static Command CreateTotpCommand() {
var totp = new Command("totp", "TOTP / 2FA secrets, recovery codes, and validation");
totp.Subcommands.Add(CreateTotpSecretCommand());
totp.Subcommands.Add(CreateTotpRecoveryCommand());
totp.Subcommands.Add(CreateTotpLinkCommand());
totp.Subcommands.Add(CreateTotpValidateCommand());
return totp;
}
private static Command CreateTotpSecretCommand() {
var command = new Command("secret", "Generate a Base32 TOTP shared secret");
command.SetAction(_ => CliActions.GenerateTotpSecret());
return command;
}
private static Command CreateTotpRecoveryCommand() {
var countOption = new Option<int>("--count") {
Description = "Number of recovery codes",
DefaultValueFactory = _ => 10
};
var command = new Command("recovery", "Generate TOTP recovery codes (one per line)") {
countOption
};
command.SetAction(parseResult =>
CliActions.GenerateRecoveryCodes(parseResult.GetValue(countOption)));
return command;
}
private static Command CreateTotpLinkCommand() {
var labelOption = RequiredString("--label", "Authenticator label");
var usernameOption = RequiredString("--username", "Account username");
var secretOption = RequiredString("--secret", "Base32 TOTP secret");
var issuerOption = RequiredString("--issuer", "Issuer name");
var command = new Command("link", "Build an otpauth:// URI") {
labelOption,
usernameOption,
secretOption,
issuerOption
};
command.SetAction(parseResult =>
CliActions.GenerateTotpAuthLink(
parseResult.GetValue(labelOption)!,
parseResult.GetValue(usernameOption)!,
parseResult.GetValue(secretOption)!,
parseResult.GetValue(issuerOption)!
));
return command;
}
private static Command CreateTotpValidateCommand() {
var secretOption = RequiredString("--secret", "Base32 TOTP secret");
var codeOption = RequiredString("--code", "Six-digit TOTP code");
var toleranceOption = new Option<int>("--tolerance") {
Description = "Time-step windows to accept on each side",
DefaultValueFactory = _ => 1
};
var command = new Command("validate", "Validate a TOTP code (prints valid/invalid)") {
secretOption,
codeOption,
toleranceOption
};
command.SetAction(parseResult =>
CliActions.ValidateTotp(
parseResult.GetValue(secretOption)!,
parseResult.GetValue(codeOption)!,
parseResult.GetValue(toleranceOption)
));
return command;
}
private static Command CreatePasswordCommand() {
var password = new Command("password", "Password hashing");
var pepperOption = RequiredString("--pepper", "Application pepper");
var passwordOption = RequiredString("--password", "Password to hash");
var hash = new Command("hash", "Create a salted hash (JSON with salt and hash)") {
pepperOption,
passwordOption
};
hash.SetAction(parseResult =>
CliActions.HashPassword(
parseResult.GetValue(pepperOption)!,
parseResult.GetValue(passwordOption)!
));
password.Subcommands.Add(hash);
return password;
}
private static Command CreateGuidCommand() {
var guid = new Command("guid", "COMB GUID generation");
var typeOption = new Option<string>("--type") {
Description = "PostgreSql or SqlServer",
DefaultValueFactory = _ => "PostgreSql"
};
var comb = new Command("comb", "Generate a COMB GUID") {
typeOption
};
comb.SetAction(parseResult =>
CliActions.GenerateCombGuid(parseResult.GetValue(typeOption)));
guid.Subcommands.Add(comb);
return guid;
}
private static Option<int> BytesOption() =>
new("--bytes") {
Description = "Random key size in bytes",
DefaultValueFactory = _ => 32
};
private static Option<string> RequiredString(string name, string description) =>
new(name) {
Description = description,
Required = true
};
}

View File

@ -0,0 +1,76 @@
using MaksIT.Core.Comb;
namespace MaksIT.Core.Cli;
/// <summary>
/// Parses interactive menu input for the Core CLI.
/// </summary>
public static class InputParsers {
/// <summary>
/// Parses a positive integer, using <paramref name="defaultValue"/> when input is blank.
/// </summary>
public static bool TryParsePositiveInt(
string? input,
int defaultValue,
out int value,
out string? errorMessage
) {
if (string.IsNullOrWhiteSpace(input)) {
value = defaultValue;
errorMessage = null;
return true;
}
if (!int.TryParse(input.Trim(), out value) || value <= 0) {
value = 0;
errorMessage = "Value must be a positive integer.";
return false;
}
errorMessage = null;
return true;
}
/// <summary>
/// Parses a COMB GUID type, defaulting to <see cref="CombGuidType.PostgreSql"/> when input is blank.
/// </summary>
public static bool TryParseCombGuidType(
string? input,
out CombGuidType type,
out string? errorMessage
) {
if (string.IsNullOrWhiteSpace(input)) {
type = CombGuidType.PostgreSql;
errorMessage = null;
return true;
}
if (Enum.TryParse(input.Trim(), ignoreCase: true, out type)
&& Enum.IsDefined(type)) {
errorMessage = null;
return true;
}
type = default;
errorMessage = "Type must be PostgreSql or SqlServer.";
return false;
}
/// <summary>
/// Splits a comma-separated list; returns <c>null</c> when input is blank.
/// </summary>
public static List<string>? ParseOptionalList(string? input) {
if (string.IsNullOrWhiteSpace(input))
return null;
var items = input
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
if (items.Count == 0)
return null;
return items;
}
}

View File

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
<IsPackable>false</IsPackable>
<Version>1.6.10</Version>
<Authors>Maksym Sadovnychyy</Authors>
<Company>MAKS-IT</Company>
<Product>MaksIT.Core.Cli</Product>
<Copyright>Copyright © Maksym Sadovnychyy (MAKS-IT)</Copyright>
<Description>Interactive and flag-based console toolkit for generating MaksIT.Core secrets (JWT, AES-GCM, TOTP, password hashes, COMB GUIDs).</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MaksIT.Core\MaksIT.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.CommandLine" Version="2.0.11" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,9 @@
namespace MaksIT.Core.Cli;
public static class Program {
public static int Main(string[] args) {
Console.OutputEncoding = System.Text.Encoding.UTF8;
return CommandFactory.CreateRootCommand().Parse(args).Invoke();
}
}

View File

@ -0,0 +1,131 @@
using System.Diagnostics.CodeAnalysis;
using MaksIT.Core.Comb;
using MaksIT.Core.Security;
using MaksIT.Core.Security.JWT;
namespace MaksIT.Core.Cli;
/// <summary>
/// Thin wrappers around MaksIT.Core secret and token helpers.
/// </summary>
public static class SecretOperations {
/// <summary>
/// Generates a Base64 secret suitable for JWT signing or a password pepper.
/// </summary>
public static string GenerateSecret(int keySize = 32) =>
JwtGenerator.GenerateSecret(keySize);
/// <summary>
/// Generates an opaque refresh token.
/// </summary>
public static string GenerateRefreshToken() =>
JwtGenerator.GenerateRefreshToken();
/// <summary>
/// Generates a Base64 AES-256 key.
/// </summary>
public static string GenerateAesKey() =>
AESGCMUtility.GenerateKeyBase64();
/// <summary>
/// Generates a COMB GUID for the given layout.
/// </summary>
public static Guid GenerateCombGuid(CombGuidType type) =>
CombGuidGenerator.CreateCombGuid(DateTime.UtcNow, type);
/// <summary>
/// Signs an access JWT.
/// </summary>
public static bool TryGenerateJwt(
JWTTokenGenerateRequest request,
[NotNullWhen(true)] out string? token,
[NotNullWhen(false)] out string? errorMessage
) {
if (!JwtGenerator.TryGenerateToken(request, out var tokenData, out errorMessage)) {
token = null;
return false;
}
token = tokenData.Value.Item1;
return true;
}
/// <summary>
/// Validates an access JWT and returns its claims.
/// </summary>
public static bool TryValidateJwt(
string secret,
string issuer,
string audience,
string token,
out JWTTokenClaims? claims,
[NotNullWhen(false)] out string? errorMessage
) =>
JwtGenerator.TryValidateToken(secret, issuer, audience, token, out claims, out errorMessage);
/// <summary>
/// Generates a Base32 TOTP shared secret.
/// </summary>
public static bool TryGenerateTotpSecret(
[NotNullWhen(true)] out string? secret,
[NotNullWhen(false)] out string? errorMessage
) =>
TotpGenerator.TryGenerateSecret(out secret, out errorMessage);
/// <summary>
/// Generates TOTP recovery codes.
/// </summary>
public static bool TryGenerateRecoveryCodes(
int count,
[NotNullWhen(true)] out List<string>? codes,
[NotNullWhen(false)] out string? errorMessage
) =>
TotpGenerator.TryGenerateRecoveryCodes(count, out codes, out errorMessage);
/// <summary>
/// Builds an <c>otpauth://</c> URI for authenticator apps.
/// </summary>
public static bool TryGenerateTotpAuthLink(
string label,
string username,
string secret,
string issuer,
[NotNullWhen(true)] out string? authLink,
[NotNullWhen(false)] out string? errorMessage
) =>
TotpGenerator.TryGenerateTotpAuthLink(
label,
username,
secret,
issuer,
algorithm: null,
digits: null,
period: null,
out authLink,
out errorMessage
);
/// <summary>
/// Validates a TOTP code against a Base32 secret.
/// </summary>
public static bool TryValidateTotp(
string totpCode,
string base32Secret,
int timeTolerance,
out bool isValid,
[NotNullWhen(false)] out string? errorMessage
) =>
TotpGenerator.TryValidate(totpCode, base32Secret, timeTolerance, out isValid, out errorMessage);
/// <summary>
/// Creates a salted password hash with the given pepper.
/// </summary>
public static bool TryHashPassword(
string password,
string pepper,
[NotNullWhen(true)] out (string Salt, string Hash)? saltedHash,
[NotNullWhen(false)] out string? errorMessage
) =>
PasswordHasher.TryCreateSaltedHash(password, pepper, out saltedHash, out errorMessage);
}

View File

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

View File

@ -1,4 +1,6 @@
<Solution>
<Project Path="MaksIT.Core.Cli.Tests/MaksIT.Core.Cli.Tests.csproj" />
<Project Path="MaksIT.Core.Cli/MaksIT.Core.Cli.csproj" />
<Project Path="MaksIT.Core.Tests/MaksIT.Core.Tests.csproj" />
<Project Path="MaksIT.Core/MaksIT.Core.csproj" />
</Solution>

View File

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

5
src/global.json Normal file
View File

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

View File

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

View File

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

View File

@ -6,16 +6,65 @@
Keep a Changelog header parsing and section extraction.
.DESCRIPTION
Supports only the standard Keep a Changelog version line:
Supports Keep a Changelog version lines and shared SemVer checks, including prerelease:
## [1.0.0] - 2026-05-24
## [0.1.0-alpha.1] - 2026-08-21
## [0.1.0-beta.1] - 2026-08-21
## [0.1.0-rc.1] - 2026-08-21
#>
function Get-ChangelogSemverPattern {
return '\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?'
}
function Test-ReleaseSemver {
param(
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Version
)
if ([string]::IsNullOrWhiteSpace($Version)) {
return $false
}
return [bool]($Version -match ('^' + (Get-ChangelogSemverPattern) + '$'))
}
function Test-ReleaseSemverPrerelease {
param(
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Version
)
return (Test-ReleaseSemver -Version $Version) -and ($Version -match '-')
}
function Get-ReleaseSemverPrereleaseLabel {
param(
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Version
)
if (-not (Test-ReleaseSemverPrerelease -Version $Version)) {
return $null
}
if ($Version -match '^\d+\.\d+\.\d+-([A-Za-z][0-9A-Za-z]*)') {
return $Matches[1].ToLowerInvariant()
}
return 'next'
}
function Get-ChangelogVersionHeaderPattern {
return '(?m)^##\s+\[(\d+\.\d+\.\d+)\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
return '(?m)^##\s+\[(' + (Get-ChangelogSemverPattern) + ')\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
}
function Get-ChangelogNextVersionHeaderPattern {
return '(?m)^##\s+\[\d+\.\d+\.\d+\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
return '(?m)^##\s+\[' + (Get-ChangelogSemverPattern) + '\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
}
function Get-LatestChangelogVersion {
@ -53,4 +102,4 @@ function Get-ChangelogReleaseNotesSection {
return $match.Value.Trim()
}
Export-ModuleMember -Function Get-ChangelogVersionHeaderPattern, Get-ChangelogNextVersionHeaderPattern, Get-LatestChangelogVersion, Get-ChangelogReleaseNotesSection
Export-ModuleMember -Function Get-ChangelogSemverPattern, Test-ReleaseSemver, Test-ReleaseSemverPrerelease, Get-ReleaseSemverPrereleaseLabel, Get-ChangelogVersionHeaderPattern, Get-ChangelogNextVersionHeaderPattern, Get-LatestChangelogVersion, Get-ChangelogReleaseNotesSection

View File

@ -7,7 +7,7 @@
.DESCRIPTION
Provides the Invoke-TestsWithCoverage function for running .NET tests
with Coverlet code coverage collection and parsing results.
with Microsoft.Testing.Platform and coverlet.MTP code coverage.
.NOTES
Author: MaksIT
@ -65,6 +65,20 @@ function Write-TestRunnerLogInternal {
Write-Host $Message -ForegroundColor Gray
}
function Get-CoberturaCoverageFiles {
param(
[Parameter(Mandatory = $true)]
[string]$ResultsDirectory
)
# coverlet.MTP: [prefix.]coverage.cobertura[.timestamp].xml
$files = @(
Get-ChildItem -Path $ResultsDirectory -Recurse -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like '*coverage.cobertura*.xml' }
)
return @($files | Sort-Object FullName -Unique)
}
function Invoke-TestsWithCoverage {
<#
.SYNOPSIS
@ -155,7 +169,7 @@ function Invoke-TestsWithCoverage {
New-Item -ItemType Directory -Path $ResultsDir -Force | Out-Null
if (-not $Silent) {
Write-TestRunnerLogInternal -Level "STEP" -Message "Running tests with code coverage..."
Write-TestRunnerLogInternal -Level "STEP" -Message "Running tests with code coverage (Microsoft.Testing.Platform / coverlet.MTP)..."
foreach ($d in $resolvedProjectDirs) {
Write-TestRunnerLogInternal -Level "INFO" -Message "Test Project: $d"
}
@ -164,11 +178,15 @@ function Invoke-TestsWithCoverage {
foreach ($TestProjectDir in $resolvedProjectDirs) {
Push-Location $TestProjectDir
try {
$projectName = [System.IO.Path]::GetFileName($TestProjectDir)
$dotnetArgs = @(
"test"
"--collect:XPlat Code Coverage"
"--results-directory", $ResultsDir
"--verbosity", $(if ($Silent) { "quiet" } else { "normal" })
"--coverlet"
"--coverlet-output-format", "cobertura"
"--coverlet-file-prefix", $projectName
"--coverlet-include", "[MaksIT.*]*"
)
Import-ExternalCommandSupportInternal
@ -192,7 +210,7 @@ function Invoke-TestsWithCoverage {
}
}
$coverageFiles = @(Get-ChildItem -Path $ResultsDir -Filter "coverage.cobertura.xml" -Recurse | Sort-Object FullName)
$coverageFiles = @(Get-CoberturaCoverageFiles -ResultsDirectory $ResultsDir)
if ($coverageFiles.Count -eq 0) {
return [PSCustomObject]@{
@ -455,7 +473,7 @@ function Get-DotNetCoverageFromResultsDirectory {
[switch]$Silent
)
$coverageFiles = @(Get-ChildItem -Path $ResultsDirectory -Filter 'coverage.cobertura.xml' -Recurse -ErrorAction SilentlyContinue | Sort-Object FullName)
$coverageFiles = @(Get-CoberturaCoverageFiles -ResultsDirectory $ResultsDirectory)
if ($coverageFiles.Count -eq 0) {
return [PSCustomObject]@{
Success = $false
@ -566,7 +584,7 @@ function Get-CoverageFromResultsDirectory {
}
}
$hasDotNet = @(Get-ChildItem -Path $resolvedDirectory -Filter 'coverage.cobertura.xml' -Recurse -ErrorAction SilentlyContinue).Count -gt 0
$hasDotNet = @(Get-CoberturaCoverageFiles -ResultsDirectory $resolvedDirectory).Count -gt 0
$jestSummary = Join-Path $resolvedDirectory 'coverage-summary.json'
$hasNpm = Test-Path -LiteralPath $jestSummary -PathType Leaf

View File

@ -6,9 +6,10 @@
.NET publish plugin for producing application release artifacts.
.DESCRIPTION
This plugin publishes the configured .NET project into a release output
directory and exposes that published directory to the shared release
context so later release-stage plugins can archive and publish it.
This plugin publishes configured .NET projects into the artifacts directory
and appends those publish folders to shared archive inputs so later plugins
can zip them next to any earlier pack outputs. Existing NuGet package facts
(packageFile) are left unchanged.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
@ -29,25 +30,58 @@ 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]
$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)
}
}
}
foreach ($publishProjectPath in $projectFiles) {
$publishDir = Join-Path $artifactsDirectory ([System.IO.Path]::GetFileNameWithoutExtension($publishProjectPath))
if (Test-Path $publishDir) {
@ -55,7 +89,10 @@ function Invoke-Plugin {
}
Write-Log -Level "STEP" -Message "Publishing release artifact..."
dotnet publish $publishProjectPath -c Release -o $publishDir --nologo
$dotnetPublishArguments = @(
'publish', $publishProjectPath, '-c', 'Release', '-o', $publishDir, '--nologo'
)
& dotnet @dotnetPublishArguments
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed for $publishProjectPath."
}
@ -66,10 +103,10 @@ function Invoke-Plugin {
}
Write-Log -Level "OK" -Message " Published artifact ready: $publishDir"
$archiveInputs.Add($publishDir)
}
Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -Value $null -Overwrite Replace -LegacyProperty 'packageFile'
Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -Value $null -Overwrite Replace -LegacyProperty 'symbolsPackageFile'
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value @($publishDir) -Overwrite Replace -LegacyProperty 'releaseArchiveInputs'
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value @($archiveInputs) -Overwrite Replace -LegacyProperty 'releaseArchiveInputs'
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -7,9 +7,11 @@
.DESCRIPTION
Dedicated version-loading plugin. Reads <Version> from the first configured
projectFiles entry and writes it (plus the resolved projectFiles) to the
shared runtime context. Declares providesVersion = $true so the engine can
discover it as the single release version source.
projectFiles .csproj, or from the nearest Directory.Build.props when the
csproj omits it. Accepts SemVer prerelease (0.1.0-alpha.1 / beta / rc). Writes
version plus the resolved projectFiles (csproj paths for later pack/publish)
to the shared runtime context. Declares providesVersion = $true so the engine
can discover it as the single release version source.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
@ -20,6 +22,22 @@ if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
}
}
function ConvertTo-MsbuildPropertyStringInternal {
param(
$Value
)
if ($null -eq $Value) {
return $null
}
if ($Value -is [System.Xml.XmlElement]) {
return [string]$Value.InnerText
}
return [string]$Value
}
function Get-CsprojPropertyValueInternal {
param(
[Parameter(Mandatory = $true)]
@ -36,7 +54,33 @@ function Get-CsprojPropertyValueInternal {
Select-Object -First 1
if ($propNode) {
return $propNode.$PropertyName
return ConvertTo-MsbuildPropertyStringInternal -Value $propNode.$PropertyName
}
return $null
}
function Get-DirectoryBuildPropsVersionInternal {
param(
[Parameter(Mandatory = $true)]
[string]$ProjectPath
)
# MSBuild uses the first Directory.Build.props found walking up from the project directory.
$dir = [System.IO.Path]::GetDirectoryName((Resolve-Path -LiteralPath $ProjectPath))
while (-not [string]::IsNullOrWhiteSpace($dir)) {
$propsPath = Join-Path $dir 'Directory.Build.props'
if (Test-Path -LiteralPath $propsPath -PathType Leaf) {
[xml]$props = Get-Content -LiteralPath $propsPath
return Get-CsprojPropertyValueInternal -Csproj $props -PropertyName 'Version'
}
$parent = [System.IO.Directory]::GetParent($dir)
if ($null -eq $parent) {
break
}
$dir = $parent.FullName
}
return $null
@ -58,14 +102,18 @@ function Get-CsprojVersionInternal {
[xml]$csproj = Get-Content $ProjectPath
$version = Get-CsprojPropertyValueInternal -Csproj $csproj -PropertyName "Version"
if ([string]::IsNullOrWhiteSpace([string]$version)) {
throw "DotNetReleaseVersion: <Version> not found in '$ProjectPath'."
if (-not [string]::IsNullOrWhiteSpace([string]$version)) {
return [string]$version
}
$version = Get-DirectoryBuildPropsVersionInternal -ProjectPath $ProjectPath
if (-not [string]::IsNullOrWhiteSpace([string]$version)) {
return [string]$version
}
throw "DotNetReleaseVersion: <Version> not found in '$ProjectPath' or a parent Directory.Build.props."
}
function Get-PluginMetadata {
return [pscustomobject]@{ providesVersion = $true }
}
@ -87,6 +135,10 @@ function Invoke-Plugin {
Write-Log -Level "INFO" -Message "Reading version from SDK-style project file (projectFiles)..."
$version = Get-CsprojVersionInternal -ProjectPath $projectFiles[0]
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
if (-not (Test-ReleaseSemver -Version $version)) {
throw "DotNetReleaseVersion: version '$version' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)."
}
Set-EngineState -Context $shared -Name 'version' -Value $version
Set-EngineFact -Context $shared -Namespace 'dotnet' -Name 'projectFiles' -Value $projectFiles -Overwrite Replace -LegacyProperty 'projectFiles'

View File

@ -7,12 +7,15 @@
.DESCRIPTION
Resolves one or more .NET test projects (`project` or `projects`), runs tests once
via TestRunner, then publishes metrics on the shared engine context for any later
via TestRunner (Microsoft Testing Platform + coverlet.MTP only — no VSTest /
coverlet.collector), then publishes metrics on the shared engine context for any later
plugin: `qualityLineCoverage`, `testResult`, `coverageLineRate` / `coverageBranchRate` / `coverageMethodRate`,
method counts, `testResultsDirectory`, `coverageCoberturaPaths`. Quality gates read
those keys generically (not tied to this plugin by name). When `resultsDir` (or the
multi-project default TestResults folder) is used, Cobertura output is kept on disk
via TestRunner `-KeepResults` so repo-root `test-results/` persists after the run.
Product solutions must place `src/global.json` with `test.runner` =
Microsoft.Testing.Platform next to the `.sln`/`.slnx`.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {

View File

@ -28,6 +28,7 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-ReleaseSemverPrereleaseLabel"
$pluginSettings = $Settings
$shared = $Settings.context
@ -62,6 +63,14 @@ function Invoke-Plugin {
[string]$pluginSettings.access
}
$npmDistTag = $null
if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.npmDistTag)) {
$npmDistTag = [string]$pluginSettings.npmDistTag
}
else {
$npmDistTag = Get-ReleaseSemverPrereleaseLabel -Version ([string]$shared.version)
}
$publishOrder = @()
if ($pluginSettings.publishOrder) {
if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) {
@ -84,7 +93,8 @@ function Invoke-Plugin {
if ($dryRun) {
foreach ($packageName in $publishOrder) {
Write-Log -Level "INFO" -Message "Dry run: would publish npm package '$packageName' to $registry"
$tagNote = if ([string]::IsNullOrWhiteSpace($npmDistTag)) { 'latest' } else { $npmDistTag }
Write-Log -Level "INFO" -Message "Dry run: would publish npm package '$packageName' to $registry (dist-tag $tagNote)"
}
return
}
@ -112,13 +122,20 @@ registry=$registry
foreach ($packageName in $publishOrder) {
Write-Log -Level "STEP" -Message "Publishing npm package '$packageName'..."
$publishArgs = @('publish')
if ($useWorkspaces) {
npm publish -w $packageName --access $access --userconfig $tempNpmRcPath
$publishArgs += @('-w', $packageName)
}
else {
Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName
npm publish --access $access --userconfig $tempNpmRcPath
}
$publishArgs += @('--access', $access, '--userconfig', $tempNpmRcPath)
if (-not [string]::IsNullOrWhiteSpace($npmDistTag)) {
$publishArgs += @('--tag', $npmDistTag)
Write-Log -Level "INFO" -Message " Using npm dist-tag '$npmDistTag' (prerelease)."
}
npm @publishArgs
if ($LASTEXITCODE -ne 0) {
throw "Failed to publish npm package '$packageName'."

View File

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

View File

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

View File

@ -10,7 +10,8 @@
repository, and creates the configured GitHub release using the
shared release artifacts and release notes from CHANGELOG.md.
Release notes must use Keep a Changelog headers: ## [semver] - YYYY-MM-DD
(see ChangelogSupport.psm1).
(including optional SemVer prerelease, e.g. ## [0.1.0-alpha.1] / [0.1.0-beta.1] / [0.1.0-rc.1];
see ChangelogSupport.psm1). Hyphenated versions are created with gh --prerelease.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
@ -95,6 +96,7 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-LatestChangelogVersion"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemverPrerelease"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Get-EngineFact"
$pluginSettings = $Settings
@ -128,6 +130,9 @@ function Invoke-Plugin {
}
$releaseName = $releaseTitlePattern -replace '\{version\}', $version
Write-Log -Level "INFO" -Message "Dry run: would create GitHub release '$releaseName' ($tag) on $repo"
if (Test-ReleaseSemverPrerelease -Version ([string]$version)) {
Write-Log -Level "INFO" -Message "Dry run: release would be marked prerelease."
}
return
}
@ -261,6 +266,10 @@ function Invoke-Plugin {
"--title", $releaseName,
"--notes-file", $notesFilePath
)
if (Test-ReleaseSemverPrerelease -Version ([string]$version)) {
$createReleaseArgs += '--prerelease'
}
& gh @createReleaseArgs
if ($LASTEXITCODE -ne 0) {

View File

@ -11,8 +11,9 @@
when they do not (whenRequirementsNotMet: skip). Publish plugins no longer use per-plugin
branch lists; put allowed branches here instead.
Typical checks: allowed branches, optional clean working tree, exact semver tag on HEAD,
tag version vs DotNetReleaseVersion, optional push tag to remote.
Typical checks: allowed branches, optional clean working tree, exact semver tag on HEAD
(vX.Y.Z or vX.Y.Z-prerelease such as v0.1.0-alpha.1 / v0.1.0-beta.1 / v0.1.0-rc.1),
tag version vs release version, optional push tag to remote.
The engine preflight no longer reads git tags; this plugin sets context.tag from the
git tag on HEAD when required. Shared context version always remains from DotNetReleaseVersion.
@ -77,6 +78,7 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Get-GitStatusShort"
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Test-RemoteTagExists"
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Push-TagToRemote"
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-ChangelogSemverPattern"
$pluginSettings = $Settings
$shared = $Settings.context
@ -123,8 +125,9 @@ function Invoke-Plugin {
return
}
if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') {
Invoke-NotMetInternal -Shared $shared -When $when -Reason "tag '$tag' must match vX.Y.Z."
$tagPattern = '^v(' + (Get-ChangelogSemverPattern) + ')$'
if ($tag -notmatch $tagPattern) {
Invoke-NotMetInternal -Shared $shared -When $when -Reason "tag '$tag' must match vX.Y.Z or vX.Y.Z-prerelease (e.g. v0.1.0-alpha.1, v0.1.0-beta.1, v0.1.0-rc.1)."
return
}