(feature): add secrets cli and migrate tests to xunit v3
This commit is contained in:
parent
434b569b3a
commit
5443f385d7
@ -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
|
||||
|
||||
@ -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`
|
||||
|
||||
50
README.md
50
README.md
@ -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
|
||||
|
||||
38
src/MaksIT.Core.Cli.Tests/CliActionsTests.cs
Normal file
38
src/MaksIT.Core.Cli.Tests/CliActionsTests.cs
Normal file
@ -0,0 +1,38 @@
|
||||
using MaksIT.Core.Cli;
|
||||
|
||||
|
||||
namespace MaksIT.Core.Cli.Tests;
|
||||
|
||||
public class CliActionsTests {
|
||||
[Fact]
|
||||
public void GenerateSecret_InvalidBytes_ReturnsOne() {
|
||||
var exit = CliActions.GenerateSecret(0);
|
||||
|
||||
Assert.Equal(1, exit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateCombGuid_InvalidType_ReturnsOne() {
|
||||
var exit = CliActions.GenerateCombGuid("rsa");
|
||||
|
||||
Assert.Equal(1, exit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateCombGuid_PostgreSql_ReturnsZero() {
|
||||
var exit = CliActions.GenerateCombGuid(null);
|
||||
|
||||
Assert.Equal(0, exit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashPassword_EmptyPassword_ReturnsOne() {
|
||||
var exit = CliActions.HashPassword("pepper", "");
|
||||
|
||||
Assert.Equal(1, exit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateAesKey_ReturnsZero() =>
|
||||
Assert.Equal(0, CliActions.GenerateAesKey());
|
||||
}
|
||||
43
src/MaksIT.Core.Cli.Tests/CommandFactoryTests.cs
Normal file
43
src/MaksIT.Core.Cli.Tests/CommandFactoryTests.cs
Normal file
@ -0,0 +1,43 @@
|
||||
using MaksIT.Core.Cli;
|
||||
|
||||
|
||||
namespace MaksIT.Core.Cli.Tests;
|
||||
|
||||
public class CommandFactoryTests {
|
||||
[Fact]
|
||||
public void Secret_HasNoParseErrors() {
|
||||
var result = CommandFactory.CreateRootCommand().Parse(["secret"]);
|
||||
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JwtGenerate_MissingSecret_HasParseError() {
|
||||
var result = CommandFactory.CreateRootCommand().Parse([
|
||||
"jwt", "generate", "--issuer", "i", "--audience", "a"
|
||||
]);
|
||||
|
||||
Assert.NotEmpty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JwtGenerate_RequiredOptions_HasNoParseErrors() {
|
||||
var result = CommandFactory.CreateRootCommand().Parse([
|
||||
"jwt", "generate",
|
||||
"--secret", "s",
|
||||
"--issuer", "i",
|
||||
"--audience", "a"
|
||||
]);
|
||||
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TotpValidate_RequiredOptions_HasNoParseErrors() {
|
||||
var result = CommandFactory.CreateRootCommand().Parse([
|
||||
"totp", "validate", "--secret", "s", "--code", "123456"
|
||||
]);
|
||||
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
}
|
||||
60
src/MaksIT.Core.Cli.Tests/InputParsersTests.cs
Normal file
60
src/MaksIT.Core.Cli.Tests/InputParsersTests.cs
Normal file
@ -0,0 +1,60 @@
|
||||
using MaksIT.Core.Cli;
|
||||
using MaksIT.Core.Comb;
|
||||
|
||||
|
||||
namespace MaksIT.Core.Cli.Tests;
|
||||
|
||||
public class InputParsersTests {
|
||||
[Fact]
|
||||
public void TryParsePositiveInt_Blank_ReturnsDefault() {
|
||||
var result = InputParsers.TryParsePositiveInt(" ", 32, out var value, out var errorMessage);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(32, value);
|
||||
Assert.Null(errorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParsePositiveInt_ValidNumber_ReturnsValue() {
|
||||
var result = InputParsers.TryParsePositiveInt("64", 32, out var value, out var errorMessage);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(64, value);
|
||||
Assert.Null(errorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParsePositiveInt_Invalid_ReturnsError() {
|
||||
var result = InputParsers.TryParsePositiveInt("abc", 32, out var value, out var errorMessage);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, value);
|
||||
Assert.NotNull(errorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseCombGuidType_Blank_ReturnsPostgreSql() {
|
||||
var result = InputParsers.TryParseCombGuidType(null, out var type, out var errorMessage);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(CombGuidType.PostgreSql, type);
|
||||
Assert.Null(errorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseCombGuidType_SqlServer_ParsesIgnoreCase() {
|
||||
var result = InputParsers.TryParseCombGuidType("sqlserver", out var type, out var errorMessage);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(CombGuidType.SqlServer, type);
|
||||
Assert.Null(errorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseOptionalList_SplitsAndTrims() {
|
||||
var items = InputParsers.ParseOptionalList(" Admin, User , ");
|
||||
|
||||
Assert.NotNull(items);
|
||||
Assert.Equal(["Admin", "User"], items);
|
||||
}
|
||||
}
|
||||
27
src/MaksIT.Core.Cli.Tests/MaksIT.Core.Cli.Tests.csproj
Normal file
27
src/MaksIT.Core.Cli.Tests/MaksIT.Core.Cli.Tests.csproj
Normal file
@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.MTP" Version="10.0.1" />
|
||||
<PackageReference Include="xunit.v3" Version="4.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MaksIT.Core.Cli\MaksIT.Core.Cli.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
60
src/MaksIT.Core.Cli.Tests/SecretOperationsTests.cs
Normal file
60
src/MaksIT.Core.Cli.Tests/SecretOperationsTests.cs
Normal file
@ -0,0 +1,60 @@
|
||||
using MaksIT.Core.Cli;
|
||||
using MaksIT.Core.Comb;
|
||||
using MaksIT.Core.Security.JWT;
|
||||
|
||||
|
||||
namespace MaksIT.Core.Cli.Tests;
|
||||
|
||||
public class SecretOperationsTests {
|
||||
[Fact]
|
||||
public void GenerateSecret_ReturnsUniqueNonEmptyValues() {
|
||||
var secret1 = SecretOperations.GenerateSecret();
|
||||
var secret2 = SecretOperations.GenerateSecret();
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(secret1));
|
||||
Assert.False(string.IsNullOrWhiteSpace(secret2));
|
||||
Assert.NotEqual(secret1, secret2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateAesKey_ReturnsNonEmptyValue() =>
|
||||
Assert.False(string.IsNullOrWhiteSpace(SecretOperations.GenerateAesKey()));
|
||||
|
||||
[Fact]
|
||||
public void GenerateCombGuid_PostgreSql_ReturnsNonEmptyGuid() {
|
||||
var guid = SecretOperations.GenerateCombGuid(CombGuidType.PostgreSql);
|
||||
|
||||
Assert.NotEqual(Guid.Empty, guid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGenerateJwt_ThenValidate_Succeeds() {
|
||||
var secret = SecretOperations.GenerateSecret();
|
||||
var request = new JWTTokenGenerateRequest {
|
||||
Secret = secret,
|
||||
Issuer = "cli-tests",
|
||||
Audience = "cli-tests",
|
||||
Expiration = 5,
|
||||
Username = "tester"
|
||||
};
|
||||
|
||||
var generated = SecretOperations.TryGenerateJwt(request, out var token, out var generateError);
|
||||
|
||||
Assert.True(generated);
|
||||
Assert.False(string.IsNullOrWhiteSpace(token));
|
||||
Assert.Null(generateError);
|
||||
|
||||
var validated = SecretOperations.TryValidateJwt(
|
||||
secret,
|
||||
request.Issuer,
|
||||
request.Audience,
|
||||
token!,
|
||||
out var claims,
|
||||
out var validateError
|
||||
);
|
||||
|
||||
Assert.True(validated);
|
||||
Assert.Equal("tester", claims?.Username);
|
||||
Assert.Null(validateError);
|
||||
}
|
||||
}
|
||||
317
src/MaksIT.Core.Cli/Application.cs
Normal file
317
src/MaksIT.Core.Cli/Application.cs
Normal file
@ -0,0 +1,317 @@
|
||||
using System.Text;
|
||||
using System.Reflection;
|
||||
using MaksIT.Core.Comb;
|
||||
using MaksIT.Core.Extensions;
|
||||
using MaksIT.Core.Security.JWT;
|
||||
|
||||
|
||||
namespace MaksIT.Core.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Interactive numbered menu for generating MaksIT.Core secrets.
|
||||
/// </summary>
|
||||
public sealed class Application {
|
||||
/// <summary>
|
||||
/// Runs the main menu until the user exits.
|
||||
/// </summary>
|
||||
public void Run() {
|
||||
Console.OutputEncoding = Encoding.UTF8;
|
||||
var version = typeof(Application).Assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||
.InformationalVersion?
|
||||
.Split('+')[0]
|
||||
?? "0.0.0";
|
||||
|
||||
while (true) {
|
||||
Console.WriteLine($"MaksIT.Core.Cli v{version}");
|
||||
Console.WriteLine("© Maksym Sadovnychyy (MAKS-IT) 2026");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("1. Generate secret (JWT / pepper)");
|
||||
Console.WriteLine("2. JWT");
|
||||
Console.WriteLine("3. AES-GCM key");
|
||||
Console.WriteLine("4. TOTP / 2FA");
|
||||
Console.WriteLine("5. Password hash");
|
||||
Console.WriteLine("6. COMB GUID");
|
||||
Console.WriteLine("0. Exit");
|
||||
Console.Write("Enter your choice: ");
|
||||
|
||||
var choice = Console.ReadLine();
|
||||
try {
|
||||
switch (choice) {
|
||||
case "1":
|
||||
GenerateSecret();
|
||||
Pause();
|
||||
break;
|
||||
case "2":
|
||||
RunJwtMenu();
|
||||
break;
|
||||
case "3":
|
||||
WriteLabeled("AES-256 key", SecretOperations.GenerateAesKey());
|
||||
Pause();
|
||||
break;
|
||||
case "4":
|
||||
RunTotpMenu();
|
||||
break;
|
||||
case "5":
|
||||
HashPassword();
|
||||
Pause();
|
||||
break;
|
||||
case "6":
|
||||
GenerateCombGuid();
|
||||
Pause();
|
||||
break;
|
||||
case "0":
|
||||
return;
|
||||
default:
|
||||
Console.WriteLine("Invalid option.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) {
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
Pause();
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
|
||||
private static void RunJwtMenu() {
|
||||
while (true) {
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("JWT");
|
||||
Console.WriteLine("1. Generate signing secret");
|
||||
Console.WriteLine("2. Generate refresh token");
|
||||
Console.WriteLine("3. Generate access token");
|
||||
Console.WriteLine("4. Validate token");
|
||||
Console.WriteLine("0. Back");
|
||||
Console.Write("Enter your choice: ");
|
||||
|
||||
var choice = Console.ReadLine();
|
||||
try {
|
||||
switch (choice) {
|
||||
case "1":
|
||||
GenerateSecret();
|
||||
Pause();
|
||||
break;
|
||||
case "2":
|
||||
WriteLabeled("Refresh token", SecretOperations.GenerateRefreshToken());
|
||||
Pause();
|
||||
break;
|
||||
case "3":
|
||||
GenerateAccessToken();
|
||||
Pause();
|
||||
break;
|
||||
case "4":
|
||||
ValidateAccessToken();
|
||||
Pause();
|
||||
break;
|
||||
case "0":
|
||||
return;
|
||||
default:
|
||||
Console.WriteLine("Invalid option.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) {
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
Pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void RunTotpMenu() {
|
||||
while (true) {
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("TOTP / 2FA");
|
||||
Console.WriteLine("1. Generate secret");
|
||||
Console.WriteLine("2. Generate recovery codes");
|
||||
Console.WriteLine("3. Generate otpauth link");
|
||||
Console.WriteLine("4. Validate code");
|
||||
Console.WriteLine("0. Back");
|
||||
Console.Write("Enter your choice: ");
|
||||
|
||||
var choice = Console.ReadLine();
|
||||
try {
|
||||
switch (choice) {
|
||||
case "1":
|
||||
if (!SecretOperations.TryGenerateTotpSecret(out var secret, out var secretError))
|
||||
throw new InvalidOperationException(secretError);
|
||||
|
||||
WriteLabeled("TOTP secret", secret);
|
||||
Pause();
|
||||
break;
|
||||
case "2":
|
||||
GenerateRecoveryCodes();
|
||||
Pause();
|
||||
break;
|
||||
case "3":
|
||||
GenerateTotpAuthLink();
|
||||
Pause();
|
||||
break;
|
||||
case "4":
|
||||
ValidateTotp();
|
||||
Pause();
|
||||
break;
|
||||
case "0":
|
||||
return;
|
||||
default:
|
||||
Console.WriteLine("Invalid option.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) {
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
Pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void GenerateSecret() {
|
||||
var bytes = ReadPositiveInt("Key size in bytes", 32);
|
||||
WriteLabeled("Secret", SecretOperations.GenerateSecret(bytes));
|
||||
}
|
||||
|
||||
private static void GenerateAccessToken() {
|
||||
var request = new JWTTokenGenerateRequest {
|
||||
Secret = ReadRequired("Secret"),
|
||||
Issuer = ReadRequired("Issuer"),
|
||||
Audience = ReadRequired("Audience"),
|
||||
Expiration = ReadPositiveInt("Expiration (minutes)", 60),
|
||||
UserId = ReadOptional("User id"),
|
||||
Username = ReadOptional("Username"),
|
||||
Roles = InputParsers.ParseOptionalList(ReadOptional("Roles (comma-separated)")),
|
||||
AclEntries = InputParsers.ParseOptionalList(ReadOptional("ACL entries (comma-separated)"))
|
||||
};
|
||||
|
||||
if (!SecretOperations.TryGenerateJwt(request, out var token, out var errorMessage))
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
|
||||
WriteLabeled("Access token", token);
|
||||
}
|
||||
|
||||
private static void ValidateAccessToken() {
|
||||
var secret = ReadRequired("Secret");
|
||||
var issuer = ReadRequired("Issuer");
|
||||
var audience = ReadRequired("Audience");
|
||||
var token = ReadRequired("Token");
|
||||
|
||||
if (!SecretOperations.TryValidateJwt(secret, issuer, audience, token, out var claims, out var errorMessage))
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
|
||||
WriteLabeled("Claims", claims.ToJson());
|
||||
}
|
||||
|
||||
private static void GenerateRecoveryCodes() {
|
||||
var count = ReadPositiveInt("Number of codes", 10);
|
||||
if (!SecretOperations.TryGenerateRecoveryCodes(count, out var codes, out var errorMessage))
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
|
||||
Console.WriteLine("Recovery codes:");
|
||||
foreach (var code in codes)
|
||||
Console.WriteLine(code);
|
||||
}
|
||||
|
||||
private static void GenerateTotpAuthLink() {
|
||||
var label = ReadRequired("Label");
|
||||
var username = ReadRequired("Username");
|
||||
var secret = ReadRequired("TOTP secret");
|
||||
var issuer = ReadRequired("Issuer");
|
||||
|
||||
if (!SecretOperations.TryGenerateTotpAuthLink(label, username, secret, issuer, out var authLink, out var errorMessage))
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
|
||||
WriteLabeled("otpauth link", authLink);
|
||||
}
|
||||
|
||||
private static void ValidateTotp() {
|
||||
var secret = ReadRequired("TOTP secret");
|
||||
var code = ReadRequired("Code");
|
||||
var tolerance = ReadPositiveInt("Time-step tolerance", 1);
|
||||
|
||||
if (!SecretOperations.TryValidateTotp(code, secret, tolerance, out var isValid, out var errorMessage))
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
|
||||
Console.WriteLine(isValid ? "Valid." : "Invalid.");
|
||||
}
|
||||
|
||||
private static void HashPassword() {
|
||||
var pepper = ReadRequired("Pepper");
|
||||
var password = ReadSecret("Password");
|
||||
if (string.IsNullOrEmpty(password))
|
||||
throw new InvalidOperationException("Password is required.");
|
||||
|
||||
if (!SecretOperations.TryHashPassword(password, pepper, out var saltedHash, out var errorMessage))
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
|
||||
Console.WriteLine($"Salt: {saltedHash.Value.Salt}");
|
||||
Console.WriteLine($"Hash: {saltedHash.Value.Hash}");
|
||||
}
|
||||
|
||||
private static void GenerateCombGuid() {
|
||||
Console.Write("COMB type (PostgreSql/SqlServer) [PostgreSql]: ");
|
||||
if (!InputParsers.TryParseCombGuidType(Console.ReadLine(), out var type, out var errorMessage))
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
|
||||
WriteLabeled($"COMB GUID ({type})", SecretOperations.GenerateCombGuid(type).ToString());
|
||||
}
|
||||
|
||||
private static int ReadPositiveInt(string prompt, int defaultValue) {
|
||||
Console.Write($"{prompt} [{defaultValue}]: ");
|
||||
if (!InputParsers.TryParsePositiveInt(Console.ReadLine(), defaultValue, out var value, out var errorMessage))
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string ReadRequired(string prompt) {
|
||||
Console.Write($"{prompt}: ");
|
||||
var value = Console.ReadLine();
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw new InvalidOperationException($"{prompt} is required.");
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static string? ReadOptional(string prompt) {
|
||||
Console.Write($"{prompt}: ");
|
||||
var value = Console.ReadLine();
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return null;
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static string ReadSecret(string prompt) {
|
||||
Console.Write($"{prompt}: ");
|
||||
var builder = new StringBuilder();
|
||||
while (true) {
|
||||
var key = Console.ReadKey(intercept: true);
|
||||
if (key.Key == ConsoleKey.Enter) {
|
||||
Console.WriteLine();
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
if (key.Key == ConsoleKey.Backspace) {
|
||||
if (builder.Length > 0)
|
||||
builder.Length--;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!char.IsControl(key.KeyChar))
|
||||
builder.Append(key.KeyChar);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteLabeled(string label, string value) {
|
||||
Console.WriteLine($"{label}:");
|
||||
Console.WriteLine(value);
|
||||
}
|
||||
|
||||
private static void Pause() {
|
||||
Console.WriteLine();
|
||||
Console.Write("Press Enter to continue...");
|
||||
Console.ReadLine();
|
||||
}
|
||||
}
|
||||
170
src/MaksIT.Core.Cli/CliActions.cs
Normal file
170
src/MaksIT.Core.Cli/CliActions.cs
Normal file
@ -0,0 +1,170 @@
|
||||
using MaksIT.Core.Extensions;
|
||||
using MaksIT.Core.Security.JWT;
|
||||
|
||||
|
||||
namespace MaksIT.Core.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Non-interactive command handlers: values on stdout, errors on stderr, exit 0/1.
|
||||
/// </summary>
|
||||
public static class CliActions {
|
||||
/// <summary>
|
||||
/// Writes an error to stderr and returns exit code 1.
|
||||
/// </summary>
|
||||
public static int Fail(string errorMessage) {
|
||||
Console.Error.WriteLine(errorMessage);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Base64 secret.
|
||||
/// </summary>
|
||||
public static int GenerateSecret(int bytes) {
|
||||
if (!InputParsers.TryParsePositiveInt(bytes.ToString(), 32, out var keySize, out var errorMessage))
|
||||
return Fail(errorMessage!);
|
||||
|
||||
Console.WriteLine(SecretOperations.GenerateSecret(keySize));
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates an opaque refresh token.
|
||||
/// </summary>
|
||||
public static int GenerateRefreshToken() {
|
||||
Console.WriteLine(SecretOperations.GenerateRefreshToken());
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Base64 AES-256 key.
|
||||
/// </summary>
|
||||
public static int GenerateAesKey() {
|
||||
Console.WriteLine(SecretOperations.GenerateAesKey());
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a COMB GUID.
|
||||
/// </summary>
|
||||
public static int GenerateCombGuid(string? typeName) {
|
||||
if (!InputParsers.TryParseCombGuidType(typeName, out var type, out var errorMessage))
|
||||
return Fail(errorMessage!);
|
||||
|
||||
Console.WriteLine(SecretOperations.GenerateCombGuid(type));
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signs an access JWT.
|
||||
/// </summary>
|
||||
public static int GenerateJwt(
|
||||
string secret,
|
||||
string issuer,
|
||||
string audience,
|
||||
int expiration,
|
||||
string? userId,
|
||||
string? username,
|
||||
string? roles,
|
||||
string? aclEntries
|
||||
) {
|
||||
if (!InputParsers.TryParsePositiveInt(expiration.ToString(), 60, out var minutes, out var errorMessage))
|
||||
return Fail(errorMessage!);
|
||||
|
||||
var request = new JWTTokenGenerateRequest {
|
||||
Secret = secret,
|
||||
Issuer = issuer,
|
||||
Audience = audience,
|
||||
Expiration = minutes,
|
||||
UserId = EmptyToNull(userId),
|
||||
Username = EmptyToNull(username),
|
||||
Roles = InputParsers.ParseOptionalList(roles),
|
||||
AclEntries = InputParsers.ParseOptionalList(aclEntries)
|
||||
};
|
||||
|
||||
if (!SecretOperations.TryGenerateJwt(request, out var token, out var generateError))
|
||||
return Fail(generateError);
|
||||
|
||||
Console.WriteLine(token);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates an access JWT and writes claims JSON.
|
||||
/// </summary>
|
||||
public static int ValidateJwt(string secret, string issuer, string audience, string token) {
|
||||
if (!SecretOperations.TryValidateJwt(secret, issuer, audience, token, out var claims, out var errorMessage))
|
||||
return Fail(errorMessage);
|
||||
|
||||
Console.WriteLine(claims.ToJson());
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Base32 TOTP secret.
|
||||
/// </summary>
|
||||
public static int GenerateTotpSecret() {
|
||||
if (!SecretOperations.TryGenerateTotpSecret(out var secret, out var errorMessage))
|
||||
return Fail(errorMessage);
|
||||
|
||||
Console.WriteLine(secret);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates TOTP recovery codes (one per line).
|
||||
/// </summary>
|
||||
public static int GenerateRecoveryCodes(int count) {
|
||||
if (!InputParsers.TryParsePositiveInt(count.ToString(), 10, out var codeCount, out var errorMessage))
|
||||
return Fail(errorMessage!);
|
||||
|
||||
if (!SecretOperations.TryGenerateRecoveryCodes(codeCount, out var codes, out var generateError))
|
||||
return Fail(generateError);
|
||||
|
||||
foreach (var code in codes)
|
||||
Console.WriteLine(code);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an otpauth URI.
|
||||
/// </summary>
|
||||
public static int GenerateTotpAuthLink(string label, string username, string secret, string issuer) {
|
||||
if (!SecretOperations.TryGenerateTotpAuthLink(label, username, secret, issuer, out var authLink, out var errorMessage))
|
||||
return Fail(errorMessage);
|
||||
|
||||
Console.WriteLine(authLink);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a TOTP code. Exit 1 when the code is invalid.
|
||||
/// </summary>
|
||||
public static int ValidateTotp(string secret, string code, int tolerance) {
|
||||
if (!InputParsers.TryParsePositiveInt(tolerance.ToString(), 1, out var timeTolerance, out var errorMessage))
|
||||
return Fail(errorMessage!);
|
||||
|
||||
if (!SecretOperations.TryValidateTotp(code, secret, timeTolerance, out var isValid, out var validateError))
|
||||
return Fail(validateError);
|
||||
|
||||
Console.WriteLine(isValid ? "valid" : "invalid");
|
||||
return isValid ? 0 : 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a salted password hash as JSON.
|
||||
/// </summary>
|
||||
public static int HashPassword(string pepper, string password) {
|
||||
if (string.IsNullOrEmpty(password))
|
||||
return Fail("Password is required.");
|
||||
|
||||
if (!SecretOperations.TryHashPassword(password, pepper, out var saltedHash, out var errorMessage))
|
||||
return Fail(errorMessage);
|
||||
|
||||
Console.WriteLine(new { salt = saltedHash.Value.Salt, hash = saltedHash.Value.Hash }.ToJson());
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static string? EmptyToNull(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
277
src/MaksIT.Core.Cli/CommandFactory.cs
Normal file
277
src/MaksIT.Core.Cli/CommandFactory.cs
Normal file
@ -0,0 +1,277 @@
|
||||
using System.CommandLine;
|
||||
|
||||
|
||||
namespace MaksIT.Core.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the agent-facing command tree. No arguments runs the interactive menu.
|
||||
/// </summary>
|
||||
public static class CommandFactory {
|
||||
/// <summary>
|
||||
/// Creates the root command with secret, jwt, aes, totp, password, and guid subcommands.
|
||||
/// </summary>
|
||||
public static RootCommand CreateRootCommand() {
|
||||
var root = new RootCommand("Generate MaksIT.Core secrets. No arguments opens the interactive menu.") {
|
||||
CreateSecretCommand(),
|
||||
CreateJwtCommand(),
|
||||
CreateAesCommand(),
|
||||
CreateTotpCommand(),
|
||||
CreatePasswordCommand(),
|
||||
CreateGuidCommand()
|
||||
};
|
||||
|
||||
root.SetAction(_ => {
|
||||
new Application().Run();
|
||||
return 0;
|
||||
});
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
private static Command CreateSecretCommand() {
|
||||
var bytesOption = BytesOption();
|
||||
var command = new Command("secret", "Generate a Base64 secret (JWT signing key or password pepper)") {
|
||||
bytesOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
CliActions.GenerateSecret(parseResult.GetValue(bytesOption)));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command CreateJwtCommand() {
|
||||
var jwt = new Command("jwt", "JWT signing secrets, tokens, and validation");
|
||||
jwt.Subcommands.Add(CreateJwtSecretCommand());
|
||||
jwt.Subcommands.Add(CreateJwtRefreshCommand());
|
||||
jwt.Subcommands.Add(CreateJwtGenerateCommand());
|
||||
jwt.Subcommands.Add(CreateJwtValidateCommand());
|
||||
return jwt;
|
||||
}
|
||||
|
||||
private static Command CreateJwtSecretCommand() {
|
||||
var bytesOption = BytesOption();
|
||||
var command = new Command("secret", "Generate a JWT signing secret") {
|
||||
bytesOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
CliActions.GenerateSecret(parseResult.GetValue(bytesOption)));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command CreateJwtRefreshCommand() {
|
||||
var command = new Command("refresh", "Generate an opaque refresh token");
|
||||
command.SetAction(_ => CliActions.GenerateRefreshToken());
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command CreateJwtGenerateCommand() {
|
||||
var secretOption = RequiredString("--secret", "Signing secret");
|
||||
var issuerOption = RequiredString("--issuer", "Token issuer");
|
||||
var audienceOption = RequiredString("--audience", "Token audience");
|
||||
var expirationOption = new Option<int>("--expiration") {
|
||||
Description = "Lifetime in minutes",
|
||||
DefaultValueFactory = _ => 60
|
||||
};
|
||||
var userIdOption = new Option<string?>("--user-id") {
|
||||
Description = "Optional user id claim"
|
||||
};
|
||||
var usernameOption = new Option<string?>("--username") {
|
||||
Description = "Optional username claim"
|
||||
};
|
||||
var rolesOption = new Option<string?>("--roles") {
|
||||
Description = "Optional comma-separated roles"
|
||||
};
|
||||
var aclOption = new Option<string?>("--acl") {
|
||||
Description = "Optional comma-separated ACL entries"
|
||||
};
|
||||
|
||||
var command = new Command("generate", "Sign an access JWT") {
|
||||
secretOption,
|
||||
issuerOption,
|
||||
audienceOption,
|
||||
expirationOption,
|
||||
userIdOption,
|
||||
usernameOption,
|
||||
rolesOption,
|
||||
aclOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
CliActions.GenerateJwt(
|
||||
parseResult.GetValue(secretOption)!,
|
||||
parseResult.GetValue(issuerOption)!,
|
||||
parseResult.GetValue(audienceOption)!,
|
||||
parseResult.GetValue(expirationOption),
|
||||
parseResult.GetValue(userIdOption),
|
||||
parseResult.GetValue(usernameOption),
|
||||
parseResult.GetValue(rolesOption),
|
||||
parseResult.GetValue(aclOption)
|
||||
));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command CreateJwtValidateCommand() {
|
||||
var secretOption = RequiredString("--secret", "Signing secret");
|
||||
var issuerOption = RequiredString("--issuer", "Token issuer");
|
||||
var audienceOption = RequiredString("--audience", "Token audience");
|
||||
var tokenOption = RequiredString("--token", "JWT to validate");
|
||||
|
||||
var command = new Command("validate", "Validate an access JWT and print claims JSON") {
|
||||
secretOption,
|
||||
issuerOption,
|
||||
audienceOption,
|
||||
tokenOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
CliActions.ValidateJwt(
|
||||
parseResult.GetValue(secretOption)!,
|
||||
parseResult.GetValue(issuerOption)!,
|
||||
parseResult.GetValue(audienceOption)!,
|
||||
parseResult.GetValue(tokenOption)!
|
||||
));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command CreateAesCommand() {
|
||||
var aes = new Command("aes", "AES-GCM keys");
|
||||
var key = new Command("key", "Generate a Base64 AES-256 key");
|
||||
key.SetAction(_ => CliActions.GenerateAesKey());
|
||||
aes.Subcommands.Add(key);
|
||||
return aes;
|
||||
}
|
||||
|
||||
private static Command CreateTotpCommand() {
|
||||
var totp = new Command("totp", "TOTP / 2FA secrets, recovery codes, and validation");
|
||||
totp.Subcommands.Add(CreateTotpSecretCommand());
|
||||
totp.Subcommands.Add(CreateTotpRecoveryCommand());
|
||||
totp.Subcommands.Add(CreateTotpLinkCommand());
|
||||
totp.Subcommands.Add(CreateTotpValidateCommand());
|
||||
return totp;
|
||||
}
|
||||
|
||||
private static Command CreateTotpSecretCommand() {
|
||||
var command = new Command("secret", "Generate a Base32 TOTP shared secret");
|
||||
command.SetAction(_ => CliActions.GenerateTotpSecret());
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command CreateTotpRecoveryCommand() {
|
||||
var countOption = new Option<int>("--count") {
|
||||
Description = "Number of recovery codes",
|
||||
DefaultValueFactory = _ => 10
|
||||
};
|
||||
|
||||
var command = new Command("recovery", "Generate TOTP recovery codes (one per line)") {
|
||||
countOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
CliActions.GenerateRecoveryCodes(parseResult.GetValue(countOption)));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command CreateTotpLinkCommand() {
|
||||
var labelOption = RequiredString("--label", "Authenticator label");
|
||||
var usernameOption = RequiredString("--username", "Account username");
|
||||
var secretOption = RequiredString("--secret", "Base32 TOTP secret");
|
||||
var issuerOption = RequiredString("--issuer", "Issuer name");
|
||||
|
||||
var command = new Command("link", "Build an otpauth:// URI") {
|
||||
labelOption,
|
||||
usernameOption,
|
||||
secretOption,
|
||||
issuerOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
CliActions.GenerateTotpAuthLink(
|
||||
parseResult.GetValue(labelOption)!,
|
||||
parseResult.GetValue(usernameOption)!,
|
||||
parseResult.GetValue(secretOption)!,
|
||||
parseResult.GetValue(issuerOption)!
|
||||
));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command CreateTotpValidateCommand() {
|
||||
var secretOption = RequiredString("--secret", "Base32 TOTP secret");
|
||||
var codeOption = RequiredString("--code", "Six-digit TOTP code");
|
||||
var toleranceOption = new Option<int>("--tolerance") {
|
||||
Description = "Time-step windows to accept on each side",
|
||||
DefaultValueFactory = _ => 1
|
||||
};
|
||||
|
||||
var command = new Command("validate", "Validate a TOTP code (prints valid/invalid)") {
|
||||
secretOption,
|
||||
codeOption,
|
||||
toleranceOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
CliActions.ValidateTotp(
|
||||
parseResult.GetValue(secretOption)!,
|
||||
parseResult.GetValue(codeOption)!,
|
||||
parseResult.GetValue(toleranceOption)
|
||||
));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command CreatePasswordCommand() {
|
||||
var password = new Command("password", "Password hashing");
|
||||
var pepperOption = RequiredString("--pepper", "Application pepper");
|
||||
var passwordOption = RequiredString("--password", "Password to hash");
|
||||
|
||||
var hash = new Command("hash", "Create a salted hash (JSON with salt and hash)") {
|
||||
pepperOption,
|
||||
passwordOption
|
||||
};
|
||||
|
||||
hash.SetAction(parseResult =>
|
||||
CliActions.HashPassword(
|
||||
parseResult.GetValue(pepperOption)!,
|
||||
parseResult.GetValue(passwordOption)!
|
||||
));
|
||||
|
||||
password.Subcommands.Add(hash);
|
||||
return password;
|
||||
}
|
||||
|
||||
private static Command CreateGuidCommand() {
|
||||
var guid = new Command("guid", "COMB GUID generation");
|
||||
var typeOption = new Option<string>("--type") {
|
||||
Description = "PostgreSql or SqlServer",
|
||||
DefaultValueFactory = _ => "PostgreSql"
|
||||
};
|
||||
|
||||
var comb = new Command("comb", "Generate a COMB GUID") {
|
||||
typeOption
|
||||
};
|
||||
|
||||
comb.SetAction(parseResult =>
|
||||
CliActions.GenerateCombGuid(parseResult.GetValue(typeOption)));
|
||||
|
||||
guid.Subcommands.Add(comb);
|
||||
return guid;
|
||||
}
|
||||
|
||||
private static Option<int> BytesOption() =>
|
||||
new("--bytes") {
|
||||
Description = "Random key size in bytes",
|
||||
DefaultValueFactory = _ => 32
|
||||
};
|
||||
|
||||
private static Option<string> RequiredString(string name, string description) =>
|
||||
new(name) {
|
||||
Description = description,
|
||||
Required = true
|
||||
};
|
||||
}
|
||||
76
src/MaksIT.Core.Cli/InputParsers.cs
Normal file
76
src/MaksIT.Core.Cli/InputParsers.cs
Normal file
@ -0,0 +1,76 @@
|
||||
using MaksIT.Core.Comb;
|
||||
|
||||
|
||||
namespace MaksIT.Core.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Parses interactive menu input for the Core CLI.
|
||||
/// </summary>
|
||||
public static class InputParsers {
|
||||
/// <summary>
|
||||
/// Parses a positive integer, using <paramref name="defaultValue"/> when input is blank.
|
||||
/// </summary>
|
||||
public static bool TryParsePositiveInt(
|
||||
string? input,
|
||||
int defaultValue,
|
||||
out int value,
|
||||
out string? errorMessage
|
||||
) {
|
||||
if (string.IsNullOrWhiteSpace(input)) {
|
||||
value = defaultValue;
|
||||
errorMessage = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!int.TryParse(input.Trim(), out value) || value <= 0) {
|
||||
value = 0;
|
||||
errorMessage = "Value must be a positive integer.";
|
||||
return false;
|
||||
}
|
||||
|
||||
errorMessage = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a COMB GUID type, defaulting to <see cref="CombGuidType.PostgreSql"/> when input is blank.
|
||||
/// </summary>
|
||||
public static bool TryParseCombGuidType(
|
||||
string? input,
|
||||
out CombGuidType type,
|
||||
out string? errorMessage
|
||||
) {
|
||||
if (string.IsNullOrWhiteSpace(input)) {
|
||||
type = CombGuidType.PostgreSql;
|
||||
errorMessage = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Enum.TryParse(input.Trim(), ignoreCase: true, out type)
|
||||
&& Enum.IsDefined(type)) {
|
||||
errorMessage = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
type = default;
|
||||
errorMessage = "Type must be PostgreSql or SqlServer.";
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a comma-separated list; returns <c>null</c> when input is blank.
|
||||
/// </summary>
|
||||
public static List<string>? ParseOptionalList(string? input) {
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return null;
|
||||
|
||||
var items = input
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToList();
|
||||
|
||||
if (items.Count == 0)
|
||||
return null;
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
27
src/MaksIT.Core.Cli/MaksIT.Core.Cli.csproj
Normal file
27
src/MaksIT.Core.Cli/MaksIT.Core.Cli.csproj
Normal file
@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
|
||||
<IsPackable>false</IsPackable>
|
||||
|
||||
<Version>1.6.10</Version>
|
||||
<Authors>Maksym Sadovnychyy</Authors>
|
||||
<Company>MAKS-IT</Company>
|
||||
<Product>MaksIT.Core.Cli</Product>
|
||||
<Copyright>Copyright © Maksym Sadovnychyy (MAKS-IT)</Copyright>
|
||||
<Description>Interactive and flag-based console toolkit for generating MaksIT.Core secrets (JWT, AES-GCM, TOTP, password hashes, COMB GUIDs).</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MaksIT.Core\MaksIT.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
9
src/MaksIT.Core.Cli/Program.cs
Normal file
9
src/MaksIT.Core.Cli/Program.cs
Normal file
@ -0,0 +1,9 @@
|
||||
namespace MaksIT.Core.Cli;
|
||||
|
||||
|
||||
public static class Program {
|
||||
public static int Main(string[] args) {
|
||||
Console.OutputEncoding = System.Text.Encoding.UTF8;
|
||||
return CommandFactory.CreateRootCommand().Parse(args).Invoke();
|
||||
}
|
||||
}
|
||||
131
src/MaksIT.Core.Cli/SecretOperations.cs
Normal file
131
src/MaksIT.Core.Cli/SecretOperations.cs
Normal file
@ -0,0 +1,131 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using MaksIT.Core.Comb;
|
||||
using MaksIT.Core.Security;
|
||||
using MaksIT.Core.Security.JWT;
|
||||
|
||||
|
||||
namespace MaksIT.Core.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Thin wrappers around MaksIT.Core secret and token helpers.
|
||||
/// </summary>
|
||||
public static class SecretOperations {
|
||||
/// <summary>
|
||||
/// Generates a Base64 secret suitable for JWT signing or a password pepper.
|
||||
/// </summary>
|
||||
public static string GenerateSecret(int keySize = 32) =>
|
||||
JwtGenerator.GenerateSecret(keySize);
|
||||
|
||||
/// <summary>
|
||||
/// Generates an opaque refresh token.
|
||||
/// </summary>
|
||||
public static string GenerateRefreshToken() =>
|
||||
JwtGenerator.GenerateRefreshToken();
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Base64 AES-256 key.
|
||||
/// </summary>
|
||||
public static string GenerateAesKey() =>
|
||||
AESGCMUtility.GenerateKeyBase64();
|
||||
|
||||
/// <summary>
|
||||
/// Generates a COMB GUID for the given layout.
|
||||
/// </summary>
|
||||
public static Guid GenerateCombGuid(CombGuidType type) =>
|
||||
CombGuidGenerator.CreateCombGuid(DateTime.UtcNow, type);
|
||||
|
||||
/// <summary>
|
||||
/// Signs an access JWT.
|
||||
/// </summary>
|
||||
public static bool TryGenerateJwt(
|
||||
JWTTokenGenerateRequest request,
|
||||
[NotNullWhen(true)] out string? token,
|
||||
[NotNullWhen(false)] out string? errorMessage
|
||||
) {
|
||||
if (!JwtGenerator.TryGenerateToken(request, out var tokenData, out errorMessage)) {
|
||||
token = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
token = tokenData.Value.Item1;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates an access JWT and returns its claims.
|
||||
/// </summary>
|
||||
public static bool TryValidateJwt(
|
||||
string secret,
|
||||
string issuer,
|
||||
string audience,
|
||||
string token,
|
||||
out JWTTokenClaims? claims,
|
||||
[NotNullWhen(false)] out string? errorMessage
|
||||
) =>
|
||||
JwtGenerator.TryValidateToken(secret, issuer, audience, token, out claims, out errorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Base32 TOTP shared secret.
|
||||
/// </summary>
|
||||
public static bool TryGenerateTotpSecret(
|
||||
[NotNullWhen(true)] out string? secret,
|
||||
[NotNullWhen(false)] out string? errorMessage
|
||||
) =>
|
||||
TotpGenerator.TryGenerateSecret(out secret, out errorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Generates TOTP recovery codes.
|
||||
/// </summary>
|
||||
public static bool TryGenerateRecoveryCodes(
|
||||
int count,
|
||||
[NotNullWhen(true)] out List<string>? codes,
|
||||
[NotNullWhen(false)] out string? errorMessage
|
||||
) =>
|
||||
TotpGenerator.TryGenerateRecoveryCodes(count, out codes, out errorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Builds an <c>otpauth://</c> URI for authenticator apps.
|
||||
/// </summary>
|
||||
public static bool TryGenerateTotpAuthLink(
|
||||
string label,
|
||||
string username,
|
||||
string secret,
|
||||
string issuer,
|
||||
[NotNullWhen(true)] out string? authLink,
|
||||
[NotNullWhen(false)] out string? errorMessage
|
||||
) =>
|
||||
TotpGenerator.TryGenerateTotpAuthLink(
|
||||
label,
|
||||
username,
|
||||
secret,
|
||||
issuer,
|
||||
algorithm: null,
|
||||
digits: null,
|
||||
period: null,
|
||||
out authLink,
|
||||
out errorMessage
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Validates a TOTP code against a Base32 secret.
|
||||
/// </summary>
|
||||
public static bool TryValidateTotp(
|
||||
string totpCode,
|
||||
string base32Secret,
|
||||
int timeTolerance,
|
||||
out bool isValid,
|
||||
[NotNullWhen(false)] out string? errorMessage
|
||||
) =>
|
||||
TotpGenerator.TryValidate(totpCode, base32Secret, timeTolerance, out isValid, out errorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a salted password hash with the given pepper.
|
||||
/// </summary>
|
||||
public static bool TryHashPassword(
|
||||
string password,
|
||||
string pepper,
|
||||
[NotNullWhen(true)] out (string Salt, string Hash)? saltedHash,
|
||||
[NotNullWhen(false)] out string? errorMessage
|
||||
) =>
|
||||
PasswordHasher.TryCreateSaltedHash(password, pepper, out saltedHash, out errorMessage);
|
||||
}
|
||||
@ -7,20 +7,14 @@
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
<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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
5
src/global.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"test": {
|
||||
"runner": "Microsoft.Testing.Platform"
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -7,9 +7,11 @@
|
||||
|
||||
.DESCRIPTION
|
||||
Dedicated version-loading plugin. Reads <Version> from the first configured
|
||||
projectFiles entry and writes it (plus the resolved projectFiles) to the
|
||||
shared runtime context. Declares providesVersion = $true so the engine can
|
||||
discover it as the single release version source.
|
||||
projectFiles .csproj, or from the nearest Directory.Build.props when the
|
||||
csproj omits it. Accepts SemVer prerelease (0.1.0-alpha.1 / beta / rc). Writes
|
||||
version plus the resolved projectFiles (csproj paths for later pack/publish)
|
||||
to the shared runtime context. Declares providesVersion = $true so the engine
|
||||
can discover it as the single release version source.
|
||||
#>
|
||||
|
||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||
@ -20,6 +22,22 @@ if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||
}
|
||||
}
|
||||
|
||||
function ConvertTo-MsbuildPropertyStringInternal {
|
||||
param(
|
||||
$Value
|
||||
)
|
||||
|
||||
if ($null -eq $Value) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($Value -is [System.Xml.XmlElement]) {
|
||||
return [string]$Value.InnerText
|
||||
}
|
||||
|
||||
return [string]$Value
|
||||
}
|
||||
|
||||
function Get-CsprojPropertyValueInternal {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
@ -36,7 +54,33 @@ function Get-CsprojPropertyValueInternal {
|
||||
Select-Object -First 1
|
||||
|
||||
if ($propNode) {
|
||||
return $propNode.$PropertyName
|
||||
return ConvertTo-MsbuildPropertyStringInternal -Value $propNode.$PropertyName
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-DirectoryBuildPropsVersionInternal {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ProjectPath
|
||||
)
|
||||
|
||||
# MSBuild uses the first Directory.Build.props found walking up from the project directory.
|
||||
$dir = [System.IO.Path]::GetDirectoryName((Resolve-Path -LiteralPath $ProjectPath))
|
||||
while (-not [string]::IsNullOrWhiteSpace($dir)) {
|
||||
$propsPath = Join-Path $dir 'Directory.Build.props'
|
||||
if (Test-Path -LiteralPath $propsPath -PathType Leaf) {
|
||||
[xml]$props = Get-Content -LiteralPath $propsPath
|
||||
return Get-CsprojPropertyValueInternal -Csproj $props -PropertyName 'Version'
|
||||
}
|
||||
|
||||
$parent = [System.IO.Directory]::GetParent($dir)
|
||||
if ($null -eq $parent) {
|
||||
break
|
||||
}
|
||||
|
||||
$dir = $parent.FullName
|
||||
}
|
||||
|
||||
return $null
|
||||
@ -58,12 +102,16 @@ function Get-CsprojVersionInternal {
|
||||
|
||||
[xml]$csproj = Get-Content $ProjectPath
|
||||
$version = Get-CsprojPropertyValueInternal -Csproj $csproj -PropertyName "Version"
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace([string]$version)) {
|
||||
throw "DotNetReleaseVersion: <Version> not found in '$ProjectPath'."
|
||||
if (-not [string]::IsNullOrWhiteSpace([string]$version)) {
|
||||
return [string]$version
|
||||
}
|
||||
|
||||
return [string]$version
|
||||
$version = Get-DirectoryBuildPropsVersionInternal -ProjectPath $ProjectPath
|
||||
if (-not [string]::IsNullOrWhiteSpace([string]$version)) {
|
||||
return [string]$version
|
||||
}
|
||||
|
||||
throw "DotNetReleaseVersion: <Version> not found in '$ProjectPath' or a parent Directory.Build.props."
|
||||
}
|
||||
|
||||
function Get-PluginMetadata {
|
||||
@ -87,6 +135,10 @@ function Invoke-Plugin {
|
||||
|
||||
Write-Log -Level "INFO" -Message "Reading version from SDK-style project file (projectFiles)..."
|
||||
$version = Get-CsprojVersionInternal -ProjectPath $projectFiles[0]
|
||||
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
|
||||
if (-not (Test-ReleaseSemver -Version $version)) {
|
||||
throw "DotNetReleaseVersion: version '$version' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)."
|
||||
}
|
||||
|
||||
Set-EngineState -Context $shared -Name 'version' -Value $version
|
||||
Set-EngineFact -Context $shared -Namespace 'dotnet' -Name 'projectFiles' -Value $projectFiles -Overwrite Replace -LegacyProperty 'projectFiles'
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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'."
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user