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)]
184 {
185 output::print_warning(&format!(
186 "The {} copy is still here: Windows cannot uninstall a binary while it \
187 is running. Run this once, in a new shell:\n {}",
188 current.label(),
189 argv.join(" ")
190 ));
191 }
192 #[cfg(not(windows))]
193 match spawn(&argv) {
194 Ok(()) => output::print_success(&format!("Removed the {} copy.", current.label())),
195 Err(e) => output::print_warning(&format!(
196 "The new copy is installed, but removing the old one failed ({e:#}).\n\
197 Run it yourself when convenient: {}",
198 argv.join(" ")
199 )),
200 }
201 }
202
203 println!();
204 output::print_info(
205 "Your configuration, repository registry and undo history are unchanged — they \
206 live in the config directory, which no channel owns.",
207 );
208 output::print_info("Open a new shell, then `devp update` to confirm which copy it finds.");
209 Ok(())
210}
211
212fn converge(exe: &std::path::Path, dry_run: bool, yes: bool) -> Result<()> {
225 use crate::commands::uninstall::{canon_key, find_stray_copies, group_by_channel};
226
227 let here = exe.parent().map(canon_key);
228 let others: Vec<_> = find_stray_copies()
229 .into_iter()
230 .filter(|s| s.path.parent().map(canon_key) != here)
231 .collect();
232
233 println!();
234 if others.is_empty() {
235 output::print_info("No other copy of dev-prune is on this machine.");
236 return Ok(());
237 }
238
239 output::print_warning(&format!(
240 "{} other cop{} of dev-prune {} on this machine:",
241 others.len(),
242 if others.len() == 1 { "y" } else { "ies" },
243 if others.len() == 1 { "is" } else { "are" }
244 ));
245 println!();
246 for stray in &others {
247 println!(" {}", output::clean_path(&stray.path));
248 match stray.channel.uninstall_argv() {
249 Some(argv) => println!(" {}: {}", stray.channel.label(), argv.join(" ")),
250 None => println!(" {}: delete the file", stray.channel.label()),
253 }
254 }
255 println!();
256
257 if dry_run {
258 output::print_info("`--dry-run`: nothing was run.");
259 return Ok(());
260 }
261
262 if Registry::load().is_ok_and(|r| r.settings.version_lock) {
266 anyhow::bail!(
267 "Removing another copy would change which version answers on PATH. {}",
268 super::update::locked_notice(None)
269 );
270 }
271
272 if !confirm(yes) {
273 output::print_info("Left in place. Nothing was changed.");
274 return Ok(());
275 }
276
277 let mut removed = 0usize;
278 let mut failed: Vec<(std::path::PathBuf, String)> = Vec::new();
279 for (channel, paths) in group_by_channel(others) {
283 let Some(argv) = channel.uninstall_argv() else {
284 for path in paths {
286 match std::fs::remove_file(&path) {
287 Ok(()) => removed += 1,
288 Err(e) => failed.push((path, e.to_string())),
289 }
290 }
291 continue;
292 };
293 match spawn(&argv) {
294 Ok(()) => removed += paths.len(),
295 Err(e) => {
296 for path in paths {
297 failed.push((path, format!("{e:#}")));
298 }
299 }
300 }
301 }
302
303 println!();
304 if removed > 0 {
305 output::print_success(&format!(
306 "Removed {removed} other cop{}.",
307 if removed == 1 { "y" } else { "ies" }
308 ));
309 }
310 for (path, why) in &failed {
311 output::print_warning(&format!(
312 "{} is still there: {why}",
313 output::clean_path(path)
314 ));
315 }
316 output::print_info("Open a new shell, then `devp update` to confirm which copy it finds.");
317 Ok(())
318}
319
320fn report(current: Channel, exe: &std::path::Path) -> Result<()> {
323 output::print_header("dev-prune install channel");
324 println!();
325 println!(" Installed by: {}", current.label());
326 println!(" Binary: {}", exe.display());
327 if current == Channel::Installer
330 && let Some(receipt) = crate::receipt::load()
331 {
332 println!(" Receipt: {}", crate::receipt::summary(&receipt));
333 }
334 if let Some(cmd) = current.upgrade_command() {
335 println!(" Upgrade: {cmd}");
336 }
337 println!();
338 let names = TargetChannel::value_variants()
341 .iter()
342 .filter_map(|t| t.to_possible_value())
343 .map(|v| v.get_name().to_string())
344 .collect::<Vec<_>>()
345 .join(", ");
346 output::print_info(&format!(
347 "Move it to another package manager with `devp install --channel <name>`:\n \
348 {names}."
349 ));
350 output::print_info("`--dry-run` prints the whole plan without running any of it.");
351 Ok(())
352}
353
354fn spawn(argv: &[String]) -> Result<()> {
357 output::print_info(&format!("Running: {}", argv.join(" ")));
358 let status = crate::spawn::command(crate::adapters::resolve_program(&argv[0]))
359 .args(&argv[1..])
360 .env(crate::constants::ENV_NO_MIGRATE_PROMPT, "1")
367 .status()
368 .with_context(|| format!("could not start `{}`", argv[0]))?;
369 if !status.success() {
370 anyhow::bail!("`{}` exited with {status}", argv.join(" "));
371 }
372 Ok(())
373}
374
375fn confirm(yes: bool) -> bool {
378 use std::io::{IsTerminal, Write};
379 if yes {
380 return true;
381 }
382 if !std::io::stdin().is_terminal() {
383 output::print_info("Not running in a terminal — pass `--yes` to go ahead.");
384 return false;
385 }
386 eprint!("Run this plan? [y/N]: ");
387 if std::io::stderr().flush().is_err() {
388 return false;
389 }
390 let mut input = String::new();
391 if std::io::stdin().read_line(&mut input).is_err() {
392 return false;
393 }
394 matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 #[test]
402 fn every_offered_destination_has_an_install_command() {
403 for target in TargetChannel::value_variants() {
406 assert!(
407 target.channel().install_argv().is_some(),
408 "`--channel {target:?}` has no install command"
409 );
410 }
411 }
412
413 #[test]
414 fn the_old_copy_is_removed_through_the_manager_that_owns_it() {
415 for channel in [
419 Channel::Cargo,
420 Channel::Npm,
421 Channel::Bun,
422 Channel::Pnpm,
423 Channel::Yarn,
424 Channel::UvTool,
425 Channel::Pipx,
426 Channel::Pip,
427 Channel::WinGet,
428 Channel::Scoop,
429 Channel::Homebrew,
430 ] {
431 assert!(channel.owns_its_files());
432 assert!(
433 channel.uninstall_argv().is_some(),
434 "{channel:?} keeps a record but has no uninstall command"
435 );
436 }
437 assert!(Channel::Installer.uninstall_argv().is_none());
438 assert!(Channel::Unknown.uninstall_argv().is_none());
439 }
440}