UI work to fill SBOM sourcing management gap. UI planning remaining functionality exposure. Work on CI/Tests stabilization

Introduces CGS determinism test runs to CI workflows for Windows, macOS, Linux, Alpine, and Debian, fulfilling CGS-008 cross-platform requirements. Updates local-ci scripts to support new smoke steps, test timeouts, progress intervals, and project slicing for improved test isolation and diagnostics.
This commit is contained in:
master
2025-12-29 19:12:38 +02:00
parent 41552d26ec
commit a4badc275e
286 changed files with 50918 additions and 992 deletions

View File

@@ -0,0 +1,126 @@
// <copyright file="HarvestCommand.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Security.Cryptography;
using System.Text.Json;
using StellaOps.Testing.FixtureHarvester.Models;
namespace StellaOps.Testing.FixtureHarvester.Commands;
/// <summary>
/// Harvest command - fetch, hash, store, and create metadata for fixtures
/// </summary>
internal static class HarvestCommand
{
internal static async Task ExecuteAsync(string type, string id, string? source, string output)
{
Console.WriteLine($"Harvesting {type} fixture '{id}'...");
var fixtureDir = Path.Combine(output, type, id);
Directory.CreateDirectory(fixtureDir);
Directory.CreateDirectory(Path.Combine(fixtureDir, "raw"));
Directory.CreateDirectory(Path.Combine(fixtureDir, "normalized"));
Directory.CreateDirectory(Path.Combine(fixtureDir, "expected"));
string contentPath;
string sha256Hash;
if (string.IsNullOrEmpty(source))
{
Console.WriteLine("No source provided - manual fixture creation mode");
Console.WriteLine($"Place fixture content in: {Path.Combine(fixtureDir, "raw")}");
return;
}
// Determine if source is URL or file path
if (Uri.TryCreate(source, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
{
Console.WriteLine($"Fetching from URL: {source}");
contentPath = await FetchFromUrlAsync(source, fixtureDir);
}
else if (File.Exists(source))
{
Console.WriteLine($"Copying from file: {source}");
contentPath = await CopyFromFileAsync(source, fixtureDir);
}
else
{
Console.WriteLine($"ERROR: Invalid source - not a valid URL or file path: {source}");
return;
}
// Compute SHA-256 hash
sha256Hash = await ComputeSha256Async(contentPath);
Console.WriteLine($"SHA-256: {sha256Hash}");
// Create metadata
var meta = new FixtureMeta
{
Id = id,
Source = Uri.TryCreate(source, UriKind.Absolute, out _) ? "url" : "file",
SourceUrl = Uri.TryCreate(source, UriKind.Absolute, out _) ? source : null,
RetrievedAt = DateTime.UtcNow.ToString("O"),
License = "CC0-1.0", // Default; update manually if needed
Sha256 = sha256Hash,
RefreshPolicy = "manual", // Default
Notes = $"Harvested from: {source}",
Tier = "T2", // Real sample default
};
var metaPath = Path.Combine(fixtureDir, "meta.json");
var metaJson = JsonSerializer.Serialize(meta, new JsonSerializerOptions
{
WriteIndented = true,
});
await File.WriteAllTextAsync(metaPath, metaJson);
Console.WriteLine($"✓ Fixture harvested: {fixtureDir}");
Console.WriteLine($" Meta: {metaPath}");
Console.WriteLine($" Content: {contentPath}");
Console.WriteLine();
Console.WriteLine("Next steps:");
Console.WriteLine("1. Review and update meta.json (license, tier, notes, etc.)");
Console.WriteLine("2. Add fixture to fixtures.manifest.yml");
Console.WriteLine("3. Run: fixture-harvester validate");
}
private static async Task<string> FetchFromUrlAsync(string url, string fixtureDir)
{
using var client = new HttpClient();
var response = await client.GetAsync(url);
response.EnsureSuccessStatusCode();
var filename = Path.GetFileName(new Uri(url).LocalPath);
if (string.IsNullOrEmpty(filename) || filename == "/")
{
filename = "downloaded.json";
}
var rawPath = Path.Combine(fixtureDir, "raw", filename);
await using var fileStream = File.Create(rawPath);
await response.Content.CopyToAsync(fileStream);
return rawPath;
}
private static async Task<string> CopyFromFileAsync(string sourcePath, string fixtureDir)
{
var filename = Path.GetFileName(sourcePath);
var rawPath = Path.Combine(fixtureDir, "raw", filename);
await using var sourceStream = File.OpenRead(sourcePath);
await using var destStream = File.Create(rawPath);
await sourceStream.CopyToAsync(destStream);
return rawPath;
}
private static async Task<string> ComputeSha256Async(string filePath)
{
using var sha256 = SHA256.Create();
await using var stream = File.OpenRead(filePath);
var hashBytes = await sha256.ComputeHashAsync(stream);
return "sha256:" + BitConverter.ToString(hashBytes).Replace("-", string.Empty).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,59 @@
// <copyright file="RegenCommand.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Testing.FixtureHarvester.Commands;
/// <summary>
/// Regen command - regenerate expected outputs (use with caution)
/// </summary>
internal static class RegenCommand
{
internal static Task ExecuteAsync(string? fixture, bool all, bool confirm)
{
if (!confirm)
{
Console.WriteLine("ERROR: Regeneration requires --confirm flag");
Console.WriteLine();
Console.WriteLine("WARNING: This command will regenerate expected outputs for test fixtures.");
Console.WriteLine(" Only use this after MANUALLY VERIFYING that the new outputs are correct.");
Console.WriteLine(" Incorrect regeneration can mask bugs and break determinism guarantees.");
Console.WriteLine();
Console.WriteLine("Usage:");
Console.WriteLine(" fixture-harvester regen --fixture <id> --confirm");
Console.WriteLine(" fixture-harvester regen --all --confirm");
return Task.CompletedTask;
}
if (all)
{
Console.WriteLine("Regenerating all fixtures...");
Console.WriteLine("(Not implemented - manual process required)");
Console.WriteLine();
Console.WriteLine("Steps:");
Console.WriteLine("1. Run Scanner/VexLens/etc with fixture inputs");
Console.WriteLine("2. Capture outputs to expected/ directory");
Console.WriteLine("3. Compute and record expected hash in meta.json");
Console.WriteLine("4. Run: fixture-harvester validate");
return Task.CompletedTask;
}
if (string.IsNullOrEmpty(fixture))
{
Console.WriteLine("ERROR: Either --fixture <id> or --all must be specified");
return Task.CompletedTask;
}
Console.WriteLine($"Regenerating fixture: {fixture}");
Console.WriteLine("(Not implemented - manual process required)");
Console.WriteLine();
Console.WriteLine("Steps:");
Console.WriteLine($"1. Locate fixture: src/__Tests/fixtures/*/{fixture}");
Console.WriteLine("2. Run relevant tool with fixture inputs");
Console.WriteLine("3. Copy output to expected/ directory");
Console.WriteLine("4. Update meta.json with new expected hash");
Console.WriteLine("5. Run: fixture-harvester validate");
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,176 @@
// <copyright file="ValidateCommand.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Security.Cryptography;
using System.Text.Json;
using StellaOps.Testing.FixtureHarvester.Models;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.Testing.FixtureHarvester.Commands;
/// <summary>
/// Validate command - verify fixture integrity and manifest consistency
/// </summary>
internal static class ValidateCommand
{
internal static async Task ExecuteAsync(string path)
{
Console.WriteLine($"Validating fixtures in: {path}");
Console.WriteLine();
var manifestPath = Path.Combine(path, "fixtures.manifest.yml");
if (!File.Exists(manifestPath))
{
Console.WriteLine($"ERROR: Manifest not found: {manifestPath}");
Console.WriteLine("Create fixtures.manifest.yml first.");
return;
}
// Load manifest
var yamlContent = await File.ReadAllTextAsync(manifestPath);
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
var manifest = deserializer.Deserialize<FixtureManifest>(yamlContent);
int totalFixtures = 0;
int validFixtures = 0;
int errors = 0;
// Validate SBOM fixtures
Console.WriteLine("Validating SBOM fixtures...");
foreach (var fixture in manifest.Fixtures.Sbom)
{
totalFixtures++;
if (await ValidateFixtureAsync(path, "sbom", fixture.Id))
{
validFixtures++;
}
else
{
errors++;
}
}
// Validate Feed fixtures
Console.WriteLine("\nValidating Feed fixtures...");
foreach (var fixture in manifest.Fixtures.Feeds)
{
totalFixtures++;
if (await ValidateFixtureAsync(path, "feeds", fixture.Id))
{
validFixtures++;
}
else
{
errors++;
}
}
// Validate VEX fixtures
Console.WriteLine("\nValidating VEX fixtures...");
foreach (var fixture in manifest.Fixtures.Vex)
{
totalFixtures++;
if (await ValidateFixtureAsync(path, "vex", fixture.Id))
{
validFixtures++;
}
else
{
errors++;
}
}
// Summary
Console.WriteLine();
Console.WriteLine("=".PadRight(50, '='));
Console.WriteLine($"Total fixtures: {totalFixtures}");
Console.WriteLine($"Valid: {validFixtures}");
Console.WriteLine($"Errors: {errors}");
if (errors == 0)
{
Console.WriteLine("✓ All fixtures valid!");
}
else
{
Console.WriteLine($"✗ {errors} fixture(s) failed validation");
Environment.Exit(1);
}
}
private static async Task<bool> ValidateFixtureAsync(string basePath, string type, string id)
{
var fixtureDir = Path.Combine(basePath, type, id);
var metaPath = Path.Combine(fixtureDir, "meta.json");
Console.Write($" [{id}] ");
// Check directory exists
if (!Directory.Exists(fixtureDir))
{
Console.WriteLine($"✗ Directory not found: {fixtureDir}");
return false;
}
// Check meta.json exists
if (!File.Exists(metaPath))
{
Console.WriteLine($"✗ meta.json not found");
return false;
}
// Load and validate meta
var metaJson = await File.ReadAllTextAsync(metaPath);
FixtureMeta? meta;
try
{
meta = JsonSerializer.Deserialize<FixtureMeta>(metaJson);
if (meta == null)
{
Console.WriteLine("✗ Invalid meta.json format");
return false;
}
}
catch (JsonException ex)
{
Console.WriteLine($"✗ Failed to parse meta.json: {ex.Message}");
return false;
}
// Verify raw directory exists and has files
var rawDir = Path.Combine(fixtureDir, "raw");
if (!Directory.Exists(rawDir) || !Directory.EnumerateFiles(rawDir).Any())
{
Console.WriteLine("✗ No files in raw/ directory");
return false;
}
// Verify SHA-256 hash
var rawFiles = Directory.GetFiles(rawDir);
if (rawFiles.Length > 0)
{
var actualHash = await ComputeSha256Async(rawFiles[0]);
if (actualHash != meta.Sha256)
{
Console.WriteLine($"✗ Hash mismatch! Expected: {meta.Sha256}, Got: {actualHash}");
return false;
}
}
Console.WriteLine("✓ Valid");
return true;
}
private static async Task<string> ComputeSha256Async(string filePath)
{
using var sha256 = SHA256.Create();
await using var stream = File.OpenRead(filePath);
var hashBytes = await sha256.ComputeHashAsync(stream);
return "sha256:" + BitConverter.ToString(hashBytes).Replace("-", string.Empty).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>StellaOps.Testing.FixtureHarvester.Tests</RootNamespace>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FixtureHarvester.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>StellaOps.Testing.FixtureHarvester</RootNamespace>
<AssemblyName>fixture-harvester</AssemblyName>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
<PackageReference Include="YamlDotNet" Version="16.2.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,169 @@
// <copyright file="FixtureValidationTests.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Text.Json;
using StellaOps.Testing.FixtureHarvester.Models;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
using Xunit;
namespace StellaOps.Testing.FixtureHarvester.Tests;
/// <summary>
/// Validation tests for fixture infrastructure
/// </summary>
public sealed class FixtureValidationTests
{
private const string FixturesBasePath = "../../../fixtures";
private readonly string _manifestPath = Path.Combine(FixturesBasePath, "fixtures.manifest.yml");
[Fact(Skip = "Fixtures not yet populated")]
public void ManifestFile_Exists_AndIsValid()
{
// Arrange & Act
var exists = File.Exists(_manifestPath);
// Assert
Assert.True(exists, $"fixtures.manifest.yml should exist at {_manifestPath}");
}
[Fact(Skip = "Fixtures not yet populated")]
public async Task ManifestFile_CanBeParsed_Successfully()
{
// Arrange
if (!File.Exists(_manifestPath))
{
// Skip if manifest doesn't exist yet
return;
}
var yamlContent = await File.ReadAllTextAsync(_manifestPath);
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
// Act
var manifest = deserializer.Deserialize<FixtureManifest>(yamlContent);
// Assert
Assert.NotNull(manifest);
Assert.Equal("1.0", manifest.SchemaVersion);
Assert.NotNull(manifest.Fixtures);
}
[Fact(Skip = "Fixtures not yet populated")]
public async Task AllFixtures_HaveValidMetadata()
{
// Arrange
if (!File.Exists(_manifestPath))
{
return;
}
var yamlContent = await File.ReadAllTextAsync(_manifestPath);
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
var manifest = deserializer.Deserialize<FixtureManifest>(yamlContent);
var allFixtures = new List<(string Type, string Id)>();
foreach (var sbom in manifest.Fixtures.Sbom)
{
allFixtures.Add(("sbom", sbom.Id));
}
foreach (var feed in manifest.Fixtures.Feeds)
{
allFixtures.Add(("feeds", feed.Id));
}
foreach (var vex in manifest.Fixtures.Vex)
{
allFixtures.Add(("vex", vex.Id));
}
// Act & Assert
foreach (var (type, id) in allFixtures)
{
var fixtureDir = Path.Combine(FixturesBasePath, type, id);
var metaPath = Path.Combine(fixtureDir, "meta.json");
if (!File.Exists(metaPath))
{
// Fixture not yet created - skip
continue;
}
var metaJson = await File.ReadAllTextAsync(metaPath);
var meta = JsonSerializer.Deserialize<FixtureMeta>(metaJson);
Assert.NotNull(meta);
Assert.Equal(id, meta.Id);
Assert.NotEmpty(meta.Source);
Assert.NotEmpty(meta.License);
Assert.NotEmpty(meta.Sha256);
Assert.StartsWith("sha256:", meta.Sha256);
Assert.NotEmpty(meta.RefreshPolicy);
}
}
[Fact(Skip = "Fixtures not yet populated")]
public async Task AllFixtures_HaveRawDirectory()
{
// Arrange
if (!File.Exists(_manifestPath))
{
return;
}
var yamlContent = await File.ReadAllTextAsync(_manifestPath);
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
var manifest = deserializer.Deserialize<FixtureManifest>(yamlContent);
var allFixtures = new List<(string Type, string Id)>();
foreach (var sbom in manifest.Fixtures.Sbom)
{
allFixtures.Add(("sbom", sbom.Id));
}
foreach (var feed in manifest.Fixtures.Feeds)
{
allFixtures.Add(("feeds", feed.Id));
}
foreach (var vex in manifest.Fixtures.Vex)
{
allFixtures.Add(("vex", vex.Id));
}
// Act & Assert
foreach (var (type, id) in allFixtures)
{
var fixtureDir = Path.Combine(FixturesBasePath, type, id);
var rawDir = Path.Combine(fixtureDir, "raw");
if (!Directory.Exists(fixtureDir))
{
// Fixture not yet created - skip
continue;
}
Assert.True(Directory.Exists(rawDir), $"Fixture {id} should have raw/ directory");
}
}
[Theory(Skip = "Fixtures not yet populated")]
[InlineData("T0")]
[InlineData("T1")]
[InlineData("T2")]
[InlineData("T3")]
public void FixtureTiers_AreDocumented(string tier)
{
// This is a documentation test to ensure all tiers are defined
var validTiers = new[] { "T0", "T1", "T2", "T3" };
Assert.Contains(tier, validTiers);
}
}

View File

@@ -0,0 +1,64 @@
// <copyright file="FixtureManifest.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Testing.FixtureHarvester.Models;
/// <summary>
/// Root manifest listing all fixture sets
/// </summary>
/// <param name="SchemaVersion">Manifest schema version</param>
/// <param name="Fixtures">Fixture sets grouped by category</param>
public sealed record FixtureManifest(
string SchemaVersion,
FixtureSets Fixtures
);
/// <summary>
/// Fixture sets grouped by category
/// </summary>
/// <param name="Sbom">SBOM fixture definitions</param>
/// <param name="Feeds">Feed snapshot fixtures</param>
/// <param name="Vex">VEX document fixtures</param>
public sealed record FixtureSets(
List<SbomFixtureDef> Sbom,
List<FeedFixtureDef> Feeds,
List<VexFixtureDef> Vex
);
/// <summary>
/// SBOM fixture definition
/// </summary>
public sealed record SbomFixtureDef
{
public required string Id { get; init; }
public required string Description { get; init; }
public required string Source { get; init; }
public string? ImageDigest { get; init; }
public string? ExpectedSbomHash { get; init; }
public required string RefreshPolicy { get; init; }
}
/// <summary>
/// Feed snapshot fixture definition
/// </summary>
public sealed record FeedFixtureDef
{
public required string Id { get; init; }
public required string Description { get; init; }
public required string Source { get; init; }
public int Count { get; init; }
public required string CapturedAt { get; init; }
public required string Sha256 { get; init; }
}
/// <summary>
/// VEX document fixture definition
/// </summary>
public sealed record VexFixtureDef
{
public required string Id { get; init; }
public required string Description { get; init; }
public required string Source { get; init; }
public required string Sha256 { get; init; }
}

View File

@@ -0,0 +1,60 @@
// <copyright file="FixtureMeta.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Testing.FixtureHarvester.Models;
/// <summary>
/// Per-fixture metadata
/// </summary>
public sealed record FixtureMeta
{
/// <summary>
/// Unique fixture identifier
/// </summary>
public required string Id { get; init; }
/// <summary>
/// Source type (local-build, url, api, etc.)
/// </summary>
public required string Source { get; init; }
/// <summary>
/// Optional source URL for remote fixtures
/// </summary>
public string? SourceUrl { get; init; }
/// <summary>
/// Timestamp when fixture was retrieved/created (ISO 8601 UTC)
/// </summary>
public required string RetrievedAt { get; init; }
/// <summary>
/// License identifier (SPDX format)
/// </summary>
public required string License { get; init; }
/// <summary>
/// SHA-256 hash of fixture content
/// </summary>
public required string Sha256 { get; init; }
/// <summary>
/// Refresh policy (manual, daily, weekly, etc.)
/// </summary>
public required string RefreshPolicy { get; init; }
/// <summary>
/// Optional notes about the fixture
/// </summary>
public string? Notes { get; init; }
/// <summary>
/// Fixture tier (T0-T3)
/// T0: Synthetic (generated, minimal)
/// T1: Spec examples (from standards)
/// T2: Real samples (production-like)
/// T3: Regression (captures actual bugs)
/// </summary>
public string? Tier { get; init; }
}

View File

@@ -0,0 +1,76 @@
// <copyright file="Program.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.CommandLine;
using StellaOps.Testing.FixtureHarvester.Commands;
namespace StellaOps.Testing.FixtureHarvester;
/// <summary>
/// Fixture Harvester CLI entry point
/// </summary>
internal static class Program
{
internal static async Task<int> Main(string[] args)
{
var rootCommand = new RootCommand("Stella Ops Fixture Harvester - Acquire, curate, and pin test fixtures");
// Harvest command
var harvestCommand = new Command("harvest", "Harvest and store a fixture with metadata");
var harvestTypeOption = new Option<string>(
"--type",
description: "Fixture type: sbom, feed, vex") { IsRequired = true };
var harvestIdOption = new Option<string>(
"--id",
description: "Unique fixture identifier") { IsRequired = true };
var harvestSourceOption = new Option<string>(
"--source",
description: "Source URL or path");
var harvestOutputOption = new Option<string>(
"--output",
description: "Output directory",
getDefaultValue: () => "src/__Tests/fixtures");
harvestCommand.AddOption(harvestTypeOption);
harvestCommand.AddOption(harvestIdOption);
harvestCommand.AddOption(harvestSourceOption);
harvestCommand.AddOption(harvestOutputOption);
harvestCommand.SetHandler(HarvestCommand.ExecuteAsync, harvestTypeOption, harvestIdOption, harvestSourceOption, harvestOutputOption);
// Validate command
var validateCommand = new Command("validate", "Validate fixtures against manifest");
var validatePathOption = new Option<string>(
"--path",
description: "Fixtures directory path",
getDefaultValue: () => "src/__Tests/fixtures");
validateCommand.AddOption(validatePathOption);
validateCommand.SetHandler(ValidateCommand.ExecuteAsync, validatePathOption);
// Regen command
var regenCommand = new Command("regen", "Regenerate expected outputs (manual, use with caution)");
var regenFixtureOption = new Option<string>(
"--fixture",
description: "Fixture ID to regenerate");
var regenAllOption = new Option<bool>(
"--all",
description: "Regenerate all fixtures",
getDefaultValue: () => false);
var regenConfirmOption = new Option<bool>(
"--confirm",
description: "Confirm regeneration",
getDefaultValue: () => false);
regenCommand.AddOption(regenFixtureOption);
regenCommand.AddOption(regenAllOption);
regenCommand.AddOption(regenConfirmOption);
regenCommand.SetHandler(RegenCommand.ExecuteAsync, regenFixtureOption, regenAllOption, regenConfirmOption);
rootCommand.AddCommand(harvestCommand);
rootCommand.AddCommand(validateCommand);
rootCommand.AddCommand(regenCommand);
return await rootCommand.InvokeAsync(args);
}
}