using System.Diagnostics; using System.Text.Json; using System.Text.RegularExpressions; using Sdt.Config; using Sdt.Runner; namespace Sdt.Core; public sealed class PrereqInstallerService : IPrereqInstaller { public async Task GetInstallPlanAsync( string tool, string projectRoot, DevToolConfig? config = null, CancellationToken cancellationToken = default) { var fromConfig = TryGetPlanFromConfig(tool, config); if (fromConfig is not null) return fromConfig; var scriptPath = ScriptLocator.FindHelperScript(projectRoot, "diag.py"); if (scriptPath is null) return FallbackPlan(tool); try { var psi = new ProcessStartInfo { FileName = PythonResolver.ResolveExecutable(), RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, WorkingDirectory = projectRoot, }; psi.ArgumentList.Add(scriptPath); psi.ArgumentList.Add("install-plan"); psi.ArgumentList.Add("--tool"); psi.ArgumentList.Add(tool); psi.ArgumentList.Add("--json"); using var process = new Process { StartInfo = psi }; process.Start(); var stdout = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false); var stderr = await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false); await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); if (process.ExitCode != 0) { var fallback = FallbackPlan(tool); return fallback with { Summary = $"diag.py install-plan failed for {tool}; using fallback templates. " + $"{(string.IsNullOrWhiteSpace(stderr) ? "No stderr output." : stderr.Trim())}" }; } var parsed = JsonSerializer.Deserialize(stdout); if (parsed is null) { var fallback = FallbackPlan(tool); return fallback with { Summary = $"diag.py returned invalid JSON for {tool}; using fallback templates." }; } var commands = parsed.Commands .Select(c => new InstallCommand(c.Command ?? "", c.Args ?? [])) .Where(c => !string.IsNullOrWhiteSpace(c.Command)) .ToList(); if (!parsed.Supported || commands.Count == 0) { var fallback = FallbackPlan(tool); return fallback with { Summary = $"diag.py returned no usable commands for {tool}; using fallback templates." }; } return new InstallPlan( parsed.Tool ?? tool, parsed.Supported, parsed.Summary ?? $"Install {tool}", commands); } catch (Exception ex) { var fallback = FallbackPlan(tool); return fallback with { Summary = $"diag.py install-plan exception for {tool}; using fallback templates. {ex.Message}" }; } } public Task RunInstallAsync( InstallCommand command, string projectRoot, Action onOutput, CancellationToken cancellationToken = default) => ProcessRunner.RunAsync(command.Command, command.Args, projectRoot, onOutput, cancellationToken: cancellationToken); private static InstallPlan FallbackPlan(string tool) { var isWindows = OperatingSystem.IsWindows(); var normalized = tool.ToLowerInvariant(); if (normalized == "tauri") return BuildTauriFallbackPlan(); var installCommand = normalized switch { "dotnet" => isWindows ? new InstallCommand("winget", ["install", "Microsoft.DotNet.SDK.10"]) : new InstallCommand("sh", ["-c", "echo Install dotnet SDK from your distro package manager"]), "node" => isWindows ? new InstallCommand("winget", ["install", "OpenJS.NodeJS.LTS"]) : new InstallCommand("sh", ["-c", "echo Install nodejs via package manager"]), "npm" => isWindows ? new InstallCommand("winget", ["install", "OpenJS.NodeJS.LTS"]) : new InstallCommand("sh", ["-c", "echo Install npm via package manager"]), "cargo" => isWindows ? new InstallCommand("winget", ["install", "Rustlang.Rustup"]) : new InstallCommand("sh", ["-c", "curl https://sh.rustup.rs -sSf | sh"]), "git" => isWindows ? new InstallCommand("winget", ["install", "Git.Git"]) : new InstallCommand("sh", ["-c", "echo Install git via package manager"]), "docker" => isWindows ? new InstallCommand("winget", ["install", "Docker.DockerDesktop"]) : new InstallCommand("sh", ["-c", "echo Install docker engine via package manager"]), "python" => isWindows ? new InstallCommand("winget", ["install", "Python.Python.3.12"]) : new InstallCommand("sh", ["-c", "echo Install python3 via package manager"]), _ => new InstallCommand("sh", ["-c", $"echo No installer template for '{tool}'"]), }; return new InstallPlan( tool, Supported: true, Summary: $"Fallback install plan for {tool}", Commands: [installCommand]); } private static InstallPlan BuildTauriFallbackPlan() { if (OperatingSystem.IsWindows()) { return new InstallPlan( "tauri", Supported: true, Summary: "Fallback tauri plan (Windows): install Node.js, Rust toolchain, and Tauri CLI. Visual Studio C++ build tools/WebView2 may also be required.", Commands: [ new InstallCommand("winget", ["install", "OpenJS.NodeJS.LTS"]), new InstallCommand("winget", ["install", "Rustlang.Rustup"]), new InstallCommand("npm", ["install", "-g", "@tauri-apps/cli"]), ]); } if (OperatingSystem.IsMacOS()) { return new InstallPlan( "tauri", Supported: true, Summary: "Fallback tauri plan (macOS): install Xcode command line tools, Rust toolchain, and Tauri CLI.", Commands: [ new InstallCommand("sh", ["-c", "xcode-select --install || true"]), new InstallCommand("sh", ["-c", "curl https://sh.rustup.rs -sSf | sh -s -- -y"]), new InstallCommand("npm", ["install", "-g", "@tauri-apps/cli"]), ]); } var linuxPlan = BuildLinuxTauriPrereqCommand(); return new InstallPlan( "tauri", Supported: true, Summary: $"Fallback tauri plan (Linux): detected package manager `{linuxPlan.PackageManager}` for system deps, then install Rust toolchain and Tauri CLI.", Commands: [ new InstallCommand("sh", ["-c", linuxPlan.Command]), new InstallCommand("sh", ["-c", "curl https://sh.rustup.rs -sSf | sh -s -- -y"]), new InstallCommand("npm", ["install", "-g", "@tauri-apps/cli"]), ]); } private static (string PackageManager, string Command) BuildLinuxTauriPrereqCommand() { if (CommandExists("apt-get")) { return ("apt-get", "sudo apt-get update && sudo apt-get install -y build-essential libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev"); } if (CommandExists("dnf")) { return ("dnf", "sudo dnf install -y gcc gcc-c++ make webkit2gtk4.1-devel gtk3-devel libappindicator-gtk3 librsvg2-devel"); } if (CommandExists("pacman")) { return ("pacman", "sudo pacman -S --needed base-devel webkit2gtk gtk3 libappindicator-gtk3 librsvg"); } if (CommandExists("zypper")) { return ("zypper", "sudo zypper install -y gcc gcc-c++ make webkit2gtk3-devel gtk3-devel libappindicator3-devel librsvg-devel"); } if (CommandExists("apk")) { return ("apk", "sudo apk add build-base webkit2gtk-dev gtk+3.0-dev libayatana-appindicator-dev librsvg-dev"); } return ("unknown", "echo Install tauri system dependencies using your distro package manager, then rerun SDT."); } private static bool CommandExists(string command) { try { var psi = new ProcessStartInfo { FileName = OperatingSystem.IsWindows() ? "where" : "which", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }; psi.ArgumentList.Add(command); using var process = Process.Start(psi); if (process is null) return false; process.WaitForExit(1500); return process.ExitCode == 0; } catch { return false; } } private static InstallPlan? TryGetPlanFromConfig(string tool, DevToolConfig? config) { var preferred = config?.Tooling?.Tools .FirstOrDefault(t => string.Equals(t.Tool, tool, StringComparison.OrdinalIgnoreCase)) ?.PreferredInstallCommands; if (preferred is null || preferred.Count == 0) return null; var commands = new List(); foreach (var line in preferred) { var parts = SplitShellLike(line); if (parts.Count == 0) continue; commands.Add(new InstallCommand(parts[0], parts.Skip(1).ToList())); } if (commands.Count == 0) return null; return new InstallPlan( tool, Supported: true, Summary: $"Configured install commands for {tool}", Commands: commands); } private static List SplitShellLike(string input) { if (string.IsNullOrWhiteSpace(input)) return []; var tokens = new List(); var matches = Regex.Matches(input, "\"([^\"]*)\"|'([^']*)'|\\S+"); foreach (Match match in matches) { var value = match.Value.Trim(); if (value.Length >= 2 && ( (value.StartsWith("\"", StringComparison.Ordinal) && value.EndsWith("\"", StringComparison.Ordinal)) || (value.StartsWith("'", StringComparison.Ordinal) && value.EndsWith("'", StringComparison.Ordinal)))) { value = value[1..^1]; } if (!string.IsNullOrWhiteSpace(value)) tokens.Add(value); } return tokens; } private sealed class InstallPlanJson { public string? Tool { get; init; } public bool Supported { get; init; } public string? Summary { get; init; } public List Commands { get; init; } = []; } private sealed class InstallCommandJson { public string? Command { get; init; } public List? Args { get; init; } } }