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:
Generated
+1
-1
@@ -21,7 +21,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-manager"
|
||||
version = "0.2.6"
|
||||
version = "0.2.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+117
-42
@@ -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<i32> {
|
||||
}
|
||||
}
|
||||
|
||||
let exe = std::env::current_exe().ok();
|
||||
let mut renamed: Option<PathBuf> = 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<i32> {
|
||||
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<i32> {
|
||||
}
|
||||
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<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)
|
||||
}
|
||||
|
||||
@@ -201,7 +213,7 @@ fn schedule_locked(_locked: &[PathBuf]) -> Vec<PathBuf> {
|
||||
/// 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<u16> = exe
|
||||
let wide: Vec<u16> = 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()
|
||||
);
|
||||
}
|
||||
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<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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user