dev_prune/commands/
uninstall.rs1use anyhow::Result;
26use std::collections::HashSet;
27use std::fs;
28use std::path::{Path, PathBuf};
29
30use crate::commands::hook;
31use crate::config::Registry;
32use crate::output;
33use crate::setup;
34
35pub fn run(deep: bool, yes: bool) -> Result<()> {
36 output::print_header(if deep {
37 "dev-prune Deep Uninstaller (Full Purge)"
38 } else {
39 "dev-prune Uninstaller"
40 });
41
42 let registry = Registry::load().ok();
43
44 if deep && !yes {
47 use std::io::{IsTerminal, Write};
48 let repo_count = registry.as_ref().map(|r| r.repo_count()).unwrap_or(0);
49 output::print_warning(&format!(
50 "This deletes the global config directory (including prune history) and \
51 removes `.devprune.json` from {repo_count} registered repositories."
52 ));
53 if !std::io::stdin().is_terminal() {
54 anyhow::bail!("Refusing to deep-uninstall without confirmation. Re-run with `--yes`.");
55 }
56 print!("Continue? [y/N]: ");
57 std::io::stdout().flush()?;
58 let mut input = String::new();
59 std::io::stdin().read_line(&mut input)?;
60 if !matches!(input.trim().to_lowercase().as_str(), "y" | "yes") {
61 output::print_info("Deep uninstall cancelled.");
62 return Ok(());
63 }
64 }
65
66 let mut left_behind: Vec<String> = Vec::new();
70 let mut pending_files: Vec<PathBuf> = Vec::new();
74 let mut pending_dirs: Vec<PathBuf> = Vec::new();
75
76 output::print_info("Removing background daemon scheduler...");
78 if let Err(e) = crate::daemon::uninstall_daemon() {
79 output::print_error(&format!("Background scheduler: {e:#}"));
80 left_behind.push("the background scheduler".to_string());
81 }
82
83 output::print_info("Removing global Git auto-registration hooks...");
85 if let Err(e) = hook::run_uninstall() {
86 output::print_error(&format!("Git hooks: {e:#}"));
87 left_behind.push("the global Git hooks".to_string());
88 }
89
90 crate::commands::icon::unregister_file_type();
92
93 for root in setup::agent_skill_roots() {
96 if !root.exists() {
97 continue;
98 }
99 match fs::remove_dir_all(&root) {
100 Ok(()) => output::print_info(&format!(
101 "Removed the agent skill at {}.",
102 output::clean_path(&root)
103 )),
104 Err(e) => {
105 output::print_error(&format!(
106 "Could not remove {}: {e}",
107 output::clean_path(&root)
108 ));
109 left_behind.push("the AI agent skill".to_string());
110 }
111 }
112 }
113
114 if let Ok(bin_dir) = setup::managed_bin_dir() {
118 match crate::pathenv::remove_reachability(&bin_dir) {
119 Ok(true) => output::print_info("Removed dev-prune from your PATH."),
120 Ok(false) => {}
121 Err(e) => {
122 output::print_error(&format!("Could not update your PATH: {e:#}"));
123 left_behind.push("the PATH entry".to_string());
124 }
125 }
126 }
127
128 let manager = std::env::current_exe()
130 .ok()
131 .as_deref()
132 .and_then(owning_package_manager);
133 remove_binaries(
134 deep,
135 manager.is_some(),
136 &mut left_behind,
137 &mut pending_files,
138 &mut pending_dirs,
139 );
140
141 let mut manager_hints: Vec<(&'static str, &'static str)> = Vec::new();
146 if let Some(hint) = manager {
147 manager_hints.push(hint);
148 }
149 sweep_stray_copies(
150 yes,
151 &mut manager_hints,
152 &mut left_behind,
153 &mut pending_files,
154 );
155
156 if deep {
157 if let Some(reg) = registry {
159 for repo_path in reg.repositories.keys() {
160 let cfg_file = repo_path.join(crate::constants::PER_REPO_CONFIG_FILE);
161 if cfg_file.exists() {
162 let _ = fs::remove_file(cfg_file);
163 }
164 }
165 }
166
167 if let Ok(config_dir) = Registry::config_dir() {
168 if config_dir.exists() {
169 match fs::remove_dir_all(&config_dir) {
170 Ok(()) => output::print_info("Removed global configuration directory."),
171 Err(e) => {
172 let running_inside =
177 std::env::current_exe().is_ok_and(|exe| exe.starts_with(&config_dir));
178 if cfg!(windows) && running_inside {
179 pending_dirs.push(config_dir);
180 } else {
181 output::print_error(&format!(
182 "Could not remove {}: {e}",
183 output::clean_path(&config_dir)
184 ));
185 left_behind.push("the global configuration directory".to_string());
186 }
187 }
188 }
189 }
190 }
191 } else {
192 setup::suppress_next_auto_setup();
196 }
197
198 let scheduled = !pending_files.is_empty() || !pending_dirs.is_empty();
200 if scheduled {
201 if spawn_deletion_helper(&pending_files, &pending_dirs) {
202 output::print_info(
203 "The running binary cannot delete itself — the rest is removed \
204 automatically a few seconds after this command exits.",
205 );
206 } else {
207 output::print_error("Could not schedule removal of the running binary.");
208 left_behind.push("the binaries".to_string());
209 }
210 }
211
212 println!();
213 if left_behind.is_empty() {
214 output::print_success(if deep {
215 "Deep uninstall complete: program, integrations, configuration and registry removed."
216 } else {
217 "Uninstall complete: program and integrations removed. Configuration and \
218 prune history preserved for a future reinstall."
219 });
220 }
221 for (name, command) in &manager_hints {
222 output::print_info(&format!(
223 "{name} still lists dev-prune as installed — finish with `{command}` to clear \
224 its records."
225 ));
226 }
227 output::print_info(&format!("Reinstall any time with: {}", reinstall_hint()));
228
229 if !left_behind.is_empty() {
230 anyhow::bail!("Uninstall finished, but {} is still installed.", {
231 left_behind.join(" and ")
232 });
233 }
234
235 Ok(())
236}
237
238fn remove_binaries(
246 deep: bool,
247 manager_owned: bool,
248 left_behind: &mut Vec<String>,
249 pending_files: &mut Vec<PathBuf>,
250 pending_dirs: &mut Vec<PathBuf>,
251) {
252 let mut candidates: Vec<PathBuf> = Vec::new();
253 let managed_bin_dir = setup::managed_bin_dir().ok();
254
255 if let Some(bin_dir) = &managed_bin_dir {
256 for stem in ["dev-prune", "devp"] {
257 candidates.push(bin_dir.join(exe_name(stem)));
258 }
259 }
260
261 if let Ok(current) = std::env::current_exe() {
262 if is_dev_build(¤t) {
263 output::print_info(
264 "This is a development build — leaving the `target/` binaries alone.",
265 );
266 } else if manager_owned {
267 } else if let Some(parent) = current.parent() {
269 for stem in ["dev-prune", "devp"] {
270 let twin = parent.join(exe_name(stem));
271 if !candidates.contains(&twin) {
272 candidates.push(twin);
273 }
274 }
275 }
276 }
277
278 let mut removed_any = false;
279 for exe in candidates {
280 if !exe.is_file() {
281 continue;
282 }
283 match fs::remove_file(&exe) {
284 Ok(()) => removed_any = true,
285 Err(e) => {
286 if cfg!(windows) {
287 pending_files.push(exe);
288 } else {
289 output::print_error(&format!(
290 "Could not remove {}: {e}",
291 output::clean_path(&exe)
292 ));
293 left_behind.push("the binaries".to_string());
294 }
295 }
296 }
297 }
298 if removed_any {
299 output::print_info("Removed the dev-prune binaries.");
300 }
301
302 if !deep {
306 if let Some(bin_dir) = managed_bin_dir {
307 if bin_dir.is_dir() && fs::remove_dir(&bin_dir).is_err() && !pending_files.is_empty() {
308 pending_dirs.push(bin_dir);
309 }
310 }
311 }
312}
313
314struct StrayCopy {
316 path: PathBuf,
317 manager: Option<(&'static str, &'static str)>,
318}
319
320fn sweep_stray_copies(
334 yes: bool,
335 manager_hints: &mut Vec<(&'static str, &'static str)>,
336 left_behind: &mut Vec<String>,
337 pending_files: &mut Vec<PathBuf>,
338) {
339 let already_pending: HashSet<String> = pending_files.iter().map(|p| canon_key(p)).collect();
342 let strays: Vec<StrayCopy> = find_stray_copies()
343 .into_iter()
344 .filter(|s| !already_pending.contains(&canon_key(&s.path)))
345 .collect();
346 if strays.is_empty() {
347 return;
348 }
349
350 println!();
351 output::print_warning(&format!(
352 "Found {} more cop{} of dev-prune, from other install channels:",
353 strays.len(),
354 if strays.len() == 1 { "y" } else { "ies" }
355 ));
356 for stray in &strays {
357 match stray.manager {
358 Some((name, _)) => println!(
359 " {} (installed with {name})",
360 output::clean_path(&stray.path)
361 ),
362 None => println!(" {}", output::clean_path(&stray.path)),
363 }
364 }
365
366 if !confirm_sweep(yes) {
367 output::print_info(
368 "Left in place. Remove them yourself, or re-run `devp uninstall` any time.",
369 );
370 return;
371 }
372
373 let mut removed = 0usize;
374 for stray in strays {
375 if let Some(hint) = stray.manager {
376 if !manager_hints.iter().any(|(name, _)| *name == hint.0) {
377 manager_hints.push(hint);
378 }
379 }
380 match fs::remove_file(&stray.path) {
381 Ok(()) => removed += 1,
382 Err(e) => {
383 if cfg!(windows) {
386 pending_files.push(stray.path);
387 } else {
388 output::print_error(&format!(
389 "Could not remove {}: {e}",
390 output::clean_path(&stray.path)
391 ));
392 left_behind.push("a stray copy".to_string());
393 }
394 }
395 }
396 }
397 if removed > 0 {
398 output::print_info(&format!(
399 "Removed {removed} stray cop{}.",
400 if removed == 1 { "y" } else { "ies" }
401 ));
402 }
403}
404
405fn confirm_sweep(yes: bool) -> bool {
408 use std::io::{IsTerminal, Write};
409 if yes {
410 return true;
411 }
412 if !std::io::stdin().is_terminal() {
413 output::print_info("Not running in a terminal — pass `--yes` to remove these too.");
414 return false;
415 }
416 print!("Remove them all? [Y/n]: ");
417 if std::io::stdout().flush().is_err() {
418 return false;
419 }
420 let mut input = String::new();
421 if std::io::stdin().read_line(&mut input).is_err() {
422 return false;
423 }
424 matches!(input.trim().to_lowercase().as_str(), "" | "y" | "yes")
425}
426
427fn find_stray_copies() -> Vec<StrayCopy> {
433 let managed = setup::managed_bin_dir().ok().map(|d| canon_key(&d));
434 let names = sweep_names();
435 let mut seen_dirs: HashSet<String> = HashSet::new();
436 let mut seen_files: HashSet<String> = HashSet::new();
437 let mut found = Vec::new();
438
439 for dir in sweep_dirs() {
440 let dir_key = canon_key(&dir);
441 if !seen_dirs.insert(dir_key.clone()) {
442 continue;
443 }
444 if managed.as_deref() == Some(dir_key.as_str()) {
445 continue;
446 }
447 for name in &names {
448 let candidate = dir.join(name);
449 let Ok(meta) = fs::symlink_metadata(&candidate) else {
450 continue;
451 };
452 if meta.is_dir() || is_dev_build(&candidate) {
453 continue;
454 }
455 if !seen_files.insert(canon_key(&candidate)) {
456 continue;
457 }
458 let manager = owning_package_manager(&candidate);
459 found.push(StrayCopy {
460 path: candidate,
461 manager,
462 });
463 }
464 }
465 found
466}
467
468fn sweep_dirs() -> Vec<PathBuf> {
472 let mut dirs: Vec<PathBuf> = Vec::new();
473 if let Some(path_var) = std::env::var_os("PATH") {
474 dirs.extend(std::env::split_paths(&path_var));
475 }
476 if let Some(home) = dirs::home_dir() {
477 dirs.push(home.join(".cargo").join("bin"));
478 dirs.push(home.join(".local").join("bin"));
479 if !cfg!(windows) {
480 dirs.push(home.join(".npm-global").join("bin"));
481 }
482 }
483 if cfg!(windows) {
484 if let Some(appdata) = dirs::config_dir() {
487 dirs.push(appdata.join("npm"));
488 if let Ok(entries) = fs::read_dir(appdata.join("Python")) {
489 for entry in entries.flatten() {
490 let scripts = entry.path().join("Scripts");
491 if scripts.is_dir() {
492 dirs.push(scripts);
493 }
494 }
495 }
496 }
497 }
498 if let Ok(exe) = std::env::current_exe() {
499 if let Some(parent) = exe.parent() {
500 dirs.push(parent.to_path_buf());
501 }
502 }
503 dirs
504}
505
506fn sweep_names() -> Vec<String> {
510 let stems = ["dev-prune", "devp"];
511 if cfg!(windows) {
512 let mut names: Vec<String> = Vec::new();
513 for stem in stems {
514 for ext in ["exe", "cmd", "ps1", "bat"] {
515 names.push(format!("{stem}.{ext}"));
516 }
517 names.push(stem.to_string());
518 }
519 names
520 } else {
521 stems.iter().map(|s| s.to_string()).collect()
522 }
523}
524
525fn canon_key(path: &Path) -> String {
527 let key = path.to_string_lossy().replace('\\', "/");
528 let key = key.trim_end_matches('/').to_string();
529 if cfg!(windows) {
530 key.to_lowercase()
531 } else {
532 key
533 }
534}
535
536fn exe_name(stem: &str) -> String {
538 if cfg!(windows) {
539 format!("{stem}.exe")
540 } else {
541 stem.to_string()
542 }
543}
544
545fn is_dev_build(exe: &Path) -> bool {
547 let path = exe.to_string_lossy().replace('\\', "/");
548 path.contains("/target/debug/") || path.contains("/target/release/")
549}
550
551fn owning_package_manager(exe: &Path) -> Option<(&'static str, &'static str)> {
559 let path = exe.to_string_lossy().replace('\\', "/").to_lowercase();
560 if path.contains("/.cargo/bin/") {
561 return Some(("cargo", "cargo uninstall dev-prune"));
562 }
563 if path.contains("/node_modules/") || path.contains("/_npx/") {
564 return Some(("npm", "npm uninstall -g dev-prune"));
565 }
566 if path.contains("/uv/tools/") {
567 return Some(("uv", "uv tool uninstall dev-prune"));
568 }
569 if path.contains("/pipx/") {
570 return Some(("pipx", "pipx uninstall dev-prune"));
571 }
572 if let Some(dir) = exe.parent() {
573 if dir.join("node_modules").join("dev-prune").exists() {
575 return Some(("npm", "npm uninstall -g dev-prune"));
576 }
577 for interpreter in ["python.exe", "python", "python3"] {
580 if dir.join(interpreter).exists() {
581 return Some(("pip", "pip uninstall dev-prune"));
582 }
583 }
584 }
585 None
586}
587
588fn reinstall_hint() -> &'static str {
590 if cfg!(windows) {
591 "iwr -useb https://devprune.vkrishna04.me/install.ps1 | iex"
592 } else {
593 "curl -fsSL https://devprune.vkrishna04.me/install.sh | sh"
594 }
595}
596
597#[cfg(windows)]
601fn spawn_deletion_helper(files: &[PathBuf], dirs: &[PathBuf]) -> bool {
602 use std::os::windows::process::CommandExt;
603 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
606
607 let mut attempt = String::new();
613 for file in files {
614 attempt.push_str(&format!(" & del /F /Q \"{}\"", file.display()));
615 }
616 for dir in dirs {
617 attempt.push_str(&format!(" & rmdir /S /Q \"{}\"", dir.display()));
618 }
619 let mut script = String::new();
620 for _ in 0..3 {
621 script.push_str("ping -n 3 127.0.0.1 >nul");
622 script.push_str(&attempt);
623 script.push_str(" & ");
624 }
625 script.push_str("exit");
626
627 std::process::Command::new("cmd")
628 .raw_arg(format!("/C {script}"))
631 .creation_flags(CREATE_NO_WINDOW)
632 .stdin(std::process::Stdio::null())
633 .stdout(std::process::Stdio::null())
634 .stderr(std::process::Stdio::null())
635 .spawn()
636 .is_ok()
637}
638
639#[cfg(not(windows))]
642fn spawn_deletion_helper(_files: &[PathBuf], _dirs: &[PathBuf]) -> bool {
643 false
644}