(feature): add secrets cli and migrate tests to xunit v3
This commit is contained in:
parent
434b569b3a
commit
f79aa8f3c2
12
CHANGELOG.md
12
CHANGELOG.md
@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.6.10] - 2026-08-21
|
||||
|
||||
### Added
|
||||
- **CLI:** `MaksIT.Core.Cli` for generating JWT/pepper secrets, AES-256 keys, TOTP material, password hashes, and COMB GUIDs. Interactive numbered menu when run with no arguments; flag-based commands (`secret`, `jwt`, `aes`, `totp`, `password`, `guid`) for scripts and agents. Shipped in the GitHub release zip next to the library nupkg; not pushed to nuget.org.
|
||||
|
||||
### Changed
|
||||
- **Tests:** migrate to **xunit.v3** **4.0** + **Microsoft Testing Platform** only (`src/global.json` `test.runner` next to the `.slnx`; no VSTest / **coverlet.collector** / **Microsoft.NET.Test.Sdk** / **xunit.runner.visualstudio**). Use **coverlet.MTP**; **TestRunner** always uses `--coverlet` (scoped to **`[MaksIT.*]*`**).
|
||||
- RepoUtils `DotNetPublish` now publishes listed CLI projects alongside `DotNetPack` without replacing the library NuGet artifact.
|
||||
- **README:** aligned the table of contents with the body heading hierarchy and mapped remaining public APIs (console loggers, Web API middleware, Base64Url, CRC32, exception/formats extensions, `QueryResultBase`, `PatchRequestModelBase`). Corrected saga, JWT, DateTime, PATCH, and network-share examples to match current signatures.
|
||||
|
||||
## [1.6.9] - 2026-08-14
|
||||
|
||||
### Changed
|
||||
|
||||
@ -26,11 +26,25 @@ dotnet build MaksIT.Core.slnx
|
||||
|
||||
### Running Tests
|
||||
|
||||
Preferred: `utils\Invoke-TestEngine.bat` (DotNetTest → QualityGate → CoverageBadges). Tests run under **Microsoft Testing Platform** (`src/global.json` `test.runner` next to the `.slnx`) with **xunit.v3** and **coverlet.MTP**.
|
||||
|
||||
```bash
|
||||
cd src
|
||||
dotnet test MaksIT.Core.Tests
|
||||
dotnet test MaksIT.Core.Cli.Tests
|
||||
```
|
||||
|
||||
### Running the secrets CLI
|
||||
|
||||
```bash
|
||||
cd src
|
||||
dotnet run --project MaksIT.Core.Cli
|
||||
dotnet run --project MaksIT.Core.Cli -- secret
|
||||
dotnet run --project MaksIT.Core.Cli -- --help
|
||||
```
|
||||
|
||||
No arguments opens the interactive numbered menu. Commands/flags are for scripts and agents (values on stdout, errors on stderr). It is not a `dotnet tool` and is not published to NuGet.
|
||||
|
||||
## Commit Message Format
|
||||
|
||||
This project uses the following commit message format:
|
||||
@ -119,8 +133,8 @@ Orchestration lives in **`utils/`** (from [maksit-repoutils](https://github.com/
|
||||
|
||||
### Workflow
|
||||
|
||||
1. Bump `<Version>` in `src/MaksIT.Core/MaksIT.Core.csproj` and **CHANGELOG.md**
|
||||
2. Commit, tag `vX.Y.Z` on `main`
|
||||
1. Bump `<Version>` in `src/MaksIT.Core/MaksIT.Core.csproj` and `src/MaksIT.Core.Cli/MaksIT.Core.Cli.csproj` (keep them aligned) and **CHANGELOG.md**
|
||||
2. Commit, tag `v{version}` on `main` (`v1.2.3` or SemVer prerelease such as `v0.1.0-alpha.1`, `v0.1.0-beta.1`, `v0.1.0-rc.1`). GitHub marks hyphenated versions as prerelease.
|
||||
3. Set `$env:GitHub`, `$env:NuGet`, run `utils\Invoke-ReleasePackage-Single.bat`
|
||||
|
||||
Dry-run: `pwsh -File utils\engines\release\Invoke-ReleasePackage.ps1 -DryRun`
|
||||
|
||||
701
README.md
701
README.md
@ -1,11 +1,30 @@
|
||||
# MaksIT.Core Library Documentation
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
**MaksIT.Core** is a .NET 10 library of shared helpers used across MaksIT products: domain/DTO/Web API bases, strongly-typed enumerations, extensions, logging, security (JWT, JWK, JWS, TOTP, AES-GCM), sagas, COMB GUIDs, and Web API pagination.
|
||||
|
||||
Install from NuGet:
|
||||
|
||||
```bash
|
||||
dotnet add package MaksIT.Core
|
||||
```
|
||||
|
||||
The secrets CLI (`MaksIT.Core.Cli`) is **not** published to NuGet. It ships in the GitHub release zip next to `MaksIT.Core.*.nupkg`.
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| Tests / coverage badges | `utils\Invoke-TestEngine.bat` |
|
||||
| Release (pack, NuGet, GitHub) | `utils\Invoke-ReleasePackage.bat` |
|
||||
| Contributing | [CONTRIBUTING.md](CONTRIBUTING.md) |
|
||||
| Changelog | [CHANGELOG.md](CHANGELOG.md) |
|
||||
| License | [LICENSE.md](LICENSE.md) |
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [CLI (secrets toolkit)](#cli-secrets-toolkit)
|
||||
- [Abstractions](#abstractions)
|
||||
- [Base Classes](#base-classes)
|
||||
- [Enumeration](#enumeration)
|
||||
@ -14,12 +33,15 @@
|
||||
- [DateTime Extensions](#datetime-extensions)
|
||||
- [String Extensions](#string-extensions)
|
||||
- [Object Extensions](#object-extensions)
|
||||
- [Exception Extensions](#exception-extensions)
|
||||
- [Formats Extensions](#formats-extensions)
|
||||
- [DataTable Extensions](#datatable-extensions)
|
||||
- [Guid Extensions](#guid-extensions)
|
||||
- [Enum Extensions](#enum-extensions)
|
||||
- [Logging](#logging)
|
||||
- [File Logger](#file-logger)
|
||||
- [JSON File Logger](#json-file-logger)
|
||||
- [Console Loggers](#console-loggers)
|
||||
- [Logger Prefix](#logger-prefix)
|
||||
- [Threading](#threading)
|
||||
- [Lock Manager](#lock-manager)
|
||||
@ -29,17 +51,20 @@
|
||||
- [Security](#security)
|
||||
- [AES-GCM Utility](#aes-gcm-utility)
|
||||
- [Base32 Encoder](#base32-encoder)
|
||||
- [Base64Url Utility](#base64url-utility)
|
||||
- [Checksum Utility](#checksum-utility)
|
||||
- [Password Hasher](#password-hasher)
|
||||
- [JWT Generator](#jwt-generator)
|
||||
- [JWK Generator](#jwk-generator)
|
||||
- [JWK Thumbprint Utility](#jwk-thumbprint-utility)
|
||||
- [JWS Generator](#jws-generator)
|
||||
- [JWK Thumbprint Utility](#jwk-thumbprint-utility)
|
||||
- [TOTP Generator](#totp-generator)
|
||||
- [Web API](#web-api)
|
||||
- [Paged Request](#paged-request)
|
||||
- [Paged Response](#paged-response)
|
||||
- [Patch Operation](#patch-operation)
|
||||
- [Error Handling Middleware](#error-handling-middleware)
|
||||
- [Trace ID Logging Scope Middleware](#trace-id-logging-scope-middleware)
|
||||
- [Sagas](#sagas)
|
||||
- [CombGuidGenerator](#combguidgenerator)
|
||||
- [Others](#others)
|
||||
@ -48,11 +73,60 @@
|
||||
- [File System](#file-system)
|
||||
- [Processes](#processes)
|
||||
|
||||
## CLI (secrets toolkit)
|
||||
|
||||
Generates the same secrets the library uses at runtime (JWT signing keys, password pepper, AES-256 keys, TOTP material, COMB GUIDs). It is **not** published to NuGet; the exe ships in the GitHub release zip next to `MaksIT.Core.*.nupkg`.
|
||||
|
||||
- **No arguments** — interactive numbered menu.
|
||||
- **With commands** — non-interactive flags for scripts and agents. Values go to stdout; errors to stderr; exit `0`/`1`.
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
cd src
|
||||
dotnet run --project MaksIT.Core.Cli
|
||||
dotnet run --project MaksIT.Core.Cli -- --help
|
||||
dotnet run --project MaksIT.Core.Cli -- secret
|
||||
```
|
||||
|
||||
From an unpacked release zip:
|
||||
|
||||
```bash
|
||||
MaksIT.Core.Cli/MaksIT.Core.Cli
|
||||
MaksIT.Core.Cli/MaksIT.Core.Cli secret --bytes 32
|
||||
```
|
||||
|
||||
### Agent commands
|
||||
|
||||
| Command | Output |
|
||||
|---------|--------|
|
||||
| `secret [--bytes 32]` | Base64 secret (JWT signing / pepper) |
|
||||
| `jwt secret [--bytes 32]` | Same as `secret` |
|
||||
| `jwt refresh` | Opaque refresh token |
|
||||
| `jwt generate --secret S --issuer I --audience A [--expiration 60] [--user-id] [--username] [--roles] [--acl]` | Access JWT |
|
||||
| `jwt validate --secret S --issuer I --audience A --token T` | Claims JSON |
|
||||
| `aes key` | Base64 AES-256 key |
|
||||
| `totp secret` | Base32 TOTP secret |
|
||||
| `totp recovery [--count 10]` | Recovery codes (one per line) |
|
||||
| `totp link --label L --username U --secret S --issuer I` | `otpauth://` URI |
|
||||
| `totp validate --secret S --code C [--tolerance 1]` | `valid` / `invalid` (exit 1 if invalid) |
|
||||
| `password hash --pepper P --password PWD` | JSON `{ salt, hash }` |
|
||||
| `guid comb [--type PostgreSql]` | COMB GUID (`SqlServer` also accepted) |
|
||||
|
||||
Typical `appsecrets.json` values:
|
||||
|
||||
| Menu / command | Writes |
|
||||
|----------------|--------|
|
||||
| Generate secret / `secret` | `JwtSettings` signing secret or `PasswordPepper` |
|
||||
| AES-GCM key / `aes key` | host encryption key |
|
||||
| TOTP / 2FA / `totp secret` | authenticator shared key / recovery codes |
|
||||
| JWT generate | debug access tokens against a known secret |
|
||||
|
||||
## Abstractions
|
||||
|
||||
### Base Classes
|
||||
|
||||
The following base classes in the `MaksIT.Core.Abstractions` namespace provide a foundation for implementing domain, DTO, and Web API models, ensuring consistency and maintainability in application design.
|
||||
The following base classes in the `MaksIT.Core.Abstractions` namespaces (`Domain`, `Dto`, `Webapi`, `Query`) provide a foundation for implementing domain, DTO, query, and Web API models, ensuring consistency and maintainability in application design.
|
||||
|
||||
---
|
||||
|
||||
@ -152,6 +226,55 @@ public class UserResponse : ResponseModelBase {
|
||||
|
||||
---
|
||||
|
||||
##### 7. **`PatchRequestModelBase`**
|
||||
|
||||
###### Summary
|
||||
Represents the base class for Web API PATCH request models.
|
||||
|
||||
###### Purpose
|
||||
- Extends `RequestModelBase` with a dictionary of property names to `PatchOperation` values.
|
||||
- Validates that each operation is a defined `PatchOperation` enum value.
|
||||
- Provides `TryGetOperation` for case-insensitive lookup by property name.
|
||||
|
||||
###### Example Usage
|
||||
```csharp
|
||||
public class UserPatchRequest : PatchRequestModelBase {
|
||||
public string? Name { get; set; }
|
||||
public List<string>? Roles { get; set; }
|
||||
}
|
||||
|
||||
var patch = new UserPatchRequest {
|
||||
Name = "New Name",
|
||||
Operations = new Dictionary<string, PatchOperation> {
|
||||
["Name"] = PatchOperation.SetField,
|
||||
["Roles"] = PatchOperation.AddToCollection
|
||||
}
|
||||
};
|
||||
|
||||
if (patch.TryGetOperation(nameof(UserPatchRequest.Name), out var operation)) {
|
||||
// operation == PatchOperation.SetField
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
##### 8. **`QueryResultBase<T>`**
|
||||
|
||||
###### Summary
|
||||
Represents a base class for query-layer results with a unique identifier (`MaksIT.Core.Abstractions.Query`).
|
||||
|
||||
###### Purpose
|
||||
- Provides a common `Id` property for read-model / query results.
|
||||
|
||||
###### Example Usage
|
||||
```csharp
|
||||
public class UserQueryResult : QueryResultBase<Guid> {
|
||||
public required string Name { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Features and Benefits
|
||||
|
||||
1. **Consistency**:
|
||||
@ -213,69 +336,6 @@ This structure promotes clean code principles, reducing redundancy and improving
|
||||
|
||||
---
|
||||
|
||||
### CombGuidGenerator
|
||||
|
||||
The `CombGuidGenerator` class in the `MaksIT.Core.Comb` namespace provides methods for generating and extracting COMB GUIDs (GUIDs with embedded timestamps). COMB GUIDs improve index locality by combining randomness with a sortable timestamp.
|
||||
|
||||
---
|
||||
|
||||
#### Features
|
||||
|
||||
1. **Generate COMB GUIDs**:
|
||||
- Create GUIDs with embedded timestamps for improved database indexing.
|
||||
|
||||
2. **Extract Timestamps**:
|
||||
- Retrieve the embedded timestamp from a COMB GUID.
|
||||
|
||||
3. **Support for Multiple Formats**:
|
||||
- Generate COMB GUIDs compatible with SQL Server and PostgreSQL.
|
||||
|
||||
---
|
||||
|
||||
#### Example Usage
|
||||
|
||||
##### Generating a COMB GUID
|
||||
```csharp
|
||||
var baseGuid = Guid.NewGuid();
|
||||
var timestamp = DateTime.UtcNow;
|
||||
|
||||
// Generate a COMB GUID for SQL Server
|
||||
var combGuid = CombGuidGenerator.CreateCombGuid(baseGuid, timestamp, CombGuidType.SqlServer);
|
||||
|
||||
// Generate a COMB GUID for PostgreSQL
|
||||
var combGuidPostgres = CombGuidGenerator.CreateCombGuid(baseGuid, timestamp, CombGuidType.PostgreSql);
|
||||
```
|
||||
|
||||
##### Extracting a Timestamp
|
||||
```csharp
|
||||
var extractedTimestamp = CombGuidGenerator.ExtractTimestamp(combGuid, CombGuidType.SqlServer);
|
||||
Console.WriteLine($"Extracted Timestamp: {extractedTimestamp}");
|
||||
```
|
||||
|
||||
##### Generating a COMB GUID with Current Timestamp
|
||||
```csharp
|
||||
var combGuidWithCurrentTimestamp = CombGuidGenerator.CreateCombGuid(Guid.NewGuid(), CombGuidType.SqlServer);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Best Practices
|
||||
|
||||
1. **Use COMB GUIDs for Indexing**:
|
||||
- COMB GUIDs are ideal for database indexing as they improve index locality.
|
||||
|
||||
2. **Choose the Correct Format**:
|
||||
- Use `CombGuidType.SqlServer` for SQL Server and `CombGuidType.PostgreSql` for PostgreSQL.
|
||||
|
||||
3. **Ensure UTC Timestamps**:
|
||||
- Always use UTC timestamps to ensure consistency across systems.
|
||||
|
||||
---
|
||||
|
||||
The `CombGuidGenerator` class simplifies the creation and management of COMB GUIDs, making it easier to work with GUIDs in database applications.
|
||||
|
||||
---
|
||||
|
||||
### Enumeration
|
||||
|
||||
The `Enumeration` class in the `MaksIT.Core.Abstractions` namespace provides a base class for creating strongly-typed enumerations. It enables you to define enumerable constants with additional functionality, such as methods for querying, comparing, and parsing enumerations.
|
||||
@ -353,72 +413,13 @@ values.Sort(); // Orders by ID
|
||||
|
||||
---
|
||||
|
||||
The `Enumeration` class provides a powerful alternative to traditional enums, offering flexibility and functionality for scenarios requiring additional metadata or logic.
|
||||
The `Enumeration` class provides a powerful alternative to traditional enums, offering flexibility and functionality for scenarios requiring additional metadata or logic. In-library examples include `LoggerPrefix`, `CustomClaims`, `JwkKeyType`, `JwkCurve`, and `JwkAlgorithm`.
|
||||
|
||||
---
|
||||
|
||||
### Sagas
|
||||
## Extensions
|
||||
|
||||
The `Sagas` namespace in the `MaksIT.Core` project provides a framework for managing distributed transactions or workflows. It includes classes for defining saga steps, contexts, and builders.
|
||||
|
||||
---
|
||||
|
||||
#### Features
|
||||
|
||||
1. **Saga Context**:
|
||||
- Manage the state and data of a saga.
|
||||
|
||||
2. **Saga Steps**:
|
||||
- Define individual steps in a saga workflow.
|
||||
|
||||
3. **Saga Builder**:
|
||||
- Build and execute sagas dynamically.
|
||||
|
||||
---
|
||||
|
||||
#### Example Usage
|
||||
|
||||
##### Defining a Saga Step
|
||||
```csharp
|
||||
public class MySagaStep : LocalSagaStep {
|
||||
public override Task ExecuteAsync(LocalSagaContext context) {
|
||||
// Perform step logic here
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
##### Building a Saga
|
||||
```csharp
|
||||
var saga = new LocalSagaBuilder()
|
||||
.AddStep(new MySagaStep())
|
||||
.Build();
|
||||
|
||||
await saga.ExecuteAsync();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Best Practices
|
||||
|
||||
1. **Idempotency**:
|
||||
- Ensure saga steps are idempotent to handle retries gracefully.
|
||||
|
||||
2. **Error Handling**:
|
||||
- Implement robust error handling and compensation logic for failed steps.
|
||||
|
||||
3. **State Management**:
|
||||
- Use the saga context to manage state and pass data between steps.
|
||||
|
||||
---
|
||||
|
||||
The `Sagas` namespace simplifies the implementation of distributed workflows, making it easier to manage complex transactions and processes.
|
||||
|
||||
---
|
||||
|
||||
### Extensions
|
||||
|
||||
#### Expression Extensions
|
||||
### Expression Extensions
|
||||
|
||||
The `ExpressionExtensions` class provides utility methods for combining and manipulating LINQ expressions. These methods are particularly useful for building dynamic queries in a type-safe manner.
|
||||
|
||||
@ -427,13 +428,13 @@ The `ExpressionExtensions` class provides utility methods for combining and mani
|
||||
#### Features
|
||||
|
||||
1. **Combine Expressions**:
|
||||
- Combine two expressions using logical operators like `AndAlso` and `OrElse`.
|
||||
- Combine two predicates with `AndAlso` and `OrElse` using parameter replacement (no `Expression.Invoke`), so the result is safe for `IQueryable` and EF Core.
|
||||
|
||||
2. **Negate Expressions**:
|
||||
- Negate an expression using the `Not` method.
|
||||
- Negate a predicate with `Not`.
|
||||
|
||||
3. **Batch Processing**:
|
||||
- Divide a collection into smaller batches for processing.
|
||||
- Split an `IEnumerable<T>` into smaller lists with `Batch`.
|
||||
|
||||
---
|
||||
|
||||
@ -445,6 +446,7 @@ Expression<Func<int, bool>> isEven = x => x % 2 == 0;
|
||||
Expression<Func<int, bool>> isPositive = x => x > 0;
|
||||
|
||||
var combined = isEven.AndAlso(isPositive);
|
||||
var either = isEven.OrElse(isPositive);
|
||||
var result = combined.Compile()(4); // True
|
||||
```
|
||||
|
||||
@ -457,7 +459,7 @@ var result = notEven.Compile()(3); // True
|
||||
|
||||
---
|
||||
|
||||
#### DateTime Extensions
|
||||
### DateTime Extensions
|
||||
|
||||
The `DateTimeExtensions` class provides methods for manipulating and querying `DateTime` objects. These methods simplify common date-related operations.
|
||||
|
||||
@ -466,13 +468,13 @@ The `DateTimeExtensions` class provides methods for manipulating and querying `D
|
||||
#### Features
|
||||
|
||||
1. **Add Workdays**:
|
||||
- Add a specified number of workdays to a date, excluding weekends and holidays.
|
||||
- Add a specified number of workdays to a date, skipping weekends and dates in an `IHolidayCalendar`.
|
||||
|
||||
2. **Find Specific Dates**:
|
||||
- Find the next occurrence of a specific day of the week.
|
||||
- Find the next occurrence of a specific day of the week (`NextWeekday`, `ToNextWeekday`).
|
||||
|
||||
3. **Month and Year Boundaries**:
|
||||
- Get the start or end of the current month or year.
|
||||
- Get the start or end of the current month or year, and test those boundaries.
|
||||
|
||||
---
|
||||
|
||||
@ -480,8 +482,12 @@ The `DateTimeExtensions` class provides methods for manipulating and querying `D
|
||||
|
||||
##### Adding Workdays
|
||||
```csharp
|
||||
public sealed class NoHolidays : IHolidayCalendar {
|
||||
public bool Contains(DateTime date) => false;
|
||||
}
|
||||
|
||||
DateTime today = DateTime.Today;
|
||||
DateTime futureDate = today.AddWorkdays(5);
|
||||
DateTime futureDate = today.AddWorkdays(5, new NoHolidays());
|
||||
```
|
||||
|
||||
##### Finding the Next Monday
|
||||
@ -492,7 +498,7 @@ DateTime nextMonday = today.NextWeekday(DayOfWeek.Monday);
|
||||
|
||||
---
|
||||
|
||||
#### String Extensions
|
||||
### String Extensions
|
||||
|
||||
The `StringExtensions` class provides a wide range of methods for string manipulation, validation, and conversion.
|
||||
|
||||
@ -501,13 +507,16 @@ The `StringExtensions` class provides a wide range of methods for string manipul
|
||||
#### Features
|
||||
|
||||
1. **Pattern Matching**:
|
||||
- Check if a string matches a pattern using SQL-like wildcards.
|
||||
- Check if a string matches a pattern using SQL-like wildcards (`Like`).
|
||||
|
||||
2. **Substring Extraction**:
|
||||
- Extract substrings from the left, right, or middle of a string.
|
||||
|
||||
3. **Type Conversion**:
|
||||
- Convert strings to various types, such as integers, booleans, and enums.
|
||||
- Convert strings to integers, booleans, dates, GUIDs, and enums (`ToObject<T>` deserializes JSON).
|
||||
|
||||
4. **JSON Deserialization**:
|
||||
- `ToObject<T>()` / `ToObject<T>(converters)` using `System.Text.Json`.
|
||||
|
||||
---
|
||||
|
||||
@ -523,9 +532,14 @@ bool matches = "example".Like("exa*e"); // True
|
||||
string result = "example".Left(3); // "exa"
|
||||
```
|
||||
|
||||
##### JSON Deserialization
|
||||
```csharp
|
||||
var person = json.ToObject<Person>();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Object Extensions
|
||||
### Object Extensions
|
||||
|
||||
The `ObjectExtensions` class provides advanced methods for working with objects, including serialization, deep cloning, and structural equality comparison.
|
||||
|
||||
@ -598,7 +612,56 @@ current.RevertFrom(snapshot);
|
||||
|
||||
---
|
||||
|
||||
#### DataTable Extensions
|
||||
### Exception Extensions
|
||||
|
||||
The `ExceptionExtensions` class in the `MaksIT.Core.Extensions` namespace walks an exception chain and collects messages from the exception and every inner exception.
|
||||
|
||||
---
|
||||
|
||||
#### Features
|
||||
|
||||
1. **Extract Messages**:
|
||||
- `ExtractMessages()` returns a `List<string>` from the exception and its `InnerException` chain.
|
||||
|
||||
---
|
||||
|
||||
#### Example Usage
|
||||
|
||||
```csharp
|
||||
try {
|
||||
// ...
|
||||
}
|
||||
catch (Exception ex) {
|
||||
var messages = ex.ExtractMessages();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Formats Extensions
|
||||
|
||||
The `FormatsExtensions` class in the `MaksIT.Core.Extensions` namespace creates a Pax TAR archive from a directory tree.
|
||||
|
||||
---
|
||||
|
||||
#### Features
|
||||
|
||||
1. **Create TAR Archives**:
|
||||
- `TryCreateTarFromDirectory(sourceDirectory, outputTarPath)` writes all files under the source directory into a TAR file. Returns `false` if the source is missing, empty, or the output path cannot be created.
|
||||
|
||||
---
|
||||
|
||||
#### Example Usage
|
||||
|
||||
```csharp
|
||||
if (FormatsExtensions.TryCreateTarFromDirectory(@"C:\data", @"C:\out\archive.tar")) {
|
||||
Console.WriteLine("TAR created");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DataTable Extensions
|
||||
|
||||
The `DataTableExtensions` class provides methods for working with `DataTable` objects, such as counting duplicate rows and retrieving distinct records.
|
||||
|
||||
@ -628,7 +691,7 @@ DataTable distinctTable = table.DistinctRecords(new[] { "Name", "Age" });
|
||||
|
||||
---
|
||||
|
||||
#### Guid Extensions
|
||||
### Guid Extensions
|
||||
|
||||
The `GuidExtensions` class provides methods for working with `Guid` values, including converting them to nullable types.
|
||||
|
||||
@ -651,6 +714,47 @@ Guid? nullableId = id.ToNullable();
|
||||
|
||||
---
|
||||
|
||||
### Enum Extensions
|
||||
|
||||
The `EnumExtensions` class provides utility methods for working with enum types, specifically for retrieving display names defined via the `DisplayAttribute`.
|
||||
|
||||
---
|
||||
|
||||
#### Features
|
||||
|
||||
1. **Get Display Name**:
|
||||
- Retrieve the value of the `DisplayAttribute.Name` property for an enum value, or fall back to the enum's name if the attribute is not present.
|
||||
|
||||
---
|
||||
|
||||
#### Example Usage
|
||||
|
||||
```csharp
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using MaksIT.Core.Extensions;
|
||||
|
||||
public enum Status {
|
||||
[Display(Name = "In Progress")]
|
||||
InProgress,
|
||||
Completed
|
||||
}
|
||||
|
||||
var status = Status.InProgress;
|
||||
string displayName = status.GetDisplayName(); // "In Progress"
|
||||
|
||||
var completed = Status.Completed;
|
||||
string completedName = completed.GetDisplayName(); // "Completed"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Best Practices
|
||||
|
||||
- Use the `Display` attribute on enum members to provide user-friendly names for UI or logging.
|
||||
- Use `GetDisplayName()` to consistently retrieve display names for enums throughout your application.
|
||||
|
||||
---
|
||||
|
||||
## Logging
|
||||
|
||||
The `Logging` namespace provides a custom file-based logging implementation that integrates with the `Microsoft.Extensions.Logging` framework.
|
||||
@ -717,6 +821,30 @@ logger.LogInformation("Logging to JSON file!");
|
||||
|
||||
---
|
||||
|
||||
### Console Loggers
|
||||
|
||||
`LoggingBuilderExtensions` in the `MaksIT.Core.Logging` namespace also registers console logging used by MaksIT hosts (`builder.Logging.AddConsoleLogger()`).
|
||||
|
||||
#### Methods
|
||||
|
||||
| Method | Behavior |
|
||||
|--------|----------|
|
||||
| `AddSimpleConsoleLogger()` | Adds a timestamped simple console logger. Does not clear existing providers. |
|
||||
| `AddConsoleLogger(fileLoggerPath?)` | Clears providers, adds simple console, and optionally `AddFileLogger` when a folder path is passed. |
|
||||
| `AddJsonConsoleLogger(fileLoggerPath)` | Clears providers, adds JSON console, and optionally `AddJsonFileLogger` when a folder path is passed. |
|
||||
|
||||
Timestamps use `yyyy-MM-ddTHH:mm:ss.fffZ` and scopes are included.
|
||||
|
||||
#### Example Usage
|
||||
|
||||
```csharp
|
||||
builder.Logging.AddConsoleLogger();
|
||||
builder.Logging.AddConsoleLogger("logs");
|
||||
builder.Logging.AddJsonConsoleLogger("logs");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Logger Prefix
|
||||
|
||||
The `LoggerPrefix` class in the `MaksIT.Core.Logging` namespace provides a type-safe way to specify logger categories with special prefixes. It extends the `Enumeration` base class and enables organizing logs into subfolders or applying custom categorization without using magic strings.
|
||||
@ -839,7 +967,7 @@ lockManager.Dispose();
|
||||
|
||||
### Network Connection
|
||||
|
||||
The `NetworkConnection` class provides methods for managing connections to network shares on Windows.
|
||||
The `NetworkConnection` class in the `MaksIT.Core.Networking.Windows` namespace provides methods for managing connections to network shares on Windows.
|
||||
|
||||
---
|
||||
|
||||
@ -857,7 +985,7 @@ The `NetworkConnection` class provides methods for managing connections to netwo
|
||||
|
||||
```csharp
|
||||
var credentials = new NetworkCredential("username", "password");
|
||||
if (NetworkConnection.TryCreate(logger, "\\server\share", credentials, out var connection, out var error)) {
|
||||
if (NetworkConnection.TryCreate(logger, @"\\server\share", credentials, out var connection, out var error)) {
|
||||
connection.Dispose();
|
||||
}
|
||||
```
|
||||
@ -915,6 +1043,7 @@ The `AESGCMUtility` class provides methods for encrypting and decrypting data us
|
||||
```csharp
|
||||
var key = AESGCMUtility.GenerateKeyBase64();
|
||||
AESGCMUtility.TryEncryptData(data, key, out var encryptedData, out var error);
|
||||
AESGCMUtility.TryDecryptData(encryptedData, key, out var decrypted, out var decryptError);
|
||||
```
|
||||
|
||||
---
|
||||
@ -944,16 +1073,41 @@ Base32Encoder.TryEncode(data, out var encoded, out var error);
|
||||
|
||||
---
|
||||
|
||||
### Base64Url Utility
|
||||
|
||||
The `Base64UrlUtility` class in the `MaksIT.Core.Security` namespace provides RFC 4648 §5 Base64Url encoding and decoding (used by JWK/JWS).
|
||||
|
||||
---
|
||||
|
||||
#### Features
|
||||
|
||||
1. **Encode**:
|
||||
- Encode a UTF-8 string or byte array to a Base64Url string (no padding; `+`/`/` replaced with `-`/`_`).
|
||||
|
||||
2. **Decode**:
|
||||
- Decode a Base64Url string to bytes (`Decode`) or a UTF-8 string (`DecodeToString`).
|
||||
|
||||
---
|
||||
|
||||
#### Example Usage
|
||||
|
||||
```csharp
|
||||
var encoded = Base64UrlUtility.Encode("hello");
|
||||
var decoded = Base64UrlUtility.DecodeToString(encoded);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Checksum Utility
|
||||
|
||||
The `ChecksumUtility` class provides methods for calculating and verifying CRC32 checksums.
|
||||
The `ChecksumUtility` class provides methods for calculating and verifying CRC32 checksums. `Crc32` is a public `HashAlgorithm` implementation used by these helpers.
|
||||
|
||||
---
|
||||
|
||||
#### Features
|
||||
|
||||
1. **Checksum Calculation**:
|
||||
- Calculate CRC32 checksums for data.
|
||||
- Calculate CRC32 checksums for in-memory data, files, or files in chunks.
|
||||
|
||||
2. **Checksum Verification**:
|
||||
- Verify data integrity using CRC32 checksums.
|
||||
@ -965,6 +1119,7 @@ The `ChecksumUtility` class provides methods for calculating and verifying CRC32
|
||||
##### Calculating a Checksum
|
||||
```csharp
|
||||
ChecksumUtility.TryCalculateCRC32Checksum(data, out var checksum, out var error);
|
||||
ChecksumUtility.TryCalculateCRC32ChecksumFromFile(path, out var fileChecksum, out var fileError);
|
||||
```
|
||||
|
||||
---
|
||||
@ -1044,17 +1199,20 @@ public static bool TryValidateHash(
|
||||
|
||||
### JWT Generator
|
||||
|
||||
The `JwtGenerator` class provides methods for generating and validating JSON Web Tokens (JWTs).
|
||||
The `JwtGenerator` class in the `MaksIT.Core.Security.JWT` namespace provides methods for generating and validating JSON Web Tokens (JWTs). ACL entries are stored with the `CustomClaims.AclEntry` claim type (`acl_entry`).
|
||||
|
||||
---
|
||||
|
||||
#### Features
|
||||
|
||||
1. **Token Generation**:
|
||||
- Generate JWTs with claims and metadata.
|
||||
- Generate JWTs from a `JWTTokenGenerateRequest` (secret, issuer, audience, expiration, optional user id, username, roles, ACL entries).
|
||||
|
||||
2. **Token Validation**:
|
||||
- Validate JWTs against a secret.
|
||||
- Validate JWTs against a secret, issuer, and audience; returns `JWTTokenClaims`.
|
||||
|
||||
3. **Secrets**:
|
||||
- `GenerateSecret(keySize)` and `GenerateRefreshToken()` produce Base64 random values.
|
||||
|
||||
---
|
||||
|
||||
@ -1062,7 +1220,24 @@ The `JwtGenerator` class provides methods for generating and validating JSON Web
|
||||
|
||||
##### Generating a Token
|
||||
```csharp
|
||||
JwtGenerator.TryGenerateToken(secret, issuer, audience, 60, "user", roles, out var token, out var error);
|
||||
var request = new JWTTokenGenerateRequest {
|
||||
Secret = secret,
|
||||
Issuer = issuer,
|
||||
Audience = audience,
|
||||
Expiration = 60,
|
||||
UserId = "user-1",
|
||||
Username = "jane",
|
||||
Roles = ["Admin"],
|
||||
AclEntries = ["vault:read"]
|
||||
};
|
||||
|
||||
if (JwtGenerator.TryGenerateToken(request, out var tokenData, out var error)) {
|
||||
var (token, claims) = tokenData.Value;
|
||||
}
|
||||
|
||||
if (JwtGenerator.TryValidateToken(secret, issuer, audience, token, out var validated, out var validateError)) {
|
||||
// validated.UserId, validated.Roles, validated.AclEntries
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
@ -1119,6 +1294,7 @@ public static bool TryGenerateFromRSA(
|
||||
#### Notes
|
||||
- Only supports RSA public keys.
|
||||
- The generated JWK includes only the public exponent and modulus.
|
||||
- `JwkKeyType`, `JwkCurve`, and `JwkAlgorithm` are `Enumeration` types for JWK metadata.
|
||||
- Returns `false` and an error message if the RSA parameters are missing or invalid.
|
||||
|
||||
---
|
||||
@ -1437,7 +1613,7 @@ var response = new PagedResponse<UserDto>(items, totalCount, pageNumber, pageSiz
|
||||
|
||||
### Patch Operation
|
||||
|
||||
The `PatchOperation` enum in the `MaksIT.Core.Webapi.Models` namespace defines operations for partial updates (PATCH requests).
|
||||
The `PatchOperation` enum in the `MaksIT.Core.Webapi.Models` namespace defines operations for partial updates (PATCH requests). Pair it with `PatchRequestModelBase` (`Operations` dictionary + `TryGetOperation`).
|
||||
|
||||
#### Values
|
||||
|
||||
@ -1452,28 +1628,166 @@ The `PatchOperation` enum in the `MaksIT.Core.Webapi.Models` namespace defines o
|
||||
|
||||
```csharp
|
||||
public class UserPatchRequest : PatchRequestModelBase {
|
||||
public PatchOperation Operation { get; set; }
|
||||
public string PropertyName { get; set; }
|
||||
public object? Value { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public List<string>? Roles { get; set; }
|
||||
}
|
||||
|
||||
// Example: Set a field
|
||||
var patch = new UserPatchRequest {
|
||||
Operation = PatchOperation.SetField,
|
||||
PropertyName = "Name",
|
||||
Value = "New Name"
|
||||
Name = "New Name",
|
||||
Operations = new Dictionary<string, PatchOperation> {
|
||||
["Name"] = PatchOperation.SetField,
|
||||
["Roles"] = PatchOperation.AddToCollection
|
||||
}
|
||||
};
|
||||
|
||||
// Example: Add to collection
|
||||
var patch = new UserPatchRequest {
|
||||
Operation = PatchOperation.AddToCollection,
|
||||
PropertyName = "Roles",
|
||||
Value = "Admin"
|
||||
};
|
||||
if (patch.TryGetOperation(nameof(UserPatchRequest.Name), out var operation)) {
|
||||
// operation == PatchOperation.SetField
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Error Handling Middleware
|
||||
|
||||
The `ErrorHandlingMiddleware` class in the `MaksIT.Core.Webapi.Middlewares` namespace catches unhandled exceptions, logs them, and returns HTTP 500 with a JSON body `{ error, details }`. Register it early in the ASP.NET pipeline.
|
||||
|
||||
#### Example Usage
|
||||
|
||||
```csharp
|
||||
app.UseMiddleware<ErrorHandlingMiddleware>();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Trace ID Logging Scope Middleware
|
||||
|
||||
The `TraceIdLoggingScopeMiddleware` class in the `MaksIT.Core.Webapi.Middlewares` namespace adds a `TraceId` logging scope from `Activity.Current` or `HttpContext.TraceIdentifier`.
|
||||
|
||||
#### Example Usage
|
||||
|
||||
```csharp
|
||||
app.UseMiddleware<TraceIdLoggingScopeMiddleware>();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sagas
|
||||
|
||||
The `MaksIT.Core.Sagas` namespace provides a local saga runner with LIFO compensation on failure. Steps are registered on `LocalSagaBuilder` (an `ILogger` is required). `LocalSagaStep<T>` is internal; use `AddAction` / `AddStep` / `AddActionIf` / `AddStepIf`. Share state through `LocalSagaContext`.
|
||||
|
||||
---
|
||||
|
||||
#### Features
|
||||
|
||||
1. **Saga Context**:
|
||||
- `Get` / `Set` / `Contains` for passing values between steps.
|
||||
|
||||
2. **Actions and Steps**:
|
||||
- `AddAction` for side effects; `AddStep<T>` to store a result under `outputKey`. Conditional variants skip when the predicate is false.
|
||||
|
||||
3. **Compensation**:
|
||||
- Optional compensate callbacks run in reverse order when a later step throws.
|
||||
|
||||
---
|
||||
|
||||
#### Example Usage
|
||||
|
||||
```csharp
|
||||
var saga = new LocalSagaBuilder(logger)
|
||||
.AddAction("Reserve", async (ctx, ct) => {
|
||||
ctx.Set("orderId", "123");
|
||||
await Task.CompletedTask;
|
||||
}, compensate: async (ctx, ct) => {
|
||||
await Task.CompletedTask;
|
||||
})
|
||||
.AddStep<int>("Charge", async (ctx, ct) => 42, outputKey: "amount")
|
||||
.AddActionIf(ctx => ctx.Contains("amount"), "Notify", async (ctx, ct) => {
|
||||
await Task.CompletedTask;
|
||||
})
|
||||
.Build();
|
||||
|
||||
await saga.ExecuteAsync();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Best Practices
|
||||
|
||||
1. **Idempotency**:
|
||||
- Ensure saga steps are idempotent to handle retries gracefully.
|
||||
|
||||
2. **Error Handling**:
|
||||
- Implement compensation for steps that mutate external state.
|
||||
|
||||
3. **State Management**:
|
||||
- Use `LocalSagaContext` to pass data between steps; back up values you need to restore on compensate.
|
||||
|
||||
---
|
||||
|
||||
The `Sagas` namespace simplifies in-process workflows with compensation, not distributed two-phase commit.
|
||||
|
||||
---
|
||||
|
||||
## CombGuidGenerator
|
||||
|
||||
The `CombGuidGenerator` class in the `MaksIT.Core.Comb` namespace provides methods for generating and extracting COMB GUIDs (GUIDs with embedded timestamps). COMB GUIDs improve index locality by combining randomness with a sortable timestamp.
|
||||
|
||||
---
|
||||
|
||||
#### Features
|
||||
|
||||
1. **Generate COMB GUIDs**:
|
||||
- Create GUIDs with embedded timestamps for improved database indexing.
|
||||
|
||||
2. **Extract Timestamps**:
|
||||
- Retrieve the embedded timestamp from a COMB GUID.
|
||||
|
||||
3. **Support for Multiple Formats**:
|
||||
- Generate COMB GUIDs compatible with SQL Server and PostgreSQL.
|
||||
|
||||
---
|
||||
|
||||
#### Example Usage
|
||||
|
||||
##### Generating a COMB GUID
|
||||
```csharp
|
||||
var baseGuid = Guid.NewGuid();
|
||||
var timestamp = DateTime.UtcNow;
|
||||
|
||||
var combGuid = CombGuidGenerator.CreateCombGuid(baseGuid, timestamp, CombGuidType.SqlServer);
|
||||
var combGuidPostgres = CombGuidGenerator.CreateCombGuid(baseGuid, timestamp, CombGuidType.PostgreSql);
|
||||
```
|
||||
|
||||
##### Extracting a Timestamp
|
||||
```csharp
|
||||
var extractedTimestamp = CombGuidGenerator.ExtractTimestamp(combGuid, CombGuidType.SqlServer);
|
||||
Console.WriteLine($"Extracted Timestamp: {extractedTimestamp}");
|
||||
```
|
||||
|
||||
##### Generating a COMB GUID with Current Timestamp
|
||||
```csharp
|
||||
var combGuidWithCurrentTimestamp = CombGuidGenerator.CreateCombGuid(Guid.NewGuid(), CombGuidType.SqlServer);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Best Practices
|
||||
|
||||
1. **Use COMB GUIDs for Indexing**:
|
||||
- COMB GUIDs are ideal for database indexing as they improve index locality.
|
||||
|
||||
2. **Choose the Correct Format**:
|
||||
- Use `CombGuidType.SqlServer` for SQL Server and `CombGuidType.PostgreSql` for PostgreSQL.
|
||||
|
||||
3. **Ensure UTC Timestamps**:
|
||||
- Always use UTC timestamps to ensure consistency across systems.
|
||||
|
||||
---
|
||||
|
||||
The `CombGuidGenerator` class simplifies the creation and management of COMB GUIDs, making it easier to work with GUIDs in database applications.
|
||||
|
||||
---
|
||||
|
||||
## Others
|
||||
|
||||
### Culture
|
||||
@ -1519,6 +1833,8 @@ The `EnvVar` class provides methods for managing environment variables.
|
||||
##### Adding to PATH
|
||||
```csharp
|
||||
EnvVar.TryAddToPath("/usr/local/bin", out var error);
|
||||
EnvVar.TrySet("MY_VAR", "value", "process", out var setError);
|
||||
EnvVar.TryUnSet("MY_VAR", "process", out var unsetError);
|
||||
```
|
||||
|
||||
---
|
||||
@ -1532,10 +1848,16 @@ The `FileSystem` class provides methods for working with files and directories.
|
||||
#### Features
|
||||
|
||||
1. **Copy Files and Folders**:
|
||||
- Copy files or directories to a target location.
|
||||
- Copy files or directories to a target location (`TryCopyToFolder`).
|
||||
|
||||
2. **Delete Files and Folders**:
|
||||
- Delete files or directories.
|
||||
- Delete files or directories (`TryDeleteFileOrDirectory`).
|
||||
|
||||
3. **Wildcard Paths**:
|
||||
- `ResolveWildcardedPath` expands `*` / `?` path segments (including `?:` for drives on Windows).
|
||||
|
||||
4. **Duplicate File Names**:
|
||||
- `DuplicateFileNameCheck` returns a non-colliding path (`file(1).ext`).
|
||||
|
||||
---
|
||||
|
||||
@ -1560,7 +1882,7 @@ The `Processes` class provides methods for managing system processes.
|
||||
- Start new processes with optional arguments.
|
||||
|
||||
2. **Kill Processes**:
|
||||
- Terminate processes by name.
|
||||
- Terminate processes by name (`TryKill` accepts `*` / `?` wildcards).
|
||||
|
||||
---
|
||||
|
||||
@ -1573,47 +1895,6 @@ Processes.TryStart("notepad.exe", "", 0, false, out var error);
|
||||
|
||||
---
|
||||
|
||||
## Enum Extensions
|
||||
|
||||
The `EnumExtensions` class provides utility methods for working with enum types, specifically for retrieving display names defined via the `DisplayAttribute`.
|
||||
|
||||
---
|
||||
|
||||
#### Features
|
||||
|
||||
1. **Get Display Name**:
|
||||
- Retrieve the value of the `DisplayAttribute.Name` property for an enum value, or fall back to the enum's name if the attribute is not present.
|
||||
|
||||
---
|
||||
|
||||
#### Example Usage
|
||||
|
||||
```csharp
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using MaksIT.Core.Extensions;
|
||||
|
||||
public enum Status {
|
||||
[Display(Name = "In Progress")]
|
||||
InProgress,
|
||||
Completed
|
||||
}
|
||||
|
||||
var status = Status.InProgress;
|
||||
string displayName = status.GetDisplayName(); // "In Progress"
|
||||
|
||||
var completed = Status.Completed;
|
||||
string completedName = completed.GetDisplayName(); // "Completed"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Best Practices
|
||||
|
||||
- Use the `Display` attribute on enum members to provide user-friendly names for UI or logging.
|
||||
- Use `GetDisplayName()` to consistently retrieve display names for enums throughout your application.
|
||||
|
||||
---
|
||||
|
||||
## Contact
|
||||
|
||||
If you have any questions or need further assistance, feel free to reach out:
|
||||
|
||||
38
src/MaksIT.Core.Cli.Tests/CliActionsTests.cs
Normal file
38
src/MaksIT.Core.Cli.Tests/CliActionsTests.cs
Normal file
@ -0,0 +1,38 @@
|
||||
using MaksIT.Core.Cli;
|
||||
|
||||
|
||||
namespace MaksIT.Core.Cli.Tests;
|
||||
|
||||
public class CliActionsTests {
|
||||
[Fact]
|
||||
public void GenerateSecret_InvalidBytes_ReturnsOne() {
|
||||
var exit = CliActions.GenerateSecret(0);
|
||||
|
||||
Assert.Equal(1, exit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateCombGuid_InvalidType_ReturnsOne() {
|
||||
var exit = CliActions.GenerateCombGuid("rsa");
|
||||
|
||||
Assert.Equal(1, exit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateCombGuid_PostgreSql_ReturnsZero() {
|
||||
var exit = CliActions.GenerateCombGuid(null);
|
||||
|
||||
Assert.Equal(0, exit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashPassword_EmptyPassword_ReturnsOne() {
|
||||
var exit = CliActions.HashPassword("pepper", "");
|
||||
|
||||
Assert.Equal(1, exit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateAesKey_ReturnsZero() =>
|
||||
Assert.Equal(0, CliActions.GenerateAesKey());
|
||||
}
|
||||
43
src/MaksIT.Core.Cli.Tests/CommandFactoryTests.cs
Normal file
43
src/MaksIT.Core.Cli.Tests/CommandFactoryTests.cs
Normal file
@ -0,0 +1,43 @@
|
||||
using MaksIT.Core.Cli;
|
||||
|
||||
|
||||
namespace MaksIT.Core.Cli.Tests;
|
||||
|
||||
public class CommandFactoryTests {
|
||||
[Fact]
|
||||
public void Secret_HasNoParseErrors() {
|
||||
var result = CommandFactory.CreateRootCommand().Parse(["secret"]);
|
||||
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JwtGenerate_MissingSecret_HasParseError() {
|
||||
var result = CommandFactory.CreateRootCommand().Parse([
|
||||
"jwt", "generate", "--issuer", "i", "--audience", "a"
|
||||
]);
|
||||
|
||||
Assert.NotEmpty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JwtGenerate_RequiredOptions_HasNoParseErrors() {
|
||||
var result = CommandFactory.CreateRootCommand().Parse([
|
||||
"jwt", "generate",
|
||||
"--secret", "s",
|
||||
"--issuer", "i",
|
||||
"--audience", "a"
|
||||
]);
|
||||
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TotpValidate_RequiredOptions_HasNoParseErrors() {
|
||||
var result = CommandFactory.CreateRootCommand().Parse([
|
||||
"totp", "validate", "--secret", "s", "--code", "123456"
|
||||
]);
|
||||
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
}
|
||||
60
src/MaksIT.Core.Cli.Tests/InputParsersTests.cs
Normal file
60
src/MaksIT.Core.Cli.Tests/InputParsersTests.cs
Normal file
@ -0,0 +1,60 @@
|
||||
using MaksIT.Core.Cli;
|
||||
using MaksIT.Core.Comb;
|
||||
|
||||
|
||||
namespace MaksIT.Core.Cli.Tests;
|
||||
|
||||
public class InputParsersTests {
|
||||
[Fact]
|
||||
public void TryParsePositiveInt_Blank_ReturnsDefault() {
|
||||
var result = InputParsers.TryParsePositiveInt(" ", 32, out var value, out var errorMessage);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(32, value);
|
||||
Assert.Null(errorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParsePositiveInt_ValidNumber_ReturnsValue() {
|
||||
var result = InputParsers.TryParsePositiveInt("64", 32, out var value, out var errorMessage);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(64, value);
|
||||
Assert.Null(errorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParsePositiveInt_Invalid_ReturnsError() {
|
||||
var result = InputParsers.TryParsePositiveInt("abc", 32, out var value, out var errorMessage);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, value);
|
||||
Assert.NotNull(errorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseCombGuidType_Blank_ReturnsPostgreSql() {
|
||||
var result = InputParsers.TryParseCombGuidType(null, out var type, out var errorMessage);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(CombGuidType.PostgreSql, type);
|
||||
Assert.Null(errorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseCombGuidType_SqlServer_ParsesIgnoreCase() {
|
||||
var result = InputParsers.TryParseCombGuidType("sqlserver", out var type, out var errorMessage);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(CombGuidType.SqlServer, type);
|
||||
Assert.Null(errorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseOptionalList_SplitsAndTrims() {
|
||||
var items = InputParsers.ParseOptionalList(" Admin, User , ");
|
||||
|
||||
Assert.NotNull(items);
|
||||
Assert.Equal(["Admin", "User"], items);
|
||||
}
|
||||
}
|
||||
27
src/MaksIT.Core.Cli.Tests/MaksIT.Core.Cli.Tests.csproj
Normal file
27
src/MaksIT.Core.Cli.Tests/MaksIT.Core.Cli.Tests.csproj
Normal file
@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.MTP" Version="10.0.1" />
|
||||
<PackageReference Include="xunit.v3" Version="4.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MaksIT.Core.Cli\MaksIT.Core.Cli.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
60
src/MaksIT.Core.Cli.Tests/SecretOperationsTests.cs
Normal file
60
src/MaksIT.Core.Cli.Tests/SecretOperationsTests.cs
Normal file
@ -0,0 +1,60 @@
|
||||
using MaksIT.Core.Cli;
|
||||
using MaksIT.Core.Comb;
|
||||
using MaksIT.Core.Security.JWT;
|
||||
|
||||
|
||||
namespace MaksIT.Core.Cli.Tests;
|
||||
|
||||
public class SecretOperationsTests {
|
||||
[Fact]
|
||||
public void GenerateSecret_ReturnsUniqueNonEmptyValues() {
|
||||
var secret1 = SecretOperations.GenerateSecret();
|
||||
var secret2 = SecretOperations.GenerateSecret();
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(secret1));
|
||||
Assert.False(string.IsNullOrWhiteSpace(secret2));
|
||||
Assert.NotEqual(secret1, secret2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateAesKey_ReturnsNonEmptyValue() =>
|
||||
Assert.False(string.IsNullOrWhiteSpace(SecretOperations.GenerateAesKey()));
|
||||
|
||||
[Fact]
|
||||
public void GenerateCombGuid_PostgreSql_ReturnsNonEmptyGuid() {
|
||||
var guid = SecretOperations.GenerateCombGuid(CombGuidType.PostgreSql);
|
||||
|
||||
Assert.NotEqual(Guid.Empty, guid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGenerateJwt_ThenValidate_Succeeds() {
|
||||
var secret = SecretOperations.GenerateSecret();
|
||||
var request = new JWTTokenGenerateRequest {
|
||||
Secret = secret,
|
||||
Issuer = "cli-tests",
|
||||
Audience = "cli-tests",
|
||||
Expiration = 5,
|
||||
Username = "tester"
|
||||
};
|
||||
|
||||
var generated = SecretOperations.TryGenerateJwt(request, out var token, out var generateError);
|
||||
|
||||
Assert.True(generated);
|
||||
Assert.False(string.IsNullOrWhiteSpace(token));
|
||||
Assert.Null(generateError);
|
||||
|
||||
var validated = SecretOperations.TryValidateJwt(
|
||||
secret,
|
||||
request.Issuer,
|
||||
request.Audience,
|
||||
token!,
|
||||
out var claims,
|
||||
out var validateError
|
||||
);
|
||||
|
||||
Assert.True(validated);
|
||||
Assert.Equal("tester", claims?.Username);
|
||||
Assert.Null(validateError);
|
||||
}
|
||||
}
|
||||
317
src/MaksIT.Core.Cli/Application.cs
Normal file
317
src/MaksIT.Core.Cli/Application.cs
Normal file
@ -0,0 +1,317 @@
|
||||
using System.Text;
|
||||
using System.Reflection;
|
||||
using MaksIT.Core.Comb;
|
||||
using MaksIT.Core.Extensions;
|
||||
using MaksIT.Core.Security.JWT;
|
||||
|
||||
|
||||
namespace MaksIT.Core.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Interactive numbered menu for generating MaksIT.Core secrets.
|
||||
/// </summary>
|
||||
public sealed class Application {
|
||||
/// <summary>
|
||||
/// Runs the main menu until the user exits.
|
||||
/// </summary>
|
||||
public void Run() {
|
||||
Console.OutputEncoding = Encoding.UTF8;
|
||||
var version = typeof(Application).Assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||
.InformationalVersion?
|
||||
.Split('+')[0]
|
||||
?? "0.0.0";
|
||||
|
||||
while (true) {
|
||||
Console.WriteLine($"MaksIT.Core.Cli v{version}");
|
||||
Console.WriteLine("© Maksym Sadovnychyy (MAKS-IT) 2026");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("1. Generate secret (JWT / pepper)");
|
||||
Console.WriteLine("2. JWT");
|
||||
Console.WriteLine("3. AES-GCM key");
|
||||
Console.WriteLine("4. TOTP / 2FA");
|
||||
Console.WriteLine("5. Password hash");
|
||||
Console.WriteLine("6. COMB GUID");
|
||||
Console.WriteLine("0. Exit");
|
||||
Console.Write("Enter your choice: ");
|
||||
|
||||
var choice = Console.ReadLine();
|
||||
try {
|
||||
switch (choice) {
|
||||
case "1":
|
||||
GenerateSecret();
|
||||
Pause();
|
||||
break;
|
||||
case "2":
|
||||
RunJwtMenu();
|
||||
break;
|
||||
case "3":
|
||||
WriteLabeled("AES-256 key", SecretOperations.GenerateAesKey());
|
||||
Pause();
|
||||
break;
|
||||
case "4":
|
||||
RunTotpMenu();
|
||||
break;
|
||||
case "5":
|
||||
HashPassword();
|
||||
Pause();
|
||||
break;
|
||||
case "6":
|
||||
GenerateCombGuid();
|
||||
Pause();
|
||||
break;
|
||||
case "0":
|
||||
return;
|
||||
default:
|
||||
Console.WriteLine("Invalid option.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) {
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
Pause();
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
|
||||
private static void RunJwtMenu() {
|
||||
while (true) {
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("JWT");
|
||||
Console.WriteLine("1. Generate signing secret");
|
||||
Console.WriteLine("2. Generate refresh token");
|
||||
Console.WriteLine("3. Generate access token");
|
||||
Console.WriteLine("4. Validate token");
|
||||
Console.WriteLine("0. Back");
|
||||
Console.Write("Enter your choice: ");
|
||||
|
||||
var choice = Console.ReadLine();
|
||||
try {
|
||||
switch (choice) {
|
||||
case "1":
|
||||
GenerateSecret();
|
||||
Pause();
|
||||
break;
|
||||
case "2":
|
||||
WriteLabeled("Refresh token", SecretOperations.GenerateRefreshToken());
|
||||
Pause();
|
||||
break;
|
||||
case "3":
|
||||
GenerateAccessToken();
|
||||
Pause();
|
||||
break;
|
||||
case "4":
|
||||
ValidateAccessToken();
|
||||
Pause();
|
||||
break;
|
||||
case "0":
|
||||
return;
|
||||
default:
|
||||
Console.WriteLine("Invalid option.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) {
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
Pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void RunTotpMenu() {
|
||||
while (true) {
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("TOTP / 2FA");
|
||||
Console.WriteLine("1. Generate secret");
|
||||
Console.WriteLine("2. Generate recovery codes");
|
||||
Console.WriteLine("3. Generate otpauth link");
|
||||
Console.WriteLine("4. Validate code");
|
||||
Console.WriteLine("0. Back");
|
||||
Console.Write("Enter your choice: ");
|
||||
|
||||
var choice = Console.ReadLine();
|
||||
try {
|
||||
switch (choice) {
|
||||
case "1":
|
||||
if (!SecretOperations.TryGenerateTotpSecret(out var secret, out var secretError))
|
||||
throw new InvalidOperationException(secretError);
|
||||
|
||||
WriteLabeled("TOTP secret", secret);
|
||||
Pause();
|
||||
break;
|
||||
case "2":
|
||||
GenerateRecoveryCodes();
|
||||
Pause();
|
||||
break;
|
||||
case "3":
|
||||
GenerateTotpAuthLink();
|
||||
Pause();
|
||||
break;
|
||||
case "4":
|
||||
ValidateTotp();
|
||||
Pause();
|
||||
break;
|
||||
case "0":
|
||||
return;
|
||||
default:
|
||||
Console.WriteLine("Invalid option.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) {
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
Pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void GenerateSecret() {
|
||||
var bytes = ReadPositiveInt("Key size in bytes", 32);
|
||||
WriteLabeled("Secret", SecretOperations.GenerateSecret(bytes));
|
||||
}
|
||||
|
||||
private static void GenerateAccessToken() {
|
||||
var request = new JWTTokenGenerateRequest {
|
||||
Secret = ReadRequired("Secret"),
|
||||
Issuer = ReadRequired("Issuer"),
|
||||
Audience = ReadRequired("Audience"),
|
||||
Expiration = ReadPositiveInt("Expiration (minutes)", 60),
|
||||
UserId = ReadOptional("User id"),
|
||||
Username = ReadOptional("Username"),
|
||||
Roles = InputParsers.ParseOptionalList(ReadOptional("Roles (comma-separated)")),
|
||||
AclEntries = InputParsers.ParseOptionalList(ReadOptional("ACL entries (comma-separated)"))
|
||||
};
|
||||
|
||||
if (!SecretOperations.TryGenerateJwt(request, out var token, out var errorMessage))
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
|
||||
WriteLabeled("Access token", token);
|
||||
}
|
||||
|
||||
private static void ValidateAccessToken() {
|
||||
var secret = ReadRequired("Secret");
|
||||
var issuer = ReadRequired("Issuer");
|
||||
var audience = ReadRequired("Audience");
|
||||
var token = ReadRequired("Token");
|
||||
|
||||
if (!SecretOperations.TryValidateJwt(secret, issuer, audience, token, out var claims, out var errorMessage))
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
|
||||
WriteLabeled("Claims", claims.ToJson());
|
||||
}
|
||||
|
||||
private static void GenerateRecoveryCodes() {
|
||||
var count = ReadPositiveInt("Number of codes", 10);
|
||||
if (!SecretOperations.TryGenerateRecoveryCodes(count, out var codes, out var errorMessage))
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
|
||||
Console.WriteLine("Recovery codes:");
|
||||
foreach (var code in codes)
|
||||
Console.WriteLine(code);
|
||||
}
|
||||
|
||||
private static void GenerateTotpAuthLink() {
|
||||
var label = ReadRequired("Label");
|
||||
var username = ReadRequired("Username");
|
||||
var secret = ReadRequired("TOTP secret");
|
||||
var issuer = ReadRequired("Issuer");
|
||||
|
||||
if (!SecretOperations.TryGenerateTotpAuthLink(label, username, secret, issuer, out var authLink, out var errorMessage))
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
|
||||
WriteLabeled("otpauth link", authLink);
|
||||
}
|
||||
|
||||
private static void ValidateTotp() {
|
||||
var secret = ReadRequired("TOTP secret");
|
||||
var code = ReadRequired("Code");
|
||||
var tolerance = ReadPositiveInt("Time-step tolerance", 1);
|
||||
|
||||
if (!SecretOperations.TryValidateTotp(code, secret, tolerance, out var isValid, out var errorMessage))
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
|
||||
Console.WriteLine(isValid ? "Valid." : "Invalid.");
|
||||
}
|
||||
|
||||
private static void HashPassword() {
|
||||
var pepper = ReadRequired("Pepper");
|
||||
var password = ReadSecret("Password");
|
||||
if (string.IsNullOrEmpty(password))
|
||||
throw new InvalidOperationException("Password is required.");
|
||||
|
||||
if (!SecretOperations.TryHashPassword(password, pepper, out var saltedHash, out var errorMessage))
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
|
||||
Console.WriteLine($"Salt: {saltedHash.Value.Salt}");
|
||||
Console.WriteLine($"Hash: {saltedHash.Value.Hash}");
|
||||
}
|
||||
|
||||
private static void GenerateCombGuid() {
|
||||
Console.Write("COMB type (PostgreSql/SqlServer) [PostgreSql]: ");
|
||||
if (!InputParsers.TryParseCombGuidType(Console.ReadLine(), out var type, out var errorMessage))
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
|
||||
WriteLabeled($"COMB GUID ({type})", SecretOperations.GenerateCombGuid(type).ToString());
|
||||
}
|
||||
|
||||
private static int ReadPositiveInt(string prompt, int defaultValue) {
|
||||
Console.Write($"{prompt} [{defaultValue}]: ");
|
||||
if (!InputParsers.TryParsePositiveInt(Console.ReadLine(), defaultValue, out var value, out var errorMessage))
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string ReadRequired(string prompt) {
|
||||
Console.Write($"{prompt}: ");
|
||||
var value = Console.ReadLine();
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw new InvalidOperationException($"{prompt} is required.");
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static string? ReadOptional(string prompt) {
|
||||
Console.Write($"{prompt}: ");
|
||||
var value = Console.ReadLine();
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return null;
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static string ReadSecret(string prompt) {
|
||||
Console.Write($"{prompt}: ");
|
||||
var builder = new StringBuilder();
|
||||
while (true) {
|
||||
var key = Console.ReadKey(intercept: true);
|
||||
if (key.Key == ConsoleKey.Enter) {
|
||||
Console.WriteLine();
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
if (key.Key == ConsoleKey.Backspace) {
|
||||
if (builder.Length > 0)
|
||||
builder.Length--;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!char.IsControl(key.KeyChar))
|
||||
builder.Append(key.KeyChar);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteLabeled(string label, string value) {
|
||||
Console.WriteLine($"{label}:");
|
||||
Console.WriteLine(value);
|
||||
}
|
||||
|
||||
private static void Pause() {
|
||||
Console.WriteLine();
|
||||
Console.Write("Press Enter to continue...");
|
||||
Console.ReadLine();
|
||||
}
|
||||
}
|
||||
170
src/MaksIT.Core.Cli/CliActions.cs
Normal file
170
src/MaksIT.Core.Cli/CliActions.cs
Normal file
@ -0,0 +1,170 @@
|
||||
using MaksIT.Core.Extensions;
|
||||
using MaksIT.Core.Security.JWT;
|
||||
|
||||
|
||||
namespace MaksIT.Core.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Non-interactive command handlers: values on stdout, errors on stderr, exit 0/1.
|
||||
/// </summary>
|
||||
public static class CliActions {
|
||||
/// <summary>
|
||||
/// Writes an error to stderr and returns exit code 1.
|
||||
/// </summary>
|
||||
public static int Fail(string errorMessage) {
|
||||
Console.Error.WriteLine(errorMessage);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Base64 secret.
|
||||
/// </summary>
|
||||
public static int GenerateSecret(int bytes) {
|
||||
if (!InputParsers.TryParsePositiveInt(bytes.ToString(), 32, out var keySize, out var errorMessage))
|
||||
return Fail(errorMessage!);
|
||||
|
||||
Console.WriteLine(SecretOperations.GenerateSecret(keySize));
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates an opaque refresh token.
|
||||
/// </summary>
|
||||
public static int GenerateRefreshToken() {
|
||||
Console.WriteLine(SecretOperations.GenerateRefreshToken());
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Base64 AES-256 key.
|
||||
/// </summary>
|
||||
public static int GenerateAesKey() {
|
||||
Console.WriteLine(SecretOperations.GenerateAesKey());
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a COMB GUID.
|
||||
/// </summary>
|
||||
public static int GenerateCombGuid(string? typeName) {
|
||||
if (!InputParsers.TryParseCombGuidType(typeName, out var type, out var errorMessage))
|
||||
return Fail(errorMessage!);
|
||||
|
||||
Console.WriteLine(SecretOperations.GenerateCombGuid(type));
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signs an access JWT.
|
||||
/// </summary>
|
||||
public static int GenerateJwt(
|
||||
string secret,
|
||||
string issuer,
|
||||
string audience,
|
||||
int expiration,
|
||||
string? userId,
|
||||
string? username,
|
||||
string? roles,
|
||||
string? aclEntries
|
||||
) {
|
||||
if (!InputParsers.TryParsePositiveInt(expiration.ToString(), 60, out var minutes, out var errorMessage))
|
||||
return Fail(errorMessage!);
|
||||
|
||||
var request = new JWTTokenGenerateRequest {
|
||||
Secret = secret,
|
||||
Issuer = issuer,
|
||||
Audience = audience,
|
||||
Expiration = minutes,
|
||||
UserId = EmptyToNull(userId),
|
||||
Username = EmptyToNull(username),
|
||||
Roles = InputParsers.ParseOptionalList(roles),
|
||||
AclEntries = InputParsers.ParseOptionalList(aclEntries)
|
||||
};
|
||||
|
||||
if (!SecretOperations.TryGenerateJwt(request, out var token, out var generateError))
|
||||
return Fail(generateError);
|
||||
|
||||
Console.WriteLine(token);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates an access JWT and writes claims JSON.
|
||||
/// </summary>
|
||||
public static int ValidateJwt(string secret, string issuer, string audience, string token) {
|
||||
if (!SecretOperations.TryValidateJwt(secret, issuer, audience, token, out var claims, out var errorMessage))
|
||||
return Fail(errorMessage);
|
||||
|
||||
Console.WriteLine(claims.ToJson());
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Base32 TOTP secret.
|
||||
/// </summary>
|
||||
public static int GenerateTotpSecret() {
|
||||
if (!SecretOperations.TryGenerateTotpSecret(out var secret, out var errorMessage))
|
||||
return Fail(errorMessage);
|
||||
|
||||
Console.WriteLine(secret);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates TOTP recovery codes (one per line).
|
||||
/// </summary>
|
||||
public static int GenerateRecoveryCodes(int count) {
|
||||
if (!InputParsers.TryParsePositiveInt(count.ToString(), 10, out var codeCount, out var errorMessage))
|
||||
return Fail(errorMessage!);
|
||||
|
||||
if (!SecretOperations.TryGenerateRecoveryCodes(codeCount, out var codes, out var generateError))
|
||||
return Fail(generateError);
|
||||
|
||||
foreach (var code in codes)
|
||||
Console.WriteLine(code);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an otpauth URI.
|
||||
/// </summary>
|
||||
public static int GenerateTotpAuthLink(string label, string username, string secret, string issuer) {
|
||||
if (!SecretOperations.TryGenerateTotpAuthLink(label, username, secret, issuer, out var authLink, out var errorMessage))
|
||||
return Fail(errorMessage);
|
||||
|
||||
Console.WriteLine(authLink);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a TOTP code. Exit 1 when the code is invalid.
|
||||
/// </summary>
|
||||
public static int ValidateTotp(string secret, string code, int tolerance) {
|
||||
if (!InputParsers.TryParsePositiveInt(tolerance.ToString(), 1, out var timeTolerance, out var errorMessage))
|
||||
return Fail(errorMessage!);
|
||||
|
||||
if (!SecretOperations.TryValidateTotp(code, secret, timeTolerance, out var isValid, out var validateError))
|
||||
return Fail(validateError);
|
||||
|
||||
Console.WriteLine(isValid ? "valid" : "invalid");
|
||||
return isValid ? 0 : 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a salted password hash as JSON.
|
||||
/// </summary>
|
||||
public static int HashPassword(string pepper, string password) {
|
||||
if (string.IsNullOrEmpty(password))
|
||||
return Fail("Password is required.");
|
||||
|
||||
if (!SecretOperations.TryHashPassword(password, pepper, out var saltedHash, out var errorMessage))
|
||||
return Fail(errorMessage);
|
||||
|
||||
Console.WriteLine(new { salt = saltedHash.Value.Salt, hash = saltedHash.Value.Hash }.ToJson());
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static string? EmptyToNull(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
277
src/MaksIT.Core.Cli/CommandFactory.cs
Normal file
277
src/MaksIT.Core.Cli/CommandFactory.cs
Normal file
@ -0,0 +1,277 @@
|
||||
using System.CommandLine;
|
||||
|
||||
|
||||
namespace MaksIT.Core.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the agent-facing command tree. No arguments runs the interactive menu.
|
||||
/// </summary>
|
||||
public static class CommandFactory {
|
||||
/// <summary>
|
||||
/// Creates the root command with secret, jwt, aes, totp, password, and guid subcommands.
|
||||
/// </summary>
|
||||
public static RootCommand CreateRootCommand() {
|
||||
var root = new RootCommand("Generate MaksIT.Core secrets. No arguments opens the interactive menu.") {
|
||||
CreateSecretCommand(),
|
||||
CreateJwtCommand(),
|
||||
CreateAesCommand(),
|
||||
CreateTotpCommand(),
|
||||
CreatePasswordCommand(),
|
||||
CreateGuidCommand()
|
||||
};
|
||||
|
||||
root.SetAction(_ => {
|
||||
new Application().Run();
|
||||
return 0;
|
||||
});
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
private static Command CreateSecretCommand() {
|
||||
var bytesOption = BytesOption();
|
||||
var command = new Command("secret", "Generate a Base64 secret (JWT signing key or password pepper)") {
|
||||
bytesOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
CliActions.GenerateSecret(parseResult.GetValue(bytesOption)));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command CreateJwtCommand() {
|
||||
var jwt = new Command("jwt", "JWT signing secrets, tokens, and validation");
|
||||
jwt.Subcommands.Add(CreateJwtSecretCommand());
|
||||
jwt.Subcommands.Add(CreateJwtRefreshCommand());
|
||||
jwt.Subcommands.Add(CreateJwtGenerateCommand());
|
||||
jwt.Subcommands.Add(CreateJwtValidateCommand());
|
||||
return jwt;
|
||||
}
|
||||
|
||||
private static Command CreateJwtSecretCommand() {
|
||||
var bytesOption = BytesOption();
|
||||
var command = new Command("secret", "Generate a JWT signing secret") {
|
||||
bytesOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
CliActions.GenerateSecret(parseResult.GetValue(bytesOption)));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command CreateJwtRefreshCommand() {
|
||||
var command = new Command("refresh", "Generate an opaque refresh token");
|
||||
command.SetAction(_ => CliActions.GenerateRefreshToken());
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command CreateJwtGenerateCommand() {
|
||||
var secretOption = RequiredString("--secret", "Signing secret");
|
||||
var issuerOption = RequiredString("--issuer", "Token issuer");
|
||||
var audienceOption = RequiredString("--audience", "Token audience");
|
||||
var expirationOption = new Option<int>("--expiration") {
|
||||
Description = "Lifetime in minutes",
|
||||
DefaultValueFactory = _ => 60
|
||||
};
|
||||
var userIdOption = new Option<string?>("--user-id") {
|
||||
Description = "Optional user id claim"
|
||||
};
|
||||
var usernameOption = new Option<string?>("--username") {
|
||||
Description = "Optional username claim"
|
||||
};
|
||||
var rolesOption = new Option<string?>("--roles") {
|
||||
Description = "Optional comma-separated roles"
|
||||
};
|
||||
var aclOption = new Option<string?>("--acl") {
|
||||
Description = "Optional comma-separated ACL entries"
|
||||
};
|
||||
|
||||
var command = new Command("generate", "Sign an access JWT") {
|
||||
secretOption,
|
||||
issuerOption,
|
||||
audienceOption,
|
||||
expirationOption,
|
||||
userIdOption,
|
||||
usernameOption,
|
||||
rolesOption,
|
||||
aclOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
CliActions.GenerateJwt(
|
||||
parseResult.GetValue(secretOption)!,
|
||||
parseResult.GetValue(issuerOption)!,
|
||||
parseResult.GetValue(audienceOption)!,
|
||||
parseResult.GetValue(expirationOption),
|
||||
parseResult.GetValue(userIdOption),
|
||||
parseResult.GetValue(usernameOption),
|
||||
parseResult.GetValue(rolesOption),
|
||||
parseResult.GetValue(aclOption)
|
||||
));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command CreateJwtValidateCommand() {
|
||||
var secretOption = RequiredString("--secret", "Signing secret");
|
||||
var issuerOption = RequiredString("--issuer", "Token issuer");
|
||||
var audienceOption = RequiredString("--audience", "Token audience");
|
||||
var tokenOption = RequiredString("--token", "JWT to validate");
|
||||
|
||||
var command = new Command("validate", "Validate an access JWT and print claims JSON") {
|
||||
secretOption,
|
||||
issuerOption,
|
||||
audienceOption,
|
||||
tokenOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
CliActions.ValidateJwt(
|
||||
parseResult.GetValue(secretOption)!,
|
||||
parseResult.GetValue(issuerOption)!,
|
||||
parseResult.GetValue(audienceOption)!,
|
||||
parseResult.GetValue(tokenOption)!
|
||||
));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command CreateAesCommand() {
|
||||
var aes = new Command("aes", "AES-GCM keys");
|
||||
var key = new Command("key", "Generate a Base64 AES-256 key");
|
||||
key.SetAction(_ => CliActions.GenerateAesKey());
|
||||
aes.Subcommands.Add(key);
|
||||
return aes;
|
||||
}
|
||||
|
||||
private static Command CreateTotpCommand() {
|
||||
var totp = new Command("totp", "TOTP / 2FA secrets, recovery codes, and validation");
|
||||
totp.Subcommands.Add(CreateTotpSecretCommand());
|
||||
totp.Subcommands.Add(CreateTotpRecoveryCommand());
|
||||
totp.Subcommands.Add(CreateTotpLinkCommand());
|
||||
totp.Subcommands.Add(CreateTotpValidateCommand());
|
||||
return totp;
|
||||
}
|
||||
|
||||
private static Command CreateTotpSecretCommand() {
|
||||
var command = new Command("secret", "Generate a Base32 TOTP shared secret");
|
||||
command.SetAction(_ => CliActions.GenerateTotpSecret());
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command CreateTotpRecoveryCommand() {
|
||||
var countOption = new Option<int>("--count") {
|
||||
Description = "Number of recovery codes",
|
||||
DefaultValueFactory = _ => 10
|
||||
};
|
||||
|
||||
var command = new Command("recovery", "Generate TOTP recovery codes (one per line)") {
|
||||
countOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
CliActions.GenerateRecoveryCodes(parseResult.GetValue(countOption)));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command CreateTotpLinkCommand() {
|
||||
var labelOption = RequiredString("--label", "Authenticator label");
|
||||
var usernameOption = RequiredString("--username", "Account username");
|
||||
var secretOption = RequiredString("--secret", "Base32 TOTP secret");
|
||||
var issuerOption = RequiredString("--issuer", "Issuer name");
|
||||
|
||||
var command = new Command("link", "Build an otpauth:// URI") {
|
||||
labelOption,
|
||||
usernameOption,
|
||||
secretOption,
|
||||
issuerOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
CliActions.GenerateTotpAuthLink(
|
||||
parseResult.GetValue(labelOption)!,
|
||||
parseResult.GetValue(usernameOption)!,
|
||||
parseResult.GetValue(secretOption)!,
|
||||
parseResult.GetValue(issuerOption)!
|
||||
));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command CreateTotpValidateCommand() {
|
||||
var secretOption = RequiredString("--secret", "Base32 TOTP secret");
|
||||
var codeOption = RequiredString("--code", "Six-digit TOTP code");
|
||||
var toleranceOption = new Option<int>("--tolerance") {
|
||||
Description = "Time-step windows to accept on each side",
|
||||
DefaultValueFactory = _ => 1
|
||||
};
|
||||
|
||||
var command = new Command("validate", "Validate a TOTP code (prints valid/invalid)") {
|
||||
secretOption,
|
||||
codeOption,
|
||||
toleranceOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
CliActions.ValidateTotp(
|
||||
parseResult.GetValue(secretOption)!,
|
||||
parseResult.GetValue(codeOption)!,
|
||||
parseResult.GetValue(toleranceOption)
|
||||
));
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command CreatePasswordCommand() {
|
||||
var password = new Command("password", "Password hashing");
|
||||
var pepperOption = RequiredString("--pepper", "Application pepper");
|
||||
var passwordOption = RequiredString("--password", "Password to hash");
|
||||
|
||||
var hash = new Command("hash", "Create a salted hash (JSON with salt and hash)") {
|
||||
pepperOption,
|
||||
passwordOption
|
||||
};
|
||||
|
||||
hash.SetAction(parseResult =>
|
||||
CliActions.HashPassword(
|
||||
parseResult.GetValue(pepperOption)!,
|
||||
parseResult.GetValue(passwordOption)!
|
||||
));
|
||||
|
||||
password.Subcommands.Add(hash);
|
||||
return password;
|
||||
}
|
||||
|
||||
private static Command CreateGuidCommand() {
|
||||
var guid = new Command("guid", "COMB GUID generation");
|
||||
var typeOption = new Option<string>("--type") {
|
||||
Description = "PostgreSql or SqlServer",
|
||||
DefaultValueFactory = _ => "PostgreSql"
|
||||
};
|
||||
|
||||
var comb = new Command("comb", "Generate a COMB GUID") {
|
||||
typeOption
|
||||
};
|
||||
|
||||
comb.SetAction(parseResult =>
|
||||
CliActions.GenerateCombGuid(parseResult.GetValue(typeOption)));
|
||||
|
||||
guid.Subcommands.Add(comb);
|
||||
return guid;
|
||||
}
|
||||
|
||||
private static Option<int> BytesOption() =>
|
||||
new("--bytes") {
|
||||
Description = "Random key size in bytes",
|
||||
DefaultValueFactory = _ => 32
|
||||
};
|
||||
|
||||
private static Option<string> RequiredString(string name, string description) =>
|
||||
new(name) {
|
||||
Description = description,
|
||||
Required = true
|
||||
};
|
||||
}
|
||||
76
src/MaksIT.Core.Cli/InputParsers.cs
Normal file
76
src/MaksIT.Core.Cli/InputParsers.cs
Normal file
@ -0,0 +1,76 @@
|
||||
using MaksIT.Core.Comb;
|
||||
|
||||
|
||||
namespace MaksIT.Core.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Parses interactive menu input for the Core CLI.
|
||||
/// </summary>
|
||||
public static class InputParsers {
|
||||
/// <summary>
|
||||
/// Parses a positive integer, using <paramref name="defaultValue"/> when input is blank.
|
||||
/// </summary>
|
||||
public static bool TryParsePositiveInt(
|
||||
string? input,
|
||||
int defaultValue,
|
||||
out int value,
|
||||
out string? errorMessage
|
||||
) {
|
||||
if (string.IsNullOrWhiteSpace(input)) {
|
||||
value = defaultValue;
|
||||
errorMessage = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!int.TryParse(input.Trim(), out value) || value <= 0) {
|
||||
value = 0;
|
||||
errorMessage = "Value must be a positive integer.";
|
||||
return false;
|
||||
}
|
||||
|
||||
errorMessage = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a COMB GUID type, defaulting to <see cref="CombGuidType.PostgreSql"/> when input is blank.
|
||||
/// </summary>
|
||||
public static bool TryParseCombGuidType(
|
||||
string? input,
|
||||
out CombGuidType type,
|
||||
out string? errorMessage
|
||||
) {
|
||||
if (string.IsNullOrWhiteSpace(input)) {
|
||||
type = CombGuidType.PostgreSql;
|
||||
errorMessage = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Enum.TryParse(input.Trim(), ignoreCase: true, out type)
|
||||
&& Enum.IsDefined(type)) {
|
||||
errorMessage = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
type = default;
|
||||
errorMessage = "Type must be PostgreSql or SqlServer.";
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a comma-separated list; returns <c>null</c> when input is blank.
|
||||
/// </summary>
|
||||
public static List<string>? ParseOptionalList(string? input) {
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return null;
|
||||
|
||||
var items = input
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToList();
|
||||
|
||||
if (items.Count == 0)
|
||||
return null;
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
27
src/MaksIT.Core.Cli/MaksIT.Core.Cli.csproj
Normal file
27
src/MaksIT.Core.Cli/MaksIT.Core.Cli.csproj
Normal file
@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
|
||||
<IsPackable>false</IsPackable>
|
||||
|
||||
<Version>1.6.10</Version>
|
||||
<Authors>Maksym Sadovnychyy</Authors>
|
||||
<Company>MAKS-IT</Company>
|
||||
<Product>MaksIT.Core.Cli</Product>
|
||||
<Copyright>Copyright © Maksym Sadovnychyy (MAKS-IT)</Copyright>
|
||||
<Description>Interactive and flag-based console toolkit for generating MaksIT.Core secrets (JWT, AES-GCM, TOTP, password hashes, COMB GUIDs).</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MaksIT.Core\MaksIT.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
9
src/MaksIT.Core.Cli/Program.cs
Normal file
9
src/MaksIT.Core.Cli/Program.cs
Normal file
@ -0,0 +1,9 @@
|
||||
namespace MaksIT.Core.Cli;
|
||||
|
||||
|
||||
public static class Program {
|
||||
public static int Main(string[] args) {
|
||||
Console.OutputEncoding = System.Text.Encoding.UTF8;
|
||||
return CommandFactory.CreateRootCommand().Parse(args).Invoke();
|
||||
}
|
||||
}
|
||||
131
src/MaksIT.Core.Cli/SecretOperations.cs
Normal file
131
src/MaksIT.Core.Cli/SecretOperations.cs
Normal file
@ -0,0 +1,131 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using MaksIT.Core.Comb;
|
||||
using MaksIT.Core.Security;
|
||||
using MaksIT.Core.Security.JWT;
|
||||
|
||||
|
||||
namespace MaksIT.Core.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Thin wrappers around MaksIT.Core secret and token helpers.
|
||||
/// </summary>
|
||||
public static class SecretOperations {
|
||||
/// <summary>
|
||||
/// Generates a Base64 secret suitable for JWT signing or a password pepper.
|
||||
/// </summary>
|
||||
public static string GenerateSecret(int keySize = 32) =>
|
||||
JwtGenerator.GenerateSecret(keySize);
|
||||
|
||||
/// <summary>
|
||||
/// Generates an opaque refresh token.
|
||||
/// </summary>
|
||||
public static string GenerateRefreshToken() =>
|
||||
JwtGenerator.GenerateRefreshToken();
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Base64 AES-256 key.
|
||||
/// </summary>
|
||||
public static string GenerateAesKey() =>
|
||||
AESGCMUtility.GenerateKeyBase64();
|
||||
|
||||
/// <summary>
|
||||
/// Generates a COMB GUID for the given layout.
|
||||
/// </summary>
|
||||
public static Guid GenerateCombGuid(CombGuidType type) =>
|
||||
CombGuidGenerator.CreateCombGuid(DateTime.UtcNow, type);
|
||||
|
||||
/// <summary>
|
||||
/// Signs an access JWT.
|
||||
/// </summary>
|
||||
public static bool TryGenerateJwt(
|
||||
JWTTokenGenerateRequest request,
|
||||
[NotNullWhen(true)] out string? token,
|
||||
[NotNullWhen(false)] out string? errorMessage
|
||||
) {
|
||||
if (!JwtGenerator.TryGenerateToken(request, out var tokenData, out errorMessage)) {
|
||||
token = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
token = tokenData.Value.Item1;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates an access JWT and returns its claims.
|
||||
/// </summary>
|
||||
public static bool TryValidateJwt(
|
||||
string secret,
|
||||
string issuer,
|
||||
string audience,
|
||||
string token,
|
||||
out JWTTokenClaims? claims,
|
||||
[NotNullWhen(false)] out string? errorMessage
|
||||
) =>
|
||||
JwtGenerator.TryValidateToken(secret, issuer, audience, token, out claims, out errorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Base32 TOTP shared secret.
|
||||
/// </summary>
|
||||
public static bool TryGenerateTotpSecret(
|
||||
[NotNullWhen(true)] out string? secret,
|
||||
[NotNullWhen(false)] out string? errorMessage
|
||||
) =>
|
||||
TotpGenerator.TryGenerateSecret(out secret, out errorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Generates TOTP recovery codes.
|
||||
/// </summary>
|
||||
public static bool TryGenerateRecoveryCodes(
|
||||
int count,
|
||||
[NotNullWhen(true)] out List<string>? codes,
|
||||
[NotNullWhen(false)] out string? errorMessage
|
||||
) =>
|
||||
TotpGenerator.TryGenerateRecoveryCodes(count, out codes, out errorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Builds an <c>otpauth://</c> URI for authenticator apps.
|
||||
/// </summary>
|
||||
public static bool TryGenerateTotpAuthLink(
|
||||
string label,
|
||||
string username,
|
||||
string secret,
|
||||
string issuer,
|
||||
[NotNullWhen(true)] out string? authLink,
|
||||
[NotNullWhen(false)] out string? errorMessage
|
||||
) =>
|
||||
TotpGenerator.TryGenerateTotpAuthLink(
|
||||
label,
|
||||
username,
|
||||
secret,
|
||||
issuer,
|
||||
algorithm: null,
|
||||
digits: null,
|
||||
period: null,
|
||||
out authLink,
|
||||
out errorMessage
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Validates a TOTP code against a Base32 secret.
|
||||
/// </summary>
|
||||
public static bool TryValidateTotp(
|
||||
string totpCode,
|
||||
string base32Secret,
|
||||
int timeTolerance,
|
||||
out bool isValid,
|
||||
[NotNullWhen(false)] out string? errorMessage
|
||||
) =>
|
||||
TotpGenerator.TryValidate(totpCode, base32Secret, timeTolerance, out isValid, out errorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a salted password hash with the given pepper.
|
||||
/// </summary>
|
||||
public static bool TryHashPassword(
|
||||
string password,
|
||||
string pepper,
|
||||
[NotNullWhen(true)] out (string Salt, string Hash)? saltedHash,
|
||||
[NotNullWhen(false)] out string? errorMessage
|
||||
) =>
|
||||
PasswordHasher.TryCreateSaltedHash(password, pepper, out saltedHash, out errorMessage);
|
||||
}
|
||||
@ -7,20 +7,14 @@
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="10.0.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.MTP" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.3.12" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
||||
<PackageReference Include="xunit.v3" Version="4.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
<Solution>
|
||||
<Project Path="MaksIT.Core.Cli.Tests/MaksIT.Core.Cli.Tests.csproj" />
|
||||
<Project Path="MaksIT.Core.Cli/MaksIT.Core.Cli.csproj" />
|
||||
<Project Path="MaksIT.Core.Tests/MaksIT.Core.Tests.csproj" />
|
||||
<Project Path="MaksIT.Core/MaksIT.Core.csproj" />
|
||||
</Solution>
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
|
||||
<!-- NuGet package metadata -->
|
||||
<PackageId>MaksIT.Core</PackageId>
|
||||
<Version>1.6.9</Version>
|
||||
<Version>1.6.10</Version>
|
||||
<Authors>Maksym Sadovnychyy</Authors>
|
||||
<Company>MAKS-IT</Company>
|
||||
<Product>MaksIT.Core</Product>
|
||||
|
||||
5
src/global.json
Normal file
5
src/global.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"test": {
|
||||
"runner": "Microsoft.Testing.Platform"
|
||||
}
|
||||
}
|
||||
@ -15,7 +15,10 @@
|
||||
"name": "DotNetTest",
|
||||
"stageLabel": "test",
|
||||
"enabled": true,
|
||||
"project": "..\\..\\..\\src\\MaksIT.Core.Tests",
|
||||
"projects": [
|
||||
"..\\..\\..\\src\\MaksIT.Core.Tests",
|
||||
"..\\..\\..\\src\\MaksIT.Core.Cli.Tests"
|
||||
],
|
||||
"resultsDir": "..\\..\\..\\testResults"
|
||||
},
|
||||
{
|
||||
@ -37,6 +40,15 @@
|
||||
],
|
||||
"artifactsDir": "..\\..\\..\\releases"
|
||||
},
|
||||
{
|
||||
"name": "DotNetPublish",
|
||||
"stageLabel": "build",
|
||||
"enabled": true,
|
||||
"projectFiles": [
|
||||
"..\\..\\..\\src\\MaksIT.Core.Cli\\MaksIT.Core.Cli.csproj"
|
||||
],
|
||||
"artifactsDir": "..\\..\\..\\releases"
|
||||
},
|
||||
{
|
||||
"name": "DotNetCreateArchive",
|
||||
"stageLabel": "build",
|
||||
|
||||
@ -8,7 +8,8 @@
|
||||
"stageLabel": "test",
|
||||
"enabled": true,
|
||||
"projects": [
|
||||
"..\\..\\..\\src\\MaksIT.Core.Tests"
|
||||
"..\\..\\..\\src\\MaksIT.Core.Tests",
|
||||
"..\\..\\..\\src\\MaksIT.Core.Cli.Tests"
|
||||
],
|
||||
"resultsDir": "..\\..\\..\\test-results"
|
||||
},
|
||||
|
||||
@ -6,16 +6,65 @@
|
||||
Keep a Changelog header parsing and section extraction.
|
||||
|
||||
.DESCRIPTION
|
||||
Supports only the standard Keep a Changelog version line:
|
||||
Supports Keep a Changelog version lines and shared SemVer checks, including prerelease:
|
||||
## [1.0.0] - 2026-05-24
|
||||
## [0.1.0-alpha.1] - 2026-08-21
|
||||
## [0.1.0-beta.1] - 2026-08-21
|
||||
## [0.1.0-rc.1] - 2026-08-21
|
||||
#>
|
||||
|
||||
function Get-ChangelogSemverPattern {
|
||||
return '\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?'
|
||||
}
|
||||
|
||||
function Test-ReleaseSemver {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[AllowEmptyString()]
|
||||
[string]$Version
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Version)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
return [bool]($Version -match ('^' + (Get-ChangelogSemverPattern) + '$'))
|
||||
}
|
||||
|
||||
function Test-ReleaseSemverPrerelease {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[AllowEmptyString()]
|
||||
[string]$Version
|
||||
)
|
||||
|
||||
return (Test-ReleaseSemver -Version $Version) -and ($Version -match '-')
|
||||
}
|
||||
|
||||
function Get-ReleaseSemverPrereleaseLabel {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[AllowEmptyString()]
|
||||
[string]$Version
|
||||
)
|
||||
|
||||
if (-not (Test-ReleaseSemverPrerelease -Version $Version)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($Version -match '^\d+\.\d+\.\d+-([A-Za-z][0-9A-Za-z]*)') {
|
||||
return $Matches[1].ToLowerInvariant()
|
||||
}
|
||||
|
||||
return 'next'
|
||||
}
|
||||
|
||||
function Get-ChangelogVersionHeaderPattern {
|
||||
return '(?m)^##\s+\[(\d+\.\d+\.\d+)\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
|
||||
return '(?m)^##\s+\[(' + (Get-ChangelogSemverPattern) + ')\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
|
||||
}
|
||||
|
||||
function Get-ChangelogNextVersionHeaderPattern {
|
||||
return '(?m)^##\s+\[\d+\.\d+\.\d+\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
|
||||
return '(?m)^##\s+\[' + (Get-ChangelogSemverPattern) + '\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$'
|
||||
}
|
||||
|
||||
function Get-LatestChangelogVersion {
|
||||
@ -53,4 +102,4 @@ function Get-ChangelogReleaseNotesSection {
|
||||
return $match.Value.Trim()
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Get-ChangelogVersionHeaderPattern, Get-ChangelogNextVersionHeaderPattern, Get-LatestChangelogVersion, Get-ChangelogReleaseNotesSection
|
||||
Export-ModuleMember -Function Get-ChangelogSemverPattern, Test-ReleaseSemver, Test-ReleaseSemverPrerelease, Get-ReleaseSemverPrereleaseLabel, Get-ChangelogVersionHeaderPattern, Get-ChangelogNextVersionHeaderPattern, Get-LatestChangelogVersion, Get-ChangelogReleaseNotesSection
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
|
||||
.DESCRIPTION
|
||||
Provides the Invoke-TestsWithCoverage function for running .NET tests
|
||||
with Coverlet code coverage collection and parsing results.
|
||||
with Microsoft.Testing.Platform and coverlet.MTP code coverage.
|
||||
|
||||
.NOTES
|
||||
Author: MaksIT
|
||||
@ -65,6 +65,20 @@ function Write-TestRunnerLogInternal {
|
||||
Write-Host $Message -ForegroundColor Gray
|
||||
}
|
||||
|
||||
function Get-CoberturaCoverageFiles {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ResultsDirectory
|
||||
)
|
||||
|
||||
# coverlet.MTP: [prefix.]coverage.cobertura[.timestamp].xml
|
||||
$files = @(
|
||||
Get-ChildItem -Path $ResultsDirectory -Recurse -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -like '*coverage.cobertura*.xml' }
|
||||
)
|
||||
return @($files | Sort-Object FullName -Unique)
|
||||
}
|
||||
|
||||
function Invoke-TestsWithCoverage {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
@ -155,7 +169,7 @@ function Invoke-TestsWithCoverage {
|
||||
New-Item -ItemType Directory -Path $ResultsDir -Force | Out-Null
|
||||
|
||||
if (-not $Silent) {
|
||||
Write-TestRunnerLogInternal -Level "STEP" -Message "Running tests with code coverage..."
|
||||
Write-TestRunnerLogInternal -Level "STEP" -Message "Running tests with code coverage (Microsoft.Testing.Platform / coverlet.MTP)..."
|
||||
foreach ($d in $resolvedProjectDirs) {
|
||||
Write-TestRunnerLogInternal -Level "INFO" -Message "Test Project: $d"
|
||||
}
|
||||
@ -164,11 +178,15 @@ function Invoke-TestsWithCoverage {
|
||||
foreach ($TestProjectDir in $resolvedProjectDirs) {
|
||||
Push-Location $TestProjectDir
|
||||
try {
|
||||
$projectName = [System.IO.Path]::GetFileName($TestProjectDir)
|
||||
$dotnetArgs = @(
|
||||
"test"
|
||||
"--collect:XPlat Code Coverage"
|
||||
"--results-directory", $ResultsDir
|
||||
"--verbosity", $(if ($Silent) { "quiet" } else { "normal" })
|
||||
"--coverlet"
|
||||
"--coverlet-output-format", "cobertura"
|
||||
"--coverlet-file-prefix", $projectName
|
||||
"--coverlet-include", "[MaksIT.*]*"
|
||||
)
|
||||
|
||||
Import-ExternalCommandSupportInternal
|
||||
@ -192,7 +210,7 @@ function Invoke-TestsWithCoverage {
|
||||
}
|
||||
}
|
||||
|
||||
$coverageFiles = @(Get-ChildItem -Path $ResultsDir -Filter "coverage.cobertura.xml" -Recurse | Sort-Object FullName)
|
||||
$coverageFiles = @(Get-CoberturaCoverageFiles -ResultsDirectory $ResultsDir)
|
||||
|
||||
if ($coverageFiles.Count -eq 0) {
|
||||
return [PSCustomObject]@{
|
||||
@ -455,7 +473,7 @@ function Get-DotNetCoverageFromResultsDirectory {
|
||||
[switch]$Silent
|
||||
)
|
||||
|
||||
$coverageFiles = @(Get-ChildItem -Path $ResultsDirectory -Filter 'coverage.cobertura.xml' -Recurse -ErrorAction SilentlyContinue | Sort-Object FullName)
|
||||
$coverageFiles = @(Get-CoberturaCoverageFiles -ResultsDirectory $ResultsDirectory)
|
||||
if ($coverageFiles.Count -eq 0) {
|
||||
return [PSCustomObject]@{
|
||||
Success = $false
|
||||
@ -566,7 +584,7 @@ function Get-CoverageFromResultsDirectory {
|
||||
}
|
||||
}
|
||||
|
||||
$hasDotNet = @(Get-ChildItem -Path $resolvedDirectory -Filter 'coverage.cobertura.xml' -Recurse -ErrorAction SilentlyContinue).Count -gt 0
|
||||
$hasDotNet = @(Get-CoberturaCoverageFiles -ResultsDirectory $resolvedDirectory).Count -gt 0
|
||||
$jestSummary = Join-Path $resolvedDirectory 'coverage-summary.json'
|
||||
$hasNpm = Test-Path -LiteralPath $jestSummary -PathType Leaf
|
||||
|
||||
|
||||
@ -6,9 +6,10 @@
|
||||
.NET publish plugin for producing application release artifacts.
|
||||
|
||||
.DESCRIPTION
|
||||
This plugin publishes the configured .NET project into a release output
|
||||
directory and exposes that published directory to the shared release
|
||||
context so later release-stage plugins can archive and publish it.
|
||||
This plugin publishes configured .NET projects into the artifacts directory
|
||||
and appends those publish folders to shared archive inputs so later plugins
|
||||
can zip them next to any earlier pack outputs. Existing NuGet package facts
|
||||
(packageFile) are left unchanged.
|
||||
#>
|
||||
|
||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||
@ -29,47 +30,83 @@ function Invoke-Plugin {
|
||||
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
|
||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact"
|
||||
|
||||
$pluginSettings = $Settings
|
||||
$sharedSettings = $Settings.context
|
||||
$projectFiles = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'projectFiles' -LegacyProperty @('projectFiles')
|
||||
$artifactsDirectory = $sharedSettings.artifactsDirectory
|
||||
$publishProjectPath = $null
|
||||
$scriptDir = $sharedSettings.scriptDir
|
||||
$projectFiles = @()
|
||||
|
||||
Assert-Command dotnet
|
||||
|
||||
if ($null -eq $projectFiles -or @($projectFiles).Count -eq 0) {
|
||||
throw "DotNetPublish plugin requires project files in the shared context."
|
||||
if ($pluginSettings.PSObject.Properties['projectFiles'] -and $null -ne $pluginSettings.projectFiles) {
|
||||
$projectFiles = @(Resolve-RelativePaths -Value $pluginSettings.projectFiles -BasePath $scriptDir)
|
||||
}
|
||||
else {
|
||||
$fromFact = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'projectFiles' -LegacyProperty @('projectFiles')
|
||||
if ($null -ne $fromFact) {
|
||||
$projectFiles = @($fromFact)
|
||||
}
|
||||
elseif ($sharedSettings.PSObject.Properties['projectFiles'] -and $null -ne $sharedSettings.projectFiles) {
|
||||
$projectFiles = @($sharedSettings.projectFiles)
|
||||
}
|
||||
}
|
||||
|
||||
$projectFiles = @($projectFiles)
|
||||
if ($projectFiles.Count -eq 0) {
|
||||
throw "DotNetPublish plugin requires projectFiles in plugin settings or projectFiles on shared context."
|
||||
}
|
||||
|
||||
if ($pluginSettings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.artifactsDir)) {
|
||||
$artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$pluginSettings.artifactsDir)))
|
||||
Set-EngineState -Context $sharedSettings -Name 'artifactsDirectory' -Value $artifactsDirectory
|
||||
Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory
|
||||
}
|
||||
else {
|
||||
$artifactsDirectory = $sharedSettings.artifactsDirectory
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace([string]$artifactsDirectory)) {
|
||||
throw "DotNetPublish plugin requires artifactsDir in plugin settings or artifactsDirectory on shared context."
|
||||
}
|
||||
|
||||
if (!(Test-Path $artifactsDirectory)) {
|
||||
New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null
|
||||
}
|
||||
|
||||
# The first configured project remains the canonical release artifact source.
|
||||
$publishProjectPath = $projectFiles[0]
|
||||
$publishDir = Join-Path $artifactsDirectory ([System.IO.Path]::GetFileNameWithoutExtension($publishProjectPath))
|
||||
|
||||
if (Test-Path $publishDir) {
|
||||
Remove-Item -Path $publishDir -Recurse -Force
|
||||
$existing = Get-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -LegacyProperty @('releaseArchiveInputs')
|
||||
$archiveInputs = [System.Collections.Generic.List[object]]::new()
|
||||
if ($null -ne $existing) {
|
||||
foreach ($item in @($existing)) {
|
||||
if ($null -ne $item) {
|
||||
$archiveInputs.Add($item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log -Level "STEP" -Message "Publishing release artifact..."
|
||||
dotnet publish $publishProjectPath -c Release -o $publishDir --nologo
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "dotnet publish failed for $publishProjectPath."
|
||||
foreach ($publishProjectPath in $projectFiles) {
|
||||
$publishDir = Join-Path $artifactsDirectory ([System.IO.Path]::GetFileNameWithoutExtension($publishProjectPath))
|
||||
|
||||
if (Test-Path $publishDir) {
|
||||
Remove-Item -Path $publishDir -Recurse -Force
|
||||
}
|
||||
|
||||
Write-Log -Level "STEP" -Message "Publishing release artifact..."
|
||||
$dotnetPublishArguments = @(
|
||||
'publish', $publishProjectPath, '-c', 'Release', '-o', $publishDir, '--nologo'
|
||||
)
|
||||
& dotnet @dotnetPublishArguments
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "dotnet publish failed for $publishProjectPath."
|
||||
}
|
||||
|
||||
$publishedItems = @(Get-ChildItem -Path $publishDir -Force -ErrorAction SilentlyContinue)
|
||||
if ($publishedItems.Count -eq 0) {
|
||||
throw "dotnet publish completed, but no files were produced in: $publishDir"
|
||||
}
|
||||
|
||||
Write-Log -Level "OK" -Message " Published artifact ready: $publishDir"
|
||||
$archiveInputs.Add($publishDir)
|
||||
}
|
||||
|
||||
$publishedItems = @(Get-ChildItem -Path $publishDir -Force -ErrorAction SilentlyContinue)
|
||||
if ($publishedItems.Count -eq 0) {
|
||||
throw "dotnet publish completed, but no files were produced in: $publishDir"
|
||||
}
|
||||
|
||||
Write-Log -Level "OK" -Message " Published artifact ready: $publishDir"
|
||||
|
||||
Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -Value $null -Overwrite Replace -LegacyProperty 'packageFile'
|
||||
Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -Value $null -Overwrite Replace -LegacyProperty 'symbolsPackageFile'
|
||||
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value @($publishDir) -Overwrite Replace -LegacyProperty 'releaseArchiveInputs'
|
||||
Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value @($archiveInputs) -Overwrite Replace -LegacyProperty 'releaseArchiveInputs'
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Invoke-Plugin
|
||||
|
||||
@ -7,9 +7,11 @@
|
||||
|
||||
.DESCRIPTION
|
||||
Dedicated version-loading plugin. Reads <Version> from the first configured
|
||||
projectFiles entry and writes it (plus the resolved projectFiles) to the
|
||||
shared runtime context. Declares providesVersion = $true so the engine can
|
||||
discover it as the single release version source.
|
||||
projectFiles .csproj, or from the nearest Directory.Build.props when the
|
||||
csproj omits it. Accepts SemVer prerelease (0.1.0-alpha.1 / beta / rc). Writes
|
||||
version plus the resolved projectFiles (csproj paths for later pack/publish)
|
||||
to the shared runtime context. Declares providesVersion = $true so the engine
|
||||
can discover it as the single release version source.
|
||||
#>
|
||||
|
||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||
@ -20,6 +22,22 @@ if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||
}
|
||||
}
|
||||
|
||||
function ConvertTo-MsbuildPropertyStringInternal {
|
||||
param(
|
||||
$Value
|
||||
)
|
||||
|
||||
if ($null -eq $Value) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($Value -is [System.Xml.XmlElement]) {
|
||||
return [string]$Value.InnerText
|
||||
}
|
||||
|
||||
return [string]$Value
|
||||
}
|
||||
|
||||
function Get-CsprojPropertyValueInternal {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
@ -36,7 +54,33 @@ function Get-CsprojPropertyValueInternal {
|
||||
Select-Object -First 1
|
||||
|
||||
if ($propNode) {
|
||||
return $propNode.$PropertyName
|
||||
return ConvertTo-MsbuildPropertyStringInternal -Value $propNode.$PropertyName
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-DirectoryBuildPropsVersionInternal {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ProjectPath
|
||||
)
|
||||
|
||||
# MSBuild uses the first Directory.Build.props found walking up from the project directory.
|
||||
$dir = [System.IO.Path]::GetDirectoryName((Resolve-Path -LiteralPath $ProjectPath))
|
||||
while (-not [string]::IsNullOrWhiteSpace($dir)) {
|
||||
$propsPath = Join-Path $dir 'Directory.Build.props'
|
||||
if (Test-Path -LiteralPath $propsPath -PathType Leaf) {
|
||||
[xml]$props = Get-Content -LiteralPath $propsPath
|
||||
return Get-CsprojPropertyValueInternal -Csproj $props -PropertyName 'Version'
|
||||
}
|
||||
|
||||
$parent = [System.IO.Directory]::GetParent($dir)
|
||||
if ($null -eq $parent) {
|
||||
break
|
||||
}
|
||||
|
||||
$dir = $parent.FullName
|
||||
}
|
||||
|
||||
return $null
|
||||
@ -58,12 +102,16 @@ function Get-CsprojVersionInternal {
|
||||
|
||||
[xml]$csproj = Get-Content $ProjectPath
|
||||
$version = Get-CsprojPropertyValueInternal -Csproj $csproj -PropertyName "Version"
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace([string]$version)) {
|
||||
throw "DotNetReleaseVersion: <Version> not found in '$ProjectPath'."
|
||||
if (-not [string]::IsNullOrWhiteSpace([string]$version)) {
|
||||
return [string]$version
|
||||
}
|
||||
|
||||
return [string]$version
|
||||
$version = Get-DirectoryBuildPropsVersionInternal -ProjectPath $ProjectPath
|
||||
if (-not [string]::IsNullOrWhiteSpace([string]$version)) {
|
||||
return [string]$version
|
||||
}
|
||||
|
||||
throw "DotNetReleaseVersion: <Version> not found in '$ProjectPath' or a parent Directory.Build.props."
|
||||
}
|
||||
|
||||
function Get-PluginMetadata {
|
||||
@ -87,6 +135,10 @@ function Invoke-Plugin {
|
||||
|
||||
Write-Log -Level "INFO" -Message "Reading version from SDK-style project file (projectFiles)..."
|
||||
$version = Get-CsprojVersionInternal -ProjectPath $projectFiles[0]
|
||||
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
|
||||
if (-not (Test-ReleaseSemver -Version $version)) {
|
||||
throw "DotNetReleaseVersion: version '$version' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)."
|
||||
}
|
||||
|
||||
Set-EngineState -Context $shared -Name 'version' -Value $version
|
||||
Set-EngineFact -Context $shared -Namespace 'dotnet' -Name 'projectFiles' -Value $projectFiles -Overwrite Replace -LegacyProperty 'projectFiles'
|
||||
|
||||
@ -7,12 +7,15 @@
|
||||
|
||||
.DESCRIPTION
|
||||
Resolves one or more .NET test projects (`project` or `projects`), runs tests once
|
||||
via TestRunner, then publishes metrics on the shared engine context for any later
|
||||
via TestRunner (Microsoft Testing Platform + coverlet.MTP only — no VSTest /
|
||||
coverlet.collector), then publishes metrics on the shared engine context for any later
|
||||
plugin: `qualityLineCoverage`, `testResult`, `coverageLineRate` / `coverageBranchRate` / `coverageMethodRate`,
|
||||
method counts, `testResultsDirectory`, `coverageCoberturaPaths`. Quality gates read
|
||||
those keys generically (not tied to this plugin by name). When `resultsDir` (or the
|
||||
multi-project default TestResults folder) is used, Cobertura output is kept on disk
|
||||
via TestRunner `-KeepResults` so repo-root `test-results/` persists after the run.
|
||||
Product solutions must place `src/global.json` with `test.runner` =
|
||||
Microsoft.Testing.Platform next to the `.sln`/`.slnx`.
|
||||
#>
|
||||
|
||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||
|
||||
@ -28,6 +28,7 @@ function Invoke-Plugin {
|
||||
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
||||
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
|
||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths"
|
||||
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-ReleaseSemverPrereleaseLabel"
|
||||
|
||||
$pluginSettings = $Settings
|
||||
$shared = $Settings.context
|
||||
@ -62,6 +63,14 @@ function Invoke-Plugin {
|
||||
[string]$pluginSettings.access
|
||||
}
|
||||
|
||||
$npmDistTag = $null
|
||||
if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.npmDistTag)) {
|
||||
$npmDistTag = [string]$pluginSettings.npmDistTag
|
||||
}
|
||||
else {
|
||||
$npmDistTag = Get-ReleaseSemverPrereleaseLabel -Version ([string]$shared.version)
|
||||
}
|
||||
|
||||
$publishOrder = @()
|
||||
if ($pluginSettings.publishOrder) {
|
||||
if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) {
|
||||
@ -84,7 +93,8 @@ function Invoke-Plugin {
|
||||
|
||||
if ($dryRun) {
|
||||
foreach ($packageName in $publishOrder) {
|
||||
Write-Log -Level "INFO" -Message "Dry run: would publish npm package '$packageName' to $registry"
|
||||
$tagNote = if ([string]::IsNullOrWhiteSpace($npmDistTag)) { 'latest' } else { $npmDistTag }
|
||||
Write-Log -Level "INFO" -Message "Dry run: would publish npm package '$packageName' to $registry (dist-tag $tagNote)"
|
||||
}
|
||||
return
|
||||
}
|
||||
@ -112,13 +122,20 @@ registry=$registry
|
||||
|
||||
foreach ($packageName in $publishOrder) {
|
||||
Write-Log -Level "STEP" -Message "Publishing npm package '$packageName'..."
|
||||
$publishArgs = @('publish')
|
||||
if ($useWorkspaces) {
|
||||
npm publish -w $packageName --access $access --userconfig $tempNpmRcPath
|
||||
$publishArgs += @('-w', $packageName)
|
||||
}
|
||||
else {
|
||||
Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName
|
||||
npm publish --access $access --userconfig $tempNpmRcPath
|
||||
}
|
||||
$publishArgs += @('--access', $access, '--userconfig', $tempNpmRcPath)
|
||||
if (-not [string]::IsNullOrWhiteSpace($npmDistTag)) {
|
||||
$publishArgs += @('--tag', $npmDistTag)
|
||||
Write-Log -Level "INFO" -Message " Using npm dist-tag '$npmDistTag' (prerelease)."
|
||||
}
|
||||
|
||||
npm @publishArgs
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Failed to publish npm package '$packageName'."
|
||||
|
||||
@ -35,8 +35,9 @@ function Get-PackageJsonVersionInternal {
|
||||
throw "NpmReleaseVersion: 'version' is missing in '$PackageJsonPath'."
|
||||
}
|
||||
|
||||
if ($version -notmatch '^\d+\.\d+\.\d+') {
|
||||
throw "NpmReleaseVersion: version '$version' in '$PackageJsonPath' is not a valid semver."
|
||||
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
|
||||
if (-not (Test-ReleaseSemver -Version $version)) {
|
||||
throw "NpmReleaseVersion: version '$version' in '$PackageJsonPath' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)."
|
||||
}
|
||||
|
||||
return $version
|
||||
@ -69,6 +70,7 @@ function Invoke-Plugin {
|
||||
|
||||
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState"
|
||||
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
|
||||
|
||||
$pluginSettings = $Settings
|
||||
$shared = $Settings.context
|
||||
|
||||
@ -7,9 +7,10 @@
|
||||
|
||||
.DESCRIPTION
|
||||
Reads a single-line semver from the configured versionFilePath (default
|
||||
repo-root VERSION). Useful for repositories without .csproj or package.json
|
||||
version metadata. Declares providesVersion = $true so the engine can
|
||||
discover it as the single release version source.
|
||||
repo-root VERSION), including optional prerelease (0.1.0-alpha.1). Useful for
|
||||
repositories without .csproj or package.json version metadata. Declares
|
||||
providesVersion = $true so the engine can discover it as the single release
|
||||
version source.
|
||||
#>
|
||||
|
||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||
@ -36,8 +37,9 @@ function Get-VersionFileSemverInternal {
|
||||
}
|
||||
|
||||
$version = $version -replace '^[vV]', ''
|
||||
if ($version -notmatch '^\d+\.\d+\.\d+') {
|
||||
throw "FileReleaseVersion: version '$version' in '$VersionFilePath' is not a valid semver."
|
||||
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
|
||||
if (-not (Test-ReleaseSemver -Version $version)) {
|
||||
throw "FileReleaseVersion: version '$version' in '$VersionFilePath' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)."
|
||||
}
|
||||
|
||||
return $version
|
||||
@ -55,6 +57,7 @@ function Invoke-Plugin {
|
||||
|
||||
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState"
|
||||
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver"
|
||||
|
||||
$shared = $Settings.context
|
||||
$versionFileSetting = if ($Settings.versionFilePath) {
|
||||
|
||||
@ -10,7 +10,8 @@
|
||||
repository, and creates the configured GitHub release using the
|
||||
shared release artifacts and release notes from CHANGELOG.md.
|
||||
Release notes must use Keep a Changelog headers: ## [semver] - YYYY-MM-DD
|
||||
(see ChangelogSupport.psm1).
|
||||
(including optional SemVer prerelease, e.g. ## [0.1.0-alpha.1] / [0.1.0-beta.1] / [0.1.0-rc.1];
|
||||
see ChangelogSupport.psm1). Hyphenated versions are created with gh --prerelease.
|
||||
#>
|
||||
|
||||
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
|
||||
@ -95,6 +96,7 @@ function Invoke-Plugin {
|
||||
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
|
||||
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
|
||||
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-LatestChangelogVersion"
|
||||
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemverPrerelease"
|
||||
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Get-EngineFact"
|
||||
|
||||
$pluginSettings = $Settings
|
||||
@ -128,6 +130,9 @@ function Invoke-Plugin {
|
||||
}
|
||||
$releaseName = $releaseTitlePattern -replace '\{version\}', $version
|
||||
Write-Log -Level "INFO" -Message "Dry run: would create GitHub release '$releaseName' ($tag) on $repo"
|
||||
if (Test-ReleaseSemverPrerelease -Version ([string]$version)) {
|
||||
Write-Log -Level "INFO" -Message "Dry run: release would be marked prerelease."
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@ -261,6 +266,10 @@ function Invoke-Plugin {
|
||||
"--title", $releaseName,
|
||||
"--notes-file", $notesFilePath
|
||||
)
|
||||
if (Test-ReleaseSemverPrerelease -Version ([string]$version)) {
|
||||
$createReleaseArgs += '--prerelease'
|
||||
}
|
||||
|
||||
& gh @createReleaseArgs
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
|
||||
@ -11,8 +11,9 @@
|
||||
when they do not (whenRequirementsNotMet: skip). Publish plugins no longer use per-plugin
|
||||
branch lists; put allowed branches here instead.
|
||||
|
||||
Typical checks: allowed branches, optional clean working tree, exact semver tag on HEAD,
|
||||
tag version vs DotNetReleaseVersion, optional push tag to remote.
|
||||
Typical checks: allowed branches, optional clean working tree, exact semver tag on HEAD
|
||||
(vX.Y.Z or vX.Y.Z-prerelease such as v0.1.0-alpha.1 / v0.1.0-beta.1 / v0.1.0-rc.1),
|
||||
tag version vs release version, optional push tag to remote.
|
||||
|
||||
The engine preflight no longer reads git tags; this plugin sets context.tag from the
|
||||
git tag on HEAD when required. Shared context version always remains from DotNetReleaseVersion.
|
||||
@ -77,6 +78,7 @@ function Invoke-Plugin {
|
||||
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Get-GitStatusShort"
|
||||
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Test-RemoteTagExists"
|
||||
Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Push-TagToRemote"
|
||||
Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-ChangelogSemverPattern"
|
||||
|
||||
$pluginSettings = $Settings
|
||||
$shared = $Settings.context
|
||||
@ -123,8 +125,9 @@ function Invoke-Plugin {
|
||||
return
|
||||
}
|
||||
|
||||
if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') {
|
||||
Invoke-NotMetInternal -Shared $shared -When $when -Reason "tag '$tag' must match vX.Y.Z."
|
||||
$tagPattern = '^v(' + (Get-ChangelogSemverPattern) + ')$'
|
||||
if ($tag -notmatch $tagPattern) {
|
||||
Invoke-NotMetInternal -Shared $shared -When $when -Reason "tag '$tag' must match vX.Y.Z or vX.Y.Z-prerelease (e.g. v0.1.0-alpha.1, v0.1.0-beta.1, v0.1.0-rc.1)."
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user