310 lines
9.4 KiB
Rust
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,
|
|
}
|
|
}
|