stan44 5b383858ae feat(sdt): add Journal.DevTool TUI, devtool.json, and justfile
- Journal.DevTool: C# TUI dev tool (SDT) using Spectre.Console
  - Phosphor green (#00FF41) terminal aesthetic with amber/red accents
  - Grouped build target menu driven by devtool.json config
  - Topological dependency resolver for DependsOn chains
  - Live stdout/stderr streaming per target step
  - Interactive environment variable editor (dropdown + free-text)
  - Toolchain management screen: Python venv create/install/upgrade, Node npm install
  - Workspace switcher: reads sdt-workspace.json, hot-switches between projects
  - Outputs as 'sdt' executable (net10.0)

- devtool.json: project config for SDT
  - All build targets: sidecar, web, webgateway, tauri, tauri-nsis, all (virtual)
  - Dev targets: run-gateway
  - Test targets: test (smoke), gate (migration)
  - Cache targets: nuget-export, nuget-import
  - Toolchains: Python 3.14 (cpu/gpu/nlp profiles) + Node/npm (Journal.App)
  - Env vars: AI provider, log level, NLP backend, path overrides

- justfile: just command runner recipes wrapping existing scripts
  - Correct dependency ordering (sidecar before tauri, web before webgateway)
  - OS-aware runtime detection (win-x64 / linux-x64)
  - Recipes: sidecar, web, webgateway, tauri, tauri-nsis, all, run, dev-app, test, gate, build, nuget-export/import, sdt

- Journal.slnx: added Journal.DevTool project
2026-02-27 13:12:36 -06:00

312 lines
12 KiB
C#

using Sdt.Config;
using Sdt.Runner;
using Spectre.Console;
namespace Sdt.Tui;
/// <summary>Thin wrapper used in Spectre.Console selection prompts.</summary>
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 IReadOnlyDictionary<string, BuildTarget> TargetMap =>
_config.Targets.ToDictionary(t => t.Id, StringComparer.OrdinalIgnoreCase);
public App(
DevToolConfig config,
string projectRoot,
WorkspaceConfig? workspace = null,
string? workspaceRoot = null)
{
_config = config;
_projectRoot = projectRoot;
_workspace = workspace;
_workspaceRoot = workspaceRoot;
}
public async Task<AppResult> RunAsync()
{
while (true)
{
AnsiConsole.Clear();
RenderBanner();
var choice = ShowMainMenu();
switch (choice)
{
case "__env__":
EditEnvironment();
break;
case "__toolchains__":
await new ToolchainScreen(_config, _projectRoot).RunAsync();
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 "__quit__":
AnsiConsole.MarkupLine("\n" + Theme.Faint("Later.") + "\n");
return new AppResult(AppExitReason.Quit);
default:
var targetMap = TargetMap;
if (targetMap.TryGetValue(choice, out var target))
await RunTargetAsync(target, targetMap);
break;
}
if (choice != "__quit__")
{
AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to return to the menu..."));
Console.ReadKey(intercept: true);
}
}
}
// ── Banner ────────────────────────────────────────────────────────────────
private void RenderBanner()
{
// Phosphor green figlet
AnsiConsole.Write(new FigletText("SDT").Color(Theme.GreenColor));
// Project + workspace info line
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");
}
// ── Main menu ─────────────────────────────────────────────────────────────
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);
// Targets, grouped
var groups = _config.Targets
.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);
}
// System actions
var systemItems = new List<MenuItem>
{
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 && (_workspace.Projects.Count > 1))
systemItems.Insert(0, new MenuItem(
$"[{Theme.Green}]⇄ Switch project[/] [{Theme.GreenDim}]{Markup.Escape(_workspace.Name)}[/]",
"__workspace__"));
systemItems.Add(new MenuItem($"[{Theme.GreenDim}]✗ Quit[/]", "__quit__"));
prompt.AddChoiceGroup(
new MenuItem($"[bold {Theme.Amber}]SYSTEM[/]", "__group__"),
systemItems);
return AnsiConsole.Prompt(prompt).Value;
}
// ── Target execution ──────────────────────────────────────────────────────
private async Task RunTargetAsync(BuildTarget target, IReadOnlyDictionary<string, BuildTarget> targetMap)
{
AnsiConsole.Clear();
AnsiConsole.Write(Theme.SectionRule(target.Label));
var plan = TargetRunner.ResolvePlan(target, targetMap);
if (plan.Count == 0)
{
AnsiConsole.MarkupLine(Theme.Warn("This target has no executable steps."));
return;
}
if (plan.Count > 1)
{
AnsiConsole.MarkupLine(Theme.Faint($"Execution plan — {plan.Count} steps:"));
foreach (var step in plan)
AnsiConsole.MarkupLine($" [{Theme.GreenDim}]→[/] [{Theme.Green}]{Markup.Escape(step.Label)}[/]");
AnsiConsole.WriteLine();
}
var allOk = true;
var totalSw = System.Diagnostics.Stopwatch.StartNew();
foreach (var step in plan)
{
AnsiConsole.Write(Theme.DimRule());
var workingDir = Path.GetFullPath(Path.Combine(_projectRoot, step.WorkingDir));
var cmdDisplay = $"{step.Command} {string.Join(" ", step.Args)}";
AnsiConsole.MarkupLine($"[{Theme.GreenDim}]$ {Markup.Escape(cmdDisplay)}[/]");
AnsiConsole.MarkupLine($"[{Theme.GreenDim}] {Markup.Escape(workingDir)}[/]\n");
RunResult result;
try
{
result = await ProcessRunner.RunAsync(
step.Command!,
step.Args,
workingDir,
(line, isErr) =>
{
var escaped = Markup.Escape(line);
AnsiConsole.MarkupLine(isErr
? $"[{Theme.Amber}]{escaped}[/]"
: $"[{Theme.Green}]{escaped}[/]");
});
}
catch (Exception ex)
{
AnsiConsole.MarkupLine("\n" + Theme.Fail($"Failed to launch: {ex.Message}"));
allOk = false;
break;
}
AnsiConsole.WriteLine();
if (result.Success)
AnsiConsole.MarkupLine(Theme.Ok($"{step.Label} ({result.Elapsed.TotalSeconds:F1}s)"));
else
{
AnsiConsole.MarkupLine(Theme.Fail($"{step.Label} — exited {result.ExitCode} ({result.Elapsed.TotalSeconds:F1}s)"));
allOk = false;
break;
}
}
totalSw.Stop();
AnsiConsole.Write(Theme.SectionRule());
AnsiConsole.MarkupLine(allOk
? "\n" + Theme.Ok($"Done! Total: {totalSw.Elapsed.TotalSeconds:F1}s")
: "\n" + Theme.Fail("Build failed. Check output above."));
}
// ── Environment editor ────────────────────────────────────────────────────
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();
}
}
}