journal/Journal.DevTool/Core/PrereqInstallerService.cs

304 lines
12 KiB
C#

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<InstallPlan> 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<InstallPlanJson>(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<RunResult> RunInstallAsync(
InstallCommand command,
string projectRoot,
Action<string, bool> 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<InstallCommand>();
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<string> SplitShellLike(string input)
{
if (string.IsNullOrWhiteSpace(input))
return [];
var tokens = new List<string>();
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<InstallCommandJson> Commands { get; init; } = [];
}
private sealed class InstallCommandJson
{
public string? Command { get; init; }
public List<string>? Args { get; init; }
}
}