801 lines
31 KiB
C#

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<WorkflowDefinition> _workflows;
private readonly List<string> _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<string, WorkflowDefinition> WorkflowMap =>
_workflows.ToDictionary(t => t.Id, StringComparer.OrdinalIgnoreCase);
public App(
DevToolConfig config,
string projectRoot,
IReadOnlyList<string>? 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<AppResult> 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<MenuItem>()
.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<MenuItem>();
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<MenuItem>
{
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<string, WorkflowDefinition> 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<string>();
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<string> 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<string, WorkflowDefinition> workflowMap,
WorkflowExecutionResult result,
IReadOnlyList<string> 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<IReadOnlyList<ProbeResult>> SnapshotWorkflowToolsAsync(
WorkflowDefinition workflow,
IReadOnlyDictionary<string, WorkflowDefinition> workflowMap)
{
var plan = new WorkflowPlanner().ResolvePlan(workflow, workflowMap);
var tools = new HashSet<string>(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<ProbeResult>();
foreach (var tool in tools)
probes.Add(await probeService.ProbeAsync(tool, _projectRoot, _config));
return probes;
}
private Task<bool> 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<MenuItem>()
.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<string>()
.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();
}
}
}