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    /// `uv tool install dev-prune`.
47    Uv,
48    /// `pipx install dev-prune`.
49    Pipx,
50    /// `winget install` — Windows only.
51    Winget,
52    /// `scoop install` from the project's bucket — Windows only.
53    Scoop,
54    /// `brew install` from the project's tap — macOS and Linux.
55    Homebrew,
56}
57
58impl TargetChannel {
59    fn channel(self) -> Channel {
60        match self {
61            TargetChannel::Installer => Channel::Installer,
62            TargetChannel::Cargo => Channel::Cargo,
63            TargetChannel::Npm => Channel::Npm,
64            TargetChannel::Uv => Channel::UvTool,
65            TargetChannel::Pipx => Channel::Pipx,
66            TargetChannel::Winget => Channel::WinGet,
67            TargetChannel::Scoop => Channel::Scoop,
68            TargetChannel::Homebrew => Channel::Homebrew,
69        }
70    }
71}
72
73pub fn run(channel: Option<TargetChannel>, dry_run: bool, yes: bool) -> Result<()> {
74    let exe = std::env::current_exe().context("could not locate the running binary")?;
75    let managed = crate::setup::managed_exe_path().ok();
76    let current = Channel::detect_at(&exe, managed.as_deref());
77
78    let Some(target) = channel.map(TargetChannel::channel) else {
79        return report(current, &exe);
80    };
81
82    output::print_header("dev-prune install channel");
83
84    if target == current {
85        output::print_success(&format!(
86            "This copy already came from {} — nothing to move.",
87            current.label()
88        ));
89        if let Some(cmd) = current.upgrade_command() {
90            output::print_info(&format!("Upgrade it in place with: {cmd}"));
91        }
92        return Ok(());
93    }
94
95    // A channel move installs the latest release through the new manager, so under a
96    // pin it is an update wearing a different name. Refused here rather than moved and
97    // then reported, so that one rule stays true without exception: while
98    // `version_lock` is on, nothing dev-prune does changes which version is installed.
99    if Registry::load().is_ok_and(|r| r.settings.version_lock) {
100        anyhow::bail!(
101            "Moving to {} would install the latest release through it. {}",
102            target.label(),
103            super::update::locked_notice(None)
104        );
105    }
106
107    let (sources, install) = install_plan(target);
108    let uninstall = uninstall_argv(current);
109
110    println!();
111    println!("  From:  {} ({})", current.label(), exe.display());
112    println!("  To:    {}", target.label());
113    println!();
114    let mut step = 0;
115    for argv in sources.iter().chain(install.iter()) {
116        step += 1;
117        println!("  {step}. {}", argv.join(" "));
118    }
119    step += 1;
120    match &uninstall {
121        Some(argv) => println!("  {step}. {}", argv.join(" ")),
122        None => println!(
123            "  {step}. nothing to uninstall — {}",
124            match current {
125                // The managed copy is what the scheduler and the Git hook run, and it
126                // refreshes itself from whichever binary is newest on the next healthy
127                // pass, so removing it here would break both to no purpose.
128                Channel::Installer =>
129                    "the managed copy stays, and refreshes itself from the new binary",
130                _ =>
131                    "this copy was not installed by a package manager, so remove the \
132                      file yourself if you want it gone",
133            }
134        ),
135    }
136    println!();
137
138    if dry_run {
139        output::print_info("`--dry-run`: nothing was run.");
140        return Ok(());
141    }
142
143    if !confirm(yes) {
144        output::print_info("Nothing was changed.");
145        return Ok(());
146    }
147
148    for argv in &sources {
149        // A tap or bucket that is already added reports failure, and that is not a
150        // reason to stop — the install below is what actually has to succeed.
151        if let Err(e) = spawn(argv) {
152            output::print_dimmed(&format!("  ({e:#} — continuing.)"));
153        }
154    }
155
156    // The install goes first on purpose: if it fails, the copy on PATH is still the one
157    // that was there before, and the machine is exactly as it was.
158    for argv in &install {
159        spawn(argv)?;
160    }
161    output::print_success(&format!("Installed through {}.", target.label()));
162
163    if let Some(argv) = uninstall {
164        // Removing the binary that is executing right now. Windows locks a running
165        // image against deletion but not against rename, so it steps aside first and
166        // the manager's own uninstall is not blocked by it; the `.old` is swept by the
167        // next `devp update --install`, when nothing holds it open.
168        #[cfg(windows)]
169        let aside = {
170            let aside = exe.with_extension("exe.old");
171            let _ = std::fs::remove_file(&aside);
172            std::fs::rename(&exe, &aside).ok().map(|_| aside)
173        };
174
175        let removed = spawn(&argv);
176
177        #[cfg(windows)]
178        if let Some(aside) = aside
179            && removed.is_err()
180            && !exe.exists()
181        {
182            // The uninstall failed and the old copy is now nameless. Put it back rather
183            // than leaving a PATH entry pointing at nothing.
184            let _ = std::fs::rename(&aside, &exe);
185        }
186
187        match removed {
188            Ok(()) => output::print_success(&format!("Removed the {} copy.", current.label())),
189            Err(e) => output::print_warning(&format!(
190                "The new copy is installed, but removing the old one failed ({e:#}).\n\
191                 Run it yourself when convenient: {}",
192                argv.join(" ")
193            )),
194        }
195    }
196
197    println!();
198    output::print_info(
199        "Your configuration, repository registry and undo history are unchanged — they \
200         live in the config directory, which no channel owns.",
201    );
202    output::print_info("Open a new shell, then `devp update` to confirm which copy it finds.");
203    Ok(())
204}
205
206/// What `devp install` prints on its own: which channel owns this copy, and the names
207/// `--channel` accepts.
208fn report(current: Channel, exe: &std::path::Path) -> Result<()> {
209    output::print_header("dev-prune install channel");
210    println!();
211    println!("  Installed by:  {}", current.label());
212    println!("  Binary:        {}", exe.display());
213    // The receipt describes the managed copy, so it is only true of this one when this
214    // one *is* the managed copy.
215    if current == Channel::Installer
216        && let Some(receipt) = crate::receipt::load()
217    {
218        println!("  Receipt:       {}", crate::receipt::summary(&receipt));
219    }
220    if let Some(cmd) = current.upgrade_command() {
221        println!("  Upgrade:       {cmd}");
222    }
223    println!();
224    output::print_info(
225        "Move it to another package manager with `devp install --channel <name>`:\n  \
226         installer, cargo, npm, uv, pipx, winget, scoop, homebrew.",
227    );
228    output::print_info("`--dry-run` prints the whole plan without running any of it.");
229    Ok(())
230}
231
232/// How to install dev-prune fresh through `channel`: the sources to add first, then the
233/// install itself.
234///
235/// Homebrew and Scoop are the reason for the first half. The formula and the manifest
236/// live in this project's own tap and bucket rather than the default index, and `brew
237/// install dev-prune` without the tap resolves against homebrew-core, where dev-prune is
238/// not published. Adding a source that is already added is not an error worth stopping
239/// for, so those steps are best-effort; the install itself is not.
240fn install_plan(channel: Channel) -> (Vec<Vec<String>>, Vec<Vec<String>>) {
241    let owned = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
242    let sources = match channel {
243        Channel::Scoop => vec![owned(&[
244            "scoop",
245            "bucket",
246            "add",
247            crate::constants::SCOOP_BUCKET_NAME,
248            crate::constants::SCOOP_BUCKET_URL,
249        ])],
250        Channel::Homebrew => vec![owned(&["brew", "tap", crate::constants::HOMEBREW_TAP])],
251        _ => Vec::new(),
252    };
253    (sources, install_argv(channel))
254}
255
256/// The command that installs dev-prune through `channel`, once its source exists.
257fn install_argv(channel: Channel) -> Vec<Vec<String>> {
258    let owned = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
259    match channel {
260        // Same preference as `devp update --install`: binstall fetches the prebuilt
261        // release, a plain `cargo install` compiles for minutes.
262        Channel::Cargo => {
263            if crate::adapters::binary_available("cargo-binstall") {
264                vec![owned(&["cargo", "binstall", "dev-prune", "-y"])]
265            } else {
266                vec![owned(&["cargo", "install", "dev-prune"])]
267            }
268        }
269        Channel::Npm => vec![owned(&["npm", "install", "-g", "dev-prune"])],
270        Channel::UvTool => vec![owned(&["uv", "tool", "install", "dev-prune"])],
271        Channel::Pipx => vec![owned(&["pipx", "install", "dev-prune"])],
272        Channel::WinGet => vec![vec![
273            "winget".to_string(),
274            "install".to_string(),
275            "--id".to_string(),
276            crate::constants::WINGET_PACKAGE_ID.to_string(),
277            "--accept-package-agreements".to_string(),
278            "--accept-source-agreements".to_string(),
279        ]],
280        Channel::Scoop => vec![owned(&["scoop", "install", "dev-prune"])],
281        Channel::Homebrew => vec![owned(&["brew", "install", "dev-prune"])],
282        Channel::Installer => {
283            if cfg!(windows) {
284                vec![vec![
285                    "powershell".to_string(),
286                    "-NoProfile".to_string(),
287                    "-Command".to_string(),
288                    format!("iwr -useb {} | iex", crate::constants::INSTALL_PS1_URL),
289                ]]
290            } else {
291                vec![vec![
292                    "sh".to_string(),
293                    "-c".to_string(),
294                    format!("curl -fsSL {} | sh", crate::constants::INSTALL_SH_URL),
295                ]]
296            }
297        }
298        // Not offered as a destination; see `TargetChannel`.
299        Channel::Pip | Channel::Unknown => Vec::new(),
300    }
301}
302
303/// The command that removes the copy `channel` installed, or `None` when there is no
304/// manager holding a record of it.
305fn uninstall_argv(channel: Channel) -> Option<Vec<String>> {
306    let owned = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
307    Some(match channel {
308        Channel::Cargo => owned(&["cargo", "uninstall", "dev-prune"]),
309        Channel::Npm => owned(&["npm", "uninstall", "-g", "dev-prune"]),
310        Channel::UvTool => owned(&["uv", "tool", "uninstall", "dev-prune"]),
311        Channel::Pipx => owned(&["pipx", "uninstall", "dev-prune"]),
312        Channel::Pip => owned(&["pip", "uninstall", "-y", "dev-prune"]),
313        Channel::WinGet => vec![
314            "winget".to_string(),
315            "uninstall".to_string(),
316            "--id".to_string(),
317            crate::constants::WINGET_PACKAGE_ID.to_string(),
318        ],
319        Channel::Scoop => owned(&["scoop", "uninstall", "dev-prune"]),
320        Channel::Homebrew => owned(&["brew", "uninstall", "dev-prune"]),
321        Channel::Installer | Channel::Unknown => return None,
322    })
323}
324
325/// Run one of the two commands, wired to the terminal so the manager's own progress and
326/// prompts reach the user directly.
327fn spawn(argv: &[String]) -> Result<()> {
328    output::print_info(&format!("Running: {}", argv.join(" ")));
329    let status = crate::spawn::command(crate::adapters::resolve_program(&argv[0]))
330        .args(&argv[1..])
331        // Installing through the `installer` channel re-runs `install.sh` or
332        // `install.ps1`, and those scripts offer to migrate a copy another manager owns.
333        // That is the offer whose answer brought us here — and the old copy is still on
334        // PATH, because it is removed after this command, not before — so without this
335        // the child would ask the same question again, and its answer would run this
336        // command again.
337        .env(crate::constants::ENV_NO_MIGRATE_PROMPT, "1")
338        .status()
339        .with_context(|| format!("could not start `{}`", argv[0]))?;
340    if !status.success() {
341        anyhow::bail!("`{}` exited with {status}", argv.join(" "));
342    }
343    Ok(())
344}
345
346/// Ask before anything runs. Default no, like every other prompt that removes something:
347/// this one runs two package managers back to back.
348fn confirm(yes: bool) -> bool {
349    use std::io::{IsTerminal, Write};
350    if yes {
351        return true;
352    }
353    if !std::io::stdin().is_terminal() {
354        output::print_info("Not running in a terminal — pass `--yes` to go ahead.");
355        return false;
356    }
357    eprint!("Run this plan? [y/N]: ");
358    if std::io::stderr().flush().is_err() {
359        return false;
360    }
361    let mut input = String::new();
362    if std::io::stdin().read_line(&mut input).is_err() {
363        return false;
364    }
365    matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn every_offered_destination_has_an_install_command() {
374        // A `--channel` value that maps to an empty argv would panic in `spawn`. The
375        // value list and the command table have to stay in step.
376        for target in TargetChannel::value_variants() {
377            let argv = install_argv(target.channel());
378            assert!(
379                !argv.is_empty(),
380                "`--channel {target:?}` has no install command"
381            );
382        }
383    }
384
385    #[test]
386    fn the_old_copy_is_removed_through_the_manager_that_owns_it() {
387        // Not a restatement: the rule is that a channel with bookkeeping must be told,
388        // rather than having its file deleted behind its back — otherwise the manager
389        // goes on believing dev-prune is installed and reinstalls the old binary.
390        for channel in [
391            Channel::Cargo,
392            Channel::Npm,
393            Channel::UvTool,
394            Channel::Pipx,
395            Channel::Pip,
396            Channel::WinGet,
397            Channel::Scoop,
398            Channel::Homebrew,
399        ] {
400            assert!(channel.owns_its_files());
401            assert!(
402                uninstall_argv(channel).is_some(),
403                "{channel:?} keeps a record but has no uninstall command"
404            );
405        }
406        assert!(uninstall_argv(Channel::Installer).is_none());
407        assert!(uninstall_argv(Channel::Unknown).is_none());
408    }
409}