Skip to main content

dev_prune/commands/
install.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Handler for `dev-prune install --channel`, which moves an existing install from one
5// package manager to another.
6//
7// `devp update` upgrades the copy that is running, through whichever channel installed
8// it, and that is deliberately the only thing it does. Changing *which* channel owns the
9// binary was the gap: somebody who ran `cargo install dev-prune` and later wanted WinGet
10// had to know to remove the old copy first, and if they did not, two binaries sat on
11// PATH and which one won was an accident of ordering.
12//
13// So this command does the two halves in the order that leaves a working `devp` at every
14// point in between: install through the new manager first, then remove the old copy
15// through the manager that owns it. An install that fails leaves the old copy exactly
16// where it was, which is why it is not removed first.
17//
18// Nothing has to be migrated. Configuration, the repository registry and the undo state
19// all live in the config directory, which no channel owns and none of them touch.
20//
21// It spawns another package manager to uninstall something — the one category of action
22// this tool keeps behind an explicit request — so it is a command you type, it prints the
23// whole plan before running any of it, and it asks first unless `--yes` is passed.
24
25use anyhow::{Context, Result};
26use clap::ValueEnum;
27
28use crate::channel::Channel;
29use crate::config::Registry;
30use crate::output;
31
32/// The channels a user can move *to*, as `--channel` accepts them.
33///
34/// Deliberately not every [`Channel`]: `Unknown` is not a destination, and `Pip` is
35/// omitted because a bare `pip install` of a CLI puts the console script wherever the
36/// active interpreter happens to be, which is exactly the ambiguity `uv tool` and `pipx`
37/// exist to remove.
38#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
39pub enum TargetChannel {
40    /// The install script, into the managed `<config>/bin` directory.
41    Installer,
42    /// `cargo install dev-prune` (or `cargo binstall`, when it is available).
43    Cargo,
44    /// `npm install -g dev-prune`.
45    Npm,
46    /// `bun add -g dev-prune`.
47    Bun,
48    /// `pnpm add -g dev-prune`.
49    Pnpm,
50    /// `yarn global add dev-prune` — Yarn 1.x only.
51    Yarn,
52    /// `uv tool install dev-prune`.
53    Uv,
54    /// `pipx install dev-prune`.
55    Pipx,
56    /// `winget install` — Windows only.
57    Winget,
58    /// `scoop install` from the project's bucket — Windows only.
59    Scoop,
60    /// `brew install` from the project's tap — macOS and Linux.
61    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    // A channel move installs the latest release through the new manager, so under a
105    // pin it is an update wearing a different name. Refused here rather than moved and
106    // then reported, so that one rule stays true without exception: while
107    // `version_lock` is on, nothing dev-prune does changes which version is installed.
108    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                // The managed copy is what the scheduler and the Git hook run, and it
135                // refreshes itself from whichever binary is newest on the next healthy
136                // pass, so removing it here would break both to no purpose.
137                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        // A tap or bucket that is already added reports failure, and that is not a
159        // reason to stop — the install below is what actually has to succeed.
160        if let Err(e) = spawn(argv) {
161            output::print_dimmed(&format!("  ({e:#} — continuing.)"));
162        }
163    }
164
165    // The install goes first on purpose: if it fails, the copy on PATH is still the one
166    // that was there before, and the machine is exactly as it was.
167    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        // Removing the binary that is executing right now. Windows locks a running
174        // image against deletion but not against rename, so it steps aside first and
175        // the manager's own uninstall is not blocked by it; the `.old` is swept by the
176        // next `devp update --install`, when nothing holds it open.
177        #[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            // The uninstall failed and the old copy is now nameless. Put it back rather
192            // than leaving a PATH entry pointing at nothing.
193            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
215/// Finish the job when the channel asked for is the one this copy already came from.
216///
217/// Nobody types `devp install --channel installer` on an installer copy to be told about
218/// provenance. They type it because a second copy is in the way, and they want one left.
219/// That used to be the end of the road: the message said there was nothing to move, and
220/// the installer script, knowing it, handed the same command to the *older* binary
221/// instead — the one copy least able to run it, because nothing before 1.8.0 has an
222/// `install` subcommand at all. The offer dead-ended on precisely the machines that
223/// needed it. This copy is new by definition, so the work happens here.
224///
225/// Only copies outside this binary's own directory count. The alias sitting beside it is
226/// the same install under a second name, and removing it would break `devp`.
227fn 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            // No manager holds a record of it, so there is no command to run and the
254            // file itself is the whole install.
255            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    // The same rule the channel move above is refused by, on the path that reaches this
266    // one without passing it: whichever of these copies answers on PATH is the version
267    // that runs, so removing it changes that version just as surely as an upgrade would.
268    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
310/// What `devp install` prints on its own: which channel owns this copy, and the names
311/// `--channel` accepts.
312fn 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    // The receipt describes the managed copy, so it is only true of this one when this
318    // one *is* the managed copy.
319    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    // Read off the enum clap itself parses, so the list cannot name a channel
329    // `--channel` rejects, or omit one it accepts.
330    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
344/// How to install dev-prune fresh through `channel`: the sources to add first, then the
345/// install itself.
346///
347/// Homebrew and Scoop are the reason for the first half. The formula and the manifest
348/// live in this project's own tap and bucket rather than the default index, and `brew
349/// install dev-prune` without the tap resolves against homebrew-core, where dev-prune is
350/// not published. Adding a source that is already added is not an error worth stopping
351/// for, so those steps are best-effort; the install itself is not.
352fn 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
368/// The command that installs dev-prune through `channel`, once its source exists.
369fn 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        // Same preference as `devp update --install`: binstall fetches the prebuilt
373        // release, a plain `cargo install` compiles for minutes.
374        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        // `@latest` because `uv tool install dev-prune` against an environment uv
386        // already has prints "already installed" and exits successfully without
387        // changing anything — which reads, from here, as a move that worked.
388        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        // Not offered as a destination; see `TargetChannel`.
417        Channel::Pip | Channel::Unknown => Vec::new(),
418    }
419}
420
421/// The command that removes the copy `channel` installed, or `None` when there is no
422/// manager holding a record of it.
423fn 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
446/// Run one of the two commands, wired to the terminal so the manager's own progress and
447/// prompts reach the user directly.
448fn 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        // Installing through the `installer` channel re-runs `install.sh` or
453        // `install.ps1`, and those scripts offer to migrate a copy another manager owns.
454        // That is the offer whose answer brought us here — and the old copy is still on
455        // PATH, because it is removed after this command, not before — so without this
456        // the child would ask the same question again, and its answer would run this
457        // command again.
458        .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
467/// Ask before anything runs. Default no, like every other prompt that removes something:
468/// this one runs two package managers back to back.
469fn 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        // A `--channel` value that maps to an empty argv would panic in `spawn`. The
496        // value list and the command table have to stay in step.
497        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        // Not a restatement: the rule is that a channel with bookkeeping must be told,
509        // rather than having its file deleted behind its back — otherwise the manager
510        // goes on believing dev-prune is installed and reinstalls the old binary.
511        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}