1use anyhow::{Context, Result};
26use clap::ValueEnum;
27
28use crate::channel::Channel;
29use crate::config::Registry;
30use crate::output;
31
32#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
39pub enum TargetChannel {
40 Installer,
42 Cargo,
44 Npm,
46 Bun,
48 Pnpm,
50 Yarn,
52 Uv,
54 Pipx,
56 Winget,
58 Scoop,
60 Homebrew,
62}
63
64impl TargetChannel {
65 fn channel(self) -> Channel {
66 match self {
67 TargetChannel::Installer => Channel::Installer,
68 TargetChannel::Cargo => Channel::Cargo,
69 TargetChannel::Npm => Channel::Npm,
70 TargetChannel::Bun => Channel::Bun,
71 TargetChannel::Pnpm => Channel::Pnpm,
72 TargetChannel::Yarn => Channel::Yarn,
73 TargetChannel::Uv => Channel::UvTool,
74 TargetChannel::Pipx => Channel::Pipx,
75 TargetChannel::Winget => Channel::WinGet,
76 TargetChannel::Scoop => Channel::Scoop,
77 TargetChannel::Homebrew => Channel::Homebrew,
78 }
79 }
80}
81
82pub fn run(channel: Option<TargetChannel>, dry_run: bool, yes: bool) -> Result<()> {
83 let exe = std::env::current_exe().context("could not locate the running binary")?;
84 let managed = crate::setup::managed_exe_path().ok();
85 let current = Channel::detect_at(&exe, managed.as_deref());
86
87 let Some(target) = channel.map(TargetChannel::channel) else {
88 return report(current, &exe);
89 };
90
91 output::print_header("dev-prune install channel");
92
93 if target == current {
94 output::print_success(&format!(
95 "This copy already came from {} — nothing to move.",
96 current.label()
97 ));
98 if let Some(cmd) = current.upgrade_command() {
99 output::print_info(&format!("Upgrade it in place with: {cmd}"));
100 }
101 return converge(&exe, dry_run, yes);
102 }
103
104 if Registry::load().is_ok_and(|r| r.settings.version_lock) {
109 anyhow::bail!(
110 "Moving to {} would install the latest release through it. {}",
111 target.label(),
112 super::update::locked_notice(None)
113 );
114 }
115
116 let (sources, install) = install_plan(target);
117 let uninstall = uninstall_argv(current);
118
119 println!();
120 println!(" From: {} ({})", current.label(), exe.display());
121 println!(" To: {}", target.label());
122 println!();
123 let mut step = 0;
124 for argv in sources.iter().chain(install.iter()) {
125 step += 1;
126 println!(" {step}. {}", argv.join(" "));
127 }
128 step += 1;
129 match &uninstall {
130 Some(argv) => println!(" {step}. {}", argv.join(" ")),
131 None => println!(
132 " {step}. nothing to uninstall — {}",
133 match current {
134 Channel::Installer =>
138 "the managed copy stays, and refreshes itself from the new binary",
139 _ =>
140 "this copy was not installed by a package manager, so remove the \
141 file yourself if you want it gone",
142 }
143 ),
144 }
145 println!();
146
147 if dry_run {
148 output::print_info("`--dry-run`: nothing was run.");
149 return Ok(());
150 }
151
152 if !confirm(yes) {
153 output::print_info("Nothing was changed.");
154 return Ok(());
155 }
156
157 for argv in &sources {
158 if let Err(e) = spawn(argv) {
161 output::print_dimmed(&format!(" ({e:#} — continuing.)"));
162 }
163 }
164
165 for argv in &install {
168 spawn(argv)?;
169 }
170 output::print_success(&format!("Installed through {}.", target.label()));
171
172 if let Some(argv) = uninstall {
173 #[cfg(windows)]
178 let aside = {
179 let aside = exe.with_extension("exe.old");
180 let _ = std::fs::remove_file(&aside);
181 std::fs::rename(&exe, &aside).ok().map(|_| aside)
182 };
183
184 let removed = spawn(&argv);
185
186 #[cfg(windows)]
187 if let Some(aside) = aside
188 && removed.is_err()
189 && !exe.exists()
190 {
191 let _ = std::fs::rename(&aside, &exe);
194 }
195
196 match removed {
197 Ok(()) => output::print_success(&format!("Removed the {} copy.", current.label())),
198 Err(e) => output::print_warning(&format!(
199 "The new copy is installed, but removing the old one failed ({e:#}).\n\
200 Run it yourself when convenient: {}",
201 argv.join(" ")
202 )),
203 }
204 }
205
206 println!();
207 output::print_info(
208 "Your configuration, repository registry and undo history are unchanged — they \
209 live in the config directory, which no channel owns.",
210 );
211 output::print_info("Open a new shell, then `devp update` to confirm which copy it finds.");
212 Ok(())
213}
214
215fn converge(exe: &std::path::Path, dry_run: bool, yes: bool) -> Result<()> {
228 use crate::commands::uninstall::{canon_key, find_stray_copies};
229
230 let here = exe.parent().map(canon_key);
231 let others: Vec<_> = find_stray_copies()
232 .into_iter()
233 .filter(|s| s.path.parent().map(canon_key) != here)
234 .collect();
235
236 println!();
237 if others.is_empty() {
238 output::print_info("No other copy of dev-prune is on this machine.");
239 return Ok(());
240 }
241
242 output::print_warning(&format!(
243 "{} other cop{} of dev-prune {} on this machine:",
244 others.len(),
245 if others.len() == 1 { "y" } else { "ies" },
246 if others.len() == 1 { "is" } else { "are" }
247 ));
248 println!();
249 for stray in &others {
250 println!(" {}", output::clean_path(&stray.path));
251 match uninstall_argv(stray.channel) {
252 Some(argv) => println!(" {}: {}", stray.channel.label(), argv.join(" ")),
253 None => println!(" {}: delete the file", stray.channel.label()),
256 }
257 }
258 println!();
259
260 if dry_run {
261 output::print_info("`--dry-run`: nothing was run.");
262 return Ok(());
263 }
264
265 if Registry::load().is_ok_and(|r| r.settings.version_lock) {
269 anyhow::bail!(
270 "Removing another copy would change which version answers on PATH. {}",
271 super::update::locked_notice(None)
272 );
273 }
274
275 if !confirm(yes) {
276 output::print_info("Left in place. Nothing was changed.");
277 return Ok(());
278 }
279
280 let mut removed = 0usize;
281 let mut failed: Vec<(std::path::PathBuf, String)> = Vec::new();
282 for stray in &others {
283 let outcome = match uninstall_argv(stray.channel) {
284 Some(argv) => spawn(&argv).map_err(|e| format!("{e:#}")),
285 None => std::fs::remove_file(&stray.path).map_err(|e| e.to_string()),
286 };
287 match outcome {
288 Ok(()) => removed += 1,
289 Err(e) => failed.push((stray.path.clone(), e)),
290 }
291 }
292
293 println!();
294 if removed > 0 {
295 output::print_success(&format!(
296 "Removed {removed} other cop{}.",
297 if removed == 1 { "y" } else { "ies" }
298 ));
299 }
300 for (path, why) in &failed {
301 output::print_warning(&format!(
302 "{} is still there: {why}",
303 output::clean_path(path)
304 ));
305 }
306 output::print_info("Open a new shell, then `devp update` to confirm which copy it finds.");
307 Ok(())
308}
309
310fn report(current: Channel, exe: &std::path::Path) -> Result<()> {
313 output::print_header("dev-prune install channel");
314 println!();
315 println!(" Installed by: {}", current.label());
316 println!(" Binary: {}", exe.display());
317 if current == Channel::Installer
320 && let Some(receipt) = crate::receipt::load()
321 {
322 println!(" Receipt: {}", crate::receipt::summary(&receipt));
323 }
324 if let Some(cmd) = current.upgrade_command() {
325 println!(" Upgrade: {cmd}");
326 }
327 println!();
328 let names = TargetChannel::value_variants()
331 .iter()
332 .filter_map(|t| t.to_possible_value())
333 .map(|v| v.get_name().to_string())
334 .collect::<Vec<_>>()
335 .join(", ");
336 output::print_info(&format!(
337 "Move it to another package manager with `devp install --channel <name>`:\n \
338 {names}."
339 ));
340 output::print_info("`--dry-run` prints the whole plan without running any of it.");
341 Ok(())
342}
343
344fn install_plan(channel: Channel) -> (Vec<Vec<String>>, Vec<Vec<String>>) {
353 let owned = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
354 let sources = match channel {
355 Channel::Scoop => vec![owned(&[
356 "scoop",
357 "bucket",
358 "add",
359 crate::constants::SCOOP_BUCKET_NAME,
360 crate::constants::SCOOP_BUCKET_URL,
361 ])],
362 Channel::Homebrew => vec![owned(&["brew", "tap", crate::constants::HOMEBREW_TAP])],
363 _ => Vec::new(),
364 };
365 (sources, install_argv(channel))
366}
367
368fn install_argv(channel: Channel) -> Vec<Vec<String>> {
370 let owned = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
371 match channel {
372 Channel::Cargo => {
375 if crate::adapters::binary_available("cargo-binstall") {
376 vec![owned(&["cargo", "binstall", "dev-prune", "-y"])]
377 } else {
378 vec![owned(&["cargo", "install", "dev-prune"])]
379 }
380 }
381 Channel::Npm => vec![owned(&["npm", "install", "-g", "dev-prune"])],
382 Channel::Bun => vec![owned(&["bun", "add", "-g", "dev-prune"])],
383 Channel::Pnpm => vec![owned(&["pnpm", "add", "-g", "dev-prune"])],
384 Channel::Yarn => vec![owned(&["yarn", "global", "add", "dev-prune"])],
385 Channel::UvTool => vec![owned(&["uv", "tool", "install", "dev-prune@latest"])],
389 Channel::Pipx => vec![owned(&["pipx", "install", "dev-prune"])],
390 Channel::WinGet => vec![vec![
391 "winget".to_string(),
392 "install".to_string(),
393 "--id".to_string(),
394 crate::constants::WINGET_PACKAGE_ID.to_string(),
395 "--accept-package-agreements".to_string(),
396 "--accept-source-agreements".to_string(),
397 ]],
398 Channel::Scoop => vec![owned(&["scoop", "install", "dev-prune"])],
399 Channel::Homebrew => vec![owned(&["brew", "install", "dev-prune"])],
400 Channel::Installer => {
401 if cfg!(windows) {
402 vec![vec![
403 "powershell".to_string(),
404 "-NoProfile".to_string(),
405 "-Command".to_string(),
406 format!("iwr -useb {} | iex", crate::constants::INSTALL_PS1_URL),
407 ]]
408 } else {
409 vec![vec![
410 "sh".to_string(),
411 "-c".to_string(),
412 format!("curl -fsSL {} | sh", crate::constants::INSTALL_SH_URL),
413 ]]
414 }
415 }
416 Channel::Pip | Channel::Unknown => Vec::new(),
418 }
419}
420
421fn uninstall_argv(channel: Channel) -> Option<Vec<String>> {
424 let owned = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
425 Some(match channel {
426 Channel::Cargo => owned(&["cargo", "uninstall", "dev-prune"]),
427 Channel::Npm => owned(&["npm", "uninstall", "-g", "dev-prune"]),
428 Channel::Bun => owned(&["bun", "remove", "-g", "dev-prune"]),
429 Channel::Pnpm => owned(&["pnpm", "remove", "-g", "dev-prune"]),
430 Channel::Yarn => owned(&["yarn", "global", "remove", "dev-prune"]),
431 Channel::UvTool => owned(&["uv", "tool", "uninstall", "dev-prune"]),
432 Channel::Pipx => owned(&["pipx", "uninstall", "dev-prune"]),
433 Channel::Pip => owned(&["pip", "uninstall", "-y", "dev-prune"]),
434 Channel::WinGet => vec![
435 "winget".to_string(),
436 "uninstall".to_string(),
437 "--id".to_string(),
438 crate::constants::WINGET_PACKAGE_ID.to_string(),
439 ],
440 Channel::Scoop => owned(&["scoop", "uninstall", "dev-prune"]),
441 Channel::Homebrew => owned(&["brew", "uninstall", "dev-prune"]),
442 Channel::Installer | Channel::Unknown => return None,
443 })
444}
445
446fn spawn(argv: &[String]) -> Result<()> {
449 output::print_info(&format!("Running: {}", argv.join(" ")));
450 let status = crate::spawn::command(crate::adapters::resolve_program(&argv[0]))
451 .args(&argv[1..])
452 .env(crate::constants::ENV_NO_MIGRATE_PROMPT, "1")
459 .status()
460 .with_context(|| format!("could not start `{}`", argv[0]))?;
461 if !status.success() {
462 anyhow::bail!("`{}` exited with {status}", argv.join(" "));
463 }
464 Ok(())
465}
466
467fn confirm(yes: bool) -> bool {
470 use std::io::{IsTerminal, Write};
471 if yes {
472 return true;
473 }
474 if !std::io::stdin().is_terminal() {
475 output::print_info("Not running in a terminal — pass `--yes` to go ahead.");
476 return false;
477 }
478 eprint!("Run this plan? [y/N]: ");
479 if std::io::stderr().flush().is_err() {
480 return false;
481 }
482 let mut input = String::new();
483 if std::io::stdin().read_line(&mut input).is_err() {
484 return false;
485 }
486 matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492
493 #[test]
494 fn every_offered_destination_has_an_install_command() {
495 for target in TargetChannel::value_variants() {
498 let argv = install_argv(target.channel());
499 assert!(
500 !argv.is_empty(),
501 "`--channel {target:?}` has no install command"
502 );
503 }
504 }
505
506 #[test]
507 fn the_old_copy_is_removed_through_the_manager_that_owns_it() {
508 for channel in [
512 Channel::Cargo,
513 Channel::Npm,
514 Channel::Bun,
515 Channel::Pnpm,
516 Channel::Yarn,
517 Channel::UvTool,
518 Channel::Pipx,
519 Channel::Pip,
520 Channel::WinGet,
521 Channel::Scoop,
522 Channel::Homebrew,
523 ] {
524 assert!(channel.owns_its_files());
525 assert!(
526 uninstall_argv(channel).is_some(),
527 "{channel:?} keeps a record but has no uninstall command"
528 );
529 }
530 assert!(uninstall_argv(Channel::Installer).is_none());
531 assert!(uninstall_argv(Channel::Unknown).is_none());
532 }
533}