Skip to main content

dev_prune/commands/
update.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Handler for `dev-prune update`, and the periodic release check behind it.
5//
6// The check is opt-*out*. An out-of-date cleanup tool is a tool whose safety fixes you do
7// not have, so `devp update` asks GitHub for the latest release by default, and `devp
8// run` / `devp status` repeat that quietly at most once a week. Both are switched off by
9// `devp config set update_check false`, and `devp update --offline` skips a single run.
10//
11// What leaves the machine is one unauthenticated GET to the public releases endpoint. It
12// carries no identifier, no configuration, no repository paths and no usage data — the
13// only thing the server learns is that some copy of dev-prune asked what the latest
14// version is. Nothing else in the binary opens a socket. See `docs/PRIVACY.md`.
15//
16// By default the command does not download or install anything: replacing a binary is
17// the package manager's job, and doing it ourselves would mean writing to a PATH
18// directory with whatever privileges the user happened to have. `--install` keeps that
19// division of labour — it works out which package manager owns the running binary and
20// runs *that manager's* own upgrade command, rather than writing files itself. The
21// scheduled pass is never interrupted by an upgrade: it runs the managed copy under
22// `<config>/bin`, which is replaced by atomic rename and refreshed from the new binary
23// on the next healthy run (`setup::stable_exe_path`), so a pass already in flight keeps
24// its loaded image and the next pass picks up the new one.
25
26use std::cmp::Ordering;
27use std::time::Duration;
28
29use anyhow::{Context, Result};
30use chrono::Utc;
31
32use crate::config::Registry;
33use crate::constants;
34use crate::output;
35
36pub fn run(offline: bool, install: bool) -> Result<()> {
37    if install {
38        return run_install();
39    }
40    output::print_header("dev-prune version & upgrade");
41
42    output::print_info(&format!("Installed version: v{}", constants::VERSION));
43
44    if offline {
45        output::print_info("Skipping the release check because `--offline` was passed.");
46    } else if let Ok(mut registry) = Registry::load() {
47        if registry.settings.update_check {
48            // An explicit `devp update` always asks, regardless of when the last
49            // automatic check ran — the user is standing there waiting for the answer.
50            match refresh_latest(&mut registry) {
51                Ok(latest) => report_comparison(&latest),
52                // A failed check is not a failed command. Someone offline, behind a
53                // proxy, or hitting a rate limit still wants the upgrade instructions.
54                Err(e) => output::print_warning(&format!(
55                    "Could not reach the release API ({e}). The upgrade commands below still apply."
56                )),
57            }
58            let _ = registry.save();
59        } else {
60            output::print_info(
61                "The release check is off (`devp config set update_check true` re-enables it).",
62            );
63        }
64    }
65
66    println!();
67    println!("  Latest releases:  {}", constants::RELEASES_URL);
68    println!();
69    print_upgrade_commands();
70
71    Ok(())
72}
73
74/// Ask GitHub right now — no interval — and say where the installed build stands.
75///
76/// For `devp init`, which is deliberate and infrequent enough to be worth a round trip:
77/// setting a machine up is exactly the moment to learn the binary is a version behind.
78/// `devp run` deliberately does not use this; it goes through [`notify_if_outdated`],
79/// which is interval-gated so everyday work never waits on the network.
80///
81/// Returns `true` when the registry changed and needs saving.
82pub fn check_now(registry: &mut Registry) -> bool {
83    if !registry.settings.update_check {
84        return false;
85    }
86
87    match refresh_latest(registry) {
88        Ok(latest) => {
89            report_comparison(&latest);
90            if compare_versions(constants::VERSION, &latest) == Some(Ordering::Less) {
91                print_upgrade_commands();
92            }
93        }
94        // Not being able to reach GitHub is not a failed `init`.
95        Err(e) => output::print_info(&format!("Could not check for a newer release ({e}).")),
96    }
97    true
98}
99
100/// Every install channel, in one place so they cannot drift apart.
101fn print_upgrade_commands() {
102    println!("  Upgrade with whichever channel you installed from:");
103    println!("    cargo binstall dev-prune --force");
104    println!("    cargo install dev-prune --force");
105    println!("    npm install -g dev-prune@latest");
106    println!("    uv tool upgrade dev-prune  /  pipx upgrade dev-prune");
107    println!("    curl -fsSL {} | sh", constants::INSTALL_SH_URL);
108    println!("    iwr -useb {} | iex", constants::INSTALL_PS1_URL);
109}
110
111/// The package manager that owns the running binary — the one whose upgrade command
112/// `--install` runs. One channel owns one binary: a copy installed through uv is
113/// upgraded through uv, never through npm, because two managers writing the same PATH
114/// entry would fight over it forever.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116enum Channel {
117    /// `install.sh` / `install.ps1` put it under the managed `<config>/bin`.
118    Installer,
119    /// `cargo install` / `cargo binstall` put it under `~/.cargo/bin`.
120    Cargo,
121    /// `npm install -g` — the binary lives under a `node_modules` tree.
122    Npm,
123    /// `uv tool install` — under uv's tool environments.
124    UvTool,
125    /// `pipx install` — under a `pipx` venv.
126    Pipx,
127    /// Anywhere else: a dev build, a hand-copied binary, a distro package.
128    Unknown,
129}
130
131/// Classify where `exe` came from by the directories in its path.
132///
133/// Purely lexical on purpose: this must not touch the network or spawn anything, and
134/// each channel's layout is stable enough that its marker directory is a reliable
135/// fingerprint. `managed` is passed in (rather than resolved here) so tests can probe
136/// the classification without a config directory on disk.
137fn detect_channel(exe: &std::path::Path, managed: Option<&std::path::Path>) -> Channel {
138    if let Some(managed) = managed
139        && exe == managed
140    {
141        return Channel::Installer;
142    }
143    let has_dir = |name: &str| {
144        exe.components()
145            .any(|c| c.as_os_str().to_string_lossy().eq_ignore_ascii_case(name))
146    };
147    if has_dir(".cargo") {
148        Channel::Cargo
149    } else if has_dir("node_modules") {
150        Channel::Npm
151    } else if has_dir("uv") || has_dir("uv-tool") {
152        Channel::UvTool
153    } else if has_dir("pipx") {
154        Channel::Pipx
155    } else {
156        Channel::Unknown
157    }
158}
159
160/// `devp update --install`: upgrade this binary through the channel that installed it.
161fn run_install() -> Result<()> {
162    output::print_header("dev-prune self-update");
163
164    if crate::setup::offline_requested() {
165        anyhow::bail!(
166            "{} is set — an install needs the network by definition.",
167            constants::ENV_OFFLINE
168        );
169    }
170
171    // Know before downloading whether there is anything to download. A failed check is
172    // fatal here (unlike `devp update`): running an installer blind would "upgrade" to
173    // the version already installed.
174    let mut registry = Registry::load()?;
175    let latest = refresh_latest(&mut registry)?;
176    let _ = registry.save();
177    if compare_versions(constants::VERSION, &latest) != Some(Ordering::Less) {
178        output::print_success(&format!(
179            "v{} is already the latest release — nothing to install.",
180            constants::VERSION
181        ));
182        return Ok(());
183    }
184    output::print_info(&format!("Upgrading v{} -> v{latest} …", constants::VERSION));
185
186    let exe = std::env::current_exe().context("could not locate the running binary")?;
187    let managed = crate::setup::managed_exe_path().ok();
188    let channel = detect_channel(&exe, managed.as_deref());
189
190    // On Windows a running executable's file is locked against replacement but not
191    // against rename. Moving it aside first lets the channel write a fresh file at the
192    // real path; the `.old` left behind is swept up by the *next* run, when nothing is
193    // executing it any more.
194    #[cfg(windows)]
195    let aside = {
196        let aside = exe.with_extension("exe.old");
197        let _ = std::fs::remove_file(&aside);
198        std::fs::rename(&exe, &aside).ok().map(|_| aside)
199    };
200
201    let result = spawn_channel_upgrade(channel);
202
203    #[cfg(windows)]
204    if let Some(aside) = aside {
205        if result.is_ok() {
206            // Best effort: the file is still our running image, so Windows may refuse
207            // the delete. The sweep at the top of the next `--install` gets it then.
208            let _ = std::fs::remove_file(&aside);
209        } else if !exe.exists() {
210            // The upgrade never wrote a new binary — put the old one back so the
211            // command the user has on PATH still exists.
212            let _ = std::fs::rename(&aside, &exe);
213        }
214    }
215    result?;
216
217    output::print_success(&format!("dev-prune v{latest} installed."));
218    output::print_info(
219        "The scheduled pass was not interrupted: it runs the managed copy, which \
220         refreshes itself from the new binary on its next run.",
221    );
222    Ok(())
223}
224
225/// Run one channel's own upgrade command, wired to the terminal so its progress and
226/// prompts reach the user directly.
227fn spawn_channel_upgrade(channel: Channel) -> Result<()> {
228    let install_ps1 = format!("iwr -useb {} | iex", constants::INSTALL_PS1_URL);
229    let install_sh = format!("curl -fsSL {} | sh", constants::INSTALL_SH_URL);
230    let argv: Vec<&str> = match channel {
231        Channel::Cargo => {
232            // binstall pulls the prebuilt release; plain `cargo install` compiles for
233            // minutes. Prefer the fast one when it exists.
234            if crate::adapters::binary_available("cargo-binstall") {
235                vec!["cargo", "binstall", "dev-prune", "--force", "-y"]
236            } else {
237                vec!["cargo", "install", "dev-prune", "--force"]
238            }
239        }
240        Channel::Npm => vec!["npm", "install", "-g", "dev-prune@latest"],
241        Channel::UvTool => vec!["uv", "tool", "upgrade", "dev-prune"],
242        Channel::Pipx => vec!["pipx", "upgrade", "dev-prune"],
243        Channel::Installer => {
244            if cfg!(windows) {
245                vec!["powershell", "-NoProfile", "-Command", &install_ps1]
246            } else {
247                vec!["sh", "-c", &install_sh]
248            }
249        }
250        Channel::Unknown => {
251            output::print_warning(
252                "Could not tell which channel installed this binary, so nothing was \
253                 changed. Upgrade it yourself with one of:",
254            );
255            print_upgrade_commands();
256            anyhow::bail!("unrecognised install channel");
257        }
258    };
259
260    output::print_info(&format!("Running: {}", argv.join(" ")));
261    let status = crate::spawn::command(crate::adapters::resolve_program(argv[0]))
262        .args(&argv[1..])
263        .status()
264        .with_context(|| format!("could not start `{}`", argv[0]))?;
265    if !status.success() {
266        anyhow::bail!("`{}` exited with {status}", argv.join(" "));
267    }
268    Ok(())
269}
270
271/// The end-of-run hook behind `auto_update`: when the setting is on and the last release
272/// check already knows a newer version exists, run the self-update without being asked.
273///
274/// Warn-never-fail, like everything else that runs as a side effect of `devp run` — a
275/// broken upgrade path must not turn a successful prune into a failed command.
276pub fn maybe_auto_update(registry: &Registry) {
277    if !registry.settings.auto_update
278        || crate::setup::offline_requested()
279        || crate::setup::no_auto_setup_requested()
280    {
281        return;
282    }
283    let Some(latest) = registry.latest_known_version.as_deref() else {
284        return;
285    };
286    if compare_versions(constants::VERSION, latest) != Some(Ordering::Less) {
287        return;
288    }
289    println!();
290    if let Err(e) = run_install() {
291        output::print_warning(&format!(
292            "Automatic update failed ({e}). Run `devp update --install` yourself, or \
293             `devp config set auto_update false` to stop trying."
294        ));
295    }
296}
297
298/// Quietly keep the release check current and print a one-line notice when the installed
299/// build is behind. Returns `true` when the registry changed and needs saving.
300///
301/// Called from `devp run` and `devp status`. Never returns an error: a background
302/// convenience must not be able to fail the command the user actually asked for.
303pub fn notify_if_outdated(registry: &mut Registry) -> bool {
304    if !registry.settings.update_check {
305        return false;
306    }
307
308    let interval = registry.settings.update_check_interval_days;
309    let due = registry
310        .last_update_check
311        .is_none_or(|last| Utc::now().signed_duration_since(last).num_days() >= interval);
312
313    if due {
314        // The result is deliberately ignored: `refresh_latest` moves the timestamp even
315        // when the request fails, and retrying on every command while the machine is
316        // offline would put a five-second stall in front of everyday work.
317        let _ = refresh_latest(registry);
318    }
319
320    if let Some(latest) = registry.latest_known_version.as_deref()
321        && compare_versions(constants::VERSION, latest) == Some(Ordering::Less)
322    {
323        output::print_info(&format!(
324            "dev-prune v{latest} is out (you have v{}). `devp update` has the commands; \
325                 `devp config set update_check false` silences this.",
326            constants::VERSION
327        ));
328    }
329
330    due
331}
332
333/// Ask GitHub for the latest release and record the answer on the registry.
334///
335/// The caller is responsible for saving; that keeps this usable from both the
336/// already-loaded-registry path and the standalone command.
337fn refresh_latest(registry: &mut Registry) -> Result<String> {
338    let result = latest_release(registry.settings.update_check_timeout_secs);
339    registry.last_update_check = Some(Utc::now());
340    let latest = result?;
341    registry.latest_known_version = Some(latest.clone());
342    Ok(latest)
343}
344
345/// Say whether the installed build is behind, current, or ahead of the latest release.
346fn report_comparison(latest: &str) {
347    let installed = constants::VERSION;
348    match compare_versions(installed, latest) {
349        Some(Ordering::Less) => {
350            output::print_warning(&format!(
351                "Latest release:    v{latest} — an upgrade is available."
352            ));
353        }
354        Some(Ordering::Equal) => {
355            output::print_success(&format!(
356                "Latest release:    v{latest} — you are up to date."
357            ));
358        }
359        Some(Ordering::Greater) => {
360            // Normal when running a local build between releases.
361            output::print_info(&format!(
362                "Latest release:    v{latest} — your build is newer than the last published one."
363            ));
364        }
365        None => {
366            output::print_info(&format!(
367                "Latest release:    v{latest} (could not compare it to v{installed})."
368            ));
369        }
370    }
371}
372
373/// Fetch the tag name of the most recent published release.
374///
375/// Returns the version without any leading `v`, so it can be compared to
376/// `CARGO_PKG_VERSION` directly.
377fn latest_release(timeout_secs: u64) -> Result<String> {
378    if crate::setup::offline_requested() {
379        anyhow::bail!("{} is set", constants::ENV_OFFLINE);
380    }
381    let body = ureq::get(constants::LATEST_RELEASE_API_URL)
382        .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
383        .header("Accept", "application/vnd.github+json")
384        .config()
385        .timeout_global(Some(Duration::from_secs(timeout_secs.max(1))))
386        .build()
387        .call()
388        .context("request failed")?
389        .body_mut()
390        .read_to_string()
391        .context("could not read the response")?;
392
393    let json: serde_json::Value =
394        serde_json::from_str(&body).context("the response was not JSON")?;
395    let tag = json
396        .get("tag_name")
397        .and_then(|v| v.as_str())
398        .context("the response carried no tag_name")?;
399
400    Ok(tag.trim_start_matches('v').to_string())
401}
402
403/// Compare two dotted numeric versions, ignoring any pre-release suffix.
404///
405/// Returns `None` when either side is not `major.minor.patch` — better to say "could not
406/// compare" than to claim an upgrade exists because `1.0.0` sorts before `1.0.0-rc.1`
407/// as a string.
408fn compare_versions(a: &str, b: &str) -> Option<Ordering> {
409    let parse = |v: &str| -> Option<[u64; 3]> {
410        let core = v.split(['-', '+']).next()?;
411        let mut parts = core.split('.');
412        let out = [
413            parts.next()?.parse().ok()?,
414            parts.next()?.parse().ok()?,
415            parts.next()?.parse().ok()?,
416        ];
417        // A fourth component means this is not the scheme we release under.
418        if parts.next().is_some() {
419            return None;
420        }
421        Some(out)
422    };
423    Some(parse(a)?.cmp(&parse(b)?))
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use chrono::Duration as ChronoDuration;
430
431    #[test]
432    fn orders_by_component_not_lexically() {
433        // "1.10.0" < "1.9.0" as strings, which is the bug this function exists to avoid.
434        assert_eq!(compare_versions("1.9.0", "1.10.0"), Some(Ordering::Less));
435        assert_eq!(compare_versions("1.0.0", "1.0.0"), Some(Ordering::Equal));
436        assert_eq!(
437            compare_versions("2.0.0", "1.99.99"),
438            Some(Ordering::Greater)
439        );
440    }
441
442    #[test]
443    fn pre_release_suffixes_compare_by_their_core() {
444        assert_eq!(
445            compare_versions("1.0.0", "1.0.0-rc.1"),
446            Some(Ordering::Equal)
447        );
448        assert_eq!(
449            compare_versions("1.0.0+build7", "1.0.1"),
450            Some(Ordering::Less)
451        );
452    }
453
454    #[test]
455    fn unparseable_versions_report_no_answer_rather_than_a_wrong_one() {
456        assert_eq!(compare_versions("1.0", "1.0.0"), None);
457        assert_eq!(compare_versions("1.0.0.1", "1.0.0"), None);
458        assert_eq!(compare_versions("nightly", "1.0.0"), None);
459    }
460
461    #[test]
462    fn the_check_is_on_unless_the_user_turns_it_off() {
463        assert!(Registry::default().settings.update_check);
464    }
465
466    #[test]
467    fn a_disabled_check_touches_neither_the_network_nor_the_registry() {
468        let mut registry = Registry::default();
469        registry.settings.update_check = false;
470        assert!(!notify_if_outdated(&mut registry));
471        assert!(registry.last_update_check.is_none());
472    }
473
474    #[test]
475    fn each_channel_is_recognised_by_its_marker_directory() {
476        use std::path::Path;
477        let cases: &[(&str, Channel)] = &[
478            ("/home/k/.cargo/bin/dev-prune", Channel::Cargo),
479            (
480                "/usr/lib/node_modules/dev-prune/bin/dev-prune",
481                Channel::Npm,
482            ),
483            (
484                "/home/k/.local/share/uv/tools/dev-prune/bin/dev-prune",
485                Channel::UvTool,
486            ),
487            (
488                "/home/k/.local/pipx/venvs/dev-prune/bin/dev-prune",
489                Channel::Pipx,
490            ),
491            ("/opt/somewhere/dev-prune", Channel::Unknown),
492        ];
493        for (path, expected) in cases {
494            assert_eq!(detect_channel(Path::new(path), None), *expected, "{path}");
495        }
496    }
497
498    #[test]
499    fn the_managed_copy_wins_over_every_path_heuristic() {
500        use std::path::Path;
501        // Even a managed dir that happens to live under `.cargo` is the installer's.
502        let managed = Path::new("/home/k/.cargo/odd/dev-prune/bin/dev-prune");
503        assert_eq!(detect_channel(managed, Some(managed)), Channel::Installer);
504    }
505
506    #[test]
507    fn auto_update_is_off_by_default_and_silent_when_off() {
508        let registry = Registry::default();
509        assert!(!registry.settings.auto_update);
510        // Must return without touching the network or the terminal.
511        maybe_auto_update(&registry);
512    }
513
514    #[test]
515    fn a_recent_check_is_not_repeated() {
516        let mut registry = Registry::default();
517        let stamp = Utc::now() - ChronoDuration::days(constants::UPDATE_CHECK_INTERVAL_DAYS - 1);
518        registry.last_update_check = Some(stamp);
519        // No network call, so the stamp survives untouched and nothing needs saving.
520        assert!(!notify_if_outdated(&mut registry));
521        assert_eq!(registry.last_update_check, Some(stamp));
522    }
523}