v0.2.7 : self-uninstall supprime maintenant le binaire — renommage immediat de l executable en cours dexecution (am.exe.deleteme) + script .bat temporaire avec reessais, balayage des dossiers vides et auto-suppression

This commit is contained in:
2026-08-15 23:19:28 -04:00
parent bc38cd59e5
commit d886de322c
3 changed files with 125 additions and 50 deletions
Generated
+1 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]] [[package]]
name = "agent-manager" name = "agent-manager"
version = "0.2.6" version = "0.2.7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "agent-manager" name = "agent-manager"
version = "0.2.6" 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"
+123 -48
View File
@@ -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::*;