801 lines
31 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|