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
+4
View File
@@ -12,6 +12,10 @@ pub fn run(app: &App, fix: bool) -> Result<i32> {
for r in &repaired {
app.log.success(r);
}
let runner = SystemRunner::new(false, app.cli.verbose, &app.log);
for r in doctor::install_missing_tools(app, &runner)? {
app.log.success(r);
}
app.log.info("run 'am doctor' again to verify");
return Ok(0);
}
+52 -18
View File
@@ -111,34 +111,60 @@ pub fn check_agent(agent: &AgentDef, runner: &dyn Runner) -> Vec<DepCheck> {
}
/// Fail with an actionable message when dependencies are missing.
/// With auto_install_deps enabled, dependencies that define an install_cmd
/// are offered for automatic installation first.
///
/// With auto_install_deps enabled (the default), missing tools are offered
/// for installation right away: an explicit install_cmd from the config
/// first, otherwise the command detected for the current OS
/// (scoop/winget on Windows, apt/dnf/pacman/apk on Linux, brew on macOS).
pub fn ensure_deps(app: &App, agent: &AgentDef, runner: &dyn Runner) -> Result<()> {
let mut checks = check_agent(agent, runner);
if checks.iter().all(|c| c.ok) {
return Ok(());
}
let os = crate::toolchain::OsInfo::detect();
if app.config.settings.auto_install_deps() && !app.dry_run() {
for dep in &agent.dependencies {
if dep.install_cmd.is_empty() {
continue;
}
if check_dep(runner, dep).ok {
continue;
}
let cmd_line = dep.install_cmd.join(" ");
app.log.warn(&format!("dependency '{}' is missing", dep.name));
let proceed = app
.confirm(&format!("Run '{cmd_line}' to install it?"))
.unwrap_or(false);
if proceed {
let prog = dep.install_cmd[0].clone();
let args: Vec<String> = dep.install_cmd[1..].to_vec();
let empty = BTreeMap::new();
let out = runner.run(&prog, &args, &empty, None, false)?;
if out.code != 0 {
app.log
.warn(&format!("automatic installation of '{}' failed", dep.name));
// 1) Explicit install command from the configuration.
if !dep.install_cmd.is_empty() {
let cmd_line = dep.install_cmd.join(" ");
if app.confirm(&format!("Run '{cmd_line}' to install it?"))? {
let prog = dep.install_cmd[0].clone();
let args: Vec<String> = dep.install_cmd[1..].to_vec();
let empty = BTreeMap::new();
let out = runner.run(&prog, &args, &empty, None, false)?;
if out.code != 0 {
app.log.warn(&format!(
"automatic installation of '{}' failed (exit code {})",
dep.name, out.code
));
}
}
continue;
}
// 2) Command detected for the current OS.
let cmds = crate::toolchain::install_commands(&dep.name, &os);
if let Some(cmd) = cmds.first() {
let proceed = app
.confirm(&format!("Run the detected installer for {}? -> {cmd}", os.name))?;
if proceed {
if let Ok(tokens) = shell_words::split(cmd) {
if !tokens.is_empty() {
let prog = tokens[0].clone();
let args: Vec<String> = tokens[1..].to_vec();
let empty = BTreeMap::new();
let out = runner.run(&prog, &args, &empty, None, false)?;
if out.code != 0 {
app.log.warn(&format!(
"installation of '{}' failed (exit code {})",
dep.name, out.code
));
}
}
}
}
}
}
@@ -153,7 +179,15 @@ pub fn ensure_deps(app: &App, agent: &AgentDef, runner: &dyn Runner) -> Result<(
if let Some(min) = &c.min {
line.push_str(&format!(" (minimum: {min})"));
}
if let Some(dep) = agent.dependencies.iter().find(|d| d.name == c.name) {
let cmds = crate::toolchain::install_commands(&c.name, &os);
if !cmds.is_empty() {
line.push_str(&format!(
"
on {}: {}",
os.name,
cmds.join(" | ")
));
} else if let Some(dep) = agent.dependencies.iter().find(|d| d.name == c.name) {
if let Some(hint) = &dep.install_hint {
line.push_str(&format!(" — install via {hint}"));
}
+49 -6
View File
@@ -63,6 +63,7 @@ pub fn run(app: &App, runner: &dyn Runner) -> DoctorReport {
("uv", false),
("docker", false),
];
let os = crate::toolchain::OsInfo::detect();
for (tool, required) in tools {
match runner.which(tool) {
Some(path) => {
@@ -84,14 +85,20 @@ pub fn run(app: &App, runner: &dyn Runner) -> DoctorReport {
});
}
None => {
let (status, detail) = if *required {
(
Status::Error,
"required but not found on PATH".to_string(),
)
let mut detail = if *required {
"required but not found on PATH".to_string()
} else {
(Status::Ok, "not installed".to_string())
"not installed".to_string()
};
let cmds = crate::toolchain::install_commands(tool, &os);
if !cmds.is_empty() {
detail.push_str(&format!(
" — on {}: {}",
os.name,
cmds.join(" | ")
));
}
let status = if *required { Status::Error } else { Status::Ok };
checks.push(Check {
name: tool.to_string(),
status,
@@ -199,6 +206,42 @@ fn is_on_path(dir: &std::path::Path) -> bool {
})
}
/// Offer to install the missing optional tools (node, python, ...) with
/// the command detected for the current OS. Returns what was repaired.
pub fn install_missing_tools(app: &App, runner: &dyn Runner) -> Result<Vec<String>> {
let mut installed = Vec::new();
let os = crate::toolchain::OsInfo::detect();
for tool in ["node", "python", "cargo", "go", "bun", "uv", "docker"] {
if runner.which(tool).is_some() {
continue;
}
let cmds = crate::toolchain::install_commands(tool, &os);
let Some(cmd) = cmds.first() else {
continue;
};
if !app.confirm(&format!(
"'{tool}' is missing — run the detected installer for {}? -> {cmd}",
os.name
))? {
continue;
}
if let Ok(tokens) = shell_words::split(cmd) {
if !tokens.is_empty() {
let prog = tokens[0].clone();
let args: Vec<String> = tokens[1..].to_vec();
let empty = BTreeMap::new();
let out = runner.run(&prog, &args, &empty, None, false)?;
installed.push(if out.code == 0 {
format!("installed '{tool}' ({cmd})")
} else {
format!("failed to install '{tool}' (exit code {})", out.code)
});
}
}
}
Ok(installed)
}
/// Repair what can be repaired: create directories, fix a corrupt state file.
pub fn fix(app: &App) -> Result<Vec<String>> {
let mut repaired = Vec::new();
+17 -4
View File
@@ -136,11 +136,24 @@ pub fn sh_inherit(
Ok(())
}
/// Require a tool on the PATH, with an actionable error message.
/// Require a tool on the PATH, with an actionable error message that
/// includes the install command detected for the current OS.
pub fn require_tool(runner: &dyn Runner, tool: &str, hint: &str) -> Result<PathBuf> {
runner
.which(tool)
.ok_or_else(|| anyhow!("'{tool}' not found on PATH — install it first ({hint})"))
if let Some(p) = runner.which(tool) {
return Ok(p);
}
let os = crate::toolchain::OsInfo::detect();
let mut msg = format!("'{tool}' not found on PATH — install it first ({hint})");
let cmds = crate::toolchain::install_commands(tool, &os);
if !cmds.is_empty() {
msg.push_str(&format!(
"
on {}: {}",
os.name,
cmds.join(" | ")
));
}
Err(anyhow!(msg))
}
/// Detect a binary's version via --version, then -V.
+1
View File
@@ -26,6 +26,7 @@ pub mod process;
pub mod repl;
pub mod runner;
pub mod state;
pub mod toolchain;
pub mod version;
use clap::Parser;
+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"));
}
}