Files
agent-manager/src/toolchain.rs
T

401 lines
15 KiB
Rust

//! OS detection and concrete installation commands for missing toolchains.
//!
//! When an agent requires a tool (node, python, go, ...) that is missing,
//! agent-manager detects the operating system — and the package manager
//! already present (scoop/winget on Windows, apt/dnf/pacman/apk on Linux,
//! brew on macOS) — and proposes the exact command to install it.
use crate::runner::Runner;
use anyhow::Result;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Family {
Windows,
Macos,
Linux,
}
#[derive(Debug, Clone)]
pub struct OsInfo {
pub name: String,
pub family: Family,
/// Package manager found on Linux: apt, dnf, pacman, apk or zypper.
pub linux_pkg: Option<String>,
pub has_scoop: bool,
pub has_winget: bool,
}
impl Default for OsInfo {
fn default() -> Self {
Self::detect()
}
}
impl OsInfo {
/// Detect the current operating system and available package managers.
pub fn detect() -> Self {
#[cfg(windows)]
{
let has_scoop = which::which("scoop").is_ok();
let has_winget = which::which("winget").is_ok();
return Self {
name: "Windows".to_string(),
family: Family::Windows,
linux_pkg: None,
has_scoop,
has_winget,
};
}
#[cfg(target_os = "macos")]
{
return Self {
name: "macOS".to_string(),
family: Family::Macos,
linux_pkg: None,
has_scoop: false,
has_winget: false,
};
}
#[cfg(not(any(windows, target_os = "macos")))]
{
let id = linux_distro_id();
let (name, pkg) = linux_pkg_for_id(&id);
return Self {
name: name.to_string(),
family: Family::Linux,
linux_pkg: pkg.map(|s| s.to_string()),
has_scoop: false,
has_winget: false,
};
}
}
}
#[cfg(not(any(windows, target_os = "macos")))]
fn linux_distro_id() -> String {
let Ok(text) = std::fs::read_to_string("/etc/os-release") else {
return String::new();
};
for line in text.lines() {
if let Some(v) = line.strip_prefix("ID=") {
return v.trim().trim_matches('"').to_string();
}
}
String::new()
}
/// Map a distro id (from /etc/os-release) to (display name, package manager).
pub fn linux_pkg_for_id(id: &str) -> (&'static str, Option<&'static str>) {
match id {
"debian" | "ubuntu" | "linuxmint" | "pop" | "raspbian" | "elementary" => {
("Debian/Ubuntu", Some("apt"))
}
"fedora" | "rhel" | "centos" | "rocky" | "almalinux" | "ol" => {
("Fedora/RHEL", Some("dnf"))
}
"arch" | "manjaro" | "endeavouros" | "artix" => ("Arch Linux", Some("pacman")),
"alpine" => ("Alpine", Some("apk")),
"opensuse-tumbleweed" | "opensuse-leap" | "suse" => ("openSUSE", Some("zypper")),
_ => ("Linux", None),
}
}
/// Concrete install commands for a tool on the detected OS, best option
/// first (scoop before winget when both exist, etc.).
pub fn install_commands(tool: &str, os: &OsInfo) -> Vec<String> {
let mut out = Vec::new();
match os.family {
Family::Windows => {
if os.has_scoop {
if let Some(c) = scoop_command(tool) {
out.push(c);
}
}
if os.has_winget || out.is_empty() {
if let Some(c) = winget_command(tool) {
out.push(c);
}
}
}
Family::Macos => {
if let Some(c) = brew_command(tool) {
out.push(c);
}
}
Family::Linux => {
if let Some(pkg) = os.linux_pkg.as_deref() {
if let Some(c) = linux_command(tool, pkg) {
out.push(c);
}
}
}
}
out
}
/// Run an installer command line. Commands containing shell syntax
/// (pipes, redirections, ...) are executed through the shell; plain
/// commands are spawned directly.
pub fn run_install_command(runner: &dyn Runner, cmd: &str) -> Result<i32> {
let needs_shell = cmd.contains('|')
|| cmd.contains('>')
|| cmd.contains('<')
|| cmd.contains('&')
|| cmd.contains(';');
let (prog, args) = if needs_shell {
#[cfg(windows)]
{
("cmd".to_string(), vec!["/C".to_string(), cmd.to_string()])
}
#[cfg(not(windows))]
{
("sh".to_string(), vec!["-c".to_string(), cmd.to_string()])
}
} else {
let tokens = shell_words::split(cmd)
.unwrap_or_else(|_| cmd.split_whitespace().map(String::from).collect());
if tokens.is_empty() {
return Ok(0);
}
(tokens[0].clone(), tokens[1..].to_vec())
};
let empty = std::collections::BTreeMap::new();
let out = runner.run(&prog, &args, &empty, None, false)?;
Ok(out.code)
}
/// After installers that modify ~/.profile or ~/.zshrc (rustup, bun, uv),
/// make the newly installed tools visible to the current process so the
/// dependency re-check can find them.
pub fn refresh_path() {
let mut extra: Vec<PathBuf> = Vec::new();
if let Some(home) = crate::config::home_dir() {
extra.push(home.join(".cargo").join("bin"));
extra.push(home.join(".bun").join("bin"));
extra.push(home.join(".local").join("bin"));
}
let mut parts: Vec<PathBuf> = extra.into_iter().filter(|p| p.is_dir()).collect();
if let Some(current) = std::env::var_os("PATH") {
parts.extend(std::env::split_paths(&current));
}
if let Ok(joined) = std::env::join_paths(parts) {
std::env::set_var("PATH", joined);
}
}
/// Is a tool considered present? Python is satisfied by python3 too
/// (Debian ships python3 without a python symlink).
pub fn tool_is_present(tool: &str, runner: &dyn Runner) -> bool {
if tool == "python" {
runner.which("python3").is_some() || runner.which("python").is_some()
} else {
runner.which(tool).is_some()
}
}
fn scoop_command(tool: &str) -> Option<String> {
let bucket = match tool {
"git" => "git",
"node" | "npm" => "nodejs-lts",
"python" | "python3" | "pip" => "python",
"cargo" | "rust" => "rustup",
"go" => "go",
"bun" => "bun",
"uv" => "uv",
"docker" => "docker",
_ => return None,
};
Some(format!("scoop install {bucket}"))
}
fn winget_command(tool: &str) -> Option<String> {
let package = match tool {
"git" => "Git.Git",
"node" | "npm" => "OpenJS.NodeJS.LTS",
"python" | "python3" | "pip" => "Python.Python.3.12",
"cargo" | "rust" => "Rustlang.Rustup",
"go" => "GoLang.Go",
"bun" => "Oven-sh.Bun",
"uv" => "astral-sh.uv",
"docker" => "Docker.DockerDesktop",
_ => return None,
};
Some(format!(
"winget install --accept-source-agreements --accept-package-agreements {package}"
))
}
fn brew_command(tool: &str) -> Option<String> {
Some(
match tool {
"git" => "brew install git".to_string(),
"curl" => "brew install curl".to_string(),
"node" | "npm" => "brew install node".to_string(),
"python" | "python3" | "pip" => "brew install python".to_string(),
"cargo" | "rust" => "brew install rustup".to_string(),
"go" => "brew install go".to_string(),
"bun" => "brew install oven-sh/bun/bun".to_string(),
"uv" => "brew install uv".to_string(),
"docker" => "brew install --cask docker".to_string(),
_ => return None,
},
)
}
fn linux_command(tool: &str, pkg: &str) -> Option<String> {
Some(
match (tool, pkg) {
("git", "apt") => "sudo apt install -y git",
("git", "dnf") => "sudo dnf install -y git",
("git", "pacman") => "sudo pacman -S --noconfirm git",
("git", "apk") => "sudo apk add git",
("git", "zypper") => "sudo zypper install -y git",
("curl", "apt") => "sudo apt install -y curl",
("curl", "dnf") => "sudo dnf install -y curl",
("curl", "pacman") => "sudo pacman -S --noconfirm curl",
("curl", "apk") => "sudo apk add curl",
("curl", "zypper") => "sudo zypper install -y curl",
("node" | "npm", "apt") => "sudo apt install -y nodejs npm",
("node" | "npm", "dnf") => "sudo dnf install -y nodejs npm",
("node" | "npm", "pacman") => "sudo pacman -S --noconfirm nodejs npm",
("node" | "npm", "apk") => "sudo apk add nodejs npm",
("node" | "npm", "zypper") => "sudo zypper install -y nodejs npm",
("python" | "python3", "apt") => "sudo apt install -y python3 python3-venv python3-pip",
("python" | "python3", "dnf") => "sudo dnf install -y python3 python3-pip",
("python" | "python3", "pacman") => "sudo pacman -S --noconfirm python python-pip",
("python" | "python3", "apk") => "sudo apk add python3 py3-pip",
("python" | "python3", "zypper") => "sudo zypper install -y python3 python3-pip",
("pip", "apt") => "sudo apt install -y python3-pip",
("pip", "dnf") => "sudo dnf install -y python3-pip",
("pip", "pacman") => "sudo pacman -S --noconfirm python-pip",
("pip", "apk") => "sudo apk add py3-pip",
("pip", "zypper") => "sudo zypper install -y python3-pip",
("cargo" | "rust", "apt") => {
"curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y"
}
("cargo" | "rust", "dnf") => "sudo dnf install -y rust cargo",
("cargo" | "rust", "pacman") => "sudo pacman -S --noconfirm rust",
("cargo" | "rust", "apk") => "sudo apk add rust cargo",
("cargo" | "rust", "zypper") => "sudo zypper install -y rust cargo",
("go", "apt") => "sudo apt install -y golang-go",
("go", "dnf") => "sudo dnf install -y golang",
("go", "pacman") => "sudo pacman -S --noconfirm go",
("go", "apk") => "sudo apk add go",
("go", "zypper") => "sudo zypper install -y go",
("bun", "apt") => "curl -fsSL https://bun.sh/install | bash",
("bun", "dnf") => "curl -fsSL https://bun.sh/install | bash",
("bun", "pacman") => "curl -fsSL https://bun.sh/install | bash",
("bun", "apk") => "sudo apk add bun",
("bun", "zypper") => "curl -fsSL https://bun.sh/install | bash",
("uv", "apt") => "curl -LsSf https://astral.sh/uv/install.sh | sh",
("uv", "dnf") => "sudo dnf install -y uv",
("uv", "pacman") => "sudo pacman -S --noconfirm uv",
("uv", "apk") => "sudo apk add uv",
("uv", "zypper") => "sudo zypper install -y uv",
("docker", "apt") => "sudo apt install -y docker.io",
("docker", "dnf") => "sudo dnf install -y docker",
("docker", "pacman") => "sudo pacman -S --noconfirm docker",
("docker", "apk") => "sudo apk add docker",
("docker", "zypper") => "sudo zypper install -y docker",
_ => return None,
}
.to_string(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn distro_mapping() {
assert_eq!(linux_pkg_for_id("debian").1, Some("apt"));
assert_eq!(linux_pkg_for_id("ubuntu").1, Some("apt"));
assert_eq!(linux_pkg_for_id("fedora").1, Some("dnf"));
assert_eq!(linux_pkg_for_id("arch").1, Some("pacman"));
assert_eq!(linux_pkg_for_id("alpine").1, Some("apk"));
assert_eq!(linux_pkg_for_id("opensuse-tumbleweed").1, Some("zypper"));
assert_eq!(linux_pkg_for_id("").1, None);
}
#[test]
fn windows_commands() {
assert_eq!(scoop_command("node").as_deref(), Some("scoop install nodejs-lts"));
assert_eq!(scoop_command("uv").as_deref(), Some("scoop install uv"));
assert!(winget_command("git").unwrap().contains("Git.Git"));
assert!(winget_command("python").unwrap().contains("--accept-source-agreements"));
assert!(scoop_command("unknown-tool").is_none());
}
#[test]
fn linux_commands() {
assert!(linux_command("node", "apt").unwrap().contains("nodejs"));
assert!(linux_command("cargo", "apt").unwrap().contains("rustup"));
assert!(linux_command("go", "pacman").unwrap().contains("pacman -S"));
assert!(linux_command("python", "apk").unwrap().contains("python3"));
}
#[test]
fn macos_commands() {
assert_eq!(brew_command("node").as_deref(), Some("brew install node"));
assert!(brew_command("docker").unwrap().contains("--cask"));
}
#[test]
fn pipe_commands_run_through_shell() {
let runner = crate::runner::MockRunner::new();
let code = run_install_command(&runner, "curl -sSf https://x | sh").unwrap();
assert_eq!(code, 0);
let calls = runner.snapshot();
assert_eq!(calls.len(), 1);
if cfg!(windows) {
assert!(calls[0].starts_with("cmd /C "));
} else {
assert!(calls[0].starts_with("sh -c "));
}
}
#[test]
fn plain_commands_spawn_directly() {
let runner = crate::runner::MockRunner::new();
run_install_command(&runner, "apt install -y git").unwrap();
let calls = runner.snapshot();
assert!(calls[0].starts_with("apt install -y git"));
}
#[test]
fn python3_satisfies_python() {
let runner = crate::runner::MockRunner::new().with_found("python3");
assert!(tool_is_present("python", &runner));
let runner = crate::runner::MockRunner::new();
assert!(!tool_is_present("python", &runner));
}
#[test]
fn os_aware_selection() {
// Windows without scoop falls back to winget.
let os = OsInfo {
name: "Windows".into(),
family: Family::Windows,
linux_pkg: None,
has_scoop: false,
has_winget: true,
};
let cmds = install_commands("node", &os);
assert!(!cmds.is_empty());
assert!(cmds[0].contains("winget"));
// Debian-like systems get apt commands.
let deb = OsInfo {
name: "Debian".into(),
family: Family::Linux,
linux_pkg: Some("apt".into()),
has_scoop: false,
has_winget: false,
};
let cmds = install_commands("go", &deb);
assert!(!cmds.is_empty());
assert!(cmds[0].contains("apt"));
}
}