using Sdt.Config; using Sdt.Core; using Sdt.Core.Debug; using Spectre.Console; namespace Sdt.Tui; internal sealed record MenuItem(string Display, string Value); public enum AppExitReason { Quit, SwitchProject } public sealed record AppResult(AppExitReason Reason, string? NewProjectRoot = null); public sealed class App { private DevToolConfig _config; private string _projectRoot; private readonly WorkspaceConfig? _workspace; private readonly string? _workspaceRoot; private List _workflows; private readonly List _warnings; private readonly IDebugProfileRunner _debugRunner; private readonly IDiagnosticsBundleService _diagnostics; private readonly IRequirementResolver _requirementResolver = new RequirementResolver(); private readonly WorkflowExecutor _executor = new( new WorkflowPlanner(), new ToolProbeService(), new PrereqInstallerService(), new ActionRunner(), new RequirementResolver()); private IReadOnlyDictionary WorkflowMap => _workflows.ToDictionary(t => t.Id, StringComparer.OrdinalIgnoreCase); public App( DevToolConfig config, string projectRoot, IReadOnlyList? warnings = null, WorkspaceConfig? workspace = null, string? workspaceRoot = null) { _config = config; _projectRoot = projectRoot; _workspace = workspace; _workspaceRoot = workspaceRoot; var normalized = WorkflowModelBuilder.Normalize(config, ResolveLegacyMode(), _requirementResolver); _workflows = normalized.Workflows.ToList(); _warnings = []; _debugRunner = new DebugProfileRunner(new ToolProbeService(), new PrereqInstallerService()); _diagnostics = new DiagnosticsBundleService(); if (warnings is not null) _warnings.AddRange(warnings); _warnings.AddRange(normalized.Warnings); } private static LegacyMode ResolveLegacyMode() { var raw = Environment.GetEnvironmentVariable("SDT_LEGACY_MODE"); return string.Equals(raw, "compat", StringComparison.OrdinalIgnoreCase) ? LegacyMode.Compat : LegacyMode.Strict; } public async Task RunAsync() { while (true) { AnsiConsole.Clear(); RenderBanner(); if (_warnings.Count > 0) { foreach (var warning in _warnings.Distinct(StringComparer.OrdinalIgnoreCase)) AnsiConsole.MarkupLine(Theme.Warn("Config warning: " + warning)); AnsiConsole.WriteLine(); } var choice = ShowMainMenu(); switch (choice) { case "__env__": EditEnvironment(); break; case "__toolchains__": await new ToolchainScreen(_config, _projectRoot).RunAsync(); break; case "__doctor__": await RunConfigDoctorAsync(); break; case "__events__": new EventsScreen(_projectRoot, _config.Name, _config.Version).Run(); break; case "__workspace__": if (_workspace is not null && _workspaceRoot is not null) { var switcher = new WorkspaceScreen(_workspace, _workspaceRoot, _projectRoot); var newRoot = switcher.SelectProject(); if (newRoot is not null) return new AppResult(AppExitReason.SwitchProject, newRoot); } break; case "__migrate_legacy__": ApplyLegacyMigration(); break; case "__quit__": AnsiConsole.MarkupLine("\n" + Theme.Faint("Later.") + "\n"); return new AppResult(AppExitReason.Quit); default: if (choice.StartsWith("__debugattach__:", StringComparison.Ordinal)) { var profileId = choice["__debugattach__:".Length..]; ShowAttachInstructions(profileId); break; } if (choice.StartsWith("__debug__:", StringComparison.Ordinal)) { var parts = choice.Split(':', 3, StringSplitOptions.None); if (parts.Length == 3) await RunDebugProfileAsync(parts[1], string.Equals(parts[2], "verbose", StringComparison.OrdinalIgnoreCase)); break; } var workflowMap = WorkflowMap; if (workflowMap.TryGetValue(choice, out var workflow)) await RunWorkflowAsync(workflow, workflowMap); break; } if (choice != "__quit__") { AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to return to the menu...")); Console.ReadKey(intercept: true); } } } private void RenderBanner() { AnsiConsole.Write(new FigletText("SDT").Color(Theme.GreenColor)); var wsInfo = _workspace is not null ? $" [{Theme.GreenDim}]∙ {Markup.Escape(_workspace.Name)}[/]" : string.Empty; AnsiConsole.Write( new Rule($"[bold {Theme.GreenBold}]{Markup.Escape(_config.Name)}[/] [{Theme.GreenDim}]v{Markup.Escape(_config.Version)}[/]{wsInfo}") .RuleStyle(Theme.DimStyle)); AnsiConsole.MarkupLine(Theme.Faint($"root: {_projectRoot}") + "\n"); } private string ShowMainMenu() { var prompt = new SelectionPrompt() .Title($"[{Theme.Green}]What would you like to do?[/]") .PageSize(28) .MoreChoicesText(Theme.Faint("(scroll to see more)")) .UseConverter(m => m.Display); var groups = _workflows .Where(t => !string.IsNullOrWhiteSpace(t.Label)) .GroupBy(t => string.IsNullOrWhiteSpace(t.Group) ? "General" : t.Group); foreach (var group in groups) { var header = new MenuItem( $"[bold {Theme.Amber}]{Markup.Escape(group.Key.ToUpperInvariant())}[/]", "__group__"); var items = group.Select(t => new MenuItem( $"[{Theme.Green}]{Markup.Escape(t.Label)}[/] [{Theme.GreenDim}]{Markup.Escape(t.Description)}[/]", t.Id)).ToList(); prompt.AddChoiceGroup(header, items); } var debugProfiles = _config.Debug?.Profiles ?? []; if (debugProfiles.Count > 0) { var debugItems = new List(); foreach (var profile in debugProfiles) { debugItems.Add(new MenuItem( $"[{Theme.Green}]Run {Markup.Escape(profile.Label)}[/] [{Theme.GreenDim}]debug profile[/]", $"__debug__:{profile.Id}:normal")); debugItems.Add(new MenuItem( $"[{Theme.Green}]Run {Markup.Escape(profile.Label)} (verbose)[/] [{Theme.GreenDim}]stream full output[/]", $"__debug__:{profile.Id}:verbose")); if (profile.Attach is not null) { debugItems.Add(new MenuItem( $"[{Theme.Green}]Attach instructions: {Markup.Escape(profile.Label)}[/]", $"__debugattach__:{profile.Id}")); } } prompt.AddChoiceGroup( new MenuItem($"[bold {Theme.Amber}]DEBUG[/]", "__group__"), debugItems); } var systemItems = new List { new($"[{Theme.Green}]🩺 Run config doctor[/] [{Theme.GreenDim}]validate config, tools, paths[/]", "__doctor__"), new($"[{Theme.Green}]📜 View run events[/] [{Theme.GreenDim}].sdt/events JSONL viewer[/]", "__events__"), new($"[{Theme.Green}]⚙ Edit environment variables[/]", "__env__"), }; if (_config.Toolchains is not null) systemItems.Insert(0, new MenuItem( $"[{Theme.Green}]⬡ Toolchain management[/] [{Theme.GreenDim}]python / node[/]", "__toolchains__")); if (_workspace is not null) systemItems.Insert(0, new MenuItem( $"[{Theme.Green}]⇄ Workspace projects[/] [{Theme.GreenDim}]{Markup.Escape(_workspace.Name)}[/]", "__workspace__")); if (_config.Targets.Count > 0) systemItems.Insert(0, new MenuItem( $"[{Theme.Green}]⇪ Migrate legacy targets → workflows[/] [{Theme.GreenDim}]writes devtool.json + backup[/]", "__migrate_legacy__")); systemItems.Add(new MenuItem($"[{Theme.GreenDim}]✗ Quit[/]", "__quit__")); prompt.AddChoiceGroup( new MenuItem($"[bold {Theme.Amber}]SYSTEM[/]", "__group__"), systemItems); return AnsiConsole.Prompt(prompt).Value; } private async Task RunWorkflowAsync( WorkflowDefinition workflow, IReadOnlyDictionary workflowMap) { AnsiConsole.Clear(); AnsiConsole.Write(Theme.SectionRule(workflow.Label)); var plan = new WorkflowPlanner().ResolvePlan(workflow, workflowMap); if (plan.Count == 0) { AnsiConsole.MarkupLine(Theme.Warn("This workflow has no executable steps.")); return; } if (plan.Count > 1) { AnsiConsole.MarkupLine(Theme.Faint($"Execution plan — {plan.Count} workflow(s):")); foreach (var item in plan) AnsiConsole.MarkupLine($" [{Theme.GreenDim}]→[/] [{Theme.Green}]{Markup.Escape(item.Label)}[/]"); AnsiConsole.WriteLine(); } var outputLines = new List(); using var eventRecorder = RunEventJsonlRecorder.Create(_projectRoot, "workflow"); var result = await _executor.ExecuteAsync( workflow, workflowMap, _config, _projectRoot, confirmInstallAsync: ConfirmInstallAsync, onOutput: (line, isErr) => { outputLines.Add((isErr ? "ERR: " : "OUT: ") + line); var escaped = Markup.Escape(line); AnsiConsole.MarkupLine(isErr ? $"[{Theme.Amber}]{escaped}[/]" : $"[{Theme.Green}]{escaped}[/]"); }, onEvent: evt => { eventRecorder.Write(evt); RenderRunEvent(evt); }); AnsiConsole.Write(Theme.SectionRule()); RenderStepSummary(result); RenderProbeDiagnosticsSummary(outputLines); if (result.Success) { var totalSeconds = result.Steps.Sum(s => s.Result.Elapsed.TotalSeconds); AnsiConsole.MarkupLine("\n" + Theme.Ok($"Done! Total: {totalSeconds:F1}s")); AnsiConsole.MarkupLine(Theme.Faint($"Run events: {eventRecorder.FilePath}")); return; } AnsiConsole.MarkupLine("\n" + Theme.Fail($"{result.StopReason}: {result.Message}")); AnsiConsole.MarkupLine(Theme.Faint($"Run events: {eventRecorder.FilePath}")); await WriteWorkflowDiagnosticsAsync(workflow, workflowMap, result, outputLines); } private static void RenderStepSummary(WorkflowExecutionResult result) { if (result.Steps.Count == 0) return; var table = new Table() .Border(TableBorder.Rounded) .BorderStyle(Theme.DimStyle) .AddColumn(new TableColumn($"[{Theme.Amber}]Workflow[/]")) .AddColumn(new TableColumn($"[{Theme.Amber}]Step[/]")) .AddColumn(new TableColumn($"[{Theme.Amber}]Exit[/]").Width(8)) .AddColumn(new TableColumn($"[{Theme.Amber}]Seconds[/]").Width(10)) .AddColumn(new TableColumn($"[{Theme.Amber}]Status[/]").Width(12)); foreach (var step in result.Steps) { var ok = step.Result.Success; table.AddRow( Theme.Faint(step.WorkflowId), Theme.G(step.StepLabel), Theme.Bold(step.Result.ExitCode.ToString()), Theme.Faint($"{step.Result.Elapsed.TotalSeconds:F1}"), ok ? Theme.Ok("ok") : Theme.Fail("failed")); } AnsiConsole.Write(table); } private async Task RunDebugProfileAsync(string profileId, bool verbose) { var profile = _config.Debug?.Profiles.FirstOrDefault(p => string.Equals(p.Id, profileId, StringComparison.OrdinalIgnoreCase)); if (profile is null) { AnsiConsole.MarkupLine(Theme.Fail($"Debug profile not found: {profileId}")); return; } AnsiConsole.Clear(); AnsiConsole.Write(Theme.SectionRule($"DEBUG — {profile.Label}")); using var eventRecorder = RunEventJsonlRecorder.Create(_projectRoot, "debug"); if (profile.Attach is not null) { AnsiConsole.MarkupLine(Theme.Faint($"Attach: {profile.Attach.Kind}")); if (profile.Attach.Port is not null) AnsiConsole.MarkupLine(Theme.Faint($"Port: {profile.Attach.Port}")); if (!string.IsNullOrWhiteSpace(profile.Attach.ProcessName)) AnsiConsole.MarkupLine(Theme.Faint($"Process: {profile.Attach.ProcessName}")); if (!string.IsNullOrWhiteSpace(profile.Attach.Note)) AnsiConsole.MarkupLine(Theme.Faint(profile.Attach.Note!)); AnsiConsole.WriteLine(); } var result = await _debugRunner.RunAsync( profile, _config, _projectRoot, verbose, ConfirmInstallAsync, (line, isErr) => { var escaped = Markup.Escape(line); AnsiConsole.MarkupLine(isErr ? $"[{Theme.Amber}]{escaped}[/]" : $"[{Theme.Green}]{escaped}[/]"); }, evt => { eventRecorder.Write(evt); RenderRunEvent(evt); }); if (result.Success) { var seconds = result.RunResult?.Elapsed.TotalSeconds ?? 0; AnsiConsole.MarkupLine("\n" + Theme.Ok($"Debug run completed in {seconds:F1}s")); AnsiConsole.MarkupLine(Theme.Faint($"Run events: {eventRecorder.FilePath}")); return; } AnsiConsole.MarkupLine("\n" + Theme.Fail($"{result.StopReason}: {result.Message}")); AnsiConsole.MarkupLine(Theme.Faint($"Run events: {eventRecorder.FilePath}")); var diagnostics = _config.Debug?.Diagnostics ?? new DebugDiagnosticsOptions(); if (!diagnostics.Enabled || !diagnostics.BundleOnFailure) return; var bundle = await _diagnostics.WriteBundleAsync( new DiagnosticsBundleRequest( Category: "debug", ProjectRoot: _projectRoot, SummaryMessage: result.Message, OutputLines: result.OutputLines, WorkflowSteps: [], Probes: result.Probes, DiagnosticsOptions: diagnostics, Config: _config, StopReason: result.StopReason, DebugRun: result.RunResult, DebugProfile: result.Profile)); if (bundle.Success) AnsiConsole.MarkupLine(Theme.Warn($"Diagnostics bundle: {bundle.BundleDirectory}")); else AnsiConsole.MarkupLine(Theme.Warn($"Diagnostics bundle failed: {bundle.Message}")); } private static void RenderProbeDiagnosticsSummary(IReadOnlyList outputLines) { var diagnostics = new List<(string Tool, string Detail)>(); foreach (var line in outputLines) { const string marker = "Probe detail ["; var markerIndex = line.IndexOf(marker, StringComparison.OrdinalIgnoreCase); if (markerIndex < 0) continue; var toolStart = markerIndex + marker.Length; var toolEnd = line.IndexOf(']', toolStart); if (toolEnd <= toolStart) continue; var tool = line[toolStart..toolEnd].Trim(); var detailStart = line.IndexOf(':', toolEnd); var detail = detailStart >= 0 && detailStart + 1 < line.Length ? line[(detailStart + 1)..].Trim() : ""; if (!string.IsNullOrWhiteSpace(tool)) diagnostics.Add((tool, detail)); } if (diagnostics.Count == 0) return; var table = new Table() .Border(TableBorder.Rounded) .BorderStyle(Theme.DimStyle) .AddColumn(new TableColumn($"[{Theme.Amber}]Probe Tool[/]").Width(14)) .AddColumn(new TableColumn($"[{Theme.Amber}]Resolver / Probe Detail[/]")) .AddColumn(new TableColumn($"[{Theme.Amber}]Suggested Fix[/]")); foreach (var diag in diagnostics.Distinct()) { table.AddRow( Theme.Warn(diag.Tool), Theme.Faint(diag.Detail), Theme.Faint(GetProbeFixHint(diag.Tool, diag.Detail))); } AnsiConsole.WriteLine(); AnsiConsole.Write(Theme.SectionRule("PROBE DIAGNOSTICS")); AnsiConsole.Write(table); } private static string GetProbeFixHint(string tool, string detail) { if (detail.Contains("ConfiguredOverride", StringComparison.OrdinalIgnoreCase)) return "Check tooling.tools[].executables paths."; if (detail.Contains("NodeAdjacentShim", StringComparison.OrdinalIgnoreCase)) return "Node found; verify npm/yarn shim in node directory."; if (detail.Contains("Fallback", StringComparison.OrdinalIgnoreCase)) return $"Add {tool} to PATH or set tooling.tools[].executables."; return $"Install/configure {tool} then rerun."; } private static void RenderRunEvent(RunEvent evt) { var shouldRender = evt.Type is RunEventType.WorkflowStarted or RunEventType.WorkflowStepStarted or RunEventType.WorkflowStepCompleted or RunEventType.ProbeFailed or RunEventType.InstallPlanPrepared or RunEventType.DebugStarted or RunEventType.DebugCommandStarted or RunEventType.DebugCommandCompleted or RunEventType.WorkflowCompleted or RunEventType.DebugCompleted; if (!shouldRender) return; var prefix = evt.Category.Equals("debug", StringComparison.OrdinalIgnoreCase) ? "DBG" : "RUN"; var tone = evt.Success is false ? Theme.Amber : Theme.GreenDim; AnsiConsole.MarkupLine($"[{tone}]{prefix} {Markup.Escape(evt.Message)}[/]"); } private void ShowAttachInstructions(string profileId) { var profile = _config.Debug?.Profiles.FirstOrDefault(p => string.Equals(p.Id, profileId, StringComparison.OrdinalIgnoreCase)); if (profile?.Attach is null) { AnsiConsole.MarkupLine(Theme.Warn("No attach instructions configured for this profile.")); return; } AnsiConsole.Clear(); AnsiConsole.Write(Theme.SectionRule($"ATTACH — {profile.Label}")); AnsiConsole.MarkupLine(Theme.Faint($"Kind: {profile.Attach.Kind}")); if (profile.Attach.Port is not null) AnsiConsole.MarkupLine(Theme.Faint($"Port: {profile.Attach.Port}")); if (!string.IsNullOrWhiteSpace(profile.Attach.ProcessName)) AnsiConsole.MarkupLine(Theme.Faint($"Process: {profile.Attach.ProcessName}")); if (!string.IsNullOrWhiteSpace(profile.Attach.Note)) AnsiConsole.MarkupLine(Theme.G(profile.Attach.Note!)); } private async Task WriteWorkflowDiagnosticsAsync( WorkflowDefinition workflow, IReadOnlyDictionary workflowMap, WorkflowExecutionResult result, IReadOnlyList outputLines) { var diagnostics = _config.Debug?.Diagnostics ?? new DebugDiagnosticsOptions(); if (!diagnostics.Enabled || !diagnostics.BundleOnFailure) return; var probes = await SnapshotWorkflowToolsAsync(workflow, workflowMap); var bundle = await _diagnostics.WriteBundleAsync( new DiagnosticsBundleRequest( Category: "workflow", ProjectRoot: _projectRoot, SummaryMessage: result.Message, OutputLines: outputLines, WorkflowSteps: result.Steps, Probes: probes, DiagnosticsOptions: diagnostics, Config: _config, StopReason: result.StopReason)); if (bundle.Success) AnsiConsole.MarkupLine(Theme.Warn($"Diagnostics bundle: {bundle.BundleDirectory}")); else AnsiConsole.MarkupLine(Theme.Warn($"Diagnostics bundle failed: {bundle.Message}")); } private async Task> SnapshotWorkflowToolsAsync( WorkflowDefinition workflow, IReadOnlyDictionary workflowMap) { var plan = new WorkflowPlanner().ResolvePlan(workflow, workflowMap); var tools = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var item in plan) { foreach (var step in item.Steps) { foreach (var req in _requirementResolver.Resolve(step)) { if (!string.IsNullOrWhiteSpace(req.Tool)) tools.Add(req.Tool); } } } var probeService = new ToolProbeService(); var probes = new List(); foreach (var tool in tools) probes.Add(await probeService.ProbeAsync(tool, _projectRoot, _config)); return probes; } private Task ConfirmInstallAsync(string tool, InstallPlan plan) { AnsiConsole.MarkupLine(Theme.Warn($"Missing prerequisite: {tool}")); AnsiConsole.MarkupLine(Theme.Faint(plan.Summary)); foreach (var cmd in plan.Commands) AnsiConsole.MarkupLine(Theme.Faint($" $ {cmd.Command} {string.Join(" ", cmd.Args)}")); var allow = AnsiConsole.Confirm( $"[{Theme.Amber}]Run install commands for {Markup.Escape(tool)} now?[/]", defaultValue: false); return Task.FromResult(allow); } private async Task RunConfigDoctorAsync() { AnsiConsole.Clear(); AnsiConsole.Write(Theme.SectionRule("CONFIG DOCTOR")); AnsiConsole.WriteLine(); var service = new ConfigDoctorService(new ToolProbeService(), _requirementResolver); var report = await service.RunAsync(_config, _projectRoot); var table = new Table() .Border(TableBorder.Rounded) .BorderStyle(Theme.DimStyle) .AddColumn(new TableColumn($"[{Theme.Amber}]Check[/]").Width(26)) .AddColumn(new TableColumn($"[{Theme.Amber}]Status[/]").Width(10)) .AddColumn(new TableColumn($"[{Theme.Amber}]Detail[/]")) .AddColumn(new TableColumn($"[{Theme.Amber}]Fix[/]")); foreach (var check in report.Checks) { var statusText = check.Status switch { DoctorStatus.Pass => Theme.Ok("ok"), DoctorStatus.Warn => Theme.Warn("warn"), DoctorStatus.Fail => Theme.Fail("fail"), _ => Theme.Faint("n/a"), }; table.AddRow( Theme.G(check.Name), statusText, Theme.Faint(check.Detail), string.IsNullOrWhiteSpace(check.Fix) ? Theme.Faint("-") : Theme.Faint(check.Fix)); } AnsiConsole.Write(table); AnsiConsole.WriteLine(); if (report.HasFailures) AnsiConsole.MarkupLine(Theme.Fail("Doctor found blocking issues.")); else if (report.HasWarnings) AnsiConsole.MarkupLine(Theme.Warn("Doctor completed with warnings.")); else AnsiConsole.MarkupLine(Theme.Ok("Doctor completed: no issues found.")); if (!report.HasFailures && !report.HasWarnings) return; var applyFixes = AnsiConsole.Confirm( $"[{Theme.Amber}]Apply common autofixes now?[/]", defaultValue: false); if (!applyFixes) return; var fixer = new ConfigDoctorAutoFixService(); var missingDirs = fixer.FindMissingWorkingDirectories(_config, _projectRoot); if (missingDirs.Count > 0) { var createDirs = AnsiConsole.Confirm( $"[{Theme.Amber}]Create {missingDirs.Count} missing working director{(missingDirs.Count == 1 ? "y" : "ies")}?[/]", defaultValue: true); if (createDirs) { var dirFix = fixer.CreateMissingWorkingDirectories(missingDirs); AnsiConsole.MarkupLine(dirFix.Success ? Theme.Ok(dirFix.Message) : Theme.Fail(dirFix.Message)); } } if (_config.Targets.Count > 0) { var migrate = AnsiConsole.Confirm( $"[{Theme.Amber}]Migrate legacy targets to workflows now?[/]", defaultValue: true); if (migrate) { var migration = fixer.ApplyLegacyMigration(_projectRoot); if (migration.Success) { AnsiConsole.MarkupLine(Theme.Ok("Legacy migration applied from doctor.")); if (!string.IsNullOrWhiteSpace(migration.BackupPath)) AnsiConsole.MarkupLine(Theme.Faint($"Backup: {migration.BackupPath}")); var reloaded = ConfigLoader.FindAndLoad(_projectRoot); if (reloaded is not null) { _config = reloaded.Config; var normalized = WorkflowModelBuilder.Normalize(_config, ResolveLegacyMode(), _requirementResolver); _workflows = normalized.Workflows.ToList(); _warnings.Clear(); _warnings.AddRange(reloaded.Warnings); _warnings.AddRange(normalized.Warnings); } } else { AnsiConsole.MarkupLine(Theme.Fail(migration.Message)); } } } } private void ApplyLegacyMigration() { if (_config.Targets.Count == 0) { AnsiConsole.MarkupLine(Theme.Warn("No legacy targets found in this config.")); return; } var configPath = ConfigLoader.FindConfigPath(_projectRoot); if (string.IsNullOrWhiteSpace(configPath)) { AnsiConsole.MarkupLine(Theme.Fail("Could not locate devtool.json to migrate.")); return; } var confirm = AnsiConsole.Confirm( $"[{Theme.Amber}]Migrate legacy targets to workflows and overwrite devtool.json (with backup)?[/]", defaultValue: true); if (!confirm) return; var result = ConfigLoader.ApplyLegacyTargetMigration(configPath, createBackup: true); if (!result.Success) { AnsiConsole.MarkupLine(Theme.Fail(result.Message)); return; } var reloaded = ConfigLoader.FindAndLoad(_projectRoot); if (reloaded is null) { AnsiConsole.MarkupLine(Theme.Fail("Migration wrote config, but reload failed.")); return; } _config = reloaded.Config; var normalized = WorkflowModelBuilder.Normalize(_config, ResolveLegacyMode(), _requirementResolver); _workflows = normalized.Workflows.ToList(); _warnings.Clear(); _warnings.AddRange(reloaded.Warnings); _warnings.AddRange(normalized.Warnings); AnsiConsole.MarkupLine(Theme.Ok("Migration complete.")); if (!string.IsNullOrWhiteSpace(result.BackupPath)) AnsiConsole.MarkupLine(Theme.Faint($"Backup: {result.BackupPath}")); } private void EditEnvironment() { AnsiConsole.Clear(); if (_config.Env.Count == 0) { AnsiConsole.MarkupLine(Theme.Warn("No environment variables defined in devtool.json.")); AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to go back...")); Console.ReadKey(intercept: true); return; } while (true) { AnsiConsole.Write(Theme.SectionRule("ENVIRONMENT")); var table = new Table() .Border(TableBorder.Rounded) .BorderStyle(Theme.DimStyle) .AddColumn(new TableColumn($"[{Theme.Amber}]Variable[/]")) .AddColumn(new TableColumn($"[{Theme.Amber}]Current Value[/]")) .AddColumn(new TableColumn($"[{Theme.Amber}]Description[/]")); foreach (var def in _config.Env) { var val = Environment.GetEnvironmentVariable(def.Key) ?? def.DefaultValue; table.AddRow( Theme.Warn(def.Key), Theme.Bold(val.Length > 0 ? val : "(not set)"), Theme.Faint(def.Description)); } AnsiConsole.Write(table); AnsiConsole.MarkupLine(Theme.Faint("Changes apply to this SDT session only.\n")); var choices = _config.Env .Select(e => { var curr = Environment.GetEnvironmentVariable(e.Key) ?? e.DefaultValue; return new MenuItem( $"[{Theme.Amber}]{Markup.Escape(e.Key)}[/] [{Theme.GreenDim}]= {Markup.Escape(curr)}[/]", e.Key); }) .Append(new MenuItem(Theme.Faint("← Back"), "__back__")) .ToList(); var selected = AnsiConsole.Prompt( new SelectionPrompt() .Title($"[{Theme.Green}]Select a variable to edit:[/]") .UseConverter(m => m.Display) .AddChoices(choices)); if (selected.Value == "__back__") break; var envDef = _config.Env.First(e => e.Key == selected.Value); var current = Environment.GetEnvironmentVariable(envDef.Key) ?? envDef.DefaultValue; string newVal; if (envDef.Options.Count > 0) { newVal = AnsiConsole.Prompt( new SelectionPrompt() .Title($"[{Theme.Amber}]{Markup.Escape(envDef.Key)}[/] [{Theme.GreenDim}]current: {Markup.Escape(current)}[/]") .AddChoices(envDef.Options)); } else { newVal = AnsiConsole.Ask( $"[{Theme.Amber}]{Markup.Escape(envDef.Key)}[/]", current); } Environment.SetEnvironmentVariable(envDef.Key, newVal); AnsiConsole.MarkupLine("\n" + Theme.Ok($"{envDef.Key} = {newVal}") + "\n"); Thread.Sleep(500); AnsiConsole.Clear(); } } }