Files
agent-manager/src/installers/mod.rs
T

310 lines
9.4 KiB
Rust

//! 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<PathBuf>,
/// Detected version (when possible).
pub version: Option<String>,
}
/// Execute the right installer for the method type.
pub fn run_install(
app: &App,
agent: &AgentDef,
method: &MethodSpec,
runner: &dyn Runner,
) -> Result<InstallOutcome> {
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<Option<String>> {
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<String, String>,
cwd: Option<&Path>,
) -> Result<RunOutput> {
let out = runner.run(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<String, String>,
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<PathBuf> {
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<String> {
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<String> {
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<String, PathBuf> {
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<String, PathBuf>,
after: &BTreeMap<String, PathBuf>,
) -> Vec<PathBuf> {
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<PathBuf> {
let mut out = Vec::new();
let Ok(rd) = std::fs::read_dir(dir) else {
return out;
};
let mut entries: Vec<PathBuf> = 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,
}
}