using Sdt.Config; using Sdt.Runner; using Sdt.Tui; using Spectre.Console; namespace Sdt.Tui; public sealed class ToolchainScreen(DevToolConfig config, string projectRoot) { private readonly DevToolConfig _config = config; private readonly string _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(); 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() .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() .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 { "-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(); 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 static 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 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 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)")); } }