- 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
320 lines
13 KiB
C#
320 lines
13 KiB
C#
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)"));
|
||
}
|
||
}
|