//! Installer implementations: one module per install method. //! //! All installers share the same helpers (command execution through a //! Runner, bin-dir snapshots, version detection) so they behave identically //! under --dry-run and in tests. pub mod binary; pub mod cargo; pub mod git; pub mod golang; pub mod npm; pub mod pipuv; pub mod script; use crate::app::App; use crate::config::{AgentDef, InstallType, MethodSpec}; use crate::runner::{RunOutput, Runner}; use crate::state::InstalledEntry; use anyhow::{anyhow, bail, Context, Result}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; /// Platform executable suffix ("" on Unix, ".exe" on Windows). pub const EXE_SUFFIX: &str = if cfg!(windows) { ".exe" } else { "" }; #[derive(Debug, Clone, Default)] pub struct InstallOutcome { /// Executables (or shims) that belong to this installation. pub bins: Vec, /// Detected version (when possible). pub version: Option, } /// Execute the right installer for the method type. pub fn run_install( app: &App, agent: &AgentDef, method: &MethodSpec, runner: &dyn Runner, ) -> Result { match method.kind { InstallType::Npm => npm::install(app, agent, method, runner), InstallType::Bun => npm::bun_install(app, agent, method, runner), InstallType::Pip => pipuv::install(app, agent, method, runner, false), InstallType::Uv => pipuv::install(app, agent, method, runner, true), InstallType::Cargo => cargo::install(app, agent, method, runner), InstallType::Go => golang::install(app, agent, method, runner), InstallType::Curl => script::install(app, agent, method, runner), InstallType::Binary => binary::install(app, agent, method, runner), InstallType::Git => git::install(app, agent, method, runner), } } /// Best-effort "latest available version" for the method type. pub fn latest_version( app: &App, agent: &AgentDef, method: &MethodSpec, runner: &dyn Runner, ) -> Result> { match method.kind { InstallType::Npm | InstallType::Bun => npm::latest(app, agent, method, runner), InstallType::Pip | InstallType::Uv => pipuv::latest(app, agent, method, runner), InstallType::Cargo => cargo::latest(app, agent, method, runner), InstallType::Binary => binary::latest(app, agent, method, runner), _ => Ok(None), } } // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- /// Root directory for one agent's installation. pub fn install_root(app: &App, agent_name: &str) -> PathBuf { app.paths.install_dir.join(agent_name) } /// Global bin directory (shims/executables, added to PATH). pub fn bin_dir(app: &App) -> PathBuf { app.paths.bin_dir.clone() } /// Create a directory unless running in dry-run mode. pub fn ensure_dir(app: &App, path: &Path) -> Result<()> { if !app.dry_run() { std::fs::create_dir_all(path) .with_context(|| format!("cannot create {}", path.display()))?; } Ok(()) } /// Run a captured command, failing on a non-zero exit code. pub fn sh( runner: &dyn Runner, cmd: &str, args: &[String], env: &BTreeMap, cwd: Option<&Path>, ) -> Result { // Installs are legitimately long (npm/cargo/go/... can take minutes): // no probe timeout here, only run() (doctor/version probes) is bounded. let out = runner.run_untimed(cmd, args, env, cwd, true)?; if out.code != 0 { let mut detail = out.stderr.trim().to_string(); if detail.is_empty() { detail = out.stdout.trim().to_string(); } bail!( "command failed (exit code {}): {} {} {}", out.code, cmd, args.join(" "), detail ); } Ok(out) } /// Run a command with inherited stdio, failing on a non-zero exit code. pub fn sh_inherit( runner: &dyn Runner, cmd: &str, args: &[String], env: &BTreeMap, cwd: Option<&Path>, ) -> Result<()> { let out = runner.run(cmd, args, env, cwd, false)?; if out.code != 0 { bail!( "command failed (exit code {}): {} {}", out.code, cmd, args.join(" ") ); } Ok(()) } /// 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 { 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. pub fn detect_version(runner: &dyn Runner, bin: &Path) -> Option { let empty = BTreeMap::new(); if let Ok(out) = runner.run(&bin.display().to_string(), &["--version".to_string()], &empty, None, true) { if out.code == 0 { let combined = format!("{} {}", out.stdout, out.stderr); if let Some(v) = crate::version::find_version(&combined) { return Some(v); } } } if let Ok(out) = runner.run(&bin.display().to_string(), &["-V".to_string()], &empty, None, true) { if out.code == 0 { let combined = format!("{} {}", out.stdout, out.stderr); return crate::version::find_version(&combined); } } None } /// Executable name with the platform suffix. pub fn exe_name(name: &str) -> String { format!("{name}{EXE_SUFFIX}") } /// Names the installed binaries should match: the binary hint, the run /// command's first token, and the agent name. pub fn expected_bin_names(agent: &AgentDef, method: &MethodSpec) -> Vec { let mut names = Vec::new(); if let Some(b) = &method.binary { names.push(b.clone()); } if let Some(t) = agent.first_token() { if !names.iter().any(|n| n.eq_ignore_ascii_case(&t)) { names.push(t); } } if !names.iter().any(|n| n == &agent.name) { names.push(agent.name.clone()); } names } /// Snapshot a directory: file name -> full path. pub fn snapshot_dir(dir: &Path) -> BTreeMap { let mut map = BTreeMap::new(); if let Ok(rd) = std::fs::read_dir(dir) { for entry in rd.flatten() { let p = entry.path(); if p.is_file() { if let Some(name) = p.file_name().and_then(|n| n.to_str()) { map.insert(name.to_string(), p); } } } } map } /// Entries present in "after" but not in "before". pub fn diff_dir( before: &BTreeMap, after: &BTreeMap, ) -> Vec { after .iter() .filter(|(k, _)| !before.contains_key(*k)) .map(|(_, v)| v.clone()) .collect() } /// Files in dir whose base name (sans extension) matches an expected name. /// On Windows, .cmd/.bat shims are preferred over bare scripts. pub fn find_bins(dir: &Path, expected: &[String]) -> Vec { let mut out = Vec::new(); let Ok(rd) = std::fs::read_dir(dir) else { return out; }; let mut entries: Vec = rd.flatten().map(|e| e.path()).collect(); entries.sort(); for p in entries { if !p.is_file() { continue; } let name = p .file_name() .and_then(|n| n.to_str()) .unwrap_or("") .to_string(); let stripped = name .strip_suffix(".exe") .or_else(|| name.strip_suffix(".cmd")) .or_else(|| name.strip_suffix(".bat")) .or_else(|| name.strip_suffix(".ps1")) .unwrap_or(&name) .to_string(); if expected.iter().any(|e| e.eq_ignore_ascii_case(&stripped)) { out.push(p); } } // Prefer a launchable shim for this platform. out.sort_by_key(|p| { let name = p .file_name() .and_then(|n| n.to_str()) .unwrap_or("") .to_lowercase(); if cfg!(windows) { if name.ends_with(".cmd") || name.ends_with(".bat") { 0u8 } else if name.ends_with(".exe") { 1 } else { 2 } } else if name.ends_with(".cmd") || name.ends_with(".exe") || name.ends_with(".ps1") { 2 } else { 0 } }); out } pub fn now_rfc3339() -> String { chrono::Utc::now().to_rfc3339() } /// Build the state entry recorded after a successful install. pub fn make_entry( agent: &AgentDef, method: &MethodSpec, outcome: &InstallOutcome, app: &App, ) -> InstalledEntry { InstalledEntry { name: agent.name.clone(), version: outcome.version.clone().or_else(|| agent.version.clone()), method: method.kind.as_str().to_string(), install_dir: install_root(app, &agent.name).display().to_string(), bins: outcome.bins.iter().map(|b| b.display().to_string()).collect(), run: agent.run.clone().unwrap_or_default(), installed_at: now_rfc3339(), updated_at: None, pid: None, started_at: None, } }