366 lines
13 KiB
C#
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;
|
|
}
|
|
}
|