//! 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, 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 { 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 { 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 = 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 = extra.into_iter().filter(|p| p.is_dir()).collect(); if let Some(current) = std::env::var_os("PATH") { parts.extend(std::env::split_paths(¤t)); } 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 { 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 { 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 { 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 { 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")); } }