using System; using System.CommandLine; using System.Threading; using System.Threading.Tasks; using StellaOps.Cli.Configuration; namespace StellaOps.Cli.Commands; internal static class CommandFactory { public static RootCommand Create(IServiceProvider services, StellaOpsCliOptions options, CancellationToken cancellationToken) { var verboseOption = new Option("--verbose", new[] { "-v" }) { Description = "Enable verbose logging output." }; var root = new RootCommand("StellaOps command-line interface") { TreatUnmatchedTokensAsErrors = true }; root.Add(verboseOption); root.Add(BuildScannerCommand(services, verboseOption, cancellationToken)); root.Add(BuildScanCommand(services, options, verboseOption, cancellationToken)); root.Add(BuildDatabaseCommand(services, verboseOption, cancellationToken)); root.Add(BuildExcititorCommand(services, verboseOption, cancellationToken)); root.Add(BuildRuntimeCommand(services, verboseOption, cancellationToken)); root.Add(BuildAuthCommand(services, options, verboseOption, cancellationToken)); root.Add(BuildOfflineCommand(services, verboseOption, cancellationToken)); root.Add(BuildConfigCommand(options)); return root; } private static Command BuildScannerCommand(IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var scanner = new Command("scanner", "Manage scanner artifacts and lifecycle."); var download = new Command("download", "Download the latest scanner bundle."); var channelOption = new Option("--channel", new[] { "-c" }) { Description = "Scanner channel (stable, beta, nightly)." }; var outputOption = new Option("--output") { Description = "Optional output path for the downloaded bundle." }; var overwriteOption = new Option("--overwrite") { Description = "Overwrite existing bundle if present." }; var noInstallOption = new Option("--no-install") { Description = "Skip installing the scanner container after download." }; download.Add(channelOption); download.Add(outputOption); download.Add(overwriteOption); download.Add(noInstallOption); download.SetAction((parseResult, _) => { var channel = parseResult.GetValue(channelOption) ?? "stable"; var output = parseResult.GetValue(outputOption); var overwrite = parseResult.GetValue(overwriteOption); var install = !parseResult.GetValue(noInstallOption); var verbose = parseResult.GetValue(verboseOption); return CommandHandlers.HandleScannerDownloadAsync(services, channel, output, overwrite, install, verbose, cancellationToken); }); scanner.Add(download); return scanner; } private static Command BuildScanCommand(IServiceProvider services, StellaOpsCliOptions options, Option verboseOption, CancellationToken cancellationToken) { var scan = new Command("scan", "Execute scanners and manage scan outputs."); var run = new Command("run", "Execute a scanner bundle with the configured runner."); var runnerOption = new Option("--runner") { Description = "Execution runtime (dotnet, self, docker)." }; var entryOption = new Option("--entry") { Description = "Path to the scanner entrypoint or Docker image.", Required = true }; var targetOption = new Option("--target") { Description = "Directory to scan.", Required = true }; var argsArgument = new Argument("scanner-args") { Arity = ArgumentArity.ZeroOrMore }; run.Add(runnerOption); run.Add(entryOption); run.Add(targetOption); run.Add(argsArgument); run.SetAction((parseResult, _) => { var runner = parseResult.GetValue(runnerOption) ?? options.DefaultRunner; var entry = parseResult.GetValue(entryOption) ?? string.Empty; var target = parseResult.GetValue(targetOption) ?? string.Empty; var forwardedArgs = parseResult.GetValue(argsArgument) ?? Array.Empty(); var verbose = parseResult.GetValue(verboseOption); return CommandHandlers.HandleScannerRunAsync(services, runner, entry, target, forwardedArgs, verbose, cancellationToken); }); var upload = new Command("upload", "Upload completed scan results to the backend."); var fileOption = new Option("--file") { Description = "Path to the scan result artifact.", Required = true }; upload.Add(fileOption); upload.SetAction((parseResult, _) => { var file = parseResult.GetValue(fileOption) ?? string.Empty; var verbose = parseResult.GetValue(verboseOption); return CommandHandlers.HandleScanUploadAsync(services, file, verbose, cancellationToken); }); scan.Add(run); scan.Add(upload); return scan; } private static Command BuildDatabaseCommand(IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var db = new Command("db", "Trigger Concelier database operations via backend jobs."); var fetch = new Command("fetch", "Trigger connector fetch/parse/map stages."); var sourceOption = new Option("--source") { Description = "Connector source identifier (e.g. redhat, osv, vmware).", Required = true }; var stageOption = new Option("--stage") { Description = "Stage to trigger: fetch, parse, or map." }; var modeOption = new Option("--mode") { Description = "Optional connector-specific mode (init, resume, cursor)." }; fetch.Add(sourceOption); fetch.Add(stageOption); fetch.Add(modeOption); fetch.SetAction((parseResult, _) => { var source = parseResult.GetValue(sourceOption) ?? string.Empty; var stage = parseResult.GetValue(stageOption) ?? "fetch"; var mode = parseResult.GetValue(modeOption); var verbose = parseResult.GetValue(verboseOption); return CommandHandlers.HandleConnectorJobAsync(services, source, stage, mode, verbose, cancellationToken); }); var merge = new Command("merge", "Run canonical merge reconciliation."); merge.SetAction((parseResult, _) => { var verbose = parseResult.GetValue(verboseOption); return CommandHandlers.HandleMergeJobAsync(services, verbose, cancellationToken); }); var export = new Command("export", "Run Concelier export jobs."); var formatOption = new Option("--format") { Description = "Export format: json or trivy-db." }; var deltaOption = new Option("--delta") { Description = "Request a delta export when supported." }; var publishFullOption = new Option("--publish-full") { Description = "Override whether full exports push to ORAS (true/false)." }; var publishDeltaOption = new Option("--publish-delta") { Description = "Override whether delta exports push to ORAS (true/false)." }; var includeFullOption = new Option("--bundle-full") { Description = "Override whether offline bundles include full exports (true/false)." }; var includeDeltaOption = new Option("--bundle-delta") { Description = "Override whether offline bundles include delta exports (true/false)." }; export.Add(formatOption); export.Add(deltaOption); export.Add(publishFullOption); export.Add(publishDeltaOption); export.Add(includeFullOption); export.Add(includeDeltaOption); export.SetAction((parseResult, _) => { var format = parseResult.GetValue(formatOption) ?? "json"; var delta = parseResult.GetValue(deltaOption); var publishFull = parseResult.GetValue(publishFullOption); var publishDelta = parseResult.GetValue(publishDeltaOption); var includeFull = parseResult.GetValue(includeFullOption); var includeDelta = parseResult.GetValue(includeDeltaOption); var verbose = parseResult.GetValue(verboseOption); return CommandHandlers.HandleExportJobAsync(services, format, delta, publishFull, publishDelta, includeFull, includeDelta, verbose, cancellationToken); }); db.Add(fetch); db.Add(merge); db.Add(export); return db; } private static Command BuildExcititorCommand(IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var excititor = new Command("excititor", "Manage Excititor ingest, exports, and reconciliation workflows."); var init = new Command("init", "Initialize Excititor ingest state."); var initProviders = new Option("--provider", new[] { "-p" }) { Description = "Optional provider identifier(s) to initialize.", Arity = ArgumentArity.ZeroOrMore }; var resumeOption = new Option("--resume") { Description = "Resume ingest from the last persisted checkpoint instead of starting fresh." }; init.Add(initProviders); init.Add(resumeOption); init.SetAction((parseResult, _) => { var providers = parseResult.GetValue(initProviders) ?? Array.Empty(); var resume = parseResult.GetValue(resumeOption); var verbose = parseResult.GetValue(verboseOption); return CommandHandlers.HandleExcititorInitAsync(services, providers, resume, verbose, cancellationToken); }); var pull = new Command("pull", "Trigger Excititor ingest for configured providers."); var pullProviders = new Option("--provider", new[] { "-p" }) { Description = "Optional provider identifier(s) to ingest.", Arity = ArgumentArity.ZeroOrMore }; var sinceOption = new Option("--since") { Description = "Optional ISO-8601 timestamp to begin the ingest window." }; var windowOption = new Option("--window") { Description = "Optional window duration (e.g. 24:00:00)." }; var forceOption = new Option("--force") { Description = "Force ingestion even if the backend reports no pending work." }; pull.Add(pullProviders); pull.Add(sinceOption); pull.Add(windowOption); pull.Add(forceOption); pull.SetAction((parseResult, _) => { var providers = parseResult.GetValue(pullProviders) ?? Array.Empty(); var since = parseResult.GetValue(sinceOption); var window = parseResult.GetValue(windowOption); var force = parseResult.GetValue(forceOption); var verbose = parseResult.GetValue(verboseOption); return CommandHandlers.HandleExcititorPullAsync(services, providers, since, window, force, verbose, cancellationToken); }); var resume = new Command("resume", "Resume Excititor ingest using a checkpoint token."); var resumeProviders = new Option("--provider", new[] { "-p" }) { Description = "Optional provider identifier(s) to resume.", Arity = ArgumentArity.ZeroOrMore }; var checkpointOption = new Option("--checkpoint") { Description = "Optional checkpoint identifier to resume from." }; resume.Add(resumeProviders); resume.Add(checkpointOption); resume.SetAction((parseResult, _) => { var providers = parseResult.GetValue(resumeProviders) ?? Array.Empty(); var checkpoint = parseResult.GetValue(checkpointOption); var verbose = parseResult.GetValue(verboseOption); return CommandHandlers.HandleExcititorResumeAsync(services, providers, checkpoint, verbose, cancellationToken); }); var list = new Command("list-providers", "List Excititor providers and their ingest status."); var includeDisabledOption = new Option("--include-disabled") { Description = "Include disabled providers in the listing." }; list.Add(includeDisabledOption); list.SetAction((parseResult, _) => { var includeDisabled = parseResult.GetValue(includeDisabledOption); var verbose = parseResult.GetValue(verboseOption); return CommandHandlers.HandleExcititorListProvidersAsync(services, includeDisabled, verbose, cancellationToken); }); var export = new Command("export", "Trigger Excititor export generation."); var formatOption = new Option("--format") { Description = "Export format (e.g. openvex, json)." }; var exportDeltaOption = new Option("--delta") { Description = "Request a delta export when supported." }; var exportScopeOption = new Option("--scope") { Description = "Optional policy scope or tenant identifier." }; var exportSinceOption = new Option("--since") { Description = "Optional ISO-8601 timestamp to restrict export contents." }; var exportProviderOption = new Option("--provider") { Description = "Optional provider identifier when requesting targeted exports." }; var exportOutputOption = new Option("--output") { Description = "Optional path to download the export artifact." }; export.Add(formatOption); export.Add(exportDeltaOption); export.Add(exportScopeOption); export.Add(exportSinceOption); export.Add(exportProviderOption); export.Add(exportOutputOption); export.SetAction((parseResult, _) => { var format = parseResult.GetValue(formatOption) ?? "openvex"; var delta = parseResult.GetValue(exportDeltaOption); var scope = parseResult.GetValue(exportScopeOption); var since = parseResult.GetValue(exportSinceOption); var provider = parseResult.GetValue(exportProviderOption); var output = parseResult.GetValue(exportOutputOption); var verbose = parseResult.GetValue(verboseOption); return CommandHandlers.HandleExcititorExportAsync(services, format, delta, scope, since, provider, output, verbose, cancellationToken); }); var backfill = new Command("backfill-statements", "Replay historical raw documents into Excititor statements."); var backfillRetrievedSinceOption = new Option("--retrieved-since") { Description = "Only process raw documents retrieved on or after the provided ISO-8601 timestamp." }; var backfillForceOption = new Option("--force") { Description = "Reprocess documents even if statements already exist." }; var backfillBatchSizeOption = new Option("--batch-size") { Description = "Number of raw documents to fetch per batch (default 100)." }; var backfillMaxDocumentsOption = new Option("--max-documents") { Description = "Optional maximum number of raw documents to process." }; backfill.Add(backfillRetrievedSinceOption); backfill.Add(backfillForceOption); backfill.Add(backfillBatchSizeOption); backfill.Add(backfillMaxDocumentsOption); backfill.SetAction((parseResult, _) => { var retrievedSince = parseResult.GetValue(backfillRetrievedSinceOption); var force = parseResult.GetValue(backfillForceOption); var batchSize = parseResult.GetValue(backfillBatchSizeOption); if (batchSize <= 0) { batchSize = 100; } var maxDocuments = parseResult.GetValue(backfillMaxDocumentsOption); var verbose = parseResult.GetValue(verboseOption); return CommandHandlers.HandleExcititorBackfillStatementsAsync( services, retrievedSince, force, batchSize, maxDocuments, verbose, cancellationToken); }); var verify = new Command("verify", "Verify Excititor exports or attestations."); var exportIdOption = new Option("--export-id") { Description = "Export identifier to verify." }; var digestOption = new Option("--digest") { Description = "Expected digest for the export or attestation." }; var attestationOption = new Option("--attestation") { Description = "Path to a local attestation file to verify (base64 content will be uploaded)." }; verify.Add(exportIdOption); verify.Add(digestOption); verify.Add(attestationOption); verify.SetAction((parseResult, _) => { var exportId = parseResult.GetValue(exportIdOption); var digest = parseResult.GetValue(digestOption); var attestation = parseResult.GetValue(attestationOption); var verbose = parseResult.GetValue(verboseOption); return CommandHandlers.HandleExcititorVerifyAsync(services, exportId, digest, attestation, verbose, cancellationToken); }); var reconcile = new Command("reconcile", "Trigger Excititor reconciliation against canonical advisories."); var reconcileProviders = new Option("--provider", new[] { "-p" }) { Description = "Optional provider identifier(s) to reconcile.", Arity = ArgumentArity.ZeroOrMore }; var maxAgeOption = new Option("--max-age") { Description = "Optional maximum age window (e.g. 7.00:00:00)." }; reconcile.Add(reconcileProviders); reconcile.Add(maxAgeOption); reconcile.SetAction((parseResult, _) => { var providers = parseResult.GetValue(reconcileProviders) ?? Array.Empty(); var maxAge = parseResult.GetValue(maxAgeOption); var verbose = parseResult.GetValue(verboseOption); return CommandHandlers.HandleExcititorReconcileAsync(services, providers, maxAge, verbose, cancellationToken); }); excititor.Add(init); excititor.Add(pull); excititor.Add(resume); excititor.Add(list); excititor.Add(export); excititor.Add(backfill); excititor.Add(verify); excititor.Add(reconcile); return excititor; } private static Command BuildRuntimeCommand(IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var runtime = new Command("runtime", "Interact with runtime admission policy APIs."); var policy = new Command("policy", "Runtime policy operations."); var test = new Command("test", "Evaluate runtime policy decisions for image digests."); var namespaceOption = new Option("--namespace", new[] { "--ns" }) { Description = "Namespace or logical scope for the evaluation." }; var imageOption = new Option("--image", new[] { "-i", "--images" }) { Description = "Image digests to evaluate (repeatable).", Arity = ArgumentArity.ZeroOrMore }; var fileOption = new Option("--file", new[] { "-f" }) { Description = "Path to a file containing image digests (one per line)." }; var labelOption = new Option("--label", new[] { "-l", "--labels" }) { Description = "Pod labels in key=value format (repeatable).", Arity = ArgumentArity.ZeroOrMore }; var jsonOption = new Option("--json") { Description = "Emit the raw JSON response." }; test.Add(namespaceOption); test.Add(imageOption); test.Add(fileOption); test.Add(labelOption); test.Add(jsonOption); test.SetAction((parseResult, _) => { var nsValue = parseResult.GetValue(namespaceOption); var images = parseResult.GetValue(imageOption) ?? Array.Empty(); var file = parseResult.GetValue(fileOption); var labels = parseResult.GetValue(labelOption) ?? Array.Empty(); var outputJson = parseResult.GetValue(jsonOption); var verbose = parseResult.GetValue(verboseOption); return CommandHandlers.HandleRuntimePolicyTestAsync( services, nsValue, images, file, labels, outputJson, verbose, cancellationToken); }); policy.Add(test); runtime.Add(policy); return runtime; } private static Command BuildAuthCommand(IServiceProvider services, StellaOpsCliOptions options, Option verboseOption, CancellationToken cancellationToken) { var auth = new Command("auth", "Manage authentication with StellaOps Authority."); var login = new Command("login", "Acquire and cache access tokens using the configured credentials."); var forceOption = new Option("--force") { Description = "Ignore existing cached tokens and force re-authentication." }; login.Add(forceOption); login.SetAction((parseResult, _) => { var verbose = parseResult.GetValue(verboseOption); var force = parseResult.GetValue(forceOption); return CommandHandlers.HandleAuthLoginAsync(services, options, verbose, force, cancellationToken); }); var logout = new Command("logout", "Remove cached tokens for the current credentials."); logout.SetAction((parseResult, _) => { var verbose = parseResult.GetValue(verboseOption); return CommandHandlers.HandleAuthLogoutAsync(services, options, verbose, cancellationToken); }); var status = new Command("status", "Display cached token status."); status.SetAction((parseResult, _) => { var verbose = parseResult.GetValue(verboseOption); return CommandHandlers.HandleAuthStatusAsync(services, options, verbose, cancellationToken); }); var whoami = new Command("whoami", "Display cached token claims (subject, scopes, expiry)."); whoami.SetAction((parseResult, _) => { var verbose = parseResult.GetValue(verboseOption); return CommandHandlers.HandleAuthWhoAmIAsync(services, options, verbose, cancellationToken); }); var revoke = new Command("revoke", "Manage revocation exports."); var export = new Command("export", "Export the revocation bundle and signature to disk."); var outputOption = new Option("--output") { Description = "Directory to write exported revocation files (defaults to current directory)." }; export.Add(outputOption); export.SetAction((parseResult, _) => { var output = parseResult.GetValue(outputOption); var verbose = parseResult.GetValue(verboseOption); return CommandHandlers.HandleAuthRevokeExportAsync(services, options, output, verbose, cancellationToken); }); revoke.Add(export); var verify = new Command("verify", "Verify a revocation bundle against a detached JWS signature."); var bundleOption = new Option("--bundle") { Description = "Path to the revocation-bundle.json file." }; var signatureOption = new Option("--signature") { Description = "Path to the revocation-bundle.json.jws file." }; var keyOption = new Option("--key") { Description = "Path to the PEM-encoded public/private key used for verification." }; verify.Add(bundleOption); verify.Add(signatureOption); verify.Add(keyOption); verify.SetAction((parseResult, _) => { var bundlePath = parseResult.GetValue(bundleOption) ?? string.Empty; var signaturePath = parseResult.GetValue(signatureOption) ?? string.Empty; var keyPath = parseResult.GetValue(keyOption) ?? string.Empty; var verbose = parseResult.GetValue(verboseOption); return CommandHandlers.HandleAuthRevokeVerifyAsync(bundlePath, signaturePath, keyPath, verbose, cancellationToken); }); revoke.Add(verify); auth.Add(login); auth.Add(logout); auth.Add(status); auth.Add(whoami); auth.Add(revoke); return auth; } private static Command BuildOfflineCommand(IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var offline = new Command("offline", "Offline kit workflows and utilities."); var kit = new Command("kit", "Manage offline kit bundles."); var pull = new Command("pull", "Download the latest offline kit bundle."); var bundleIdOption = new Option("--bundle-id") { Description = "Optional bundle identifier. Defaults to the latest available." }; var destinationOption = new Option("--destination") { Description = "Directory to store downloaded bundles (defaults to the configured offline kits directory)." }; var overwriteOption = new Option("--overwrite") { Description = "Overwrite existing files even if checksums match." }; var noResumeOption = new Option("--no-resume") { Description = "Disable resuming partial downloads." }; pull.Add(bundleIdOption); pull.Add(destinationOption); pull.Add(overwriteOption); pull.Add(noResumeOption); pull.SetAction((parseResult, _) => { var bundleId = parseResult.GetValue(bundleIdOption); var destination = parseResult.GetValue(destinationOption); var overwrite = parseResult.GetValue(overwriteOption); var resume = !parseResult.GetValue(noResumeOption); var verbose = parseResult.GetValue(verboseOption); return CommandHandlers.HandleOfflineKitPullAsync(services, bundleId, destination, overwrite, resume, verbose, cancellationToken); }); var import = new Command("import", "Upload an offline kit bundle to the backend."); var bundleArgument = new Argument("bundle") { Description = "Path to the offline kit tarball (.tgz)." }; var manifestOption = new Option("--manifest") { Description = "Offline manifest JSON path (defaults to metadata or sibling file)." }; var bundleSignatureOption = new Option("--bundle-signature") { Description = "Detached signature for the offline bundle (e.g. .sig)." }; var manifestSignatureOption = new Option("--manifest-signature") { Description = "Detached signature for the offline manifest (e.g. .jws)." }; import.Add(bundleArgument); import.Add(manifestOption); import.Add(bundleSignatureOption); import.Add(manifestSignatureOption); import.SetAction((parseResult, _) => { var bundlePath = parseResult.GetValue(bundleArgument) ?? string.Empty; var manifest = parseResult.GetValue(manifestOption); var bundleSignature = parseResult.GetValue(bundleSignatureOption); var manifestSignature = parseResult.GetValue(manifestSignatureOption); var verbose = parseResult.GetValue(verboseOption); return CommandHandlers.HandleOfflineKitImportAsync(services, bundlePath, manifest, bundleSignature, manifestSignature, verbose, cancellationToken); }); var status = new Command("status", "Display offline kit installation status."); var jsonOption = new Option("--json") { Description = "Emit status as JSON." }; status.Add(jsonOption); status.SetAction((parseResult, _) => { var asJson = parseResult.GetValue(jsonOption); var verbose = parseResult.GetValue(verboseOption); return CommandHandlers.HandleOfflineKitStatusAsync(services, asJson, verbose, cancellationToken); }); kit.Add(pull); kit.Add(import); kit.Add(status); offline.Add(kit); return offline; } private static Command BuildConfigCommand(StellaOpsCliOptions options) { var config = new Command("config", "Inspect CLI configuration state."); var show = new Command("show", "Display resolved configuration values."); show.SetAction((_, _) => { var authority = options.Authority ?? new StellaOpsCliAuthorityOptions(); var lines = new[] { $"Backend URL: {MaskIfEmpty(options.BackendUrl)}", $"API Key: {DescribeSecret(options.ApiKey)}", $"Scanner Cache: {options.ScannerCacheDirectory}", $"Results Directory: {options.ResultsDirectory}", $"Default Runner: {options.DefaultRunner}", $"Authority URL: {MaskIfEmpty(authority.Url)}", $"Authority Client ID: {MaskIfEmpty(authority.ClientId)}", $"Authority Client Secret: {DescribeSecret(authority.ClientSecret ?? string.Empty)}", $"Authority Username: {MaskIfEmpty(authority.Username)}", $"Authority Password: {DescribeSecret(authority.Password ?? string.Empty)}", $"Authority Scope: {MaskIfEmpty(authority.Scope)}", $"Authority Token Cache: {MaskIfEmpty(authority.TokenCacheDirectory ?? string.Empty)}" }; foreach (var line in lines) { Console.WriteLine(line); } return Task.CompletedTask; }); config.Add(show); return config; } private static string MaskIfEmpty(string value) => string.IsNullOrWhiteSpace(value) ? "" : value; private static string DescribeSecret(string value) { if (string.IsNullOrWhiteSpace(value)) { return ""; } return value.Length switch { <= 4 => "****", _ => $"{value[..2]}***{value[^2..]}" }; } }