304 lines
12 KiB
C#
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; }
|
|
}
|
|
}
|