401 lines
15 KiB
Rust
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(¤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<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"));
|
|
}
|
|
}
|