agent-manager v0.2.0 : gestionnaire d'agents IA locaux
- CLI Rust (am) : list/install/uninstall/update/start/stop/restart/run/status/search/info/config/doctor/completion/export/import/self-update + shell interactif (Tab, historique) - 9 methodes d'installation (npm, pip, uv, cargo, go, bun, curl, binary, git) - catalogue de 72 agents, dependances, processus, securite (dry-run, checksums) - sondes de version paralleles + cache (am list ~25 ms a chaud) - installateurs one-liner : install.sh, install.ps1
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
//! Binary installer: download a precompiled binary (direct URL or GitHub
|
||||
//! Releases asset), verify its checksum, extract it if needed, and install
|
||||
//! it into the global bin directory.
|
||||
|
||||
use super::*;
|
||||
|
||||
pub fn install(
|
||||
app: &App,
|
||||
agent: &AgentDef,
|
||||
method: &MethodSpec,
|
||||
runner: &dyn Runner,
|
||||
) -> Result<InstallOutcome> {
|
||||
let root = install_root(app, &agent.name);
|
||||
let dist = root.join("dist");
|
||||
let bindir = bin_dir(app);
|
||||
ensure_dir(app, &root)?;
|
||||
ensure_dir(app, &dist)?;
|
||||
ensure_dir(app, &bindir)?;
|
||||
|
||||
// Resolve the download URL (direct or GitHub release asset).
|
||||
let (url, tag): (String, Option<String>) = if let Some(u) = &method.url {
|
||||
(u.clone(), None)
|
||||
} else if let Some(repo) = &method.repo {
|
||||
let release = crate::download::latest_release(repo)?;
|
||||
let picked = crate::download::pick_asset(
|
||||
&release,
|
||||
&method.asset_match,
|
||||
&method.asset_exclude,
|
||||
method.binary.as_deref(),
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"no release asset of '{repo}' matches this platform ({}/{})",
|
||||
crate::config::current_platform(),
|
||||
std::env::consts::ARCH
|
||||
)
|
||||
})?;
|
||||
app.log
|
||||
.info(&format!("selected asset: {} ({})", picked.name, picked.tag));
|
||||
(
|
||||
picked.url,
|
||||
Some(picked.tag.trim_start_matches('v').to_string()),
|
||||
)
|
||||
} else {
|
||||
bail!("binary install requires 'url' or 'repo' (GitHub owner/repo)");
|
||||
};
|
||||
|
||||
let names = expected_bin_names(agent, method);
|
||||
let bin_name = names
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| agent.name.clone());
|
||||
let dest_bin = bindir.join(exe_name(&bin_name));
|
||||
|
||||
if app.dry_run() {
|
||||
app.log
|
||||
.dry(format!("would download {url} and install {}", dest_bin.display()));
|
||||
return Ok(InstallOutcome {
|
||||
bins: vec![dest_bin],
|
||||
version: tag,
|
||||
});
|
||||
}
|
||||
|
||||
let tmpdir = tempfile::tempdir()?;
|
||||
let tmpfile = tmpdir.path().join("download");
|
||||
crate::download::download(&url, &tmpfile, method.checksum.as_deref(), true)?;
|
||||
if crate::download::extract_archive(&tmpfile, &dist)? {
|
||||
let found = crate::download::find_executable(&dist, Some(&bin_name))?;
|
||||
std::fs::copy(&found, &dest_bin).with_context(|| {
|
||||
format!("cannot copy {} to {}", found.display(), dest_bin.display())
|
||||
})?;
|
||||
app.log
|
||||
.info(&format!("extracted {} -> {}", found.display(), dest_bin.display()));
|
||||
} else {
|
||||
std::fs::copy(&tmpfile, &dest_bin).with_context(|| {
|
||||
format!("cannot copy {} to {}", tmpfile.display(), dest_bin.display())
|
||||
})?;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&dest_bin, std::fs::Permissions::from_mode(0o755));
|
||||
}
|
||||
let version = detect_version(runner, &dest_bin).or(tag);
|
||||
Ok(InstallOutcome {
|
||||
bins: vec![dest_bin],
|
||||
version,
|
||||
})
|
||||
}
|
||||
|
||||
/// Latest release tag (without the leading "v").
|
||||
pub fn latest(
|
||||
_app: &App,
|
||||
_agent: &AgentDef,
|
||||
method: &MethodSpec,
|
||||
_runner: &dyn Runner,
|
||||
) -> Result<Option<String>> {
|
||||
let Some(repo) = method.repo.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let release = crate::download::latest_release(repo)?;
|
||||
Ok(Some(release.tag_name.trim_start_matches('v').to_string()))
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! cargo installer: cargo install --root <root> <crate>.
|
||||
|
||||
use super::*;
|
||||
|
||||
pub fn install(
|
||||
app: &App,
|
||||
agent: &AgentDef,
|
||||
method: &MethodSpec,
|
||||
runner: &dyn Runner,
|
||||
) -> Result<InstallOutcome> {
|
||||
let pkg = method
|
||||
.package
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow!("cargo install requires 'package' (crate name)"))?;
|
||||
require_tool(runner, "cargo", "https://rustup.rs")?;
|
||||
let root = install_root(app, &agent.name);
|
||||
ensure_dir(app, &root)?;
|
||||
let mut args: Vec<String> = vec![
|
||||
"install".into(),
|
||||
"--root".into(),
|
||||
root.display().to_string(),
|
||||
pkg.to_string(),
|
||||
];
|
||||
args.extend(method.args.iter().cloned());
|
||||
sh(runner, "cargo", &args, &method.env, None)?;
|
||||
// cargo puts the binaries in <root>/bin
|
||||
let expected = expected_bin_names(agent, method);
|
||||
let bins = find_bins(&root.join("bin"), &expected);
|
||||
let version = bins.first().and_then(|b| detect_version(runner, b));
|
||||
Ok(InstallOutcome { bins, version })
|
||||
}
|
||||
|
||||
/// Latest version from cargo search <crate> --limit 1.
|
||||
pub fn latest(
|
||||
_app: &App,
|
||||
_agent: &AgentDef,
|
||||
method: &MethodSpec,
|
||||
runner: &dyn Runner,
|
||||
) -> Result<Option<String>> {
|
||||
let Some(pkg) = method.package.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if runner.which("cargo").is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let empty = BTreeMap::new();
|
||||
let out = runner.run(
|
||||
"cargo",
|
||||
&[
|
||||
"search".to_string(),
|
||||
pkg.to_string(),
|
||||
"--limit".to_string(),
|
||||
"1".to_string(),
|
||||
],
|
||||
&empty,
|
||||
None,
|
||||
true,
|
||||
)?;
|
||||
if out.code != 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
// Output looks like: smelt = "0.5.0" # description
|
||||
let text = out.stdout.trim().to_string();
|
||||
let Some(eq) = text.find('=') else {
|
||||
return Ok(None);
|
||||
};
|
||||
let rest = text[eq + 1..].trim();
|
||||
let Some(start) = rest.find('"') else {
|
||||
return Ok(None);
|
||||
};
|
||||
let inner = &rest[start + 1..];
|
||||
let Some(end) = inner.find('"') else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(inner[..end].to_string()))
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Git installer: clone the repository, run the build command(s), then
|
||||
//! either copy the built binary or create a shim that runs the agent from
|
||||
//! the repository directory.
|
||||
|
||||
use super::*;
|
||||
|
||||
pub fn install(
|
||||
app: &App,
|
||||
agent: &AgentDef,
|
||||
method: &MethodSpec,
|
||||
runner: &dyn Runner,
|
||||
) -> Result<InstallOutcome> {
|
||||
let url = method
|
||||
.repo
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow!("git install requires 'repo' (clone URL)"))?;
|
||||
if !(url.starts_with("https://")
|
||||
|| url.starts_with("http://")
|
||||
|| url.starts_with("git@")
|
||||
|| url.starts_with("file://"))
|
||||
{
|
||||
bail!("unsupported git URL: {url}");
|
||||
}
|
||||
require_tool(runner, "git", "https://git-scm.com")?;
|
||||
let root = install_root(app, &agent.name);
|
||||
let repo_dir = root.join("repo");
|
||||
let bindir = bin_dir(app);
|
||||
ensure_dir(app, &root)?;
|
||||
ensure_dir(app, &bindir)?;
|
||||
|
||||
if app.dry_run() {
|
||||
app.log
|
||||
.dry(format!("would clone {url} into {} and build", repo_dir.display()));
|
||||
return Ok(InstallOutcome::default());
|
||||
}
|
||||
if repo_dir.exists() {
|
||||
std::fs::remove_dir_all(&repo_dir)
|
||||
.with_context(|| format!("cannot remove {}", repo_dir.display()))?;
|
||||
}
|
||||
let mut env = method.env.clone();
|
||||
env.insert("GIT_TERMINAL_PROMPT".to_string(), "0".to_string());
|
||||
sh(
|
||||
runner,
|
||||
"git",
|
||||
&[
|
||||
"clone".to_string(),
|
||||
"--depth".to_string(),
|
||||
"1".to_string(),
|
||||
url.to_string(),
|
||||
repo_dir.display().to_string(),
|
||||
],
|
||||
&env,
|
||||
None,
|
||||
)?;
|
||||
|
||||
// Build commands (string or list).
|
||||
if let Some(build) = &method.build {
|
||||
for cmd in build.items() {
|
||||
let tokens = shell_words::split(&cmd)
|
||||
.map_err(|e| anyhow!("cannot parse build command '{cmd}': {e}"))?;
|
||||
if tokens.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let prog = tokens[0].clone();
|
||||
let args: Vec<String> = tokens[1..].to_vec();
|
||||
let mut benv = BTreeMap::new();
|
||||
let nm = repo_dir.join("node_modules").join(".bin");
|
||||
if nm.is_dir() {
|
||||
if let Ok(path) = std::env::var("PATH") {
|
||||
benv.insert(
|
||||
"PATH".to_string(),
|
||||
format!("{}{}{}", nm.display(), std::path::MAIN_SEPARATOR, path),
|
||||
);
|
||||
}
|
||||
}
|
||||
sh(runner, &prog, &args, &benv, Some(&repo_dir))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Install the binary (copy) or a shim that runs from the repo.
|
||||
let names = expected_bin_names(agent, method);
|
||||
let bin_name = names
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| agent.name.clone());
|
||||
let mut bins = Vec::new();
|
||||
if let Some(bp) = &method.binary_path {
|
||||
let src = repo_dir.join(bp);
|
||||
if !src.exists() {
|
||||
bail!(
|
||||
"built binary not found at {} (build succeeded but 'binary_path' may be wrong)",
|
||||
src.display()
|
||||
);
|
||||
}
|
||||
let dest = bindir.join(exe_name(&bin_name));
|
||||
std::fs::copy(&src, &dest)
|
||||
.with_context(|| format!("cannot copy {} to {}", src.display(), dest.display()))?;
|
||||
bins.push(dest);
|
||||
} else {
|
||||
let shim = bindir.join(exe_name(&bin_name));
|
||||
write_shim(agent, &shim, &repo_dir)?;
|
||||
bins.push(shim);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
for b in &bins {
|
||||
let _ = std::fs::set_permissions(b, std::fs::Permissions::from_mode(0o755));
|
||||
}
|
||||
}
|
||||
|
||||
// Version: git describe --tags --always inside the repo.
|
||||
let empty = BTreeMap::new();
|
||||
let version = runner
|
||||
.run(
|
||||
"git",
|
||||
&[
|
||||
"-C".to_string(),
|
||||
repo_dir.display().to_string(),
|
||||
"describe".to_string(),
|
||||
"--tags".to_string(),
|
||||
"--always".to_string(),
|
||||
],
|
||||
&empty,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.ok()
|
||||
.filter(|o| o.code == 0)
|
||||
.map(|o| o.stdout.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
Ok(InstallOutcome { bins, version })
|
||||
}
|
||||
|
||||
/// Create a small script that runs the agent from inside the repository
|
||||
/// directory (with node_modules/.bin on PATH for Node-based projects).
|
||||
fn write_shim(agent: &AgentDef, shim: &Path, repo_dir: &Path) -> Result<()> {
|
||||
let run = agent.run.clone().unwrap_or_default();
|
||||
if run.is_empty() {
|
||||
bail!("git install without 'binary_path' needs a 'run' command for the shim");
|
||||
}
|
||||
let script = if cfg!(windows) {
|
||||
let nm = repo_dir.join("node_modules").join(".bin");
|
||||
format!(
|
||||
"@echo off\r\ncd /d \"{repo}\"\r\nset \"PATH={nm};%PATH%\"\r\n{run} %*\r\n",
|
||||
repo = repo_dir.display(),
|
||||
nm = nm.display()
|
||||
)
|
||||
} else {
|
||||
let nm = repo_dir.join("node_modules").join(".bin");
|
||||
format!(
|
||||
"#!/bin/sh\ncd \"{repo}\"\n[ -d \"{nm}\" ] && PATH=\"{nm}:$PATH\"\nexec {run} \"$@\"\n",
|
||||
repo = repo_dir.display(),
|
||||
nm = nm.display()
|
||||
)
|
||||
};
|
||||
std::fs::write(shim, script).with_context(|| format!("cannot write shim {}", shim.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Go installer: go install <module>@latest into our own GOBIN.
|
||||
|
||||
use super::*;
|
||||
|
||||
pub fn install(
|
||||
app: &App,
|
||||
agent: &AgentDef,
|
||||
method: &MethodSpec,
|
||||
runner: &dyn Runner,
|
||||
) -> Result<InstallOutcome> {
|
||||
let pkg = method
|
||||
.package
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow!("go install requires 'package' (module path)"))?;
|
||||
require_tool(runner, "go", "https://go.dev/dl")?;
|
||||
let bindir = bin_dir(app);
|
||||
ensure_dir(app, &bindir)?;
|
||||
// go install needs "@version"; default to @latest.
|
||||
let target = if pkg.contains('@') {
|
||||
pkg.to_string()
|
||||
} else {
|
||||
format!("{pkg}@latest")
|
||||
};
|
||||
let mut env = method.env.clone();
|
||||
env.insert("GOBIN".to_string(), bindir.display().to_string());
|
||||
let args = vec!["install".to_string(), target];
|
||||
sh(runner, "go", &args, &env, None)?;
|
||||
let expected = expected_bin_names(agent, method);
|
||||
let bins = find_bins(&bindir, &expected);
|
||||
let version = bins.first().and_then(|b| detect_version(runner, b));
|
||||
Ok(InstallOutcome { bins, version })
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! 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.
|
||||
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})"))
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! npm and bun installers: install a package globally inside our own
|
||||
//! prefix so nothing pollutes the system.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// npm: npm install -g --prefix <root> <package>.
|
||||
pub fn install(
|
||||
app: &App,
|
||||
agent: &AgentDef,
|
||||
method: &MethodSpec,
|
||||
runner: &dyn Runner,
|
||||
) -> Result<InstallOutcome> {
|
||||
let pkg = method
|
||||
.package
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow!("npm install requires 'package'"))?;
|
||||
require_tool(runner, "npm", "https://nodejs.org")?;
|
||||
let root = install_root(app, &agent.name);
|
||||
let bindir = bin_dir(app);
|
||||
let before = snapshot_dir(&bindir);
|
||||
ensure_dir(app, &root)?;
|
||||
ensure_dir(app, &bindir)?;
|
||||
let mut args: Vec<String> = vec![
|
||||
"install".into(),
|
||||
"-g".into(),
|
||||
"--prefix".into(),
|
||||
root.display().to_string(),
|
||||
pkg.to_string(),
|
||||
];
|
||||
args.extend(method.args.iter().cloned());
|
||||
sh(runner, "npm", &args, &method.env, None)?;
|
||||
// npm places the shims directly in <prefix> on Windows and in
|
||||
// <prefix>/bin on Unix — check both, then the global bin dir.
|
||||
let expected = expected_bin_names(agent, method);
|
||||
let mut bins = find_bins(&root, &expected);
|
||||
if bins.is_empty() {
|
||||
bins = find_bins(&root.join("bin"), &expected);
|
||||
}
|
||||
if bins.is_empty() {
|
||||
let after = snapshot_dir(&bindir);
|
||||
bins = diff_dir(&before, &after);
|
||||
}
|
||||
if bins.is_empty() {
|
||||
bins = find_bins(&bindir, &expected);
|
||||
}
|
||||
let version = bins.first().and_then(|b| detect_version(runner, b));
|
||||
Ok(InstallOutcome { bins, version })
|
||||
}
|
||||
|
||||
/// bun: bun install -g <package> with BUN_INSTALL pointed at our root.
|
||||
pub fn bun_install(
|
||||
app: &App,
|
||||
agent: &AgentDef,
|
||||
method: &MethodSpec,
|
||||
runner: &dyn Runner,
|
||||
) -> Result<InstallOutcome> {
|
||||
let pkg = method
|
||||
.package
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow!("bun install requires 'package'"))?;
|
||||
require_tool(runner, "bun", "https://bun.sh")?;
|
||||
let root = install_root(app, &agent.name);
|
||||
let bindir = bin_dir(app);
|
||||
let before = snapshot_dir(&bindir);
|
||||
ensure_dir(app, &root)?;
|
||||
ensure_dir(app, &bindir)?;
|
||||
let mut env = method.env.clone();
|
||||
env.insert("BUN_INSTALL".to_string(), root.display().to_string());
|
||||
let mut args: Vec<String> = vec!["install".into(), "-g".into(), pkg.to_string()];
|
||||
args.extend(method.args.iter().cloned());
|
||||
sh(runner, "bun", &args, &env, None)?;
|
||||
let expected = expected_bin_names(agent, method);
|
||||
let mut bins = find_bins(&root.join("bin"), &expected);
|
||||
if bins.is_empty() {
|
||||
bins = find_bins(&bindir, &expected);
|
||||
}
|
||||
if bins.is_empty() {
|
||||
let after = snapshot_dir(&bindir);
|
||||
bins = diff_dir(&before, &after);
|
||||
}
|
||||
let version = bins.first().and_then(|b| detect_version(runner, b));
|
||||
Ok(InstallOutcome { bins, version })
|
||||
}
|
||||
|
||||
/// Latest published version: npm view <package> version.
|
||||
pub fn latest(
|
||||
_app: &App,
|
||||
_agent: &AgentDef,
|
||||
method: &MethodSpec,
|
||||
runner: &dyn Runner,
|
||||
) -> Result<Option<String>> {
|
||||
let Some(pkg) = method.package.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if runner.which("npm").is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let empty = BTreeMap::new();
|
||||
let out = runner.run(
|
||||
"npm",
|
||||
&["view".to_string(), pkg.to_string(), "version".to_string()],
|
||||
&empty,
|
||||
None,
|
||||
true,
|
||||
)?;
|
||||
if out.code != 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let text = out.stdout.trim();
|
||||
if text.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
// npm view prints one version per line; take the last (latest).
|
||||
Ok(text.lines().last().map(|l| l.trim().to_string()))
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! pip and uv installers: a private virtualenv per agent keeps everything
|
||||
//! self-contained (no --user, no system pollution).
|
||||
|
||||
use super::*;
|
||||
|
||||
fn find_python(runner: &dyn Runner) -> Result<PathBuf> {
|
||||
runner
|
||||
.which("python3")
|
||||
.or_else(|| runner.which("python"))
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"neither 'python3' nor 'python' found on PATH — install Python first (https://python.org)"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn venv_bin_dir(venv: &Path) -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
venv.join("Scripts")
|
||||
} else {
|
||||
venv.join("bin")
|
||||
}
|
||||
}
|
||||
|
||||
fn venv_python(venv: &Path) -> PathBuf {
|
||||
venv_bin_dir(venv).join(if cfg!(windows) {
|
||||
"python.exe"
|
||||
} else {
|
||||
"python"
|
||||
})
|
||||
}
|
||||
|
||||
/// Shared implementation for pip (use_uv=false) and uv (use_uv=true).
|
||||
pub fn install(
|
||||
app: &App,
|
||||
agent: &AgentDef,
|
||||
method: &MethodSpec,
|
||||
runner: &dyn Runner,
|
||||
use_uv: bool,
|
||||
) -> Result<InstallOutcome> {
|
||||
let pkg = method
|
||||
.package
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow!("pip/uv install requires 'package'"))?;
|
||||
let python = find_python(runner)?;
|
||||
let root = install_root(app, &agent.name);
|
||||
let venv = root.join("venv");
|
||||
ensure_dir(app, &root)?;
|
||||
if !app.dry_run() && venv.exists() {
|
||||
std::fs::remove_dir_all(&venv)
|
||||
.with_context(|| format!("cannot remove {}", venv.display()))?;
|
||||
}
|
||||
let empty = BTreeMap::new();
|
||||
sh(
|
||||
runner,
|
||||
&python.display().to_string(),
|
||||
&[
|
||||
"-m".to_string(),
|
||||
"venv".to_string(),
|
||||
venv.display().to_string(),
|
||||
],
|
||||
&empty,
|
||||
None,
|
||||
)?;
|
||||
let vp = venv_python(&venv);
|
||||
let vbin = venv_bin_dir(&venv);
|
||||
if use_uv {
|
||||
let uv = require_tool(
|
||||
runner,
|
||||
"uv",
|
||||
"https://docs.astral.sh/uv — e.g. 'pip install uv'",
|
||||
)?;
|
||||
let mut args: Vec<String> = vec![
|
||||
"pip".into(),
|
||||
"install".into(),
|
||||
"--python".into(),
|
||||
vp.display().to_string(),
|
||||
pkg.to_string(),
|
||||
];
|
||||
args.extend(method.args.iter().cloned());
|
||||
sh(runner, &uv.display().to_string(), &args, &method.env, None)?;
|
||||
} else {
|
||||
let mut args: Vec<String> = vec![
|
||||
"-m".into(),
|
||||
"pip".into(),
|
||||
"install".into(),
|
||||
"--upgrade".into(),
|
||||
pkg.to_string(),
|
||||
];
|
||||
args.extend(method.args.iter().cloned());
|
||||
sh(runner, &vp.display().to_string(), &args, &method.env, None)?;
|
||||
}
|
||||
let expected = expected_bin_names(agent, method);
|
||||
let mut bins = find_bins(&vbin, &expected);
|
||||
if bins.is_empty() {
|
||||
bins = find_bins(&bin_dir(app), &expected);
|
||||
}
|
||||
let version = bins.first().and_then(|b| detect_version(runner, b));
|
||||
Ok(InstallOutcome { bins, version })
|
||||
}
|
||||
|
||||
/// Latest published version from the PyPI JSON API.
|
||||
pub fn latest(
|
||||
_app: &App,
|
||||
_agent: &AgentDef,
|
||||
method: &MethodSpec,
|
||||
_runner: &dyn Runner,
|
||||
) -> Result<Option<String>> {
|
||||
let Some(pkg) = method.package.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let url = format!("https://pypi.org/pypi/{pkg}/json");
|
||||
let resp = match ureq::get(&url)
|
||||
.set("User-Agent", crate::download::USER_AGENT)
|
||||
.call()
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
let Ok(text) = resp.into_string() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(v.get("info")
|
||||
.and_then(|i| i.get("version"))
|
||||
.and_then(|x| x.as_str())
|
||||
.map(|s| s.to_string()))
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! curl|sh installer: download the script, show it (verbose), confirm, then
|
||||
//! execute it in its own directory. Never pipes curl directly into a shell.
|
||||
|
||||
use super::*;
|
||||
|
||||
pub fn install(
|
||||
app: &App,
|
||||
agent: &AgentDef,
|
||||
method: &MethodSpec,
|
||||
runner: &dyn Runner,
|
||||
) -> Result<InstallOutcome> {
|
||||
let url = method
|
||||
.script
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow!("curl install requires 'script' (URL)"))?;
|
||||
crate::download::validate_url(url)?;
|
||||
if cfg!(windows) {
|
||||
bail!(
|
||||
"the curl install method needs a Unix shell; on Windows install '{}' manually or use WSL",
|
||||
agent.title()
|
||||
);
|
||||
}
|
||||
require_tool(runner, "sh", "a POSIX shell (sh)")?;
|
||||
let root = install_root(app, &agent.name);
|
||||
ensure_dir(app, &root)?;
|
||||
if app.dry_run() {
|
||||
app.log
|
||||
.dry(format!("would download and run install script {url}"));
|
||||
return Ok(InstallOutcome::default());
|
||||
}
|
||||
let tmp = tempfile::Builder::new().suffix(".sh").tempfile()?;
|
||||
crate::download::download(url, tmp.path(), None, true)?;
|
||||
if app.log.verbose {
|
||||
if let Ok(text) = std::fs::read_to_string(tmp.path()) {
|
||||
eprintln!("--- install script ({url}) ---");
|
||||
eprintln!("{text}");
|
||||
eprintln!("--- end of script ---");
|
||||
}
|
||||
}
|
||||
let shell = app
|
||||
.config
|
||||
.settings
|
||||
.default_shell
|
||||
.clone()
|
||||
.unwrap_or_else(|| "sh".to_string());
|
||||
sh_inherit(
|
||||
runner,
|
||||
&shell,
|
||||
&[tmp.path().display().to_string()],
|
||||
&method.env,
|
||||
Some(&root),
|
||||
)?;
|
||||
// Scripts usually install into the user profile; locate the binaries.
|
||||
let expected = expected_bin_names(agent, method);
|
||||
let mut bins = Vec::new();
|
||||
for name in &expected {
|
||||
if let Some(p) = runner.which(name) {
|
||||
if !bins.contains(&p) {
|
||||
bins.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
let version = bins
|
||||
.first()
|
||||
.and_then(|b| detect_version(runner, b))
|
||||
.or_else(|| agent.version.clone());
|
||||
Ok(InstallOutcome { bins, version })
|
||||
}
|
||||
Reference in New Issue
Block a user