SDT/Program.cs
2026-03-04 16:40:57 -06:00

366 lines
13 KiB
C#

using Sdt.Config;
using Sdt.Core;
using Sdt.Bridge;
using Sdt.Tui;
using Spectre.Console;
try
{
var cliArgs = Environment.GetCommandLineArgs().Skip(1).ToArray();
if (TryGetWorkspaceCommand(cliArgs, out var workspaceCommand))
return RunWorkspaceCommand(cliArgs, workspaceCommand);
if (TryGetBridgeCommand(cliArgs, out var bridgeCommand))
return await RunBridgeCommandAsync(cliArgs, bridgeCommand);
if (TryGetHeadlessCommand(cliArgs, out var headlessKind))
{
var exit = await RunHeadlessAsync(cliArgs, headlessKind);
return exit;
}
// ── Workspace + project discovery ────────────────────────────────────────
var workspaceResult = WorkspaceLoader.FindAndLoad();
var projectResult = ConfigLoader.FindAndLoad();
var forceInit = cliArgs.Any(a => string.Equals(a, "init", StringComparison.OrdinalIgnoreCase) ||
string.Equals(a, "--init", StringComparison.OrdinalIgnoreCase));
if (forceInit)
{
var scan = ConfigBootstrapper.Scan(Directory.GetCurrentDirectory());
var generated = ConfigBootstrapper.BuildDefaultConfig(scan);
var path = ConfigBootstrapper.WriteDefaultConfig(scan.ProjectRoot, generated, overwrite: false);
AnsiConsole.MarkupLine(Theme.Ok($"Initialized config at {path}"));
projectResult = ConfigLoader.FindAndLoad(scan.ProjectRoot);
}
if (projectResult is null)
{
AnsiConsole.MarkupLine($"[bold {Theme.Red}]SDT:[/] [{Theme.Amber}]No SDT project config found[/] (expected `sdtconfig-*.json` or `devtool.json`) in current directory or any parent.");
var bootstrap = AnsiConsole.Confirm(
$"[{Theme.Amber}]Generate a default SDT project config for this project now?[/]",
defaultValue: true);
if (!bootstrap)
{
AnsiConsole.MarkupLine(Theme.Faint("Create an sdtconfig-<ProjectName>.json (or devtool.json) in your project root to get started."));
return 1;
}
var scan = ConfigBootstrapper.Scan(Directory.GetCurrentDirectory());
AnsiConsole.MarkupLine(Theme.Faint($"Detected project root: {scan.ProjectRoot}"));
if (scan.ToolFamilies.Count > 0)
AnsiConsole.MarkupLine(Theme.Faint($"Detected tool families: {string.Join(", ", scan.ToolFamilies)}"));
var generated = ConfigBootstrapper.BuildDefaultConfig(scan);
var preview = ConfigBootstrapper.ToJson(generated);
AnsiConsole.Write(new Panel(Markup.Escape(preview)).Header("Generated project config preview").BorderStyle(Theme.DimStyle));
var confirmWrite = AnsiConsole.Confirm(
$"[{Theme.Amber}]Write generated project config to {scan.ProjectRoot}?[/]",
defaultValue: true);
if (!confirmWrite)
return 1;
var path = ConfigBootstrapper.WriteDefaultConfig(scan.ProjectRoot, generated, overwrite: false);
AnsiConsole.MarkupLine(Theme.Ok($"Created {path}"));
projectResult = ConfigLoader.FindAndLoad(scan.ProjectRoot);
if (projectResult is null)
{
AnsiConsole.MarkupLine(Theme.Fail("Generated config could not be reloaded."));
return 1;
}
}
// ── Main run loop (handles workspace project switching) ────────────────
var currentLoaded = projectResult;
var (workspace, workspaceRoot) = workspaceResult.HasValue
? (workspaceResult.Value.Config, workspaceResult.Value.WorkspaceRoot)
: ((WorkspaceConfig?)null, (string?)null);
string? pendingWorkflowId = null;
while (true)
{
var app = new App(
currentLoaded.Config,
currentLoaded.ProjectRoot,
currentLoaded.Warnings,
workspace,
workspaceRoot,
pendingWorkflowId);
pendingWorkflowId = null;
var result = await app.RunAsync();
if (result.Reason == AppExitReason.Quit)
break;
if (result.Reason == AppExitReason.SwitchProject && result.NewProjectRoot is not null)
{
LoadedProjectConfig? loaded;
try
{
loaded = ConfigLoader.FindAndLoad(result.NewProjectRoot);
}
catch (Exception ex)
{
AnsiConsole.MarkupLine(Theme.Fail(ex.Message));
AnsiConsole.MarkupLine(Theme.Faint("Press any key to stay on current project..."));
Console.ReadKey(intercept: true);
continue;
}
if (loaded is null)
{
AnsiConsole.MarkupLine(Theme.Fail($"No SDT project config found at: {result.NewProjectRoot}"));
AnsiConsole.MarkupLine(Theme.Faint("Press any key to stay on current project..."));
Console.ReadKey(intercept: true);
continue;
}
currentLoaded = loaded;
pendingWorkflowId = result.RunWorkflowId;
}
}
return 0;
}
catch (Exception ex)
{
var message = ex.Message;
var isExpectedMigrationError =
ex is InvalidOperationException &&
message.Contains("Legacy targets-only config detected", StringComparison.OrdinalIgnoreCase);
AnsiConsole.MarkupLine(Theme.Fail($"Fatal: {message}"));
if (isExpectedMigrationError)
{
var configPath = ConfigLoader.FindConfigPath();
if (!string.IsNullOrWhiteSpace(configPath))
{
var migrate = AnsiConsole.Confirm(
$"[{Theme.Amber}]Apply automatic migration now (creates backup + converts targets -> workflows)?[/]",
defaultValue: true);
if (migrate)
{
var result = ConfigLoader.ApplyLegacyTargetMigration(configPath, createBackup: true);
if (result.Success)
{
AnsiConsole.MarkupLine(Theme.Ok("Migration applied successfully."));
if (!string.IsNullOrWhiteSpace(result.BackupPath))
AnsiConsole.MarkupLine(Theme.Faint($"Backup: {result.BackupPath}"));
AnsiConsole.MarkupLine(Theme.Faint("Run sdt.exe again in strict mode."));
}
else
{
AnsiConsole.MarkupLine(Theme.Fail($"Migration failed: {result.Message}"));
}
}
}
}
if (!isExpectedMigrationError)
AnsiConsole.WriteException(ex, ExceptionFormats.ShortenEverything);
return 1;
}
static bool TryGetHeadlessCommand(IReadOnlyList<string> cliArgs, out string kind)
{
kind = "";
if (cliArgs.Count == 0)
return false;
if (string.Equals(cliArgs[0], "run", StringComparison.OrdinalIgnoreCase))
{
kind = "run";
return true;
}
if (string.Equals(cliArgs[0], "debug", StringComparison.OrdinalIgnoreCase))
{
kind = "debug";
return true;
}
return false;
}
static bool TryGetWorkspaceCommand(IReadOnlyList<string> cliArgs, out string command)
{
command = "";
if (cliArgs.Count < 2)
return false;
if (!string.Equals(cliArgs[0], "workspace", StringComparison.OrdinalIgnoreCase))
return false;
if (string.Equals(cliArgs[1], "scan", StringComparison.OrdinalIgnoreCase))
{
command = "scan";
return true;
}
return false;
}
static bool TryGetBridgeCommand(IReadOnlyList<string> cliArgs, out string command)
{
command = "";
if (cliArgs.Count < 2)
return false;
if (!string.Equals(cliArgs[0], "bridge", StringComparison.OrdinalIgnoreCase))
return false;
if (string.Equals(cliArgs[1], "--stdio", StringComparison.OrdinalIgnoreCase))
{
command = "stdio";
return true;
}
return false;
}
static async Task<int> RunBridgeCommandAsync(IReadOnlyList<string> cliArgs, string command)
{
if (!string.Equals(command, "stdio", StringComparison.OrdinalIgnoreCase))
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
var options = ParseOptions(cliArgs.Skip(2).ToArray(), out _);
var startDir = options.TryGetValue("--project-root", out var root) && !string.IsNullOrWhiteSpace(root)
? root
: Directory.GetCurrentDirectory();
var server = new BridgeStdioServer(startDir);
return await server.RunAsync();
}
static int RunWorkspaceCommand(IReadOnlyList<string> cliArgs, string command)
{
if (!string.Equals(command, "scan", StringComparison.OrdinalIgnoreCase))
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
var options = ParseOptions(cliArgs.Skip(2).ToArray(), out _);
var startDir = options.TryGetValue("--project-root", out var root) && !string.IsNullOrWhiteSpace(root)
? root
: Directory.GetCurrentDirectory();
var asJson = options.ContainsKey("--json");
var workspaceLoaded = WorkspaceLoader.FindAndLoad(startDir);
if (workspaceLoaded is null)
{
var payload = new { success = false, message = "No workspace could be discovered." };
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(payload));
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
}
var (workspace, workspaceRoot) = workspaceLoaded.Value;
var currentRoot = ConfigLoader.FindAndLoad(startDir)?.ProjectRoot ?? startDir;
var scan = WorkspaceLoader.ScanInventory(workspaceRoot, currentRoot, workspace);
if (asJson)
{
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(scan, new System.Text.Json.JsonSerializerOptions
{
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
WriteIndented = true
}.WithEnumStrings()));
}
else
{
Console.WriteLine($"Workspace root: {scan.WorkspaceRoot}");
Console.WriteLine($"Known projects: {scan.KnownProjects.Count}");
Console.WriteLine($"Candidates: {scan.Candidates.Count}");
foreach (var item in scan.Candidates)
Console.WriteLine($" - {item.DisplayName} [{item.PrimaryKind}] {item.RootPath}");
}
return 0;
}
static async Task<int> RunHeadlessAsync(IReadOnlyList<string> cliArgs, string kind)
{
var options = ParseOptions(cliArgs.Skip(1).ToArray(), out var positional);
var json = options.ContainsKey("--json");
var startDir = options.TryGetValue("--project-root", out var projectRootOpt)
? projectRootOpt
: Directory.GetCurrentDirectory();
var envProfile = options.TryGetValue("--env-profile", out var profile) ? profile : null;
var nonInteractive = options.ContainsKey("--non-interactive") || RuntimePolicy.IsNonInteractive();
var loaded = ConfigLoader.FindAndLoad(startDir);
if (loaded is null)
{
Console.WriteLine("{\"success\":false,\"message\":\"No SDT project config found (expected sdtconfig-*.json or devtool.json).\"}");
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
}
var service = new HeadlessExecutionService();
if (kind == "run")
{
if (positional.Count == 0)
{
Console.WriteLine("{\"success\":false,\"message\":\"Missing workflow id. Usage: sdt run <workflowId> [--json] [--project-root <path>] [--env-profile <id>] [--non-interactive]\"}");
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
}
return await service.RunWorkflowAsync(
loaded,
new HeadlessRunRequest(
WorkflowId: positional[0],
ProjectRoot: loaded.ProjectRoot,
EnvProfile: envProfile,
NonInteractive: nonInteractive,
JsonOutput: json));
}
if (positional.Count == 0)
{
Console.WriteLine("{\"success\":false,\"message\":\"Missing debug profile id. Usage: sdt debug <profileId> [--json] [--project-root <path>] [--env-profile <id>] [--non-interactive]\"}");
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
}
return await service.RunDebugAsync(
loaded,
new HeadlessDebugRequest(
ProfileId: positional[0],
ProjectRoot: loaded.ProjectRoot,
EnvProfile: envProfile,
NonInteractive: nonInteractive,
JsonOutput: json));
}
static Dictionary<string, string?> ParseOptions(string[] args, out List<string> positional)
{
var options = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
positional = [];
for (var i = 0; i < args.Length; i++)
{
var token = args[i];
if (!token.StartsWith("-", StringComparison.Ordinal))
{
positional.Add(token);
continue;
}
if (i + 1 < args.Length && !args[i + 1].StartsWith("-", StringComparison.Ordinal))
{
options[token] = args[i + 1];
i++;
}
else
{
options[token] = null;
}
}
return options;
}
static class JsonOptionsExtensions
{
public static System.Text.Json.JsonSerializerOptions WithEnumStrings(this System.Text.Json.JsonSerializerOptions options)
{
options.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
return options;
}
}