Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d886de322c | ||
|
|
bc38cd59e5 |
Generated
+1
-1
@@ -21,7 +21,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agent-manager"
|
name = "agent-manager"
|
||||||
version = "0.2.5"
|
version = "0.2.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "agent-manager"
|
name = "agent-manager"
|
||||||
version = "0.2.5"
|
version = "0.2.7"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
|
|||||||
| am install <agent> | installe (dépendances vérifiées et proposées) |
|
| am install <agent> | installe (dépendances vérifiées et proposées) |
|
||||||
| am install <agent> --method pip | choisit une méthode d'installation |
|
| am install <agent> --method pip | choisit une méthode d'installation |
|
||||||
| am update <agent> | met à jour vers la dernière version connue |
|
| am update <agent> | met à jour vers la dernière version connue |
|
||||||
| am uninstall <agent> | désinstalle et nettoie (--purge : logs + config) |
|
| am uninstall <agent> | désinstalle et nettoie (--purge : logs + config) — gère aussi les agents externes (npm/pip/uv/cargo/bun, sinon suppression des fichiers) |
|
||||||
| am export / am import | sauvegarde et restaure config + état |
|
| am export / am import | sauvegarde et restaure config + état |
|
||||||
|
|
||||||
### 🚀 Exécuter
|
### 🚀 Exécuter
|
||||||
|
|||||||
+1
-1
@@ -102,7 +102,7 @@ pub enum Command {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
force: bool,
|
force: bool,
|
||||||
},
|
},
|
||||||
/// Uninstall an agent and clean its files
|
/// Uninstall an agent (managed or external) and clean its files
|
||||||
Uninstall {
|
Uninstall {
|
||||||
/// Agent name or alias
|
/// Agent name or alias
|
||||||
agent: String,
|
agent: String,
|
||||||
|
|||||||
@@ -50,10 +50,11 @@ pub fn run(app: &App, all: bool, category: Option<&str>) -> Result<i32> {
|
|||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
let color = app.color();
|
let color = app.color();
|
||||||
let mut table = Table::new(vec!["NAME", "VERSION", "STATUS", "SOURCE", "PATH"]);
|
let mut table = Table::new(vec!["NAME", "AGENT", "VERSION", "STATUS", "SOURCE", "PATH"]);
|
||||||
for r in &rows {
|
for r in &rows {
|
||||||
table.row(vec![
|
table.row(vec![
|
||||||
r.display_name.clone(),
|
r.display_name.clone(),
|
||||||
|
r.name.clone(),
|
||||||
r.version.clone().unwrap_or_else(|| "—".to_string()),
|
r.version.clone().unwrap_or_else(|| "—".to_string()),
|
||||||
status_style(&r.status, color),
|
status_style(&r.status, color),
|
||||||
r.source.clone(),
|
r.source.clone(),
|
||||||
|
|||||||
+123
-48
@@ -3,7 +3,9 @@
|
|||||||
//! configuration directories are deleted, then the executable itself.
|
//! configuration directories are deleted, then the executable itself.
|
||||||
|
|
||||||
use crate::app::{self, App};
|
use crate::app::{self, App};
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Result};
|
||||||
|
#[cfg(unix)]
|
||||||
|
use anyhow::Context;
|
||||||
use colored::Colorize;
|
use colored::Colorize;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
@@ -54,6 +56,9 @@ pub fn run(app: &App) -> Result<i32> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let exe = std::env::current_exe().ok();
|
||||||
|
let mut renamed: Option<PathBuf> = None;
|
||||||
|
|
||||||
if roots.is_empty() {
|
if roots.is_empty() {
|
||||||
app.log
|
app.log
|
||||||
.info("nothing to remove — agent-manager left no traces on this machine");
|
.info("nothing to remove — agent-manager left no traces on this machine");
|
||||||
@@ -70,6 +75,20 @@ pub fn run(app: &App) -> Result<i32> {
|
|||||||
app.log.dry("nothing removed (dry run)");
|
app.log.dry("nothing removed (dry run)");
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
|
// Windows allows renaming a running executable (but not deleting
|
||||||
|
// it): do it up-front so the original binary disappears immediately,
|
||||||
|
// even if the deferred deletion has to wait for this process to exit.
|
||||||
|
renamed = exe.as_deref().and_then(rename_for_deletion);
|
||||||
|
if let Some(new) = &renamed {
|
||||||
|
if let Some(old) = &exe {
|
||||||
|
println!(
|
||||||
|
"{} {} -> {}",
|
||||||
|
"renamed:".cyan(),
|
||||||
|
old.display(),
|
||||||
|
new.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Remove everything first; any message logged *after* deleting the
|
// Remove everything first; any message logged *after* deleting the
|
||||||
// state directory would recreate the log folder, so the removal
|
// state directory would recreate the log folder, so the removal
|
||||||
// itself is silent and the report below goes to plain stdout.
|
// itself is silent and the report below goes to plain stdout.
|
||||||
@@ -88,38 +107,31 @@ pub fn run(app: &App) -> Result<i32> {
|
|||||||
}
|
}
|
||||||
if !locked.is_empty() {
|
if !locked.is_empty() {
|
||||||
let scheduled = schedule_locked(&locked);
|
let scheduled = schedule_locked(&locked);
|
||||||
if scheduled.len() == locked.len() {
|
if !scheduled.is_empty() {
|
||||||
println!(
|
println!(
|
||||||
"{} {} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage",
|
"{} {} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage",
|
||||||
"locked:".yellow(),
|
"locked:".yellow(),
|
||||||
locked.len()
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
println!(
|
|
||||||
"{} {} entrée(s) verrouillée(s), {} planifiée(s) au prochain redémarrage",
|
|
||||||
"locked:".yellow(),
|
|
||||||
locked.len(),
|
|
||||||
scheduled.len()
|
scheduled.len()
|
||||||
);
|
);
|
||||||
for path in &locked {
|
}
|
||||||
if !scheduled.contains(path) {
|
if scheduled.len() != locked.len() {
|
||||||
eprintln!(
|
println!(
|
||||||
"{} {} (supprimez-le manuellement ou relancez en administrateur)",
|
"{} {} entrée(s) verrouillée(s) — le processus différé les supprimera après la fermeture de am",
|
||||||
"kept:".red(),
|
"deferred:".cyan(),
|
||||||
path.display()
|
locked.len() - scheduled.len()
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Delete the executable itself, then sweep the directories left
|
// 3. Delete the executable itself, then sweep the directories left
|
||||||
// empty behind it.
|
// empty behind it.
|
||||||
let exe = std::env::current_exe()
|
|
||||||
.with_context(|| "cannot locate the agent-manager executable")?;
|
|
||||||
let cleanup_roots: Vec<PathBuf> = roots.iter().map(|(_, p)| p.clone()).collect();
|
let cleanup_roots: Vec<PathBuf> = roots.iter().map(|(_, p)| p.clone()).collect();
|
||||||
remove_self(&exe, &cleanup_roots)?;
|
match &exe {
|
||||||
|
Some(e) => remove_self(e, renamed.as_deref(), &cleanup_roots)?,
|
||||||
|
None => app.log
|
||||||
|
.warn("cannot locate the agent-manager executable — delete it manually"),
|
||||||
|
}
|
||||||
Ok(0)
|
Ok(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +213,7 @@ fn schedule_locked(_locked: &[PathBuf]) -> Vec<PathBuf> {
|
|||||||
/// runs; on Windows a hidden cmd re-tries the deletion right after this
|
/// runs; on Windows a hidden cmd re-tries the deletion right after this
|
||||||
/// process exits (the file is locked while we run). Empty directories left
|
/// process exits (the file is locked while we run). Empty directories left
|
||||||
/// behind under the removed roots are swept afterwards.
|
/// behind under the removed roots are swept afterwards.
|
||||||
fn remove_self(exe: &Path, roots: &[PathBuf]) -> Result<()> {
|
fn remove_self(exe: &Path, renamed: Option<&Path>, roots: &[PathBuf]) -> Result<()> {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
std::fs::remove_file(exe)
|
std::fs::remove_file(exe)
|
||||||
@@ -227,29 +239,27 @@ fn remove_self(exe: &Path, roots: &[PathBuf]) -> Result<()> {
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
use std::os::windows::ffi::OsStrExt;
|
use std::os::windows::ffi::OsStrExt;
|
||||||
// Immediate deletion: a hidden cmd retries for a few seconds, because
|
// The file to delete once this process exits: the renamed copy when
|
||||||
// the executable is locked while we are still running.
|
// the up-front rename worked, the original path otherwise.
|
||||||
// Once the file is gone, the now-empty directories (parent, then the
|
let target = renamed.unwrap_or(exe);
|
||||||
// removed roots) are swept — rd /q only ever removes empty dirs.
|
// A temporary batch file retries the deletion for a few seconds (the
|
||||||
let mut script = format!(
|
// image stays locked until we exit), sweeps the directories left
|
||||||
"for /L %i in (1,1,20) do @if exist \"{0}\" (del /f /q \"{0}\" >nul 2>&1 & ping -n 2 127.0.0.1 >nul)",
|
// empty, then deletes itself. A .bat avoids every cmd /C command-line
|
||||||
exe.display()
|
// quoting pitfall.
|
||||||
);
|
let bat = write_cleanup_bat(exe, target, roots);
|
||||||
if let Some(parent) = exe.parent() {
|
if let Some(b) = &bat {
|
||||||
if roots.iter().any(|r| parent.starts_with(r)) {
|
// raw_arg passes the command line verbatim: regular .arg()
|
||||||
script.push_str(&format!(" & rd /q \"{}\" >nul 2>&1", parent.display()));
|
// backslash-escapes embedded quotes, which cmd.exe does not
|
||||||
}
|
// understand.
|
||||||
|
use std::os::windows::process::CommandExt as _;
|
||||||
|
let mut cleanup = std::process::Command::new("cmd");
|
||||||
|
cleanup.raw_arg("/C");
|
||||||
|
cleanup.raw_arg(format!("\"{}\"", b.display()));
|
||||||
|
let _ = cleanup.spawn();
|
||||||
}
|
}
|
||||||
for root in roots {
|
|
||||||
script.push_str(&format!(" & rd /q \"{}\" >nul 2>&1", root.display()));
|
|
||||||
}
|
|
||||||
let spawned = std::process::Command::new("cmd")
|
|
||||||
.args(["/C", &script])
|
|
||||||
.spawn()
|
|
||||||
.is_ok();
|
|
||||||
// Belt and braces: if something keeps the file locked, the standard
|
// Belt and braces: if something keeps the file locked, the standard
|
||||||
// uninstaller mechanism deletes it at the next reboot.
|
// uninstaller mechanism deletes it at the next reboot.
|
||||||
let wide: Vec<u16> = exe
|
let wide: Vec<u16> = target
|
||||||
.as_os_str()
|
.as_os_str()
|
||||||
.encode_wide()
|
.encode_wide()
|
||||||
.chain(std::iter::once(0))
|
.chain(std::iter::once(0))
|
||||||
@@ -257,17 +267,25 @@ fn remove_self(exe: &Path, roots: &[PathBuf]) -> Result<()> {
|
|||||||
let at_reboot = unsafe {
|
let at_reboot = unsafe {
|
||||||
MoveFileExW(wide.as_ptr(), std::ptr::null(), MOVEFILE_DELAY_UNTIL_REBOOT) != 0
|
MoveFileExW(wide.as_ptr(), std::ptr::null(), MOVEFILE_DELAY_UNTIL_REBOOT) != 0
|
||||||
};
|
};
|
||||||
if !spawned && !at_reboot {
|
if bat.is_none() && !at_reboot {
|
||||||
bail!(
|
bail!(
|
||||||
"cannot schedule deletion of {} — delete it manually",
|
"cannot schedule deletion of {} — delete it manually",
|
||||||
exe.display()
|
exe.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
println!(
|
if renamed.is_some() {
|
||||||
"{} {}",
|
println!(
|
||||||
"agent-manager removed; the executable will be deleted momentarily:".green(),
|
"{} {}",
|
||||||
exe.display()
|
"agent-manager removed; the executable was renamed and will be deleted momentarily:".green(),
|
||||||
);
|
exe.display()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"{} {}",
|
||||||
|
"agent-manager removed; the executable will be deleted momentarily:".green(),
|
||||||
|
exe.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
#[cfg(not(any(unix, windows)))]
|
#[cfg(not(any(unix, windows)))]
|
||||||
@@ -279,6 +297,63 @@ fn remove_self(exe: &Path, roots: &[PathBuf]) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rename the running executable out of the way (Windows allows renaming a
|
||||||
|
/// running image — only deletion is refused while it executes). Returns the
|
||||||
|
/// new path on success.
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn rename_for_deletion(exe: &Path) -> Option<PathBuf> {
|
||||||
|
let name = exe.file_name()?.to_string_lossy();
|
||||||
|
let new = exe.with_file_name(format!("{name}.deleteme"));
|
||||||
|
std::fs::rename(exe, &new).ok()?;
|
||||||
|
Some(new)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn rename_for_deletion(_exe: &Path) -> Option<PathBuf> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a temporary batch file that deletes the target executable once it
|
||||||
|
/// is unlocked, sweeps the directories left empty and then removes itself.
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn write_cleanup_bat(exe: &Path, target: &Path, roots: &[PathBuf]) -> Option<PathBuf> {
|
||||||
|
let crlf = "\r\n";
|
||||||
|
let q = "\"";
|
||||||
|
let mut script = String::new();
|
||||||
|
let mut line = |s: &str| {
|
||||||
|
script.push_str(s);
|
||||||
|
script.push_str(crlf);
|
||||||
|
};
|
||||||
|
line("@echo off");
|
||||||
|
line("set n=0");
|
||||||
|
line(":retry");
|
||||||
|
line(&format!("del /f /q {q}{}{q} >nul 2>&1", target.display()));
|
||||||
|
line(&format!("del /f /q {q}{}{q} >nul 2>&1", exe.display()));
|
||||||
|
line(&format!(
|
||||||
|
"if not exist {q}{}{q} (if not exist {q}{}{q} goto sweep)",
|
||||||
|
target.display(),
|
||||||
|
exe.display()
|
||||||
|
));
|
||||||
|
line("set /a n+=1");
|
||||||
|
line("if %n% GEQ 60 goto end");
|
||||||
|
line("ping -n 2 127.0.0.1 >nul");
|
||||||
|
line("goto retry");
|
||||||
|
line(":sweep");
|
||||||
|
if let Some(parent) = exe.parent() {
|
||||||
|
if roots.iter().any(|r| parent.starts_with(r)) {
|
||||||
|
line(&format!("rd /q {q}{}{q} >nul 2>&1", parent.display()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for root in roots {
|
||||||
|
line(&format!("rd /q {q}{}{q} >nul 2>&1", root.display()));
|
||||||
|
}
|
||||||
|
line(":end");
|
||||||
|
line(&format!("del {q}%~f0{q} >nul 2>&1"));
|
||||||
|
let path = std::env::temp_dir().join(format!("am-uninstall-{}.bat", std::process::id()));
|
||||||
|
std::fs::write(&path, script).ok()?;
|
||||||
|
Some(path)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
//! uninstall: stop the agent if needed, remove its files, bins and state
|
//! uninstall: stop the agent if needed, remove its files, bins and state
|
||||||
//! entry; --purge also removes logs and the user-config definition.
|
//! 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 super::*;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command as ProcessCommand;
|
||||||
|
|
||||||
pub fn run(app: &App, agent_name: &str, purge: bool) -> Result<i32> {
|
pub fn run(app: &App, agent_name: &str, purge: bool) -> Result<i32> {
|
||||||
let agent = require_agent(app, agent_name)?;
|
let agent = require_agent(app, agent_name)?;
|
||||||
let entry = app.state.get(&agent.name).ok().flatten();
|
let entry = app.state.get(&agent.name).ok().flatten();
|
||||||
let Some(entry) = entry else {
|
let Some(entry) = entry else {
|
||||||
app.log.info(&format!(
|
return uninstall_external(app, agent);
|
||||||
"agent '{}' is not managed by agent-manager — nothing to uninstall",
|
|
||||||
agent.name
|
|
||||||
));
|
|
||||||
return Ok(0);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stop it first when running.
|
// Stop it first when running.
|
||||||
@@ -65,6 +65,130 @@ pub fn run(app: &App, agent_name: &str, purge: bool) -> Result<i32> {
|
|||||||
Ok(0)
|
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.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).
|
/// Remove the agent entry from the user config file (--purge).
|
||||||
fn remove_from_user_config(app: &App, name: &str) -> Result<()> {
|
fn remove_from_user_config(app: &App, name: &str) -> Result<()> {
|
||||||
let Some(path) = &app.config_origin else {
|
let Some(path) = &app.config_origin else {
|
||||||
@@ -111,3 +235,80 @@ fn remove_from_user_config(app: &App, name: &str) -> Result<()> {
|
|||||||
Ok(())
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user