(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/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [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
|
## [1.6.9] - 2026-08-14
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@ -26,11 +26,25 @@ dotnet build MaksIT.Core.slnx
|
|||||||
|
|
||||||
### Running Tests
|
### Running Tests
|
||||||
|
|
||||||
|
Preferred: `utils\Invoke-TestEngine.bat` (DotNetTest → QualityGate → CoverageBadges). Tests run under **Microsoft Testing Platform** (`src/global.json` `test.runner` next to the `.slnx`) with **xunit.v3** and **coverlet.MTP**.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd src
|
cd src
|
||||||
dotnet test MaksIT.Core.Tests
|
dotnet test MaksIT.Core.Tests
|
||||||
|
dotnet test MaksIT.Core.Cli.Tests
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Running the secrets CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
dotnet run --project MaksIT.Core.Cli
|
||||||
|
dotnet run --project MaksIT.Core.Cli -- secret
|
||||||
|
dotnet run --project MaksIT.Core.Cli -- --help
|
||||||
|
```
|
||||||
|
|
||||||
|
No arguments opens the interactive numbered menu. Commands/flags are for scripts and agents (values on stdout, errors on stderr). It is not a `dotnet tool` and is not published to NuGet.
|
||||||
|
|
||||||
## Commit Message Format
|
## Commit Message Format
|
||||||
|
|
||||||
This project uses the following commit message format:
|
This project uses the following commit message format:
|
||||||
@ -119,8 +133,8 @@ Orchestration lives in **`utils/`** (from [maksit-repoutils](https://github.com/
|
|||||||
|
|
||||||
### Workflow
|
### Workflow
|
||||||
|
|
||||||
1. Bump `<Version>` in `src/MaksIT.Core/MaksIT.Core.csproj` and **CHANGELOG.md**
|
1. Bump `<Version>` in `src/MaksIT.Core/MaksIT.Core.csproj` and `src/MaksIT.Core.Cli/MaksIT.Core.Cli.csproj` (keep them aligned) and **CHANGELOG.md**
|
||||||
2. Commit, tag `vX.Y.Z` on `main`
|
2. Commit, tag `v{version}` on `main` (`v1.2.3` or SemVer prerelease such as `v0.1.0-alpha.1`, `v0.1.0-beta.1`, `v0.1.0-rc.1`). GitHub marks hyphenated versions as prerelease.
|
||||||
3. Set `$env:GitHub`, `$env:NuGet`, run `utils\Invoke-ReleasePackage-Single.bat`
|
3. Set `$env:GitHub`, `$env:NuGet`, run `utils\Invoke-ReleasePackage-Single.bat`
|
||||||
|
|
||||||
Dry-run: `pwsh -File utils\engines\release\Invoke-ReleasePackage.ps1 -DryRun`
|
Dry-run: `pwsh -File utils\engines\release\Invoke-ReleasePackage.ps1 -DryRun`
|
||||||
|
|||||||
50
README.md
50
README.md
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
|
- [CLI (secrets toolkit)](#cli-secrets-toolkit)
|
||||||
- [Abstractions](#abstractions)
|
- [Abstractions](#abstractions)
|
||||||
- [Base Classes](#base-classes)
|
- [Base Classes](#base-classes)
|
||||||
- [Enumeration](#enumeration)
|
- [Enumeration](#enumeration)
|
||||||
@ -48,6 +49,55 @@
|
|||||||
- [File System](#file-system)
|
- [File System](#file-system)
|
||||||
- [Processes](#processes)
|
- [Processes](#processes)
|
||||||
|
|
||||||
|
## CLI (secrets toolkit)
|
||||||
|
|
||||||
|
Generates the same secrets the library uses at runtime (JWT signing keys, password pepper, AES-256 keys, TOTP material, COMB GUIDs). It is **not** published to NuGet; the exe ships in the GitHub release zip next to `MaksIT.Core.*.nupkg`.
|
||||||
|
|
||||||
|
- **No arguments** — interactive numbered menu.
|
||||||
|
- **With commands** — non-interactive flags for scripts and agents. Values go to stdout; errors to stderr; exit `0`/`1`.
|
||||||
|
|
||||||
|
### Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
dotnet run --project MaksIT.Core.Cli
|
||||||
|
dotnet run --project MaksIT.Core.Cli -- --help
|
||||||
|
dotnet run --project MaksIT.Core.Cli -- secret
|
||||||
|
```
|
||||||
|
|
||||||
|
From an unpacked release zip:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MaksIT.Core.Cli/MaksIT.Core.Cli
|
||||||
|
MaksIT.Core.Cli/MaksIT.Core.Cli secret --bytes 32
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agent commands
|
||||||
|
|
||||||
|
| Command | Output |
|
||||||
|
|---------|--------|
|
||||||
|
| `secret [--bytes 32]` | Base64 secret (JWT signing / pepper) |
|
||||||
|
| `jwt secret [--bytes 32]` | Same as `secret` |
|
||||||
|
| `jwt refresh` | Opaque refresh token |
|
||||||
|
| `jwt generate --secret S --issuer I --audience A [--expiration 60] [--user-id] [--username] [--roles] [--acl]` | Access JWT |
|
||||||
|
| `jwt validate --secret S --issuer I --audience A --token T` | Claims JSON |
|
||||||
|
| `aes key` | Base64 AES-256 key |
|
||||||
|
| `totp secret` | Base32 TOTP secret |
|
||||||
|
| `totp recovery [--count 10]` | Recovery codes (one per line) |
|
||||||
|
| `totp link --label L --username U --secret S --issuer I` | `otpauth://` URI |
|
||||||
|
| `totp validate --secret S --code C [--tolerance 1]` | `valid` / `invalid` (exit 1 if invalid) |
|
||||||
|
| `password hash --pepper P --password PWD` | JSON `{ salt, hash }` |
|
||||||
|
| `guid comb [--type PostgreSql]` | COMB GUID (`SqlServer` also accepted) |
|
||||||
|
|
||||||
|
Typical `appsecrets.json` values:
|
||||||
|
|
||||||
|
| Menu / command | Writes |
|
||||||
|
|----------------|--------|
|
||||||
|
| Generate secret / `secret` | `JwtSettings` signing secret or `PasswordPepper` |
|
||||||
|
| AES-GCM key / `aes key` | host encryption key |
|
||||||
|
| TOTP / 2FA / `totp secret` | authenticator shared key / recovery codes |
|
||||||
|
| JWT generate | debug access tokens against a known secret |
|
||||||
|
|
||||||
## Abstractions
|
## Abstractions
|
||||||
|
|
||||||
### Base Classes
|
### Base Classes
|
||||||
|
|||||||
38
src/MaksIT.Core.Cli.Tests/CliActionsTests.cs
Normal file
38
src/MaksIT.Core.Cli.Tests/CliActionsTests.cs
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
using MaksIT.Core.Cli;
|
||||||
|
|
||||||
|
|
||||||
|
namespace MaksIT.Core.Cli.Tests;
|
||||||
|
|
||||||
|
public class CliActionsTests {
|
||||||
|
[Fact]
|
||||||
|
public void GenerateSecret_InvalidBytes_ReturnsOne() {
|
||||||
|
var exit = CliActions.GenerateSecret(0);
|
||||||
|
|
||||||
|
Assert.Equal(1, exit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GenerateCombGuid_InvalidType_ReturnsOne() {
|
||||||
|
var exit = CliActions.GenerateCombGuid("rsa");
|
||||||
|
|
||||||
|
Assert.Equal(1, exit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GenerateCombGuid_PostgreSql_ReturnsZero() {
|
||||||
|
var exit = CliActions.GenerateCombGuid(null);
|
||||||
|
|
||||||
|
Assert.Equal(0, exit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HashPassword_EmptyPassword_ReturnsOne() {
|
||||||
|
var exit = CliActions.HashPassword("pepper", "");
|
||||||
|
|
||||||
|
Assert.Equal(1, exit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GenerateAesKey_ReturnsZero() =>
|
||||||
|
Assert.Equal(0, CliActions.GenerateAesKey());
|
||||||
|
}
|
||||||
43
src/MaksIT.Core.Cli.Tests/CommandFactoryTests.cs
Normal file
43
src/MaksIT.Core.Cli.Tests/CommandFactoryTests.cs
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
using MaksIT.Core.Cli;
|
||||||
|
|
||||||
|
|
||||||
|
namespace MaksIT.Core.Cli.Tests;
|
||||||
|
|
||||||
|
public class CommandFactoryTests {
|
||||||
|
[Fact]
|
||||||
|
public void Secret_HasNoParseErrors() {
|
||||||
|
var result = CommandFactory.CreateRootCommand().Parse(["secret"]);
|
||||||
|
|
||||||
|
Assert.Empty(result.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JwtGenerate_MissingSecret_HasParseError() {
|
||||||
|
var result = CommandFactory.CreateRootCommand().Parse([
|
||||||
|
"jwt", "generate", "--issuer", "i", "--audience", "a"
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.NotEmpty(result.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JwtGenerate_RequiredOptions_HasNoParseErrors() {
|
||||||
|
var result = CommandFactory.CreateRootCommand().Parse([
|
||||||
|
"jwt", "generate",
|
||||||
|
"--secret", "s",
|
||||||
|
"--issuer", "i",
|
||||||
|
"--audience", "a"
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.Empty(result.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TotpValidate_RequiredOptions_HasNoParseErrors() {
|
||||||
|
var result = CommandFactory.CreateRootCommand().Parse([
|
||||||
|
"totp", "validate", "--secret", "s", "--code", "123456"
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.Empty(result.Errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/MaksIT.Core.Cli.Tests/InputParsersTests.cs
Normal file
60
src/MaksIT.Core.Cli.Tests/InputParsersTests.cs
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
using MaksIT.Core.Cli;
|
||||||
|
using MaksIT.Core.Comb;
|
||||||
|
|
||||||
|
|
||||||
|
namespace MaksIT.Core.Cli.Tests;
|
||||||
|
|
||||||
|
public class InputParsersTests {
|
||||||
|
[Fact]
|
||||||
|
public void TryParsePositiveInt_Blank_ReturnsDefault() {
|
||||||
|
var result = InputParsers.TryParsePositiveInt(" ", 32, out var value, out var errorMessage);
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
Assert.Equal(32, value);
|
||||||
|
Assert.Null(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParsePositiveInt_ValidNumber_ReturnsValue() {
|
||||||
|
var result = InputParsers.TryParsePositiveInt("64", 32, out var value, out var errorMessage);
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
Assert.Equal(64, value);
|
||||||
|
Assert.Null(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParsePositiveInt_Invalid_ReturnsError() {
|
||||||
|
var result = InputParsers.TryParsePositiveInt("abc", 32, out var value, out var errorMessage);
|
||||||
|
|
||||||
|
Assert.False(result);
|
||||||
|
Assert.Equal(0, value);
|
||||||
|
Assert.NotNull(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParseCombGuidType_Blank_ReturnsPostgreSql() {
|
||||||
|
var result = InputParsers.TryParseCombGuidType(null, out var type, out var errorMessage);
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
Assert.Equal(CombGuidType.PostgreSql, type);
|
||||||
|
Assert.Null(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParseCombGuidType_SqlServer_ParsesIgnoreCase() {
|
||||||
|
var result = InputParsers.TryParseCombGuidType("sqlserver", out var type, out var errorMessage);
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
Assert.Equal(CombGuidType.SqlServer, type);
|
||||||
|
Assert.Null(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseOptionalList_SplitsAndTrims() {
|
||||||
|
var items = InputParsers.ParseOptionalList(" Admin, User , ");
|
||||||
|
|
||||||
|
Assert.NotNull(items);
|
||||||
|
Assert.Equal(["Admin", "User"], items);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/MaksIT.Core.Cli.Tests/MaksIT.Core.Cli.Tests.csproj
Normal file
27
src/MaksIT.Core.Cli.Tests/MaksIT.Core.Cli.Tests.csproj
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.MTP" Version="10.0.1" />
|
||||||
|
<PackageReference Include="xunit.v3" Version="4.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MaksIT.Core.Cli\MaksIT.Core.Cli.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
60
src/MaksIT.Core.Cli.Tests/SecretOperationsTests.cs
Normal file
60
src/MaksIT.Core.Cli.Tests/SecretOperationsTests.cs
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
using MaksIT.Core.Cli;
|
||||||
|
using MaksIT.Core.Comb;
|
||||||
|
using MaksIT.Core.Security.JWT;
|
||||||
|
|
||||||
|
|
||||||
|
namespace MaksIT.Core.Cli.Tests;
|
||||||
|
|
||||||
|
public class SecretOperationsTests {
|
||||||
|
[Fact]
|
||||||
|
public void GenerateSecret_ReturnsUniqueNonEmptyValues() {
|
||||||
|
var secret1 = SecretOperations.GenerateSecret();
|
||||||
|
var secret2 = SecretOperations.GenerateSecret();
|
||||||
|
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(secret1));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(secret2));
|
||||||
|
Assert.NotEqual(secret1, secret2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GenerateAesKey_ReturnsNonEmptyValue() =>
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(SecretOperations.GenerateAesKey()));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GenerateCombGuid_PostgreSql_ReturnsNonEmptyGuid() {
|
||||||
|
var guid = SecretOperations.GenerateCombGuid(CombGuidType.PostgreSql);
|
||||||
|
|
||||||
|
Assert.NotEqual(Guid.Empty, guid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGenerateJwt_ThenValidate_Succeeds() {
|
||||||
|
var secret = SecretOperations.GenerateSecret();
|
||||||
|
var request = new JWTTokenGenerateRequest {
|
||||||
|
Secret = secret,
|
||||||
|
Issuer = "cli-tests",
|
||||||
|
Audience = "cli-tests",
|
||||||
|
Expiration = 5,
|
||||||
|
Username = "tester"
|
||||||
|
};
|
||||||
|
|
||||||
|
var generated = SecretOperations.TryGenerateJwt(request, out var token, out var generateError);
|
||||||
|
|
||||||
|
Assert.True(generated);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(token));
|
||||||
|
Assert.Null(generateError);
|
||||||
|
|
||||||
|
var validated = SecretOperations.TryValidateJwt(
|
||||||
|
secret,
|
||||||
|
request.Issuer,
|
||||||
|
request.Audience,
|
||||||
|
token!,
|
||||||
|
out var claims,
|
||||||
|
out var validateError
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.True(validated);
|
||||||
|
Assert.Equal("tester", claims?.Username);
|
||||||
|
Assert.Null(validateError);
|
||||||
|
}
|
||||||
|
}
|
||||||
317
src/MaksIT.Core.Cli/Application.cs
Normal file
317
src/MaksIT.Core.Cli/Application.cs
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Reflection;
|
||||||
|
using MaksIT.Core.Comb;
|
||||||
|
using MaksIT.Core.Extensions;
|
||||||
|
using MaksIT.Core.Security.JWT;
|
||||||
|
|
||||||
|
|
||||||
|
namespace MaksIT.Core.Cli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interactive numbered menu for generating MaksIT.Core secrets.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Application {
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the main menu until the user exits.
|
||||||
|
/// </summary>
|
||||||
|
public void Run() {
|
||||||
|
Console.OutputEncoding = Encoding.UTF8;
|
||||||
|
var version = typeof(Application).Assembly
|
||||||
|
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||||
|
.InformationalVersion?
|
||||||
|
.Split('+')[0]
|
||||||
|
?? "0.0.0";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
Console.WriteLine($"MaksIT.Core.Cli v{version}");
|
||||||
|
Console.WriteLine("© Maksym Sadovnychyy (MAKS-IT) 2026");
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("1. Generate secret (JWT / pepper)");
|
||||||
|
Console.WriteLine("2. JWT");
|
||||||
|
Console.WriteLine("3. AES-GCM key");
|
||||||
|
Console.WriteLine("4. TOTP / 2FA");
|
||||||
|
Console.WriteLine("5. Password hash");
|
||||||
|
Console.WriteLine("6. COMB GUID");
|
||||||
|
Console.WriteLine("0. Exit");
|
||||||
|
Console.Write("Enter your choice: ");
|
||||||
|
|
||||||
|
var choice = Console.ReadLine();
|
||||||
|
try {
|
||||||
|
switch (choice) {
|
||||||
|
case "1":
|
||||||
|
GenerateSecret();
|
||||||
|
Pause();
|
||||||
|
break;
|
||||||
|
case "2":
|
||||||
|
RunJwtMenu();
|
||||||
|
break;
|
||||||
|
case "3":
|
||||||
|
WriteLabeled("AES-256 key", SecretOperations.GenerateAesKey());
|
||||||
|
Pause();
|
||||||
|
break;
|
||||||
|
case "4":
|
||||||
|
RunTotpMenu();
|
||||||
|
break;
|
||||||
|
case "5":
|
||||||
|
HashPassword();
|
||||||
|
Pause();
|
||||||
|
break;
|
||||||
|
case "6":
|
||||||
|
GenerateCombGuid();
|
||||||
|
Pause();
|
||||||
|
break;
|
||||||
|
case "0":
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
Console.WriteLine("Invalid option.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) {
|
||||||
|
Console.WriteLine($"Error: {ex.Message}");
|
||||||
|
Pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RunJwtMenu() {
|
||||||
|
while (true) {
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("JWT");
|
||||||
|
Console.WriteLine("1. Generate signing secret");
|
||||||
|
Console.WriteLine("2. Generate refresh token");
|
||||||
|
Console.WriteLine("3. Generate access token");
|
||||||
|
Console.WriteLine("4. Validate token");
|
||||||
|
Console.WriteLine("0. Back");
|
||||||
|
Console.Write("Enter your choice: ");
|
||||||
|
|
||||||
|
var choice = Console.ReadLine();
|
||||||
|
try {
|
||||||
|
switch (choice) {
|
||||||
|
case "1":
|
||||||
|
GenerateSecret();
|
||||||
|
Pause();
|
||||||
|
break;
|
||||||
|
case "2":
|
||||||
|
WriteLabeled("Refresh token", SecretOperations.GenerateRefreshToken());
|
||||||
|
Pause();
|
||||||
|
break;
|
||||||
|
case "3":
|
||||||
|
GenerateAccessToken();
|
||||||
|
Pause();
|
||||||
|
break;
|
||||||
|
case "4":
|
||||||
|
ValidateAccessToken();
|
||||||
|
Pause();
|
||||||
|
break;
|
||||||
|
case "0":
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
Console.WriteLine("Invalid option.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) {
|
||||||
|
Console.WriteLine($"Error: {ex.Message}");
|
||||||
|
Pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RunTotpMenu() {
|
||||||
|
while (true) {
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("TOTP / 2FA");
|
||||||
|
Console.WriteLine("1. Generate secret");
|
||||||
|
Console.WriteLine("2. Generate recovery codes");
|
||||||
|
Console.WriteLine("3. Generate otpauth link");
|
||||||
|
Console.WriteLine("4. Validate code");
|
||||||
|
Console.WriteLine("0. Back");
|
||||||
|
Console.Write("Enter your choice: ");
|
||||||
|
|
||||||
|
var choice = Console.ReadLine();
|
||||||
|
try {
|
||||||
|
switch (choice) {
|
||||||
|
case "1":
|
||||||
|
if (!SecretOperations.TryGenerateTotpSecret(out var secret, out var secretError))
|
||||||
|
throw new InvalidOperationException(secretError);
|
||||||
|
|
||||||
|
WriteLabeled("TOTP secret", secret);
|
||||||
|
Pause();
|
||||||
|
break;
|
||||||
|
case "2":
|
||||||
|
GenerateRecoveryCodes();
|
||||||
|
Pause();
|
||||||
|
break;
|
||||||
|
case "3":
|
||||||
|
GenerateTotpAuthLink();
|
||||||
|
Pause();
|
||||||
|
break;
|
||||||
|
case "4":
|
||||||
|
ValidateTotp();
|
||||||
|
Pause();
|
||||||
|
break;
|
||||||
|
case "0":
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
Console.WriteLine("Invalid option.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) {
|
||||||
|
Console.WriteLine($"Error: {ex.Message}");
|
||||||
|
Pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void GenerateSecret() {
|
||||||
|
var bytes = ReadPositiveInt("Key size in bytes", 32);
|
||||||
|
WriteLabeled("Secret", SecretOperations.GenerateSecret(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void GenerateAccessToken() {
|
||||||
|
var request = new JWTTokenGenerateRequest {
|
||||||
|
Secret = ReadRequired("Secret"),
|
||||||
|
Issuer = ReadRequired("Issuer"),
|
||||||
|
Audience = ReadRequired("Audience"),
|
||||||
|
Expiration = ReadPositiveInt("Expiration (minutes)", 60),
|
||||||
|
UserId = ReadOptional("User id"),
|
||||||
|
Username = ReadOptional("Username"),
|
||||||
|
Roles = InputParsers.ParseOptionalList(ReadOptional("Roles (comma-separated)")),
|
||||||
|
AclEntries = InputParsers.ParseOptionalList(ReadOptional("ACL entries (comma-separated)"))
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!SecretOperations.TryGenerateJwt(request, out var token, out var errorMessage))
|
||||||
|
throw new InvalidOperationException(errorMessage);
|
||||||
|
|
||||||
|
WriteLabeled("Access token", token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateAccessToken() {
|
||||||
|
var secret = ReadRequired("Secret");
|
||||||
|
var issuer = ReadRequired("Issuer");
|
||||||
|
var audience = ReadRequired("Audience");
|
||||||
|
var token = ReadRequired("Token");
|
||||||
|
|
||||||
|
if (!SecretOperations.TryValidateJwt(secret, issuer, audience, token, out var claims, out var errorMessage))
|
||||||
|
throw new InvalidOperationException(errorMessage);
|
||||||
|
|
||||||
|
WriteLabeled("Claims", claims.ToJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void GenerateRecoveryCodes() {
|
||||||
|
var count = ReadPositiveInt("Number of codes", 10);
|
||||||
|
if (!SecretOperations.TryGenerateRecoveryCodes(count, out var codes, out var errorMessage))
|
||||||
|
throw new InvalidOperationException(errorMessage);
|
||||||
|
|
||||||
|
Console.WriteLine("Recovery codes:");
|
||||||
|
foreach (var code in codes)
|
||||||
|
Console.WriteLine(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void GenerateTotpAuthLink() {
|
||||||
|
var label = ReadRequired("Label");
|
||||||
|
var username = ReadRequired("Username");
|
||||||
|
var secret = ReadRequired("TOTP secret");
|
||||||
|
var issuer = ReadRequired("Issuer");
|
||||||
|
|
||||||
|
if (!SecretOperations.TryGenerateTotpAuthLink(label, username, secret, issuer, out var authLink, out var errorMessage))
|
||||||
|
throw new InvalidOperationException(errorMessage);
|
||||||
|
|
||||||
|
WriteLabeled("otpauth link", authLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateTotp() {
|
||||||
|
var secret = ReadRequired("TOTP secret");
|
||||||
|
var code = ReadRequired("Code");
|
||||||
|
var tolerance = ReadPositiveInt("Time-step tolerance", 1);
|
||||||
|
|
||||||
|
if (!SecretOperations.TryValidateTotp(code, secret, tolerance, out var isValid, out var errorMessage))
|
||||||
|
throw new InvalidOperationException(errorMessage);
|
||||||
|
|
||||||
|
Console.WriteLine(isValid ? "Valid." : "Invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void HashPassword() {
|
||||||
|
var pepper = ReadRequired("Pepper");
|
||||||
|
var password = ReadSecret("Password");
|
||||||
|
if (string.IsNullOrEmpty(password))
|
||||||
|
throw new InvalidOperationException("Password is required.");
|
||||||
|
|
||||||
|
if (!SecretOperations.TryHashPassword(password, pepper, out var saltedHash, out var errorMessage))
|
||||||
|
throw new InvalidOperationException(errorMessage);
|
||||||
|
|
||||||
|
Console.WriteLine($"Salt: {saltedHash.Value.Salt}");
|
||||||
|
Console.WriteLine($"Hash: {saltedHash.Value.Hash}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void GenerateCombGuid() {
|
||||||
|
Console.Write("COMB type (PostgreSql/SqlServer) [PostgreSql]: ");
|
||||||
|
if (!InputParsers.TryParseCombGuidType(Console.ReadLine(), out var type, out var errorMessage))
|
||||||
|
throw new InvalidOperationException(errorMessage);
|
||||||
|
|
||||||
|
WriteLabeled($"COMB GUID ({type})", SecretOperations.GenerateCombGuid(type).ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ReadPositiveInt(string prompt, int defaultValue) {
|
||||||
|
Console.Write($"{prompt} [{defaultValue}]: ");
|
||||||
|
if (!InputParsers.TryParsePositiveInt(Console.ReadLine(), defaultValue, out var value, out var errorMessage))
|
||||||
|
throw new InvalidOperationException(errorMessage);
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadRequired(string prompt) {
|
||||||
|
Console.Write($"{prompt}: ");
|
||||||
|
var value = Console.ReadLine();
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
throw new InvalidOperationException($"{prompt} is required.");
|
||||||
|
|
||||||
|
return value.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ReadOptional(string prompt) {
|
||||||
|
Console.Write($"{prompt}: ");
|
||||||
|
var value = Console.ReadLine();
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return value.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadSecret(string prompt) {
|
||||||
|
Console.Write($"{prompt}: ");
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
while (true) {
|
||||||
|
var key = Console.ReadKey(intercept: true);
|
||||||
|
if (key.Key == ConsoleKey.Enter) {
|
||||||
|
Console.WriteLine();
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.Key == ConsoleKey.Backspace) {
|
||||||
|
if (builder.Length > 0)
|
||||||
|
builder.Length--;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!char.IsControl(key.KeyChar))
|
||||||
|
builder.Append(key.KeyChar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteLabeled(string label, string value) {
|
||||||
|
Console.WriteLine($"{label}:");
|
||||||
|
Console.WriteLine(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Pause() {
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.Write("Press Enter to continue...");
|
||||||
|
Console.ReadLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
170
src/MaksIT.Core.Cli/CliActions.cs
Normal file
170
src/MaksIT.Core.Cli/CliActions.cs
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
using MaksIT.Core.Extensions;
|
||||||
|
using MaksIT.Core.Security.JWT;
|
||||||
|
|
||||||
|
|
||||||
|
namespace MaksIT.Core.Cli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Non-interactive command handlers: values on stdout, errors on stderr, exit 0/1.
|
||||||
|
/// </summary>
|
||||||
|
public static class CliActions {
|
||||||
|
/// <summary>
|
||||||
|
/// Writes an error to stderr and returns exit code 1.
|
||||||
|
/// </summary>
|
||||||
|
public static int Fail(string errorMessage) {
|
||||||
|
Console.Error.WriteLine(errorMessage);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a Base64 secret.
|
||||||
|
/// </summary>
|
||||||
|
public static int GenerateSecret(int bytes) {
|
||||||
|
if (!InputParsers.TryParsePositiveInt(bytes.ToString(), 32, out var keySize, out var errorMessage))
|
||||||
|
return Fail(errorMessage!);
|
||||||
|
|
||||||
|
Console.WriteLine(SecretOperations.GenerateSecret(keySize));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates an opaque refresh token.
|
||||||
|
/// </summary>
|
||||||
|
public static int GenerateRefreshToken() {
|
||||||
|
Console.WriteLine(SecretOperations.GenerateRefreshToken());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a Base64 AES-256 key.
|
||||||
|
/// </summary>
|
||||||
|
public static int GenerateAesKey() {
|
||||||
|
Console.WriteLine(SecretOperations.GenerateAesKey());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a COMB GUID.
|
||||||
|
/// </summary>
|
||||||
|
public static int GenerateCombGuid(string? typeName) {
|
||||||
|
if (!InputParsers.TryParseCombGuidType(typeName, out var type, out var errorMessage))
|
||||||
|
return Fail(errorMessage!);
|
||||||
|
|
||||||
|
Console.WriteLine(SecretOperations.GenerateCombGuid(type));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Signs an access JWT.
|
||||||
|
/// </summary>
|
||||||
|
public static int GenerateJwt(
|
||||||
|
string secret,
|
||||||
|
string issuer,
|
||||||
|
string audience,
|
||||||
|
int expiration,
|
||||||
|
string? userId,
|
||||||
|
string? username,
|
||||||
|
string? roles,
|
||||||
|
string? aclEntries
|
||||||
|
) {
|
||||||
|
if (!InputParsers.TryParsePositiveInt(expiration.ToString(), 60, out var minutes, out var errorMessage))
|
||||||
|
return Fail(errorMessage!);
|
||||||
|
|
||||||
|
var request = new JWTTokenGenerateRequest {
|
||||||
|
Secret = secret,
|
||||||
|
Issuer = issuer,
|
||||||
|
Audience = audience,
|
||||||
|
Expiration = minutes,
|
||||||
|
UserId = EmptyToNull(userId),
|
||||||
|
Username = EmptyToNull(username),
|
||||||
|
Roles = InputParsers.ParseOptionalList(roles),
|
||||||
|
AclEntries = InputParsers.ParseOptionalList(aclEntries)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!SecretOperations.TryGenerateJwt(request, out var token, out var generateError))
|
||||||
|
return Fail(generateError);
|
||||||
|
|
||||||
|
Console.WriteLine(token);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates an access JWT and writes claims JSON.
|
||||||
|
/// </summary>
|
||||||
|
public static int ValidateJwt(string secret, string issuer, string audience, string token) {
|
||||||
|
if (!SecretOperations.TryValidateJwt(secret, issuer, audience, token, out var claims, out var errorMessage))
|
||||||
|
return Fail(errorMessage);
|
||||||
|
|
||||||
|
Console.WriteLine(claims.ToJson());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a Base32 TOTP secret.
|
||||||
|
/// </summary>
|
||||||
|
public static int GenerateTotpSecret() {
|
||||||
|
if (!SecretOperations.TryGenerateTotpSecret(out var secret, out var errorMessage))
|
||||||
|
return Fail(errorMessage);
|
||||||
|
|
||||||
|
Console.WriteLine(secret);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates TOTP recovery codes (one per line).
|
||||||
|
/// </summary>
|
||||||
|
public static int GenerateRecoveryCodes(int count) {
|
||||||
|
if (!InputParsers.TryParsePositiveInt(count.ToString(), 10, out var codeCount, out var errorMessage))
|
||||||
|
return Fail(errorMessage!);
|
||||||
|
|
||||||
|
if (!SecretOperations.TryGenerateRecoveryCodes(codeCount, out var codes, out var generateError))
|
||||||
|
return Fail(generateError);
|
||||||
|
|
||||||
|
foreach (var code in codes)
|
||||||
|
Console.WriteLine(code);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an otpauth URI.
|
||||||
|
/// </summary>
|
||||||
|
public static int GenerateTotpAuthLink(string label, string username, string secret, string issuer) {
|
||||||
|
if (!SecretOperations.TryGenerateTotpAuthLink(label, username, secret, issuer, out var authLink, out var errorMessage))
|
||||||
|
return Fail(errorMessage);
|
||||||
|
|
||||||
|
Console.WriteLine(authLink);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a TOTP code. Exit 1 when the code is invalid.
|
||||||
|
/// </summary>
|
||||||
|
public static int ValidateTotp(string secret, string code, int tolerance) {
|
||||||
|
if (!InputParsers.TryParsePositiveInt(tolerance.ToString(), 1, out var timeTolerance, out var errorMessage))
|
||||||
|
return Fail(errorMessage!);
|
||||||
|
|
||||||
|
if (!SecretOperations.TryValidateTotp(code, secret, timeTolerance, out var isValid, out var validateError))
|
||||||
|
return Fail(validateError);
|
||||||
|
|
||||||
|
Console.WriteLine(isValid ? "valid" : "invalid");
|
||||||
|
return isValid ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a salted password hash as JSON.
|
||||||
|
/// </summary>
|
||||||
|
public static int HashPassword(string pepper, string password) {
|
||||||
|
if (string.IsNullOrEmpty(password))
|
||||||
|
return Fail("Password is required.");
|
||||||
|
|
||||||
|
if (!SecretOperations.TryHashPassword(password, pepper, out var saltedHash, out var errorMessage))
|
||||||
|
return Fail(errorMessage);
|
||||||
|
|
||||||
|
Console.WriteLine(new { salt = saltedHash.Value.Salt, hash = saltedHash.Value.Hash }.ToJson());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? EmptyToNull(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
}
|
||||||
277
src/MaksIT.Core.Cli/CommandFactory.cs
Normal file
277
src/MaksIT.Core.Cli/CommandFactory.cs
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
using System.CommandLine;
|
||||||
|
|
||||||
|
|
||||||
|
namespace MaksIT.Core.Cli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the agent-facing command tree. No arguments runs the interactive menu.
|
||||||
|
/// </summary>
|
||||||
|
public static class CommandFactory {
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the root command with secret, jwt, aes, totp, password, and guid subcommands.
|
||||||
|
/// </summary>
|
||||||
|
public static RootCommand CreateRootCommand() {
|
||||||
|
var root = new RootCommand("Generate MaksIT.Core secrets. No arguments opens the interactive menu.") {
|
||||||
|
CreateSecretCommand(),
|
||||||
|
CreateJwtCommand(),
|
||||||
|
CreateAesCommand(),
|
||||||
|
CreateTotpCommand(),
|
||||||
|
CreatePasswordCommand(),
|
||||||
|
CreateGuidCommand()
|
||||||
|
};
|
||||||
|
|
||||||
|
root.SetAction(_ => {
|
||||||
|
new Application().Run();
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreateSecretCommand() {
|
||||||
|
var bytesOption = BytesOption();
|
||||||
|
var command = new Command("secret", "Generate a Base64 secret (JWT signing key or password pepper)") {
|
||||||
|
bytesOption
|
||||||
|
};
|
||||||
|
|
||||||
|
command.SetAction(parseResult =>
|
||||||
|
CliActions.GenerateSecret(parseResult.GetValue(bytesOption)));
|
||||||
|
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreateJwtCommand() {
|
||||||
|
var jwt = new Command("jwt", "JWT signing secrets, tokens, and validation");
|
||||||
|
jwt.Subcommands.Add(CreateJwtSecretCommand());
|
||||||
|
jwt.Subcommands.Add(CreateJwtRefreshCommand());
|
||||||
|
jwt.Subcommands.Add(CreateJwtGenerateCommand());
|
||||||
|
jwt.Subcommands.Add(CreateJwtValidateCommand());
|
||||||
|
return jwt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreateJwtSecretCommand() {
|
||||||
|
var bytesOption = BytesOption();
|
||||||
|
var command = new Command("secret", "Generate a JWT signing secret") {
|
||||||
|
bytesOption
|
||||||
|
};
|
||||||
|
|
||||||
|
command.SetAction(parseResult =>
|
||||||
|
CliActions.GenerateSecret(parseResult.GetValue(bytesOption)));
|
||||||
|
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreateJwtRefreshCommand() {
|
||||||
|
var command = new Command("refresh", "Generate an opaque refresh token");
|
||||||
|
command.SetAction(_ => CliActions.GenerateRefreshToken());
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreateJwtGenerateCommand() {
|
||||||
|
var secretOption = RequiredString("--secret", "Signing secret");
|
||||||
|
var issuerOption = RequiredString("--issuer", "Token issuer");
|
||||||
|
var audienceOption = RequiredString("--audience", "Token audience");
|
||||||
|
var expirationOption = new Option<int>("--expiration") {
|
||||||
|
Description = "Lifetime in minutes",
|
||||||
|
DefaultValueFactory = _ => 60
|
||||||
|
};
|
||||||
|
var userIdOption = new Option<string?>("--user-id") {
|
||||||
|
Description = "Optional user id claim"
|
||||||
|
};
|
||||||
|
var usernameOption = new Option<string?>("--username") {
|
||||||
|
Description = "Optional username claim"
|
||||||
|
};
|
||||||
|
var rolesOption = new Option<string?>("--roles") {
|
||||||
|
Description = "Optional comma-separated roles"
|
||||||
|
};
|
||||||
|
var aclOption = new Option<string?>("--acl") {
|
||||||
|
Description = "Optional comma-separated ACL entries"
|
||||||
|
};
|
||||||
|
|
||||||
|
var command = new Command("generate", "Sign an access JWT") {
|
||||||
|
secretOption,
|
||||||
|
issuerOption,
|
||||||
|
audienceOption,
|
||||||
|
expirationOption,
|
||||||
|
userIdOption,
|
||||||
|
usernameOption,
|
||||||
|
rolesOption,
|
||||||
|
aclOption
|
||||||
|
};
|
||||||
|
|
||||||
|
command.SetAction(parseResult =>
|
||||||
|
CliActions.GenerateJwt(
|
||||||
|
parseResult.GetValue(secretOption)!,
|
||||||
|
parseResult.GetValue(issuerOption)!,
|
||||||
|
parseResult.GetValue(audienceOption)!,
|
||||||
|
parseResult.GetValue(expirationOption),
|
||||||
|
parseResult.GetValue(userIdOption),
|
||||||
|
parseResult.GetValue(usernameOption),
|
||||||
|
parseResult.GetValue(rolesOption),
|
||||||
|
parseResult.GetValue(aclOption)
|
||||||
|
));
|
||||||
|
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreateJwtValidateCommand() {
|
||||||
|
var secretOption = RequiredString("--secret", "Signing secret");
|
||||||
|
var issuerOption = RequiredString("--issuer", "Token issuer");
|
||||||
|
var audienceOption = RequiredString("--audience", "Token audience");
|
||||||
|
var tokenOption = RequiredString("--token", "JWT to validate");
|
||||||
|
|
||||||
|
var command = new Command("validate", "Validate an access JWT and print claims JSON") {
|
||||||
|
secretOption,
|
||||||
|
issuerOption,
|
||||||
|
audienceOption,
|
||||||
|
tokenOption
|
||||||
|
};
|
||||||
|
|
||||||
|
command.SetAction(parseResult =>
|
||||||
|
CliActions.ValidateJwt(
|
||||||
|
parseResult.GetValue(secretOption)!,
|
||||||
|
parseResult.GetValue(issuerOption)!,
|
||||||
|
parseResult.GetValue(audienceOption)!,
|
||||||
|
parseResult.GetValue(tokenOption)!
|
||||||
|
));
|
||||||
|
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreateAesCommand() {
|
||||||
|
var aes = new Command("aes", "AES-GCM keys");
|
||||||
|
var key = new Command("key", "Generate a Base64 AES-256 key");
|
||||||
|
key.SetAction(_ => CliActions.GenerateAesKey());
|
||||||
|
aes.Subcommands.Add(key);
|
||||||
|
return aes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreateTotpCommand() {
|
||||||
|
var totp = new Command("totp", "TOTP / 2FA secrets, recovery codes, and validation");
|
||||||
|
totp.Subcommands.Add(CreateTotpSecretCommand());
|
||||||
|
totp.Subcommands.Add(CreateTotpRecoveryCommand());
|
||||||
|
totp.Subcommands.Add(CreateTotpLinkCommand());
|
||||||
|
totp.Subcommands.Add(CreateTotpValidateCommand());
|
||||||
|
return totp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreateTotpSecretCommand() {
|
||||||
|
var command = new Command("secret", "Generate a Base32 TOTP shared secret");
|
||||||
|
command.SetAction(_ => CliActions.GenerateTotpSecret());
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreateTotpRecoveryCommand() {
|
||||||
|
var countOption = new Option<int>("--count") {
|
||||||
|
Description = "Number of recovery codes",
|
||||||
|
DefaultValueFactory = _ => 10
|
||||||
|
};
|
||||||
|
|
||||||
|
var command = new Command("recovery", "Generate TOTP recovery codes (one per line)") {
|
||||||
|
countOption
|
||||||
|
};
|
||||||
|
|
||||||
|
command.SetAction(parseResult =>
|
||||||
|
CliActions.GenerateRecoveryCodes(parseResult.GetValue(countOption)));
|
||||||
|
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreateTotpLinkCommand() {
|
||||||
|
var labelOption = RequiredString("--label", "Authenticator label");
|
||||||
|
var usernameOption = RequiredString("--username", "Account username");
|
||||||
|
var secretOption = RequiredString("--secret", "Base32 TOTP secret");
|
||||||
|
var issuerOption = RequiredString("--issuer", "Issuer name");
|
||||||
|
|
||||||
|
var command = new Command("link", "Build an otpauth:// URI") {
|
||||||
|
labelOption,
|
||||||
|
usernameOption,
|
||||||
|
secretOption,
|
||||||
|
issuerOption
|
||||||
|
};
|
||||||
|
|
||||||
|
command.SetAction(parseResult =>
|
||||||
|
CliActions.GenerateTotpAuthLink(
|
||||||
|
parseResult.GetValue(labelOption)!,
|
||||||
|
parseResult.GetValue(usernameOption)!,
|
||||||
|
parseResult.GetValue(secretOption)!,
|
||||||
|
parseResult.GetValue(issuerOption)!
|
||||||
|
));
|
||||||
|
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreateTotpValidateCommand() {
|
||||||
|
var secretOption = RequiredString("--secret", "Base32 TOTP secret");
|
||||||
|
var codeOption = RequiredString("--code", "Six-digit TOTP code");
|
||||||
|
var toleranceOption = new Option<int>("--tolerance") {
|
||||||
|
Description = "Time-step windows to accept on each side",
|
||||||
|
DefaultValueFactory = _ => 1
|
||||||
|
};
|
||||||
|
|
||||||
|
var command = new Command("validate", "Validate a TOTP code (prints valid/invalid)") {
|
||||||
|
secretOption,
|
||||||
|
codeOption,
|
||||||
|
toleranceOption
|
||||||
|
};
|
||||||
|
|
||||||
|
command.SetAction(parseResult =>
|
||||||
|
CliActions.ValidateTotp(
|
||||||
|
parseResult.GetValue(secretOption)!,
|
||||||
|
parseResult.GetValue(codeOption)!,
|
||||||
|
parseResult.GetValue(toleranceOption)
|
||||||
|
));
|
||||||
|
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreatePasswordCommand() {
|
||||||
|
var password = new Command("password", "Password hashing");
|
||||||
|
var pepperOption = RequiredString("--pepper", "Application pepper");
|
||||||
|
var passwordOption = RequiredString("--password", "Password to hash");
|
||||||
|
|
||||||
|
var hash = new Command("hash", "Create a salted hash (JSON with salt and hash)") {
|
||||||
|
pepperOption,
|
||||||
|
passwordOption
|
||||||
|
};
|
||||||
|
|
||||||
|
hash.SetAction(parseResult =>
|
||||||
|
CliActions.HashPassword(
|
||||||
|
parseResult.GetValue(pepperOption)!,
|
||||||
|
parseResult.GetValue(passwordOption)!
|
||||||
|
));
|
||||||
|
|
||||||
|
password.Subcommands.Add(hash);
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command CreateGuidCommand() {
|
||||||
|
var guid = new Command("guid", "COMB GUID generation");
|
||||||
|
var typeOption = new Option<string>("--type") {
|
||||||
|
Description = "PostgreSql or SqlServer",
|
||||||
|
DefaultValueFactory = _ => "PostgreSql"
|
||||||
|
};
|
||||||
|
|
||||||
|
var comb = new Command("comb", "Generate a COMB GUID") {
|
||||||
|
typeOption
|
||||||
|
};
|
||||||
|
|
||||||
|
comb.SetAction(parseResult =>
|
||||||
|
CliActions.GenerateCombGuid(parseResult.GetValue(typeOption)));
|
||||||
|
|
||||||
|
guid.Subcommands.Add(comb);
|
||||||
|
return guid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Option<int> BytesOption() =>
|
||||||
|
new("--bytes") {
|
||||||
|
Description = "Random key size in bytes",
|
||||||
|
DefaultValueFactory = _ => 32
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Option<string> RequiredString(string name, string description) =>
|
||||||
|
new(name) {
|
||||||
|
Description = description,
|
||||||
|
Required = true
|
||||||
|
};
|
||||||
|
}
|
||||||
76
src/MaksIT.Core.Cli/InputParsers.cs
Normal file
76
src/MaksIT.Core.Cli/InputParsers.cs
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
using MaksIT.Core.Comb;
|
||||||
|
|
||||||
|
|
||||||
|
namespace MaksIT.Core.Cli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses interactive menu input for the Core CLI.
|
||||||
|
/// </summary>
|
||||||
|
public static class InputParsers {
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a positive integer, using <paramref name="defaultValue"/> when input is blank.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryParsePositiveInt(
|
||||||
|
string? input,
|
||||||
|
int defaultValue,
|
||||||
|
out int value,
|
||||||
|
out string? errorMessage
|
||||||
|
) {
|
||||||
|
if (string.IsNullOrWhiteSpace(input)) {
|
||||||
|
value = defaultValue;
|
||||||
|
errorMessage = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!int.TryParse(input.Trim(), out value) || value <= 0) {
|
||||||
|
value = 0;
|
||||||
|
errorMessage = "Value must be a positive integer.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorMessage = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a COMB GUID type, defaulting to <see cref="CombGuidType.PostgreSql"/> when input is blank.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryParseCombGuidType(
|
||||||
|
string? input,
|
||||||
|
out CombGuidType type,
|
||||||
|
out string? errorMessage
|
||||||
|
) {
|
||||||
|
if (string.IsNullOrWhiteSpace(input)) {
|
||||||
|
type = CombGuidType.PostgreSql;
|
||||||
|
errorMessage = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Enum.TryParse(input.Trim(), ignoreCase: true, out type)
|
||||||
|
&& Enum.IsDefined(type)) {
|
||||||
|
errorMessage = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
type = default;
|
||||||
|
errorMessage = "Type must be PostgreSql or SqlServer.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Splits a comma-separated list; returns <c>null</c> when input is blank.
|
||||||
|
/// </summary>
|
||||||
|
public static List<string>? ParseOptionalList(string? input) {
|
||||||
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var items = input
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (items.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/MaksIT.Core.Cli/MaksIT.Core.Cli.csproj
Normal file
27
src/MaksIT.Core.Cli/MaksIT.Core.Cli.csproj
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
|
||||||
|
<Version>1.6.10</Version>
|
||||||
|
<Authors>Maksym Sadovnychyy</Authors>
|
||||||
|
<Company>MAKS-IT</Company>
|
||||||
|
<Product>MaksIT.Core.Cli</Product>
|
||||||
|
<Copyright>Copyright © Maksym Sadovnychyy (MAKS-IT)</Copyright>
|
||||||
|
<Description>Interactive and flag-based console toolkit for generating MaksIT.Core secrets (JWT, AES-GCM, TOTP, password hashes, COMB GUIDs).</Description>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MaksIT.Core\MaksIT.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="System.CommandLine" Version="2.0.11" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
9
src/MaksIT.Core.Cli/Program.cs
Normal file
9
src/MaksIT.Core.Cli/Program.cs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
namespace MaksIT.Core.Cli;
|
||||||
|
|
||||||
|
|
||||||
|
public static class Program {
|
||||||
|
public static int Main(string[] args) {
|
||||||
|
Console.OutputEncoding = System.Text.Encoding.UTF8;
|
||||||
|
return CommandFactory.CreateRootCommand().Parse(args).Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/MaksIT.Core.Cli/SecretOperations.cs
Normal file
131
src/MaksIT.Core.Cli/SecretOperations.cs
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using MaksIT.Core.Comb;
|
||||||
|
using MaksIT.Core.Security;
|
||||||
|
using MaksIT.Core.Security.JWT;
|
||||||
|
|
||||||
|
|
||||||
|
namespace MaksIT.Core.Cli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thin wrappers around MaksIT.Core secret and token helpers.
|
||||||
|
/// </summary>
|
||||||
|
public static class SecretOperations {
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a Base64 secret suitable for JWT signing or a password pepper.
|
||||||
|
/// </summary>
|
||||||
|
public static string GenerateSecret(int keySize = 32) =>
|
||||||
|
JwtGenerator.GenerateSecret(keySize);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates an opaque refresh token.
|
||||||
|
/// </summary>
|
||||||
|
public static string GenerateRefreshToken() =>
|
||||||
|
JwtGenerator.GenerateRefreshToken();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a Base64 AES-256 key.
|
||||||
|
/// </summary>
|
||||||
|
public static string GenerateAesKey() =>
|
||||||
|
AESGCMUtility.GenerateKeyBase64();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a COMB GUID for the given layout.
|
||||||
|
/// </summary>
|
||||||
|
public static Guid GenerateCombGuid(CombGuidType type) =>
|
||||||
|
CombGuidGenerator.CreateCombGuid(DateTime.UtcNow, type);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Signs an access JWT.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryGenerateJwt(
|
||||||
|
JWTTokenGenerateRequest request,
|
||||||
|
[NotNullWhen(true)] out string? token,
|
||||||
|
[NotNullWhen(false)] out string? errorMessage
|
||||||
|
) {
|
||||||
|
if (!JwtGenerator.TryGenerateToken(request, out var tokenData, out errorMessage)) {
|
||||||
|
token = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
token = tokenData.Value.Item1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates an access JWT and returns its claims.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryValidateJwt(
|
||||||
|
string secret,
|
||||||
|
string issuer,
|
||||||
|
string audience,
|
||||||
|
string token,
|
||||||
|
out JWTTokenClaims? claims,
|
||||||
|
[NotNullWhen(false)] out string? errorMessage
|
||||||
|
) =>
|
||||||
|
JwtGenerator.TryValidateToken(secret, issuer, audience, token, out claims, out errorMessage);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a Base32 TOTP shared secret.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryGenerateTotpSecret(
|
||||||
|
[NotNullWhen(true)] out string? secret,
|
||||||
|
[NotNullWhen(false)] out string? errorMessage
|
||||||
|
) =>
|
||||||
|
TotpGenerator.TryGenerateSecret(out secret, out errorMessage);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates TOTP recovery codes.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryGenerateRecoveryCodes(
|
||||||
|
int count,
|
||||||
|
[NotNullWhen(true)] out List<string>? codes,
|
||||||
|
[NotNullWhen(false)] out string? errorMessage
|
||||||
|
) =>
|
||||||
|
TotpGenerator.TryGenerateRecoveryCodes(count, out codes, out errorMessage);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an <c>otpauth://</c> URI for authenticator apps.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryGenerateTotpAuthLink(
|
||||||
|
string label,
|
||||||
|
string username,
|
||||||
|
string secret,
|
||||||
|
string issuer,
|
||||||
|
[NotNullWhen(true)] out string? authLink,
|
||||||
|
[NotNullWhen(false)] out string? errorMessage
|
||||||
|
) =>
|
||||||
|
TotpGenerator.TryGenerateTotpAuthLink(
|
||||||
|
label,
|
||||||
|
username,
|
||||||
|
secret,
|
||||||
|
issuer,
|
||||||
|
algorithm: null,
|
||||||
|
digits: null,
|
||||||
|
period: null,
|
||||||
|
out authLink,
|
||||||
|
out errorMessage
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a TOTP code against a Base32 secret.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryValidateTotp(
|
||||||
|
string totpCode,
|
||||||
|
string base32Secret,
|
||||||
|
int timeTolerance,
|
||||||
|
out bool isValid,
|
||||||
|
[NotNullWhen(false)] out string? errorMessage
|
||||||
|
) =>
|
||||||
|
TotpGenerator.TryValidate(totpCode, base32Secret, timeTolerance, out isValid, out errorMessage);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a salted password hash with the given pepper.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryHashPassword(
|
||||||
|
string password,
|
||||||
|
string pepper,
|
||||||
|
[NotNullWhen(true)] out (string Salt, string Hash)? saltedHash,
|
||||||
|
[NotNullWhen(false)] out string? errorMessage
|
||||||
|
) =>
|
||||||
|
PasswordHasher.TryCreateSaltedHash(password, pepper, out saltedHash, out errorMessage);
|
||||||
|
}
|
||||||
@ -7,20 +7,14 @@
|
|||||||
|
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<IsTestProject>true</IsTestProject>
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" Version="10.0.1">
|
<PackageReference Include="coverlet.MTP" Version="10.0.1" />
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
</PackageReference>
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.3.12" />
|
<PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.3.12" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
|
<PackageReference Include="xunit.v3" Version="4.0.0" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
</PackageReference>
|
|
||||||
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
|
<Project Path="MaksIT.Core.Cli.Tests/MaksIT.Core.Cli.Tests.csproj" />
|
||||||
|
<Project Path="MaksIT.Core.Cli/MaksIT.Core.Cli.csproj" />
|
||||||
<Project Path="MaksIT.Core.Tests/MaksIT.Core.Tests.csproj" />
|
<Project Path="MaksIT.Core.Tests/MaksIT.Core.Tests.csproj" />
|
||||||
<Project Path="MaksIT.Core/MaksIT.Core.csproj" />
|
<Project Path="MaksIT.Core/MaksIT.Core.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
<!-- NuGet package metadata -->
|
<!-- NuGet package metadata -->
|
||||||
<PackageId>MaksIT.Core</PackageId>
|
<PackageId>MaksIT.Core</PackageId>
|
||||||
<Version>1.6.9</Version>
|
<Version>1.6.10</Version>
|
||||||
<Authors>Maksym Sadovnychyy</Authors>
|
<Authors>Maksym Sadovnychyy</Authors>
|
||||||
<Company>MAKS-IT</Company>
|
<Company>MAKS-IT</Company>
|
||||||
<Product>MaksIT.Core</Product>
|
<Product>MaksIT.Core</Product>
|
||||||
|
|||||||
5
src/global.json
Normal file
5
src/global.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"test": {
|
||||||
|
"runner": "Microsoft.Testing.Platform"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,7 +15,10 @@
|
|||||||
"name": "DotNetTest",
|
"name": "DotNetTest",
|
||||||
"stageLabel": "test",
|
"stageLabel": "test",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"project": "..\\..\\..\\src\\MaksIT.Core.Tests",
|
"projects": [
|
||||||
|
"..\\..\\..\\src\\MaksIT.Core.Tests",
|
||||||
|
"..\\..\\..\\src\\MaksIT.Core.Cli.Tests"
|
||||||
|
],
|
||||||
"resultsDir": "..\\..\\..\\testResults"
|
"resultsDir": "..\\..\\..\\testResults"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -37,6 +40,15 @@
|
|||||||
],
|
],
|
||||||
"artifactsDir": "..\\..\\..\\releases"
|
"artifactsDir": "..\\..\\..\\releases"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "DotNetPublish",
|
||||||
|
"stageLabel": "build",
|
||||||
|
"enabled": true,
|
||||||
|
"projectFiles": [
|
||||||
|
"..\\..\\..\\src\\MaksIT.Core.Cli\\MaksIT.Core.Cli.csproj"
|
||||||
|
],
|
||||||
|
"artifactsDir": "..\\..\\..\\releases"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "DotNetCreateArchive",
|
"name": "DotNetCreateArchive",
|
||||||
"stageLabel": "build",
|
"stageLabel": "build",
|
||||||
|
|||||||
@ -8,7 +8,8 @@
|
|||||||
"stageLabel": "test",
|
"stageLabel": "test",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"projects": [
|
"projects": [
|
||||||
"..\\..\\..\\src\\MaksIT.Core.Tests"
|
"..\\..\\..\\src\\MaksIT.Core.Tests",
|
||||||
|
"..\\..\\..\\src\\MaksIT.Core.Cli.Tests"
|
||||||
],
|
],
|
||||||
"resultsDir": "..\\..\\..\\test-results"
|
"resultsDir": "..\\..\\..\\test-results"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -6,16 +6,65 @@
|
|||||||
Keep a Changelog header parsing and section extraction.
|
Keep a Changelog header parsing and section extraction.
|
||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Supports only the standard Keep a Changelog version line:
|
Supports Keep a Changelog version lines and shared SemVer checks, including prerelease:
|
||||||
## [1.0.0] - 2026-05-24
|
## [1.0.0] - 2026-05-24
|
||||||
|
## [0.1.0-alpha.1] - 2026-08-21
|
||||||
|
## [0.1.0-beta.1] - 2026-08-21
|
||||||
|
## [0.1.0-rc.1] - 2026-08-21
|
||||||
#>
|
#>
|
||||||
|
|
||||||
|
function Get-ChangelogSemverPattern {
|
||||||
|
return '\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-ReleaseSemver {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[AllowEmptyString()]
|
||||||
|
[string]$Version
|
||||||
|
)
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Version)) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
return [bool]($Version -match ('^' + (Get-ChangelogSemverPattern) + '$'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-ReleaseSemverPrerelease {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[AllowEmptyString()]
|
||||||
|
[string]$Version
|
||||||
|
)
|
||||||
|
|
||||||
|
return (Test-ReleaseSemver -Version $Version) -and ($Version -match '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ReleaseSemverPrereleaseLabel {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[AllowEmptyString()]
|
||||||
|
[string]$Version
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not (Test-ReleaseSemverPrerelease -Version $Version)) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Version -match '^\d+\.\d+\.\d+-([A-Za-z][0-9A-Za-z]*)') {
|
||||||
|
return $Matches[1].ToLowerInvariant()
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'next'
|
||||||
|
}
|
||||||
|
|
||||||
function Get-ChangelogVersionHeaderPattern {
|
function Get-ChangelogVersionHeaderPattern {
|
||||||
return '(?m)^##\s+\[(\d+\.\d+\.\d+)\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
|
return '(?m)^##\s+\[(' + (Get-ChangelogSemverPattern) + ')\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-ChangelogNextVersionHeaderPattern {
|
function Get-ChangelogNextVersionHeaderPattern {
|
||||||
return '(?m)^##\s+\[\d+\.\d+\.\d+\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
|
return '(?m)^##\s+\[' + (Get-ChangelogSemverPattern) + '\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-LatestChangelogVersion {
|
function Get-LatestChangelogVersion {
|
||||||
@ -53,4 +102,4 @@ function Get-ChangelogReleaseNotesSection {
|
|||||||
return $match.Value.Trim()
|
return $match.Value.Trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
Export-ModuleMember -Function Get-ChangelogVersionHeaderPattern, Get-ChangelogNextVersionHeaderPattern, Get-LatestChangelogVersion, Get-ChangelogReleaseNotesSection
|
Export-ModuleMember -Function Get-ChangelogSemverPattern, Test-ReleaseSemver, Test-ReleaseSemverPrerelease, Get-ReleaseSemverPrereleaseLabel, Get-ChangelogVersionHeaderPattern, Get-ChangelogNextVersionHeaderPattern, Get-LatestChangelogVersion, Get-ChangelogReleaseNotesSection
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Provides the Invoke-TestsWithCoverage function for running .NET tests
|
Provides the Invoke-TestsWithCoverage function for running .NET tests
|
||||||
with Coverlet code coverage collection and parsing results.
|
with Microsoft.Testing.Platform and coverlet.MTP code coverage.
|
||||||
|
|
||||||
.NOTES
|
.NOTES
|
||||||
Author: MaksIT
|
Author: MaksIT
|
||||||
@ -65,6 +65,20 @@ function Write-TestRunnerLogInternal {
|
|||||||
Write-Host $Message -ForegroundColor Gray
|
Write-Host $Message -ForegroundColor Gray
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Get-CoberturaCoverageFiles {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$ResultsDirectory
|
||||||
|
)
|
||||||
|
|
||||||
|
# coverlet.MTP: [prefix.]coverage.cobertura[.timestamp].xml
|
||||||
|
$files = @(
|
||||||
|
Get-ChildItem -Path $ResultsDirectory -Recurse -File -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.Name -like '*coverage.cobertura*.xml' }
|
||||||
|
)
|
||||||
|
return @($files | Sort-Object FullName -Unique)
|
||||||
|
}
|
||||||
|
|
||||||
function Invoke-TestsWithCoverage {
|
function Invoke-TestsWithCoverage {
|
||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
@ -155,7 +169,7 @@ function Invoke-TestsWithCoverage {
|
|||||||
New-Item -ItemType Directory -Path $ResultsDir -Force | Out-Null
|
New-Item -ItemType Directory -Path $ResultsDir -Force | Out-Null
|
||||||
|
|
||||||
if (-not $Silent) {
|
if (-not $Silent) {
|
||||||
Write-TestRunnerLogInternal -Level "STEP" -Message "Running tests with code coverage..."
|
Write-TestRunnerLogInternal -Level "STEP" -Message "Running tests with code coverage (Microsoft.Testing.Platform / coverlet.MTP)..."
|
||||||
foreach ($d in $resolvedProjectDirs) {
|
foreach ($d in $resolvedProjectDirs) {
|
||||||
Write-TestRunnerLogInternal -Level "INFO" -Message "Test Project: $d"
|
Write-TestRunnerLogInternal -Level "INFO" -Message "Test Project: $d"
|
||||||
}
|
}
|
||||||
@ -164,11 +178,15 @@ function Invoke-TestsWithCoverage {
|
|||||||
foreach ($TestProjectDir in $resolvedProjectDirs) {
|
foreach ($TestProjectDir in $resolvedProjectDirs) {
|
||||||
Push-Location $TestProjectDir
|
Push-Location $TestProjectDir
|
||||||
try {
|
try {
|
||||||
|
$projectName = [System.IO.Path]::GetFileName($TestProjectDir)
|
||||||
$dotnetArgs = @(
|
$dotnetArgs = @(
|
||||||
"test"
|
"test"
|
||||||
"--collect:XPlat Code Coverage"
|
|
||||||
"--results-directory", $ResultsDir
|
"--results-directory", $ResultsDir
|
||||||
"--verbosity", $(if ($Silent) { "quiet" } else { "normal" })
|
"--verbosity", $(if ($Silent) { "quiet" } else { "normal" })
|
||||||
|
"--coverlet"
|
||||||
|
"--coverlet-output-format", "cobertura"
|
||||||
|
"--coverlet-file-prefix", $projectName
|
||||||
|
"--coverlet-include", "[MaksIT.*]*"
|
||||||
)
|
)
|
||||||
|
|
||||||
Import-ExternalCommandSupportInternal
|
Import-ExternalCommandSupportInternal
|
||||||
@ -192,7 +210,7 @@ function Invoke-TestsWithCoverage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$coverageFiles = @(Get-ChildItem -Path $ResultsDir -Filter "coverage.cobertura.xml" -Recurse | Sort-Object FullName)
|
$coverageFiles = @(Get-CoberturaCoverageFiles -ResultsDirectory $ResultsDir)
|
||||||
|
|
||||||
if ($coverageFiles.Count -eq 0) {
|
if ($coverageFiles.Count -eq 0) {
|
||||||
return [PSCustomObject]@{
|
return [PSCustomObject]@{
|
||||||
@ -455,7 +473,7 @@ function Get-DotNetCoverageFromResultsDirectory {
|
|||||||
[switch]$Silent
|
[switch]$Silent
|
||||||
)
|
)
|
||||||
|
|
||||||
$coverageFiles = @(Get-ChildItem -Path $ResultsDirectory -Filter 'coverage.cobertura.xml' -Recurse -ErrorAction SilentlyContinue | Sort-Object FullName)
|
$coverageFiles = @(Get-CoberturaCoverageFiles -ResultsDirectory $ResultsDirectory)
|
||||||
if ($coverageFiles.Count -eq 0) {
|
if ($coverageFiles.Count -eq 0) {
|
||||||
return [PSCustomObject]@{
|
return [PSCustomObject]@{
|
||||||
Success = $false
|
Success = $false
|
||||||
@ -566,7 +584,7 @@ function Get-CoverageFromResultsDirectory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$hasDotNet = @(Get-ChildItem -Path $resolvedDirectory -Filter 'coverage.cobertura.xml' -Recurse -ErrorAction SilentlyContinue).Count -gt 0
|
$hasDotNet = @(Get-CoberturaCoverageFiles -ResultsDirectory $resolvedDirectory).Count -gt 0
|
||||||
$jestSummary = Join-Path $resolvedDirectory 'coverage-summary.json'
|
$jestSummary = Join-Path $resolvedDirectory 'coverage-summary.json'
|
||||||
$hasNpm = Test-Path -LiteralPath $jestSummary -PathType Leaf
|
$hasNpm = Test-Path -LiteralPath $jestSummary -PathType Leaf
|
||||||
|
|
||||||
|
|||||||
@ -6,9 +6,10 @@
|
|||||||
.NET publish plugin for producing application release artifacts.
|
.NET publish plugin for producing application release artifacts.
|
||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
This plugin publishes the configured .NET project into a release output
|
This plugin publishes configured .NET projects into the artifacts directory
|
||||||
directory and exposes that published directory to the shared release
|
and appends those publish folders to shared archive inputs so later plugins
|
||||||
context so later release-stage plugins can archive and publish it.
|
can zip them next to any earlier pack outputs. Existing NuGet package facts
|
||||||
|
(packageFile) are left unchanged.
|
||||||
#>
|
#>
|
||||||
|
|
||||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||||
@ -29,25 +30,58 @@ function Invoke-Plugin {
|
|||||||
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
|
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
|
||||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact"
|
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact"
|
||||||
|
|
||||||
|
$pluginSettings = $Settings
|
||||||
$sharedSettings = $Settings.context
|
$sharedSettings = $Settings.context
|
||||||
$projectFiles = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'projectFiles' -LegacyProperty @('projectFiles')
|
$scriptDir = $sharedSettings.scriptDir
|
||||||
$artifactsDirectory = $sharedSettings.artifactsDirectory
|
$projectFiles = @()
|
||||||
$publishProjectPath = $null
|
|
||||||
|
|
||||||
Assert-Command dotnet
|
Assert-Command dotnet
|
||||||
|
|
||||||
if ($null -eq $projectFiles -or @($projectFiles).Count -eq 0) {
|
if ($pluginSettings.PSObject.Properties['projectFiles'] -and $null -ne $pluginSettings.projectFiles) {
|
||||||
throw "DotNetPublish plugin requires project files in the shared context."
|
$projectFiles = @(Resolve-RelativePaths -Value $pluginSettings.projectFiles -BasePath $scriptDir)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$fromFact = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'projectFiles' -LegacyProperty @('projectFiles')
|
||||||
|
if ($null -ne $fromFact) {
|
||||||
|
$projectFiles = @($fromFact)
|
||||||
|
}
|
||||||
|
elseif ($sharedSettings.PSObject.Properties['projectFiles'] -and $null -ne $sharedSettings.projectFiles) {
|
||||||
|
$projectFiles = @($sharedSettings.projectFiles)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$projectFiles = @($projectFiles)
|
if ($projectFiles.Count -eq 0) {
|
||||||
|
throw "DotNetPublish plugin requires projectFiles in plugin settings or projectFiles on shared context."
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pluginSettings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.artifactsDir)) {
|
||||||
|
$artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$pluginSettings.artifactsDir)))
|
||||||
|
Set-EngineState -Context $sharedSettings -Name 'artifactsDirectory' -Value $artifactsDirectory
|
||||||
|
Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$artifactsDirectory = $sharedSettings.artifactsDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace([string]$artifactsDirectory)) {
|
||||||
|
throw "DotNetPublish plugin requires artifactsDir in plugin settings or artifactsDirectory on shared context."
|
||||||
|
}
|
||||||
|
|
||||||
if (!(Test-Path $artifactsDirectory)) {
|
if (!(Test-Path $artifactsDirectory)) {
|
||||||
New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null
|
New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null
|
||||||
}
|
}
|
||||||
|
|
||||||
# The first configured project remains the canonical release artifact source.
|
$existing = Get-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -LegacyProperty @('releaseArchiveInputs')
|
||||||
$publishProjectPath = $projectFiles[0]
|
$archiveInputs = [System.Collections.Generic.List[object]]::new()
|
||||||
|
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))
|
$publishDir = Join-Path $artifactsDirectory ([System.IO.Path]::GetFileNameWithoutExtension($publishProjectPath))
|
||||||
|
|
||||||
if (Test-Path $publishDir) {
|
if (Test-Path $publishDir) {
|
||||||
@ -55,7 +89,10 @@ function Invoke-Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Write-Log -Level "STEP" -Message "Publishing release artifact..."
|
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) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
throw "dotnet publish failed for $publishProjectPath."
|
throw "dotnet publish failed for $publishProjectPath."
|
||||||
}
|
}
|
||||||
@ -66,10 +103,10 @@ function Invoke-Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Write-Log -Level "OK" -Message " Published artifact ready: $publishDir"
|
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 'release' -Name 'archiveInputs' -Value @($archiveInputs) -Overwrite Replace -LegacyProperty 'releaseArchiveInputs'
|
||||||
Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -Value $null -Overwrite Replace -LegacyProperty 'symbolsPackageFile'
|
|
||||||
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value @($publishDir) -Overwrite Replace -LegacyProperty 'releaseArchiveInputs'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Export-ModuleMember -Function Invoke-Plugin
|
Export-ModuleMember -Function Invoke-Plugin
|
||||||
|
|||||||
@ -7,9 +7,11 @@
|
|||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Dedicated version-loading plugin. Reads <Version> from the first configured
|
Dedicated version-loading plugin. Reads <Version> from the first configured
|
||||||
projectFiles entry and writes it (plus the resolved projectFiles) to the
|
projectFiles .csproj, or from the nearest Directory.Build.props when the
|
||||||
shared runtime context. Declares providesVersion = $true so the engine can
|
csproj omits it. Accepts SemVer prerelease (0.1.0-alpha.1 / beta / rc). Writes
|
||||||
discover it as the single release version source.
|
version plus the resolved projectFiles (csproj paths for later pack/publish)
|
||||||
|
to the shared runtime context. Declares providesVersion = $true so the engine
|
||||||
|
can discover it as the single release version source.
|
||||||
#>
|
#>
|
||||||
|
|
||||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||||
@ -20,6 +22,22 @@ if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ConvertTo-MsbuildPropertyStringInternal {
|
||||||
|
param(
|
||||||
|
$Value
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($null -eq $Value) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Value -is [System.Xml.XmlElement]) {
|
||||||
|
return [string]$Value.InnerText
|
||||||
|
}
|
||||||
|
|
||||||
|
return [string]$Value
|
||||||
|
}
|
||||||
|
|
||||||
function Get-CsprojPropertyValueInternal {
|
function Get-CsprojPropertyValueInternal {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
@ -36,7 +54,33 @@ function Get-CsprojPropertyValueInternal {
|
|||||||
Select-Object -First 1
|
Select-Object -First 1
|
||||||
|
|
||||||
if ($propNode) {
|
if ($propNode) {
|
||||||
return $propNode.$PropertyName
|
return ConvertTo-MsbuildPropertyStringInternal -Value $propNode.$PropertyName
|
||||||
|
}
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-DirectoryBuildPropsVersionInternal {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$ProjectPath
|
||||||
|
)
|
||||||
|
|
||||||
|
# MSBuild uses the first Directory.Build.props found walking up from the project directory.
|
||||||
|
$dir = [System.IO.Path]::GetDirectoryName((Resolve-Path -LiteralPath $ProjectPath))
|
||||||
|
while (-not [string]::IsNullOrWhiteSpace($dir)) {
|
||||||
|
$propsPath = Join-Path $dir 'Directory.Build.props'
|
||||||
|
if (Test-Path -LiteralPath $propsPath -PathType Leaf) {
|
||||||
|
[xml]$props = Get-Content -LiteralPath $propsPath
|
||||||
|
return Get-CsprojPropertyValueInternal -Csproj $props -PropertyName 'Version'
|
||||||
|
}
|
||||||
|
|
||||||
|
$parent = [System.IO.Directory]::GetParent($dir)
|
||||||
|
if ($null -eq $parent) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
$dir = $parent.FullName
|
||||||
}
|
}
|
||||||
|
|
||||||
return $null
|
return $null
|
||||||
@ -58,12 +102,16 @@ function Get-CsprojVersionInternal {
|
|||||||
|
|
||||||
[xml]$csproj = Get-Content $ProjectPath
|
[xml]$csproj = Get-Content $ProjectPath
|
||||||
$version = Get-CsprojPropertyValueInternal -Csproj $csproj -PropertyName "Version"
|
$version = Get-CsprojPropertyValueInternal -Csproj $csproj -PropertyName "Version"
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace([string]$version)) {
|
||||||
if ([string]::IsNullOrWhiteSpace([string]$version)) {
|
return [string]$version
|
||||||
throw "DotNetReleaseVersion: <Version> not found in '$ProjectPath'."
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$version = Get-DirectoryBuildPropsVersionInternal -ProjectPath $ProjectPath
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace([string]$version)) {
|
||||||
return [string]$version
|
return [string]$version
|
||||||
|
}
|
||||||
|
|
||||||
|
throw "DotNetReleaseVersion: <Version> not found in '$ProjectPath' or a parent Directory.Build.props."
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-PluginMetadata {
|
function Get-PluginMetadata {
|
||||||
@ -87,6 +135,10 @@ function Invoke-Plugin {
|
|||||||
|
|
||||||
Write-Log -Level "INFO" -Message "Reading version from SDK-style project file (projectFiles)..."
|
Write-Log -Level "INFO" -Message "Reading version from SDK-style project file (projectFiles)..."
|
||||||
$version = Get-CsprojVersionInternal -ProjectPath $projectFiles[0]
|
$version = Get-CsprojVersionInternal -ProjectPath $projectFiles[0]
|
||||||
|
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
|
||||||
|
if (-not (Test-ReleaseSemver -Version $version)) {
|
||||||
|
throw "DotNetReleaseVersion: version '$version' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)."
|
||||||
|
}
|
||||||
|
|
||||||
Set-EngineState -Context $shared -Name 'version' -Value $version
|
Set-EngineState -Context $shared -Name 'version' -Value $version
|
||||||
Set-EngineFact -Context $shared -Namespace 'dotnet' -Name 'projectFiles' -Value $projectFiles -Overwrite Replace -LegacyProperty 'projectFiles'
|
Set-EngineFact -Context $shared -Namespace 'dotnet' -Name 'projectFiles' -Value $projectFiles -Overwrite Replace -LegacyProperty 'projectFiles'
|
||||||
|
|||||||
@ -7,12 +7,15 @@
|
|||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Resolves one or more .NET test projects (`project` or `projects`), runs tests once
|
Resolves one or more .NET test projects (`project` or `projects`), runs tests once
|
||||||
via TestRunner, then publishes metrics on the shared engine context for any later
|
via TestRunner (Microsoft Testing Platform + coverlet.MTP only — no VSTest /
|
||||||
|
coverlet.collector), then publishes metrics on the shared engine context for any later
|
||||||
plugin: `qualityLineCoverage`, `testResult`, `coverageLineRate` / `coverageBranchRate` / `coverageMethodRate`,
|
plugin: `qualityLineCoverage`, `testResult`, `coverageLineRate` / `coverageBranchRate` / `coverageMethodRate`,
|
||||||
method counts, `testResultsDirectory`, `coverageCoberturaPaths`. Quality gates read
|
method counts, `testResultsDirectory`, `coverageCoberturaPaths`. Quality gates read
|
||||||
those keys generically (not tied to this plugin by name). When `resultsDir` (or the
|
those keys generically (not tied to this plugin by name). When `resultsDir` (or the
|
||||||
multi-project default TestResults folder) is used, Cobertura output is kept on disk
|
multi-project default TestResults folder) is used, Cobertura output is kept on disk
|
||||||
via TestRunner `-KeepResults` so repo-root `test-results/` persists after the run.
|
via TestRunner `-KeepResults` so repo-root `test-results/` persists after the run.
|
||||||
|
Product solutions must place `src/global.json` with `test.runner` =
|
||||||
|
Microsoft.Testing.Platform next to the `.sln`/`.slnx`.
|
||||||
#>
|
#>
|
||||||
|
|
||||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||||
|
|||||||
@ -28,6 +28,7 @@ function Invoke-Plugin {
|
|||||||
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
||||||
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
|
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
|
||||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
|
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
|
||||||
|
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-ReleaseSemverPrereleaseLabel"
|
||||||
|
|
||||||
$pluginSettings = $Settings
|
$pluginSettings = $Settings
|
||||||
$shared = $Settings.context
|
$shared = $Settings.context
|
||||||
@ -62,6 +63,14 @@ function Invoke-Plugin {
|
|||||||
[string]$pluginSettings.access
|
[string]$pluginSettings.access
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$npmDistTag = $null
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.npmDistTag)) {
|
||||||
|
$npmDistTag = [string]$pluginSettings.npmDistTag
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$npmDistTag = Get-ReleaseSemverPrereleaseLabel -Version ([string]$shared.version)
|
||||||
|
}
|
||||||
|
|
||||||
$publishOrder = @()
|
$publishOrder = @()
|
||||||
if ($pluginSettings.publishOrder) {
|
if ($pluginSettings.publishOrder) {
|
||||||
if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) {
|
if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) {
|
||||||
@ -84,7 +93,8 @@ function Invoke-Plugin {
|
|||||||
|
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
foreach ($packageName in $publishOrder) {
|
foreach ($packageName in $publishOrder) {
|
||||||
Write-Log -Level "INFO" -Message "Dry run: would publish npm package '$packageName' to $registry"
|
$tagNote = if ([string]::IsNullOrWhiteSpace($npmDistTag)) { 'latest' } else { $npmDistTag }
|
||||||
|
Write-Log -Level "INFO" -Message "Dry run: would publish npm package '$packageName' to $registry (dist-tag $tagNote)"
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -112,13 +122,20 @@ registry=$registry
|
|||||||
|
|
||||||
foreach ($packageName in $publishOrder) {
|
foreach ($packageName in $publishOrder) {
|
||||||
Write-Log -Level "STEP" -Message "Publishing npm package '$packageName'..."
|
Write-Log -Level "STEP" -Message "Publishing npm package '$packageName'..."
|
||||||
|
$publishArgs = @('publish')
|
||||||
if ($useWorkspaces) {
|
if ($useWorkspaces) {
|
||||||
npm publish -w $packageName --access $access --userconfig $tempNpmRcPath
|
$publishArgs += @('-w', $packageName)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName
|
Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName
|
||||||
npm publish --access $access --userconfig $tempNpmRcPath
|
|
||||||
}
|
}
|
||||||
|
$publishArgs += @('--access', $access, '--userconfig', $tempNpmRcPath)
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($npmDistTag)) {
|
||||||
|
$publishArgs += @('--tag', $npmDistTag)
|
||||||
|
Write-Log -Level "INFO" -Message " Using npm dist-tag '$npmDistTag' (prerelease)."
|
||||||
|
}
|
||||||
|
|
||||||
|
npm @publishArgs
|
||||||
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
throw "Failed to publish npm package '$packageName'."
|
throw "Failed to publish npm package '$packageName'."
|
||||||
|
|||||||
@ -35,8 +35,9 @@ function Get-PackageJsonVersionInternal {
|
|||||||
throw "NpmReleaseVersion: 'version' is missing in '$PackageJsonPath'."
|
throw "NpmReleaseVersion: 'version' is missing in '$PackageJsonPath'."
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($version -notmatch '^\d+\.\d+\.\d+') {
|
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
|
||||||
throw "NpmReleaseVersion: version '$version' in '$PackageJsonPath' is not a valid semver."
|
if (-not (Test-ReleaseSemver -Version $version)) {
|
||||||
|
throw "NpmReleaseVersion: version '$version' in '$PackageJsonPath' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)."
|
||||||
}
|
}
|
||||||
|
|
||||||
return $version
|
return $version
|
||||||
@ -69,6 +70,7 @@ function Invoke-Plugin {
|
|||||||
|
|
||||||
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
||||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState"
|
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState"
|
||||||
|
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
|
||||||
|
|
||||||
$pluginSettings = $Settings
|
$pluginSettings = $Settings
|
||||||
$shared = $Settings.context
|
$shared = $Settings.context
|
||||||
|
|||||||
@ -7,9 +7,10 @@
|
|||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Reads a single-line semver from the configured versionFilePath (default
|
Reads a single-line semver from the configured versionFilePath (default
|
||||||
repo-root VERSION). Useful for repositories without .csproj or package.json
|
repo-root VERSION), including optional prerelease (0.1.0-alpha.1). Useful for
|
||||||
version metadata. Declares providesVersion = $true so the engine can
|
repositories without .csproj or package.json version metadata. Declares
|
||||||
discover it as the single release version source.
|
providesVersion = $true so the engine can discover it as the single release
|
||||||
|
version source.
|
||||||
#>
|
#>
|
||||||
|
|
||||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||||
@ -36,8 +37,9 @@ function Get-VersionFileSemverInternal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$version = $version -replace '^[vV]', ''
|
$version = $version -replace '^[vV]', ''
|
||||||
if ($version -notmatch '^\d+\.\d+\.\d+') {
|
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
|
||||||
throw "FileReleaseVersion: version '$version' in '$VersionFilePath' is not a valid semver."
|
if (-not (Test-ReleaseSemver -Version $version)) {
|
||||||
|
throw "FileReleaseVersion: version '$version' in '$VersionFilePath' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)."
|
||||||
}
|
}
|
||||||
|
|
||||||
return $version
|
return $version
|
||||||
@ -55,6 +57,7 @@ function Invoke-Plugin {
|
|||||||
|
|
||||||
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
||||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState"
|
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState"
|
||||||
|
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
|
||||||
|
|
||||||
$shared = $Settings.context
|
$shared = $Settings.context
|
||||||
$versionFileSetting = if ($Settings.versionFilePath) {
|
$versionFileSetting = if ($Settings.versionFilePath) {
|
||||||
|
|||||||
@ -10,7 +10,8 @@
|
|||||||
repository, and creates the configured GitHub release using the
|
repository, and creates the configured GitHub release using the
|
||||||
shared release artifacts and release notes from CHANGELOG.md.
|
shared release artifacts and release notes from CHANGELOG.md.
|
||||||
Release notes must use Keep a Changelog headers: ## [semver] - YYYY-MM-DD
|
Release notes must use Keep a Changelog headers: ## [semver] - YYYY-MM-DD
|
||||||
(see ChangelogSupport.psm1).
|
(including optional SemVer prerelease, e.g. ## [0.1.0-alpha.1] / [0.1.0-beta.1] / [0.1.0-rc.1];
|
||||||
|
see ChangelogSupport.psm1). Hyphenated versions are created with gh --prerelease.
|
||||||
#>
|
#>
|
||||||
|
|
||||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||||
@ -95,6 +96,7 @@ function Invoke-Plugin {
|
|||||||
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
||||||
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
|
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
|
||||||
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-LatestChangelogVersion"
|
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-LatestChangelogVersion"
|
||||||
|
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemverPrerelease"
|
||||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Get-EngineFact"
|
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Get-EngineFact"
|
||||||
|
|
||||||
$pluginSettings = $Settings
|
$pluginSettings = $Settings
|
||||||
@ -128,6 +130,9 @@ function Invoke-Plugin {
|
|||||||
}
|
}
|
||||||
$releaseName = $releaseTitlePattern -replace '\{version\}', $version
|
$releaseName = $releaseTitlePattern -replace '\{version\}', $version
|
||||||
Write-Log -Level "INFO" -Message "Dry run: would create GitHub release '$releaseName' ($tag) on $repo"
|
Write-Log -Level "INFO" -Message "Dry run: would create GitHub release '$releaseName' ($tag) on $repo"
|
||||||
|
if (Test-ReleaseSemverPrerelease -Version ([string]$version)) {
|
||||||
|
Write-Log -Level "INFO" -Message "Dry run: release would be marked prerelease."
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -261,6 +266,10 @@ function Invoke-Plugin {
|
|||||||
"--title", $releaseName,
|
"--title", $releaseName,
|
||||||
"--notes-file", $notesFilePath
|
"--notes-file", $notesFilePath
|
||||||
)
|
)
|
||||||
|
if (Test-ReleaseSemverPrerelease -Version ([string]$version)) {
|
||||||
|
$createReleaseArgs += '--prerelease'
|
||||||
|
}
|
||||||
|
|
||||||
& gh @createReleaseArgs
|
& gh @createReleaseArgs
|
||||||
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
|||||||
@ -11,8 +11,9 @@
|
|||||||
when they do not (whenRequirementsNotMet: skip). Publish plugins no longer use per-plugin
|
when they do not (whenRequirementsNotMet: skip). Publish plugins no longer use per-plugin
|
||||||
branch lists; put allowed branches here instead.
|
branch lists; put allowed branches here instead.
|
||||||
|
|
||||||
Typical checks: allowed branches, optional clean working tree, exact semver tag on HEAD,
|
Typical checks: allowed branches, optional clean working tree, exact semver tag on HEAD
|
||||||
tag version vs DotNetReleaseVersion, optional push tag to remote.
|
(vX.Y.Z or vX.Y.Z-prerelease such as v0.1.0-alpha.1 / v0.1.0-beta.1 / v0.1.0-rc.1),
|
||||||
|
tag version vs release version, optional push tag to remote.
|
||||||
|
|
||||||
The engine preflight no longer reads git tags; this plugin sets context.tag from the
|
The engine preflight no longer reads git tags; this plugin sets context.tag from the
|
||||||
git tag on HEAD when required. Shared context version always remains from DotNetReleaseVersion.
|
git tag on HEAD when required. Shared context version always remains from DotNetReleaseVersion.
|
||||||
@ -77,6 +78,7 @@ function Invoke-Plugin {
|
|||||||
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Get-GitStatusShort"
|
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Get-GitStatusShort"
|
||||||
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Test-RemoteTagExists"
|
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Test-RemoteTagExists"
|
||||||
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Push-TagToRemote"
|
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Push-TagToRemote"
|
||||||
|
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-ChangelogSemverPattern"
|
||||||
|
|
||||||
$pluginSettings = $Settings
|
$pluginSettings = $Settings
|
||||||
$shared = $Settings.context
|
$shared = $Settings.context
|
||||||
@ -123,8 +125,9 @@ function Invoke-Plugin {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') {
|
$tagPattern = '^v(' + (Get-ChangelogSemverPattern) + ')$'
|
||||||
Invoke-NotMetInternal -Shared $shared -When $when -Reason "tag '$tag' must match vX.Y.Z."
|
if ($tag -notmatch $tagPattern) {
|
||||||
|
Invoke-NotMetInternal -Shared $shared -When $when -Reason "tag '$tag' must match vX.Y.Z or vX.Y.Z-prerelease (e.g. v0.1.0-alpha.1, v0.1.0-beta.1, v0.1.0-rc.1)."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user