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 = 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                // The managed copy is what the scheduler and the Git hook run, and it
136                // refreshes itself from whichever binary is newest on the next healthy
137                // pass, so removing it here would break both to no purpose.
138                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        // A tap or bucket that is already added reports failure, and that is not a
160        // reason to stop — the install below is what actually has to succeed.
161        if let Err(e) = spawn(argv) {
162            output::print_dimmed(&format!("  ({e:#} — continuing.)"));
163        }
164    }
165
166    // The install goes first on purpose: if it fails, the copy on PATH is still the one
167    // that was there before, and the machine is exactly as it was.
168    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        // Removing the binary that is executing right now. Windows will not let a
175        // package manager delete a running image: `cargo uninstall` fails with `Access
176        // is denied` and keeps its ledger entry, and renaming the file aside first only
177        // trades that for `corrupt metadata, ... does not exist when it should`, which
178        // keeps the entry too. Exiting first is the only order that clears the record,
179        // so the command goes to the same after-exit helper `devp uninstall` uses.
180        //
181        // `Ok(true)` means scheduled rather than done.
182        #[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
216/// Finish the job when the channel asked for is the one this copy already came from.
217///
218/// Nobody types `devp install --channel installer` on an installer copy to be told about
219/// provenance. They type it because a second copy is in the way, and they want one left.
220/// That used to be the end of the road: the message said there was nothing to move, and
221/// the installer script, knowing it, handed the same command to the *older* binary
222/// instead — the one copy least able to run it, because nothing before 1.8.0 has an
223/// `install` subcommand at all. The offer dead-ended on precisely the machines that
224/// needed it. This copy is new by definition, so the work happens here.
225///
226/// Only copies outside this binary's own directory count. The alias sitting beside it is
227/// the same install under a second name, and removing it would break `devp`.
228fn 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            // No manager holds a record of it, so there is no command to run and the
255            // file itself is the whole install.
256            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    // The same rule the channel move above is refused by, on the path that reaches this
267    // one without passing it: whichever of these copies answers on PATH is the version
268    // that runs, so removing it changes that version just as surely as an upgrade would.
269    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    // Grouped, because one manager is told once however many of its files turned up.
284    // `~/.cargo/bin` holds both names, and a second `cargo uninstall dev-prune` exits
285    // 101 — reporting that would say the removal failed when it had just worked.
286    for (channel, paths) in group_by_channel(others) {
287        let Some(argv) = channel.uninstall_argv() else {
288            // No manager holds a record, so the file is the whole install.
289            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
324/// What `devp install` prints on its own: which channel owns this copy, and the names
325/// `--channel` accepts.
326fn 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    // The receipt describes the managed copy, so it is only true of this one when this
332    // one *is* the managed copy.
333    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    // Read off the enum clap itself parses, so the list cannot name a channel
343    // `--channel` rejects, or omit one it accepts.
344    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
358/// Run one of the two commands, wired to the terminal so the manager's own progress and
359/// prompts reach the user directly.
360fn 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        // Installing through the `installer` channel re-runs `install.sh` or
365        // `install.ps1`, and those scripts offer to migrate a copy another manager owns.
366        // That is the offer whose answer brought us here — and the old copy is still on
367        // PATH, because it is removed after this command, not before — so without this
368        // the child would ask the same question again, and its answer would run this
369        // command again.
370        .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
379/// Ask before anything runs. Default no, like every other prompt that removes something:
380/// this one runs two package managers back to back.
381fn 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        // A `--channel` value with no install command would silently do nothing.
408        // The value list and the command table have to stay in step.
409        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        // Not a restatement: the rule is that a channel with bookkeeping must be told,
420        // rather than having its file deleted behind its back — otherwise the manager
421        // goes on believing dev-prune is installed and reinstalls the old binary.
422        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}