journal/Journal.DevTool/Tui/ToolchainScreen.cs
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

320 lines
13 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Sdt.Config;
using Sdt.Runner;
using Sdt.Tui;
using Spectre.Console;
namespace Sdt.Tui;
public sealed class ToolchainScreen
{
private readonly DevToolConfig _config;
private readonly string _projectRoot;
public ToolchainScreen(DevToolConfig config, string projectRoot)
{
_config = config;
_projectRoot = projectRoot;
}
public async Task RunAsync()
{
while (true)
{
AnsiConsole.Clear();
AnsiConsole.Write(Theme.SectionRule("TOOLCHAINS"));
AnsiConsole.WriteLine();
var tc = _config.Toolchains;
if (tc is null)
{
AnsiConsole.MarkupLine(Theme.Warn("No toolchains configured in devtool.json."));
AnsiConsole.MarkupLine(Theme.Faint("Add a \"toolchains\" section with \"python\" and/or \"node\" entries."));
AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to go back..."));
Console.ReadKey(intercept: true);
return;
}
// Build menu from available toolchains
var choices = new List<MenuItem>();
if (tc.Python is not null)
{
choices.Add(new MenuItem($"[bold {Theme.GreenBold}]PYTHON[/]", "__group__"));
choices.Add(new MenuItem($"{Theme.G("Check environment")} {Theme.Faint("detect python, venv, pip")}", "py:check"));
choices.Add(new MenuItem($"{Theme.G("Create / recreate venv")} {Theme.Faint($"python -m venv {tc.Python.VenvDir}")}", "py:venv"));
if (tc.Python.Profiles.Count > 0)
choices.Add(new MenuItem($"{Theme.G("Install requirements profile")} {Theme.Faint("select cpu / gpu / nlp...")}", "py:install"));
choices.Add(new MenuItem($"{Theme.G("Upgrade pip")} {Theme.Faint("pip install --upgrade pip")}", "py:upgradepip"));
}
if (tc.Node is not null)
{
choices.Add(new MenuItem($"[bold {Theme.GreenBold}]NODE / NPM[/]", "__group__"));
choices.Add(new MenuItem($"{Theme.G("Check environment")} {Theme.Faint("detect node, npm, node_modules")}", "node:check"));
choices.Add(new MenuItem($"{Theme.G($"{tc.Node.PackageManager} install")} {Theme.Faint($"in {tc.Node.WorkingDir}")}", "node:install"));
}
choices.Add(new MenuItem($"[bold {Theme.GreenBold}]──[/]", "__group__"));
choices.Add(new MenuItem(Theme.Faint("← Back"), "__back__"));
var prompt = new SelectionPrompt<MenuItem>()
.Title($"[{Theme.Green}]Select a toolchain action:[/]")
.PageSize(20)
.UseConverter(m => m.Display)
.AddChoices(choices);
var selected = AnsiConsole.Prompt(prompt);
if (selected.Value == "__back__" || selected.Value == "__group__") return;
AnsiConsole.Clear();
AnsiConsole.Write(Theme.SectionRule(selected.Value.Split(':')[0].ToUpperInvariant() + " " + selected.Value.Split(':')[1]));
AnsiConsole.WriteLine();
await HandleActionAsync(selected.Value, tc);
AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to continue..."));
Console.ReadKey(intercept: true);
}
}
// ── Actions ───────────────────────────────────────────────────────────────
private async Task HandleActionAsync(string action, ToolchainConfig tc)
{
switch (action)
{
case "py:check": await CheckPythonAsync(tc.Python!); break;
case "py:venv": await CreateVenvAsync(tc.Python!); break;
case "py:install": await InstallProfileAsync(tc.Python!); break;
case "py:upgradepip": await UpgradePipAsync(tc.Python!); break;
case "node:check": await CheckNodeAsync(tc.Node!); break;
case "node:install": await NodeInstallAsync(tc.Node!); break;
}
}
// ── Python ────────────────────────────────────────────────────────────────
private async Task CheckPythonAsync(PythonToolchain py)
{
var exe = ResolvePythonExe(py);
var venvPath = Path.GetFullPath(Path.Combine(_projectRoot, py.VenvDir));
var venvPython = GetVenvPython(venvPath);
var table = new Table()
.Border(TableBorder.Rounded)
.BorderStyle(Theme.DimStyle)
.AddColumn(new TableColumn($"[{Theme.Amber}]Check[/]").Width(24))
.AddColumn(new TableColumn($"[{Theme.GreenBold}]Result[/]"));
// System Python
var pyVersion = await ProbeAsync(exe, "--version");
table.AddRow(Theme.G("System Python"), pyVersion is not null
? Theme.Ok(pyVersion.Trim())
: Theme.Fail($"{exe} not found"));
// Venv exists?
table.AddRow(Theme.G($"Venv ({py.VenvDir})"), Directory.Exists(venvPath)
? Theme.Ok("exists " + venvPath)
: Theme.Warn("not found — use 'Create venv'"));
// Venv Python
if (File.Exists(venvPython))
{
var venvVersion = await ProbeAsync(venvPython, "--version");
table.AddRow(Theme.G("Venv Python"), venvVersion is not null
? Theme.Ok(venvVersion.Trim())
: Theme.Fail("could not launch"));
}
// Pip in venv
if (File.Exists(venvPython))
{
var pipVersion = await ProbeAsync(venvPython, "-m", "pip", "--version");
table.AddRow(Theme.G("Pip (venv)"), pipVersion is not null
? Theme.Ok(pipVersion.Trim())
: Theme.Fail("pip not available"));
}
AnsiConsole.Write(table);
}
private async Task CreateVenvAsync(PythonToolchain py)
{
var exe = ResolvePythonExe(py);
var venvDir = py.VenvDir;
var venvPath = Path.GetFullPath(Path.Combine(_projectRoot, venvDir));
if (Directory.Exists(venvPath))
{
var overwrite = AnsiConsole.Confirm(
$"[{Theme.Amber}]Venv already exists at {venvDir}. Recreate it?[/]", defaultValue: false);
if (!overwrite) return;
Directory.Delete(venvPath, recursive: true);
}
AnsiConsole.MarkupLine(Theme.G($"Creating venv: {exe} -m venv {venvDir}"));
AnsiConsole.WriteLine();
await RunLiveAsync(exe, ["-m", "venv", venvDir], _projectRoot);
}
private async Task InstallProfileAsync(PythonToolchain py)
{
var venvPath = Path.GetFullPath(Path.Combine(_projectRoot, py.VenvDir));
var venvPy = GetVenvPython(venvPath);
if (!File.Exists(venvPy))
{
AnsiConsole.MarkupLine(Theme.Warn("Venv not found. Create it first."));
return;
}
var profile = AnsiConsole.Prompt(
new SelectionPrompt<PythonProfile>()
.Title($"[{Theme.Green}]Select requirements profile:[/]")
.UseConverter(p => $"{Theme.Bold(p.Label)} {Theme.Faint(p.RequirementsFile)}")
.AddChoices(py.Profiles));
var reqFile = Path.GetFullPath(Path.Combine(_projectRoot, profile.RequirementsFile));
if (!File.Exists(reqFile))
{
AnsiConsole.MarkupLine(Theme.Fail($"Requirements file not found: {reqFile}"));
return;
}
// Upgrade pip first
AnsiConsole.MarkupLine(Theme.Faint("Upgrading pip..."));
await RunLiveAsync(venvPy, ["-m", "pip", "install", "--upgrade", "pip"], _projectRoot);
// Build install args
var installArgs = new List<string> { "-m", "pip", "install" };
if (!string.IsNullOrWhiteSpace(profile.ExtraIndexUrl))
{
installArgs.Add("--extra-index-url");
installArgs.Add(profile.ExtraIndexUrl);
}
installArgs.Add("-r");
installArgs.Add(reqFile);
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine(Theme.G($"Installing {profile.Label}..."));
AnsiConsole.WriteLine();
await RunLiveAsync(venvPy, installArgs, _projectRoot);
// Post-install commands
foreach (var cmd in profile.PostInstallCommands)
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine(Theme.Faint($"Post-install: {cmd}"));
var parts = cmd.Split(' ', 2);
var postArgs = parts.Length > 1 ? parts[1].Split(' ') : Array.Empty<string>();
await RunLiveAsync(venvPy, ["-m", ..postArgs], _projectRoot);
}
}
private async Task UpgradePipAsync(PythonToolchain py)
{
var venvPath = Path.GetFullPath(Path.Combine(_projectRoot, py.VenvDir));
var venvPy = GetVenvPython(venvPath);
var exe = File.Exists(venvPy) ? venvPy : ResolvePythonExe(py);
AnsiConsole.MarkupLine(Theme.G($"Upgrading pip using: {exe}"));
AnsiConsole.WriteLine();
await RunLiveAsync(exe, ["-m", "pip", "install", "--upgrade", "pip"], _projectRoot);
}
// ── Node ──────────────────────────────────────────────────────────────────
private async Task CheckNodeAsync(NodeToolchain node)
{
var nodeModules = Path.GetFullPath(
Path.Combine(_projectRoot, node.WorkingDir, "node_modules"));
var table = new Table()
.Border(TableBorder.Rounded)
.BorderStyle(Theme.DimStyle)
.AddColumn(new TableColumn($"[{Theme.Amber}]Check[/]").Width(24))
.AddColumn(new TableColumn($"[{Theme.GreenBold}]Result[/]"));
var nodeVersion = await ProbeAsync("node", "--version");
table.AddRow(Theme.G("Node.js"), nodeVersion is not null
? Theme.Ok(nodeVersion.Trim())
: Theme.Fail("node not found in PATH"));
var npmVersion = await ProbeAsync(node.PackageManager, "--version");
table.AddRow(Theme.G(node.PackageManager), npmVersion is not null
? Theme.Ok(npmVersion.Trim())
: Theme.Fail($"{node.PackageManager} not found in PATH"));
table.AddRow(Theme.G("node_modules"), Directory.Exists(nodeModules)
? Theme.Ok("exists")
: Theme.Warn($"not found — run {node.PackageManager} install"));
AnsiConsole.Write(table);
}
private async Task NodeInstallAsync(NodeToolchain node)
{
var workDir = Path.GetFullPath(Path.Combine(_projectRoot, node.WorkingDir));
AnsiConsole.MarkupLine(Theme.G($"{node.PackageManager} install ({workDir})"));
AnsiConsole.WriteLine();
await RunLiveAsync(node.PackageManager, ["install"], workDir);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private string ResolvePythonExe(PythonToolchain py)
{
if (OperatingSystem.IsWindows() && !string.IsNullOrWhiteSpace(py.WindowsExecutable))
return py.WindowsExecutable;
return py.Executable;
}
private static string GetVenvPython(string venvPath)
{
// Windows: .venv\Scripts\python.exe | Linux/Mac: .venv/bin/python
return OperatingSystem.IsWindows()
? Path.Combine(venvPath, "Scripts", "python.exe")
: Path.Combine(venvPath, "bin", "python");
}
private static async Task<string?> ProbeAsync(string command, params string[] args)
{
try
{
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = command,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
foreach (var a in args) psi.ArgumentList.Add(a);
using var p = new System.Diagnostics.Process { StartInfo = psi };
p.Start();
var output = await p.StandardOutput.ReadToEndAsync();
var err = await p.StandardError.ReadToEndAsync();
await p.WaitForExitAsync();
return p.ExitCode == 0 ? (output + err) : null;
}
catch { return null; }
}
private static async Task RunLiveAsync(string command, IEnumerable<string> args, string workingDir)
{
var result = await ProcessRunner.RunAsync(
command, args, workingDir,
(line, isErr) => AnsiConsole.MarkupLine(
isErr
? $"[{Theme.Amber}]{Markup.Escape(line)}[/]"
: $"[{Theme.Green}]{Markup.Escape(line)}[/]"));
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine(result.Success
? Theme.Ok($"Done ({result.Elapsed.TotalSeconds:F1}s)")
: Theme.Fail($"Exited {result.ExitCode} ({result.Elapsed.TotalSeconds:F1}s)"));
}
}