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 = target.install_sources();
117 let install = target.install_argv();
118 let uninstall = current.uninstall_argv();
119
120 println!();
121 println!(" From: {} ({})", current.label(), exe.display());
122 println!(" To: {}", target.label());
123 println!();
124 let mut step = 0;
125 for argv in sources.iter().chain(install.iter()) {
126 step += 1;
127 println!(" {step}. {}", argv.join(" "));
128 }
129 step += 1;
130 match &uninstall {
131 Some(argv) => println!(" {step}. {}", argv.join(" ")),
132 None => println!(
133 " {step}. nothing to uninstall — {}",
134 match current {
135 Channel::Installer =>
139 "the managed copy stays, and refreshes itself from the new binary",
140 _ =>
141 "this copy was not installed by a package manager, so remove the \
142 file yourself if you want it gone",
143 }
144 ),
145 }
146 println!();
147
148 if dry_run {
149 output::print_info("`--dry-run`: nothing was run.");
150 return Ok(());
151 }
152
153 if !confirm(yes) {
154 output::print_info("Nothing was changed.");
155 return Ok(());
156 }
157
158 for argv in &sources {
159 if let Err(e) = spawn(argv) {
162 output::print_dimmed(&format!(" ({e:#} — continuing.)"));
163 }
164 }
165
166 if let Some(argv) = &install {
169 spawn(argv)?;
170 }
171 output::print_success(&format!("Installed through {}.", target.label()));
172
173 if let Some(argv) = uninstall {
174 #[cfg(windows)]
183 let removed = if crate::commands::uninstall::schedule_manager_uninstall(current) {
184 Ok(true)
185 } else {
186 Err(anyhow::anyhow!(
187 "it could not be scheduled to run after this command exits"
188 ))
189 };
190 #[cfg(not(windows))]
191 let removed = spawn(&argv).map(|()| false);
192
193 match removed {
194 Ok(true) => output::print_success(&format!(
195 "The {} copy is removed a few seconds after this command exits.",
196 current.label()
197 )),
198 Ok(false) => output::print_success(&format!("Removed the {} copy.", current.label())),
199 Err(e) => output::print_warning(&format!(
200 "The new copy is installed, but removing the old one failed ({e:#}).\n\
201 Run it yourself when convenient: {}",
202 argv.join(" ")
203 )),
204 }
205 }
206
207 println!();
208 output::print_info(
209 "Your configuration, repository registry and undo history are unchanged — they \
210 live in the config directory, which no channel owns.",
211 );
212 output::print_info("Open a new shell, then `devp update` to confirm which copy it finds.");
213 Ok(())
214}
215
216fn converge(exe: &std::path::Path, dry_run: bool, yes: bool) -> Result<()> {
229 use crate::commands::uninstall::{canon_key, find_stray_copies, group_by_channel};
230
231 let here = exe.parent().map(canon_key);
232 let others: Vec<_> = find_stray_copies()
233 .into_iter()
234 .filter(|s| s.path.parent().map(canon_key) != here)
235 .collect();
236
237 println!();
238 if others.is_empty() {
239 output::print_info("No other copy of dev-prune is on this machine.");
240 return Ok(());
241 }
242
243 output::print_warning(&format!(
244 "{} other cop{} of dev-prune {} on this machine:",
245 others.len(),
246 if others.len() == 1 { "y" } else { "ies" },
247 if others.len() == 1 { "is" } else { "are" }
248 ));
249 println!();
250 for stray in &others {
251 println!(" {}", output::clean_path(&stray.path));
252 match stray.channel.uninstall_argv() {
253 Some(argv) => println!(" {}: {}", stray.channel.label(), argv.join(" ")),
254 None => println!(" {}: delete the file", stray.channel.label()),
257 }
258 }
259 println!();
260
261 if dry_run {
262 output::print_info("`--dry-run`: nothing was run.");
263 return Ok(());
264 }
265
266 if Registry::load().is_ok_and(|r| r.settings.version_lock) {
270 anyhow::bail!(
271 "Removing another copy would change which version answers on PATH. {}",
272 super::update::locked_notice(None)
273 );
274 }
275
276 if !confirm(yes) {
277 output::print_info("Left in place. Nothing was changed.");
278 return Ok(());
279 }
280
281 let mut removed = 0usize;
282 let mut failed: Vec<(std::path::PathBuf, String)> = Vec::new();
283 for (channel, paths) in group_by_channel(others) {
287 let Some(argv) = channel.uninstall_argv() else {
288 for path in paths {
290 match std::fs::remove_file(&path) {
291 Ok(()) => removed += 1,
292 Err(e) => failed.push((path, e.to_string())),
293 }
294 }
295 continue;
296 };
297 match spawn(&argv) {
298 Ok(()) => removed += paths.len(),
299 Err(e) => {
300 for path in paths {
301 failed.push((path, format!("{e:#}")));
302 }
303 }
304 }
305 }
306
307 println!();
308 if removed > 0 {
309 output::print_success(&format!(
310 "Removed {removed} other cop{}.",
311 if removed == 1 { "y" } else { "ies" }
312 ));
313 }
314 for (path, why) in &failed {
315 output::print_warning(&format!(
316 "{} is still there: {why}",
317 output::clean_path(path)
318 ));
319 }
320 output::print_info("Open a new shell, then `devp update` to confirm which copy it finds.");
321 Ok(())
322}
323
324fn report(current: Channel, exe: &std::path::Path) -> Result<()> {
327 output::print_header("dev-prune install channel");
328 println!();
329 println!(" Installed by: {}", current.label());
330 println!(" Binary: {}", exe.display());
331 if current == Channel::Installer
334 && let Some(receipt) = crate::receipt::load()
335 {
336 println!(" Receipt: {}", crate::receipt::summary(&receipt));
337 }
338 if let Some(cmd) = current.upgrade_command() {
339 println!(" Upgrade: {cmd}");
340 }
341 println!();
342 let names = TargetChannel::value_variants()
345 .iter()
346 .filter_map(|t| t.to_possible_value())
347 .map(|v| v.get_name().to_string())
348 .collect::<Vec<_>>()
349 .join(", ");
350 output::print_info(&format!(
351 "Move it to another package manager with `devp install --channel <name>`:\n \
352 {names}."
353 ));
354 output::print_info("`--dry-run` prints the whole plan without running any of it.");
355 Ok(())
356}
357
358fn spawn(argv: &[String]) -> Result<()> {
361 output::print_info(&format!("Running: {}", argv.join(" ")));
362 let status = crate::spawn::command(crate::adapters::resolve_program(&argv[0]))
363 .args(&argv[1..])
364 .env(crate::constants::ENV_NO_MIGRATE_PROMPT, "1")
371 .status()
372 .with_context(|| format!("could not start `{}`", argv[0]))?;
373 if !status.success() {
374 anyhow::bail!("`{}` exited with {status}", argv.join(" "));
375 }
376 Ok(())
377}
378
379fn confirm(yes: bool) -> bool {
382 use std::io::{IsTerminal, Write};
383 if yes {
384 return true;
385 }
386 if !std::io::stdin().is_terminal() {
387 output::print_info("Not running in a terminal — pass `--yes` to go ahead.");
388 return false;
389 }
390 eprint!("Run this plan? [y/N]: ");
391 if std::io::stderr().flush().is_err() {
392 return false;
393 }
394 let mut input = String::new();
395 if std::io::stdin().read_line(&mut input).is_err() {
396 return false;
397 }
398 matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 #[test]
406 fn every_offered_destination_has_an_install_command() {
407 for target in TargetChannel::value_variants() {
410 assert!(
411 target.channel().install_argv().is_some(),
412 "`--channel {target:?}` has no install command"
413 );
414 }
415 }
416
417 #[test]
418 fn the_old_copy_is_removed_through_the_manager_that_owns_it() {
419 for channel in [
423 Channel::Cargo,
424 Channel::Npm,
425 Channel::Bun,
426 Channel::Pnpm,
427 Channel::Yarn,
428 Channel::UvTool,
429 Channel::Pipx,
430 Channel::Pip,
431 Channel::WinGet,
432 Channel::Scoop,
433 Channel::Homebrew,
434 ] {
435 assert!(channel.owns_its_files());
436 assert!(
437 channel.uninstall_argv().is_some(),
438 "{channel:?} keeps a record but has no uninstall command"
439 );
440 }
441 assert!(Channel::Installer.uninstall_argv().is_none());
442 assert!(Channel::Unknown.uninstall_argv().is_none());
443 }
444}