322 lines
10 KiB
Rust
322 lines
10 KiB
Rust
//! uninstall: stop the agent if needed, remove its files, bins and state
|
|
//! entry; --purge also removes logs and the user-config definition.
|
|
//! External agents (installed outside agent-manager) are removed through
|
|
//! their package manager when the catalog knows it, or by deleting the
|
|
//! executables found on PATH otherwise.
|
|
|
|
use super::*;
|
|
use crate::events::{Event, EventKind};
|
|
use anyhow::{Context, Result};
|
|
use std::path::PathBuf;
|
|
use std::process::Command as ProcessCommand;
|
|
|
|
pub fn run(app: &App, agent_name: &str, purge: bool) -> Result<i32> {
|
|
let agent = require_agent(app, agent_name)?;
|
|
let entry = app.state.get(&agent.name).ok().flatten();
|
|
let Some(entry) = entry else {
|
|
return uninstall_external(app, agent);
|
|
};
|
|
|
|
// Stop it first when running.
|
|
if let Some(pid) = entry.pid {
|
|
if crate::process::is_running(pid) {
|
|
app.log.info(&format!("stopping running agent (pid {pid})"));
|
|
if app.dry_run() {
|
|
app.log.dry(format!("would stop pid {pid}"));
|
|
} else {
|
|
crate::process::stop_pid(pid, false, app.config.settings.stop_timeout());
|
|
app.state.update_pid(&agent.name, None, None)?;
|
|
}
|
|
}
|
|
}
|
|
|
|
let dir = PathBuf::from(&entry.install_dir);
|
|
if app.dry_run() {
|
|
app.log.dry(format!("would remove {}", dir.display()));
|
|
} else {
|
|
if dir.exists() {
|
|
std::fs::remove_dir_all(&dir)
|
|
.with_context(|| format!("cannot remove {}", dir.display()))?;
|
|
}
|
|
for b in &entry.bins {
|
|
let p = PathBuf::from(b);
|
|
if p.is_file() {
|
|
std::fs::remove_file(&p)
|
|
.with_context(|| format!("cannot remove {}", p.display()))?;
|
|
}
|
|
}
|
|
app.state.remove(&agent.name)?;
|
|
}
|
|
|
|
if purge {
|
|
let log = crate::process::agent_log_path(app, &agent.name);
|
|
if app.dry_run() {
|
|
app.log.dry(format!("would remove log {}", log.display()));
|
|
} else if log.exists() {
|
|
std::fs::remove_file(&log).ok();
|
|
}
|
|
remove_from_user_config(app, &agent.name)?;
|
|
}
|
|
|
|
app.emit(&Event::now(EventKind::Uninstall).with_agent(agent.name.clone()));
|
|
app.log.success(&format!(
|
|
"uninstalled {} (files removed{})",
|
|
agent.title(),
|
|
if purge { ", config purged" } else { "" }
|
|
));
|
|
Ok(0)
|
|
}
|
|
|
|
/// Build the package-manager uninstall command for an external agent whose
|
|
/// install method is a known package manager and whose package is recorded.
|
|
fn package_plan(agent: &AgentDef) -> Option<Vec<String>> {
|
|
use crate::config::InstallType;
|
|
let spec = agent.install.as_ref()?;
|
|
for m in spec.all_methods() {
|
|
let Some(pkg) = m.package.as_deref() else {
|
|
continue;
|
|
};
|
|
let plan = match m.kind {
|
|
InstallType::Npm => {
|
|
vec!["npm", "uninstall", "--global", pkg]
|
|
}
|
|
InstallType::Pip => {
|
|
if which::which("pip").is_ok() {
|
|
vec!["pip", "uninstall", "-y", pkg]
|
|
} else if which::which("python3").is_ok() {
|
|
vec!["python3", "-m", "pip", "uninstall", "-y", pkg]
|
|
} else if which::which("python").is_ok() {
|
|
vec!["python", "-m", "pip", "uninstall", "-y", pkg]
|
|
} else {
|
|
continue;
|
|
}
|
|
}
|
|
InstallType::Uv => vec!["uv", "tool", "uninstall", pkg],
|
|
InstallType::Cargo => vec!["cargo", "uninstall", pkg],
|
|
InstallType::Bun => vec!["bun", "remove", "--global", pkg],
|
|
_ => continue,
|
|
};
|
|
return Some(plan.into_iter().map(|s| s.to_string()).collect());
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Best-effort removal of an agent installed outside agent-manager: first the
|
|
/// package-manager command when the catalog knows one, then the executable
|
|
/// files left on PATH (and their directory when it is dedicated to the
|
|
/// agent, e.g. AppData\Local\atomic-agent).
|
|
fn uninstall_external(app: &App, agent: &AgentDef) -> Result<i32> {
|
|
let first_token = agent.first_token();
|
|
|
|
// 1. Package-manager plan (npm, pip, uv, cargo, bun).
|
|
if let Some(tokens) = package_plan(agent) {
|
|
if which::which(&tokens[0]).is_ok() {
|
|
app.log.info(&format!(
|
|
"agent '{}' is external (installed outside agent-manager) — uninstall with: {}",
|
|
agent.name,
|
|
tokens.join(" ")
|
|
));
|
|
if !app.confirm("Run this command?")? {
|
|
app.log.info("cancelled — nothing was uninstalled");
|
|
return Ok(0);
|
|
}
|
|
if app.dry_run() {
|
|
app.log.dry(format!("would run: {}", tokens.join(" ")));
|
|
} else {
|
|
// resolve_program handles Windows .cmd/.bat shims (npm,
|
|
// pip, ...) which std::process::Command cannot launch.
|
|
let (prog, prefix) = crate::runner::resolve_program(&tokens[0]);
|
|
let mut args = prefix;
|
|
args.extend(tokens[1..].iter().cloned());
|
|
let status = ProcessCommand::new(&prog)
|
|
.args(&args)
|
|
.status()
|
|
.with_context(|| format!("failed to run {}", tokens.join(" ")))?;
|
|
if !status.success() {
|
|
app.log.warn("the command failed — removing the files it left behind");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Whatever executable is still on PATH, delete it directly (and its
|
|
// directory when it belongs to the agent).
|
|
let still = first_token.as_deref().and_then(|t| which::which(t).ok());
|
|
if let Some(path) = still {
|
|
let parent = path.parent().map(|p| p.to_path_buf());
|
|
let dedicated = parent
|
|
.as_ref()
|
|
.and_then(|p| p.file_name())
|
|
.map(|n| n.to_string_lossy().eq_ignore_ascii_case(&agent.name))
|
|
.unwrap_or(false);
|
|
let target: PathBuf = if dedicated {
|
|
parent.clone().unwrap_or_else(|| path.clone())
|
|
} else {
|
|
path.clone()
|
|
};
|
|
app.log.info(&format!(
|
|
"remove leftover {} {}",
|
|
if dedicated { "directory" } else { "file" },
|
|
target.display()
|
|
));
|
|
if !app.confirm("Delete it?")? {
|
|
app.log.info(&format!("'{}' left in place", target.display()));
|
|
return Ok(0);
|
|
}
|
|
if app.dry_run() {
|
|
app.log.dry(format!("would remove {}", target.display()));
|
|
} else if dedicated {
|
|
std::fs::remove_dir_all(&target)
|
|
.with_context(|| format!("cannot remove {}", target.display()))?;
|
|
} else {
|
|
std::fs::remove_file(&target)
|
|
.with_context(|| format!("cannot remove {}", target.display()))?;
|
|
}
|
|
}
|
|
|
|
// 3. Final verification.
|
|
let gone = first_token
|
|
.as_deref()
|
|
.and_then(|t| which::which(t).ok())
|
|
.is_none();
|
|
if gone {
|
|
app.emit(
|
|
&Event::now(EventKind::Uninstall)
|
|
.with_agent(agent.name.clone())
|
|
.with_reason("external"),
|
|
);
|
|
app.log
|
|
.success(&format!("uninstalled {} (external)", agent.title()));
|
|
} else {
|
|
app.log.warn(&format!(
|
|
"{} is still present on PATH — remove it manually",
|
|
agent.title()
|
|
));
|
|
}
|
|
Ok(0)
|
|
}
|
|
|
|
/// Remove the agent entry from the user config file (--purge).
|
|
fn remove_from_user_config(app: &App, name: &str) -> Result<()> {
|
|
let Some(path) = &app.config_origin else {
|
|
app.log.verbose("no user config file — nothing to purge");
|
|
return Ok(());
|
|
};
|
|
let is_json = path.extension().map(|e| e == "json").unwrap_or(false);
|
|
if is_json {
|
|
app.log
|
|
.warn("config purge is not supported for JSON config files");
|
|
return Ok(());
|
|
}
|
|
let text = std::fs::read_to_string(path)
|
|
.with_context(|| format!("cannot read {}", path.display()))?;
|
|
let mut value: serde_yaml::Value = serde_yaml::from_str(&text)
|
|
.with_context(|| format!("cannot parse {}", path.display()))?;
|
|
let mut removed = false;
|
|
if let serde_yaml::Value::Mapping(map) = &mut value {
|
|
if let Some(serde_yaml::Value::Sequence(seq)) = map.get_mut("agents") {
|
|
let before = seq.len();
|
|
seq.retain(|v| {
|
|
v.get("name")
|
|
.and_then(|n| n.as_str())
|
|
.map(|n| n != name)
|
|
.unwrap_or(true)
|
|
});
|
|
removed = seq.len() != before;
|
|
}
|
|
}
|
|
if !removed {
|
|
app.log.verbose(&format!("'{name}' not defined in {}", path.display()));
|
|
return Ok(());
|
|
}
|
|
let backup = path.with_extension("yaml.bak");
|
|
std::fs::copy(path, &backup)
|
|
.with_context(|| format!("cannot create backup {}", backup.display()))?;
|
|
std::fs::write(path, serde_yaml::to_string(&value)?)
|
|
.with_context(|| format!("cannot write {}", path.display()))?;
|
|
app.log.info(&format!(
|
|
"removed '{name}' from {} (backup: {})",
|
|
path.display(),
|
|
backup.display()
|
|
));
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn agent_from_yaml(yaml: &str) -> AgentDef {
|
|
#[derive(serde::Deserialize)]
|
|
struct Wrap {
|
|
agents: Vec<AgentDef>,
|
|
}
|
|
let w: Wrap = serde_yaml::from_str(yaml).unwrap();
|
|
w.agents.into_iter().next().unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn package_plan_npm() {
|
|
let a = agent_from_yaml(
|
|
r#"
|
|
agents:
|
|
- name: x
|
|
install:
|
|
type: npm
|
|
package: "@scope/pkg"
|
|
run: x
|
|
"#,
|
|
);
|
|
let plan = package_plan(&a).unwrap();
|
|
assert_eq!(plan, vec!["npm", "uninstall", "--global", "@scope/pkg"]);
|
|
}
|
|
|
|
#[test]
|
|
fn package_plan_cargo() {
|
|
let a = agent_from_yaml(
|
|
r#"
|
|
agents:
|
|
- name: x
|
|
install:
|
|
type: cargo
|
|
package: my-crate
|
|
run: x
|
|
"#,
|
|
);
|
|
let plan = package_plan(&a).unwrap();
|
|
assert_eq!(plan, vec!["cargo", "uninstall", "my-crate"]);
|
|
}
|
|
|
|
#[test]
|
|
fn package_plan_binary_is_none() {
|
|
let a = agent_from_yaml(
|
|
r#"
|
|
agents:
|
|
- name: y
|
|
install:
|
|
type: binary
|
|
repo: owner/repo
|
|
binary: y
|
|
run: y
|
|
"#,
|
|
);
|
|
assert!(package_plan(&a).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn package_plan_unknown_method_is_none() {
|
|
let a = agent_from_yaml(
|
|
r#"
|
|
agents:
|
|
- name: z
|
|
install:
|
|
type: git
|
|
repo: https://example.com/z.git
|
|
run: z
|
|
"#,
|
|
);
|
|
assert!(package_plan(&a).is_none());
|
|
}
|
|
}
|
|
|