using System.CommandLine; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Spectre.Console; using StellaOps.Symbols.Client; using StellaOps.Symbols.Core.Models; using StellaOps.Symbols.Ingestor.Cli; return await RunAsync(args).ConfigureAwait(false); static async Task RunAsync(string[] args) { // Build command structure var rootCommand = new RootCommand("StellaOps Symbol Ingestor CLI - Ingest and publish symbol manifests"); // Global options var verboseOption = new Option("--verbose") { Description = "Enable verbose output" }; var dryRunOption = new Option("--dry-run") { Description = "Dry run mode - generate manifest without uploading" }; rootCommand.Add(verboseOption); rootCommand.Add(dryRunOption); // ingest command var ingestCommand = new Command("ingest", "Ingest symbols from a binary file"); var binaryOption = new Option("--binary") { Description = "Path to the binary file", Required = true }; var debugOption = new Option("--debug") { Description = "Path to debug symbols file (PDB, DWARF, dSYM)" }; var debugIdOption = new Option("--debug-id") { Description = "Override debug ID" }; var codeIdOption = new Option("--code-id") { Description = "Override code ID" }; var nameOption = new Option("--name") { Description = "Override binary name" }; var platformOption = new Option("--platform") { Description = "Platform identifier (linux-x64, win-x64, osx-arm64, etc.)" }; var outputOption = new Option("--output") { Description = "Output directory for manifest files (default: current directory)" }; var serverOption = new Option("--server") { Description = "Symbols server URL for upload" }; var tenantOption = new Option("--tenant") { Description = "Tenant ID for multi-tenant uploads" }; ingestCommand.Add(binaryOption); ingestCommand.Add(debugOption); ingestCommand.Add(debugIdOption); ingestCommand.Add(codeIdOption); ingestCommand.Add(nameOption); ingestCommand.Add(platformOption); ingestCommand.Add(outputOption); ingestCommand.Add(serverOption); ingestCommand.Add(tenantOption); ingestCommand.SetAction(async (parseResult, cancellationToken) => { var verbose = parseResult.GetValue(verboseOption); var dryRun = parseResult.GetValue(dryRunOption); var binary = parseResult.GetValue(binaryOption)!; var debug = parseResult.GetValue(debugOption); var debugId = parseResult.GetValue(debugIdOption); var codeId = parseResult.GetValue(codeIdOption); var name = parseResult.GetValue(nameOption); var platform = parseResult.GetValue(platformOption); var output = parseResult.GetValue(outputOption) ?? "."; var server = parseResult.GetValue(serverOption); var tenant = parseResult.GetValue(tenantOption); var options = new SymbolIngestOptions { BinaryPath = binary, DebugPath = debug, DebugId = debugId, CodeId = codeId, BinaryName = name, Platform = platform, OutputDir = output, ServerUrl = server, TenantId = tenant, Verbose = verbose, DryRun = dryRun }; await IngestAsync(options, cancellationToken).ConfigureAwait(false); }); // upload command var uploadCommand = new Command("upload", "Upload a symbol manifest to the server"); var manifestOption = new Option("--manifest") { Description = "Path to manifest JSON file", Required = true }; var uploadServerOption = new Option("--server") { Description = "Symbols server URL", Required = true }; var uploadTenantOption = new Option("--tenant") { Description = "Tenant ID for multi-tenant uploads" }; uploadCommand.Add(manifestOption); uploadCommand.Add(uploadServerOption); uploadCommand.Add(uploadTenantOption); uploadCommand.SetAction(async (parseResult, cancellationToken) => { var verbose = parseResult.GetValue(verboseOption); var dryRun = parseResult.GetValue(dryRunOption); var manifestPath = parseResult.GetValue(manifestOption)!; var server = parseResult.GetValue(uploadServerOption)!; var tenant = parseResult.GetValue(uploadTenantOption); await UploadAsync(manifestPath, server, tenant, verbose, dryRun, cancellationToken).ConfigureAwait(false); }); // verify command var verifyCommand = new Command("verify", "Verify a symbol manifest or DSSE envelope"); var verifyPathOption = new Option("--path") { Description = "Path to manifest or DSSE file", Required = true }; verifyCommand.Add(verifyPathOption); verifyCommand.SetAction(async (parseResult, cancellationToken) => { var verbose = parseResult.GetValue(verboseOption); var path = parseResult.GetValue(verifyPathOption)!; await VerifyAsync(path, verbose, cancellationToken).ConfigureAwait(false); }); // health command var healthCommand = new Command("health", "Check symbols server health"); var healthServerOption = new Option("--server") { Description = "Symbols server URL", Required = true }; healthCommand.Add(healthServerOption); healthCommand.SetAction(async (parseResult, cancellationToken) => { var server = parseResult.GetValue(healthServerOption)!; await HealthCheckAsync(server, cancellationToken).ConfigureAwait(false); }); rootCommand.Add(ingestCommand); rootCommand.Add(uploadCommand); rootCommand.Add(verifyCommand); rootCommand.Add(healthCommand); using var cts = new CancellationTokenSource(); Console.CancelKeyPress += (_, eventArgs) => { eventArgs.Cancel = true; cts.Cancel(); }; var parseResult = rootCommand.Parse(args); return await parseResult.InvokeAsync(cts.Token).ConfigureAwait(false); } // Command implementations static async Task IngestAsync(SymbolIngestOptions options, CancellationToken cancellationToken) { AnsiConsole.MarkupLine("[bold blue]StellaOps Symbol Ingestor[/]"); AnsiConsole.WriteLine(); // Validate binary exists if (!File.Exists(options.BinaryPath)) { AnsiConsole.MarkupLine($"[red]Error:[/] Binary file not found: {options.BinaryPath}"); Environment.ExitCode = 1; return; } // Detect format var format = SymbolExtractor.DetectFormat(options.BinaryPath); AnsiConsole.MarkupLine($"[green]Binary format:[/] {format}"); if (format == BinaryFormat.Unknown) { AnsiConsole.MarkupLine("[red]Error:[/] Unknown binary format"); Environment.ExitCode = 1; return; } // Create manifest SymbolManifest manifest; try { manifest = SymbolExtractor.CreateManifest(options.BinaryPath, options.DebugPath, options); } catch (Exception ex) { AnsiConsole.MarkupLine($"[red]Error creating manifest:[/] {ex.Message}"); Environment.ExitCode = 1; return; } AnsiConsole.MarkupLine($"[green]Debug ID:[/] {manifest.DebugId}"); if (!string.IsNullOrEmpty(manifest.CodeId)) AnsiConsole.MarkupLine($"[green]Code ID:[/] {manifest.CodeId}"); AnsiConsole.MarkupLine($"[green]Binary name:[/] {manifest.BinaryName}"); AnsiConsole.MarkupLine($"[green]Platform:[/] {manifest.Platform}"); AnsiConsole.MarkupLine($"[green]Symbol count:[/] {manifest.Symbols.Count}"); // Write manifest var manifestPath = await ManifestWriter.WriteJsonAsync(manifest, options.OutputDir, cancellationToken) .ConfigureAwait(false); AnsiConsole.MarkupLine($"[green]Manifest written:[/] {manifestPath}"); // Upload if server specified and not dry-run if (!string.IsNullOrEmpty(options.ServerUrl) && !options.DryRun) { await UploadAsync(manifestPath, options.ServerUrl, options.TenantId, options.Verbose, false, cancellationToken) .ConfigureAwait(false); } else if (options.DryRun) { AnsiConsole.MarkupLine("[yellow]Dry run mode - skipping upload[/]"); } AnsiConsole.WriteLine(); AnsiConsole.MarkupLine("[bold green]Done![/]"); } static async Task UploadAsync( string manifestPath, string serverUrl, string? tenantId, bool verbose, bool dryRun, CancellationToken cancellationToken) { if (dryRun) { AnsiConsole.MarkupLine("[yellow]Dry run mode - would upload to:[/] {0}", serverUrl); return; } var manifest = await ManifestWriter.ReadJsonAsync(manifestPath, cancellationToken).ConfigureAwait(false); if (manifest is null) { AnsiConsole.MarkupLine($"[red]Error:[/] Failed to read manifest: {manifestPath}"); Environment.ExitCode = 1; return; } // Set up HTTP client and symbols client var services = new ServiceCollection(); services.AddLogging(builder => { if (verbose) builder.AddConsole().SetMinimumLevel(LogLevel.Debug); }); services.AddSymbolsClient(opts => { opts.BaseUrl = serverUrl; opts.TenantId = tenantId; }); await using var provider = services.BuildServiceProvider(); var client = provider.GetRequiredService(); AnsiConsole.MarkupLine($"[blue]Uploading to:[/] {serverUrl}"); try { var result = await client.UploadManifestAsync(manifest, cancellationToken).ConfigureAwait(false); AnsiConsole.MarkupLine($"[green]Uploaded:[/] {result.ManifestId}"); AnsiConsole.MarkupLine($"[green]Symbol count:[/] {result.SymbolCount}"); if (!string.IsNullOrEmpty(result.BlobUri)) AnsiConsole.MarkupLine($"[green]Blob URI:[/] {result.BlobUri}"); } catch (HttpRequestException ex) { AnsiConsole.MarkupLine($"[red]Upload failed:[/] {ex.Message}"); Environment.ExitCode = 1; } } static Task VerifyAsync(string path, bool verbose, CancellationToken cancellationToken) { if (!File.Exists(path)) { AnsiConsole.MarkupLine($"[red]Error:[/] File not found: {path}"); Environment.ExitCode = 1; return Task.CompletedTask; } var json = File.ReadAllText(path); // Check if it's a DSSE envelope or a plain manifest if (json.Contains("\"payloadType\"") && json.Contains("\"signatures\"")) { AnsiConsole.MarkupLine("[blue]Verifying DSSE envelope...[/]"); var envelope = JsonSerializer.Deserialize(json); if (envelope is null) { AnsiConsole.MarkupLine("[red]Error:[/] Invalid DSSE envelope"); Environment.ExitCode = 1; return Task.CompletedTask; } AnsiConsole.MarkupLine($"[green]Payload type:[/] {envelope.PayloadType}"); AnsiConsole.MarkupLine($"[green]Signatures:[/] {envelope.Signatures.Count}"); foreach (var sig in envelope.Signatures) { AnsiConsole.MarkupLine($" [dim]Key ID:[/] {sig.KeyId}"); AnsiConsole.MarkupLine($" [dim]Signature:[/] {sig.Sig[..Math.Min(32, sig.Sig.Length)]}..."); } // Decode and parse payload try { var payloadJson = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(envelope.Payload)); var manifest = JsonSerializer.Deserialize(payloadJson); if (manifest is not null) { AnsiConsole.MarkupLine($"[green]Debug ID:[/] {manifest.DebugId}"); AnsiConsole.MarkupLine($"[green]Binary name:[/] {manifest.BinaryName}"); } } catch { AnsiConsole.MarkupLine("[yellow]Warning:[/] Could not decode payload"); } } else { AnsiConsole.MarkupLine("[blue]Verifying manifest...[/]"); var manifest = JsonSerializer.Deserialize(json); if (manifest is null) { AnsiConsole.MarkupLine("[red]Error:[/] Invalid manifest"); Environment.ExitCode = 1; return Task.CompletedTask; } AnsiConsole.MarkupLine($"[green]Manifest ID:[/] {manifest.ManifestId}"); AnsiConsole.MarkupLine($"[green]Debug ID:[/] {manifest.DebugId}"); AnsiConsole.MarkupLine($"[green]Binary name:[/] {manifest.BinaryName}"); AnsiConsole.MarkupLine($"[green]Format:[/] {manifest.Format}"); AnsiConsole.MarkupLine($"[green]Symbol count:[/] {manifest.Symbols.Count}"); AnsiConsole.MarkupLine($"[green]Created:[/] {manifest.CreatedAt:O}"); } AnsiConsole.MarkupLine("[bold green]Verification passed![/]"); return Task.CompletedTask; } static async Task HealthCheckAsync(string serverUrl, CancellationToken cancellationToken) { var services = new ServiceCollection(); services.AddLogging(); services.AddSymbolsClient(opts => opts.BaseUrl = serverUrl); await using var provider = services.BuildServiceProvider(); var client = provider.GetRequiredService(); AnsiConsole.MarkupLine($"[blue]Checking health:[/] {serverUrl}"); try { var health = await client.GetHealthAsync(cancellationToken).ConfigureAwait(false); AnsiConsole.MarkupLine($"[green]Status:[/] {health.Status}"); AnsiConsole.MarkupLine($"[green]Version:[/] {health.Version}"); AnsiConsole.MarkupLine($"[green]Timestamp:[/] {health.Timestamp:O}"); if (health.TotalManifests.HasValue) AnsiConsole.MarkupLine($"[green]Total manifests:[/] {health.TotalManifests}"); if (health.TotalSymbols.HasValue) AnsiConsole.MarkupLine($"[green]Total symbols:[/] {health.TotalSymbols}"); } catch (HttpRequestException ex) { AnsiConsole.MarkupLine($"[red]Health check failed:[/] {ex.Message}"); Environment.ExitCode = 1; } }