v0.2.2 : suggestions d'installation selon l'OS (scoop/winget/apt/dnf/pacman/apk/brew) + README remanie

This commit is contained in:
2026-08-15 12:56:33 -04:00
parent bd1d566a44
commit e513dd485d
9 changed files with 638 additions and 390 deletions
+306
View File
@@ -0,0 +1,306 @@
//! 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.
#[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
}
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"
}
("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 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"));
}
}