diff --git a/Cargo.lock b/Cargo.lock index 9dda779..a471dd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-manager" -version = "0.2.6" +version = "0.2.7" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 6099b2b..0832d22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-manager" -version = "0.2.6" +version = "0.2.7" edition = "2021" description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." license = "MIT" diff --git a/src/commands/self_uninstall.rs b/src/commands/self_uninstall.rs index 3cf05eb..0d2c817 100644 --- a/src/commands/self_uninstall.rs +++ b/src/commands/self_uninstall.rs @@ -3,7 +3,9 @@ //! configuration directories are deleted, then the executable itself. use crate::app::{self, App}; -use anyhow::{bail, Context, Result}; +use anyhow::{bail, Result}; +#[cfg(unix)] +use anyhow::Context; use colored::Colorize; use std::path::{Path, PathBuf}; @@ -54,6 +56,9 @@ pub fn run(app: &App) -> Result { } } + let exe = std::env::current_exe().ok(); + let mut renamed: Option = None; + if roots.is_empty() { app.log .info("nothing to remove — agent-manager left no traces on this machine"); @@ -70,6 +75,20 @@ pub fn run(app: &App) -> Result { app.log.dry("nothing removed (dry run)"); 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 // state directory would recreate the log folder, so the removal // itself is silent and the report below goes to plain stdout. @@ -88,38 +107,31 @@ pub fn run(app: &App) -> Result { } if !locked.is_empty() { let scheduled = schedule_locked(&locked); - if scheduled.len() == locked.len() { + if !scheduled.is_empty() { println!( "{} {} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage", "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() ); - for path in &locked { - if !scheduled.contains(path) { - eprintln!( - "{} {} (supprimez-le manuellement ou relancez en administrateur)", - "kept:".red(), - path.display() - ); - } - } + } + if scheduled.len() != locked.len() { + println!( + "{} {} entrée(s) verrouillée(s) — le processus différé les supprimera après la fermeture de am", + "deferred:".cyan(), + locked.len() - scheduled.len() + ); } } } // 3. Delete the executable itself, then sweep the directories left // empty behind it. - let exe = std::env::current_exe() - .with_context(|| "cannot locate the agent-manager executable")?; let cleanup_roots: Vec = 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) } @@ -201,7 +213,7 @@ fn schedule_locked(_locked: &[PathBuf]) -> Vec { /// 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 /// 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)] { std::fs::remove_file(exe) @@ -227,29 +239,27 @@ fn remove_self(exe: &Path, roots: &[PathBuf]) -> Result<()> { #[cfg(windows)] { use std::os::windows::ffi::OsStrExt; - // Immediate deletion: a hidden cmd retries for a few seconds, because - // the executable is locked while we are still running. - // Once the file is gone, the now-empty directories (parent, then the - // removed roots) are swept — rd /q only ever removes empty dirs. - let mut script = format!( - "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)", - exe.display() - ); - if let Some(parent) = exe.parent() { - if roots.iter().any(|r| parent.starts_with(r)) { - script.push_str(&format!(" & rd /q \"{}\" >nul 2>&1", parent.display())); - } + // The file to delete once this process exits: the renamed copy when + // the up-front rename worked, the original path otherwise. + let target = renamed.unwrap_or(exe); + // A temporary batch file retries the deletion for a few seconds (the + // image stays locked until we exit), sweeps the directories left + // empty, then deletes itself. A .bat avoids every cmd /C command-line + // quoting pitfall. + let bat = write_cleanup_bat(exe, target, roots); + if let Some(b) = &bat { + // raw_arg passes the command line verbatim: regular .arg() + // 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 // uninstaller mechanism deletes it at the next reboot. - let wide: Vec = exe + let wide: Vec = target .as_os_str() .encode_wide() .chain(std::iter::once(0)) @@ -257,17 +267,25 @@ fn remove_self(exe: &Path, roots: &[PathBuf]) -> Result<()> { let at_reboot = unsafe { MoveFileExW(wide.as_ptr(), std::ptr::null(), MOVEFILE_DELAY_UNTIL_REBOOT) != 0 }; - if !spawned && !at_reboot { + if bat.is_none() && !at_reboot { bail!( "cannot schedule deletion of {} — delete it manually", exe.display() ); } - println!( - "{} {}", - "agent-manager removed; the executable will be deleted momentarily:".green(), - exe.display() - ); + if renamed.is_some() { + println!( + "{} {}", + "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(()); } #[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 { + 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 { + 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 { + 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)] mod tests { use super::*;