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