1use std::cmp::Ordering;
27use std::fs;
28use std::io::Read;
29use std::path::{Path, PathBuf};
30use std::time::Duration;
31
32use anyhow::{Context, Result};
33use chrono::Utc;
34
35use crate::config::Registry;
36use crate::constants;
37use crate::output;
38
39pub fn run(offline: bool, install: bool) -> Result<()> {
40 if install {
41 return run_install();
42 }
43 output::print_header("dev-prune version & upgrade");
44
45 output::print_info(&format!("Installed version: v{}", constants::VERSION));
46
47 if offline {
48 output::print_info("Skipping the release check because `--offline` was passed.");
49 } else if let Ok(mut registry) = Registry::load() {
50 if registry.settings.update_check {
51 match refresh_latest(&mut registry) {
54 Ok(latest) => report_comparison(&latest),
55 Err(e) => output::print_warning(&format!(
58 "Could not reach the release API ({e}). The upgrade commands below still apply."
59 )),
60 }
61 let _ = registry.save();
62 } else {
63 output::print_info(
64 "The release check is off (`devp config set update_check true` re-enables it).",
65 );
66 }
67 }
68
69 println!();
70 println!(" Latest releases: {}", constants::RELEASES_URL);
71 println!();
72 print_upgrade_commands();
73
74 Ok(())
75}
76
77pub fn check_now(registry: &mut Registry) -> bool {
86 if !registry.settings.update_check {
87 return false;
88 }
89
90 match refresh_latest(registry) {
91 Ok(latest) => {
92 report_comparison(&latest);
93 if compare_versions(constants::VERSION, &latest) == Some(Ordering::Less) {
94 print_upgrade_commands();
95 }
96 }
97 Err(e) => output::print_info(&format!("Could not check for a newer release ({e}).")),
99 }
100 true
101}
102
103fn print_upgrade_commands() {
105 println!(" Upgrade with whichever channel you installed from:");
106 println!(" cargo binstall dev-prune --force");
107 println!(" cargo install dev-prune --force");
108 println!(" npm install -g dev-prune@latest");
109 println!(" uv tool upgrade dev-prune / pipx upgrade dev-prune");
110 println!(" curl -fsSL {} | sh", constants::INSTALL_SH_URL);
111 println!(" iwr -useb {} | iex", constants::INSTALL_PS1_URL);
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119enum Channel {
120 Installer,
122 Cargo,
124 Npm,
126 UvTool,
128 Pipx,
130 Unknown,
132}
133
134fn detect_channel(exe: &std::path::Path, managed: Option<&std::path::Path>) -> Channel {
141 if let Some(managed) = managed
142 && exe == managed
143 {
144 return Channel::Installer;
145 }
146 let has_dir = |name: &str| {
147 exe.components()
148 .any(|c| c.as_os_str().to_string_lossy().eq_ignore_ascii_case(name))
149 };
150 if has_dir(".cargo") {
151 Channel::Cargo
152 } else if has_dir("node_modules") {
153 Channel::Npm
154 } else if has_dir("uv") || has_dir("uv-tool") {
155 Channel::UvTool
156 } else if has_dir("pipx") {
157 Channel::Pipx
158 } else {
159 Channel::Unknown
160 }
161}
162
163fn run_install() -> Result<()> {
183 output::print_header("dev-prune self-update");
184
185 if crate::setup::offline_requested() {
186 anyhow::bail!(
187 "{} is set — an install needs the network by definition.",
188 constants::ENV_OFFLINE
189 );
190 }
191
192 let mut registry = Registry::load()?;
196 let latest = refresh_latest(&mut registry)?;
197 let _ = registry.save();
198 if compare_versions(constants::VERSION, &latest) != Some(Ordering::Less) {
199 output::print_success(&format!(
200 "v{} is already the latest release — nothing to install.",
201 constants::VERSION
202 ));
203 return Ok(());
204 }
205 output::print_info(&format!("Upgrading v{} -> v{latest} …", constants::VERSION));
206
207 let exe = std::env::current_exe().context("could not locate the running binary")?;
208 let managed = crate::setup::managed_exe_path().ok();
209 let channel = detect_channel(&exe, managed.as_deref());
210
211 match install_directly(&latest, &exe, managed.as_deref()) {
212 Ok(()) => {
213 output::print_success(&format!("dev-prune v{latest} installed."));
214 report_channel_bookkeeping(channel);
215 output::print_info(
216 "The scheduled pass was not interrupted: it runs the managed copy, which \
217 was replaced by atomic rename, so a pass already in flight keeps the \
218 image it loaded and the next one picks up the new binary.",
219 );
220 return Ok(());
221 }
222 Err(e) => output::print_warning(&format!(
223 "Direct download did not work ({e:#}).\nFalling back to the channel that \
224 installed this copy."
225 )),
226 }
227
228 #[cfg(windows)]
233 let aside = {
234 let aside = exe.with_extension("exe.old");
235 let _ = fs::remove_file(&aside);
236 fs::rename(&exe, &aside).ok().map(|_| aside)
237 };
238
239 let result = spawn_channel_upgrade(channel);
240
241 #[cfg(windows)]
242 if let Some(aside) = aside {
243 if result.is_ok() {
244 let _ = fs::remove_file(&aside);
247 } else if !exe.exists() {
248 let _ = fs::rename(&aside, &exe);
251 }
252 }
253 result?;
254
255 output::print_success(&format!("dev-prune v{latest} installed."));
256 output::print_info(
257 "The scheduled pass was not interrupted: it runs the managed copy, which \
258 refreshes itself from the new binary on its next run.",
259 );
260 Ok(())
261}
262
263fn install_directly(latest: &str, exe: &Path, managed: Option<&Path>) -> Result<()> {
276 let bytes = fetch_release_binary(latest)?;
277 let primary = managed.unwrap_or(exe);
278 install_bytes_at(&bytes, primary)?;
279
280 let mut also: Vec<PathBuf> = Vec::new();
284 if let Some(dir) = primary.parent() {
285 also.push(dir.join(if cfg!(windows) { "devp.exe" } else { "devp" }));
286 }
287 if primary != exe && exe.is_file() {
288 also.push(exe.to_path_buf());
289 }
290 for path in also {
291 if path == primary {
292 continue;
293 }
294 if let Err(e) = install_bytes_at(&bytes, &path) {
295 output::print_warning(&format!(
296 "The managed copy is now v{latest}, but {} could not be replaced ({e:#}). Until it is, that copy runs the previous version whenever it is the one invoked.",
297 path.display()
298 ));
299 }
300 }
301
302 crate::daemon::refresh_hidden_twin();
305 Ok(())
306}
307
308fn report_channel_bookkeeping(channel: Channel) {
311 let resync = match channel {
312 Channel::Cargo => "cargo install dev-prune --force",
313 Channel::Npm => "npm install -g dev-prune@latest",
314 Channel::UvTool => "uv tool upgrade dev-prune",
315 Channel::Pipx => "pipx upgrade dev-prune",
316 Channel::Installer | Channel::Unknown => return,
318 };
319 output::print_info(&format!(
320 "The binaries are up to date. `{resync}` also updates that manager's own record \
321 of the version, which still reads v{}.",
322 constants::VERSION
323 ));
324}
325
326fn fetch_release_binary(version: &str) -> Result<Vec<u8>> {
339 let asset = constants::release_asset_name(version).with_context(|| {
340 format!(
341 "no published binary for {}-{}; upgrade through the channel that installed \
342 this copy instead",
343 std::env::consts::OS,
344 std::env::consts::ARCH
345 )
346 })?;
347 let base = format!("{}/v{version}/{asset}", constants::RELEASE_DOWNLOAD_BASE);
348
349 let expected = fetch_expected_hash(&format!("{base}.sha256"))?;
350 output::print_info(&format!("Downloading {asset} …"));
351 let bytes = fetch_bytes(&base)?;
352
353 let actual = {
354 use sha2::{Digest, Sha256};
355 use std::fmt::Write as _;
356 let mut h = Sha256::new();
357 h.update(&bytes);
358 h.finalize().iter().fold(String::new(), |mut s, b| {
361 let _ = write!(s, "{b:02x}");
362 s
363 })
364 };
365 if actual != expected {
366 anyhow::bail!(
367 "checksum mismatch for {asset}\n expected {expected}\n got {actual}\n\
368 The download was corrupted or tampered with; nothing was installed."
369 );
370 }
371
372 Ok(bytes)
373}
374
375fn install_bytes_at(bytes: &[u8], target: &Path) -> Result<()> {
381 let staging = target.with_extension("new");
385 if let Some(parent) = target.parent() {
386 fs::create_dir_all(parent).ok();
387 }
388 fs::write(&staging, bytes).with_context(|| format!("could not write {}", staging.display()))?;
389
390 #[cfg(unix)]
391 {
392 use std::os::unix::fs::PermissionsExt;
393 let _ = fs::set_permissions(&staging, fs::Permissions::from_mode(0o755));
395 }
396
397 replace_binary(&staging, target)
398}
399
400fn fetch_expected_hash(url: &str) -> Result<String> {
402 let body = String::from_utf8(fetch_bytes(url)?).context("the checksum sidecar was not text")?;
403 parse_sha256_sidecar(&body)
404}
405
406fn parse_sha256_sidecar(body: &str) -> Result<String> {
415 let hash = body
416 .split_whitespace()
417 .next()
418 .context("the checksum sidecar was empty")?
419 .to_ascii_lowercase();
420 if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
421 anyhow::bail!("the checksum sidecar did not contain a SHA-256 digest");
422 }
423 Ok(hash)
424}
425
426fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
427 let mut body = ureq::get(url)
428 .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
429 .config()
430 .timeout_global(Some(Duration::from_secs(
431 constants::UPDATE_DOWNLOAD_TIMEOUT_SECS,
432 )))
433 .build()
434 .call()
435 .with_context(|| format!("could not download {url}"))?;
436 let mut buf = Vec::new();
437 body.body_mut()
438 .as_reader()
439 .read_to_end(&mut buf)
440 .with_context(|| format!("could not read {url}"))?;
441 Ok(buf)
442}
443
444fn replace_binary(staged: &Path, target: &Path) -> Result<()> {
447 #[cfg(windows)]
451 let aside = {
452 let aside = target.with_extension("exe.old");
453 let _ = fs::remove_file(&aside);
454 target
455 .exists()
456 .then(|| fs::rename(target, &aside).ok().map(|_| aside))
457 .flatten()
458 };
459
460 match fs::rename(staged, target) {
461 Ok(()) => {
462 #[cfg(windows)]
463 if let Some(aside) = aside {
464 let _ = fs::remove_file(&aside);
465 }
466 Ok(())
467 }
468 Err(e) => {
469 let _ = fs::remove_file(staged);
470 #[cfg(windows)]
471 if let Some(aside) = aside
472 && !target.exists()
473 {
474 let _ = fs::rename(&aside, target);
477 }
478 Err(e).with_context(|| format!("could not install {}", target.display()))
479 }
480 }
481}
482
483fn spawn_channel_upgrade(channel: Channel) -> Result<()> {
486 let install_ps1 = format!("iwr -useb {} | iex", constants::INSTALL_PS1_URL);
487 let install_sh = format!("curl -fsSL {} | sh", constants::INSTALL_SH_URL);
488 let argv: Vec<&str> = match channel {
489 Channel::Cargo => {
490 if crate::adapters::binary_available("cargo-binstall") {
493 vec!["cargo", "binstall", "dev-prune", "--force", "-y"]
494 } else {
495 vec!["cargo", "install", "dev-prune", "--force"]
496 }
497 }
498 Channel::Npm => vec!["npm", "install", "-g", "dev-prune@latest"],
499 Channel::UvTool => vec!["uv", "tool", "upgrade", "dev-prune"],
500 Channel::Pipx => vec!["pipx", "upgrade", "dev-prune"],
501 Channel::Installer => {
502 if cfg!(windows) {
503 vec!["powershell", "-NoProfile", "-Command", &install_ps1]
504 } else {
505 vec!["sh", "-c", &install_sh]
506 }
507 }
508 Channel::Unknown => {
509 output::print_warning(
510 "Could not tell which channel installed this binary, so nothing was \
511 changed. Upgrade it yourself with one of:",
512 );
513 print_upgrade_commands();
514 anyhow::bail!("unrecognised install channel");
515 }
516 };
517
518 output::print_info(&format!("Running: {}", argv.join(" ")));
519 let status = crate::spawn::command(crate::adapters::resolve_program(argv[0]))
520 .args(&argv[1..])
521 .status()
522 .with_context(|| format!("could not start `{}`", argv[0]))?;
523 if !status.success() {
524 anyhow::bail!("`{}` exited with {status}", argv.join(" "));
525 }
526 Ok(())
527}
528
529pub fn maybe_auto_update(registry: &Registry) {
535 if !registry.settings.auto_update
536 || crate::setup::offline_requested()
537 || crate::setup::no_auto_setup_requested()
538 {
539 return;
540 }
541 let Some(latest) = registry.latest_known_version.as_deref() else {
542 return;
543 };
544 if compare_versions(constants::VERSION, latest) != Some(Ordering::Less) {
545 return;
546 }
547 println!();
548 if let Err(e) = run_install() {
549 output::print_warning(&format!(
550 "Automatic update failed ({e}). Run `devp update --install` yourself, or \
551 `devp config set auto_update false` to stop trying."
552 ));
553 }
554}
555
556pub fn notify_if_outdated(registry: &mut Registry) -> bool {
562 if !registry.settings.update_check {
563 return false;
564 }
565
566 let interval = registry.settings.update_check_interval_days;
567 let due = registry
568 .last_update_check
569 .is_none_or(|last| Utc::now().signed_duration_since(last).num_days() >= interval);
570
571 if due {
572 let _ = refresh_latest(registry);
576 }
577
578 if let Some(latest) = registry.latest_known_version.as_deref()
579 && compare_versions(constants::VERSION, latest) == Some(Ordering::Less)
580 {
581 output::print_info(&format!(
582 "dev-prune v{latest} is out (you have v{}). `devp update` has the commands; \
583 `devp config set update_check false` silences this.",
584 constants::VERSION
585 ));
586 }
587
588 due
589}
590
591fn refresh_latest(registry: &mut Registry) -> Result<String> {
596 let result = latest_release(registry.settings.update_check_timeout_secs);
597 registry.last_update_check = Some(Utc::now());
598 let latest = result?;
599 registry.latest_known_version = Some(latest.clone());
600 Ok(latest)
601}
602
603fn report_comparison(latest: &str) {
605 let installed = constants::VERSION;
606 match compare_versions(installed, latest) {
607 Some(Ordering::Less) => {
608 output::print_warning(&format!(
609 "Latest release: v{latest} — an upgrade is available."
610 ));
611 }
612 Some(Ordering::Equal) => {
613 output::print_success(&format!(
614 "Latest release: v{latest} — you are up to date."
615 ));
616 }
617 Some(Ordering::Greater) => {
618 output::print_info(&format!(
620 "Latest release: v{latest} — your build is newer than the last published one."
621 ));
622 }
623 None => {
624 output::print_info(&format!(
625 "Latest release: v{latest} (could not compare it to v{installed})."
626 ));
627 }
628 }
629}
630
631fn latest_release(timeout_secs: u64) -> Result<String> {
636 if crate::setup::offline_requested() {
637 anyhow::bail!("{} is set", constants::ENV_OFFLINE);
638 }
639 let body = ureq::get(constants::LATEST_RELEASE_API_URL)
640 .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
641 .header("Accept", "application/vnd.github+json")
642 .config()
643 .timeout_global(Some(Duration::from_secs(timeout_secs.max(1))))
644 .build()
645 .call()
646 .context("request failed")?
647 .body_mut()
648 .read_to_string()
649 .context("could not read the response")?;
650
651 let json: serde_json::Value =
652 serde_json::from_str(&body).context("the response was not JSON")?;
653 let tag = json
654 .get("tag_name")
655 .and_then(|v| v.as_str())
656 .context("the response carried no tag_name")?;
657
658 Ok(tag.trim_start_matches('v').to_string())
659}
660
661pub(crate) fn compare_versions(a: &str, b: &str) -> Option<Ordering> {
667 let parse = |v: &str| -> Option<[u64; 3]> {
668 let core = v.split(['-', '+']).next()?;
669 let mut parts = core.split('.');
670 let out = [
671 parts.next()?.parse().ok()?,
672 parts.next()?.parse().ok()?,
673 parts.next()?.parse().ok()?,
674 ];
675 if parts.next().is_some() {
677 return None;
678 }
679 Some(out)
680 };
681 Some(parse(a)?.cmp(&parse(b)?))
682}
683
684#[cfg(test)]
685mod tests {
686 use super::*;
687 use chrono::Duration as ChronoDuration;
688
689 #[test]
690 fn orders_by_component_not_lexically() {
691 assert_eq!(compare_versions("1.9.0", "1.10.0"), Some(Ordering::Less));
693 assert_eq!(compare_versions("1.0.0", "1.0.0"), Some(Ordering::Equal));
694 assert_eq!(
695 compare_versions("2.0.0", "1.99.99"),
696 Some(Ordering::Greater)
697 );
698 }
699
700 #[test]
701 fn pre_release_suffixes_compare_by_their_core() {
702 assert_eq!(
703 compare_versions("1.0.0", "1.0.0-rc.1"),
704 Some(Ordering::Equal)
705 );
706 assert_eq!(
707 compare_versions("1.0.0+build7", "1.0.1"),
708 Some(Ordering::Less)
709 );
710 }
711
712 #[test]
713 fn unparseable_versions_report_no_answer_rather_than_a_wrong_one() {
714 assert_eq!(compare_versions("1.0", "1.0.0"), None);
715 assert_eq!(compare_versions("1.0.0.1", "1.0.0"), None);
716 assert_eq!(compare_versions("nightly", "1.0.0"), None);
717 }
718
719 #[test]
720 fn the_check_is_on_unless_the_user_turns_it_off() {
721 assert!(Registry::default().settings.update_check);
722 }
723
724 #[test]
725 fn a_disabled_check_touches_neither_the_network_nor_the_registry() {
726 let mut registry = Registry::default();
727 registry.settings.update_check = false;
728 assert!(!notify_if_outdated(&mut registry));
729 assert!(registry.last_update_check.is_none());
730 }
731
732 #[test]
733 fn each_channel_is_recognised_by_its_marker_directory() {
734 use std::path::Path;
735 let cases: &[(&str, Channel)] = &[
736 ("/home/k/.cargo/bin/dev-prune", Channel::Cargo),
737 (
738 "/usr/lib/node_modules/dev-prune/bin/dev-prune",
739 Channel::Npm,
740 ),
741 (
742 "/home/k/.local/share/uv/tools/dev-prune/bin/dev-prune",
743 Channel::UvTool,
744 ),
745 (
746 "/home/k/.local/pipx/venvs/dev-prune/bin/dev-prune",
747 Channel::Pipx,
748 ),
749 ("/opt/somewhere/dev-prune", Channel::Unknown),
750 ];
751 for (path, expected) in cases {
752 assert_eq!(detect_channel(Path::new(path), None), *expected, "{path}");
753 }
754 }
755
756 #[test]
757 fn the_managed_copy_wins_over_every_path_heuristic() {
758 use std::path::Path;
759 let managed = Path::new("/home/k/.cargo/odd/dev-prune/bin/dev-prune");
761 assert_eq!(detect_channel(managed, Some(managed)), Channel::Installer);
762 }
763
764 #[test]
765 fn auto_update_is_off_by_default_and_silent_when_off() {
766 let registry = Registry::default();
767 assert!(!registry.settings.auto_update);
768 maybe_auto_update(®istry);
770 }
771
772 #[test]
773 fn a_recent_check_is_not_repeated() {
774 let mut registry = Registry::default();
775 let stamp = Utc::now() - ChronoDuration::days(constants::UPDATE_CHECK_INTERVAL_DAYS - 1);
776 registry.last_update_check = Some(stamp);
777 assert!(!notify_if_outdated(&mut registry));
779 assert_eq!(registry.last_update_check, Some(stamp));
780 }
781
782 #[test]
783 fn the_asset_name_matches_what_the_release_workflow_builds() {
784 let name = constants::release_asset_name("1.4.0");
788 let expected = match (std::env::consts::OS, std::env::consts::ARCH) {
789 ("windows", "x86_64") => Some("dev-prune-v1.4.0-windows-x64.exe"),
790 ("windows", "aarch64") => Some("dev-prune-v1.4.0-windows-arm64.exe"),
791 ("windows", "x86") => Some("dev-prune-v1.4.0-windows-x86.exe"),
792 ("linux", "x86_64") => Some("dev-prune-v1.4.0-linux-x64"),
793 ("linux", "aarch64") => Some("dev-prune-v1.4.0-linux-arm64"),
794 ("macos", "x86_64") => Some("dev-prune-v1.4.0-darwin-x64"),
795 ("macos", "aarch64") => Some("dev-prune-v1.4.0-darwin-arm64"),
796 _ => None,
799 };
800 assert_eq!(name.as_deref(), expected);
801 }
802
803 #[test]
804 fn only_windows_has_a_32_bit_asset() {
805 let name = constants::release_asset_name("9.9.9");
808 if std::env::consts::ARCH == "x86" {
809 assert_eq!(name.is_some(), std::env::consts::OS == "windows");
810 }
811 }
812
813 #[test]
814 fn a_sidecar_is_read_as_the_first_field_of_sha256sum_format() {
815 let digest = "a".repeat(64);
816 assert_eq!(
817 parse_sha256_sidecar(&format!("{digest} dev-prune-v1.4.0-linux-x64\n")).unwrap(),
818 digest
819 );
820 assert_eq!(
823 parse_sha256_sidecar(&format!("{digest} asset.exe")).unwrap(),
824 digest
825 );
826 assert_eq!(
827 parse_sha256_sidecar(&format!("{} asset\r\n", digest.to_uppercase())).unwrap(),
828 digest,
829 "an upper-case digest must compare equal to the one we compute"
830 );
831 }
832
833 #[test]
834 fn anything_that_is_not_a_digest_is_refused_before_it_is_compared() {
835 for bad in [
838 "",
839 " ",
840 "<!DOCTYPE html>",
841 "not-a-hash asset",
842 &"a".repeat(63),
843 &"a".repeat(65),
844 &format!("{}g asset", "a".repeat(63)),
845 ] {
846 assert!(
847 parse_sha256_sidecar(bad).is_err(),
848 "{bad:?} must not be accepted as a digest"
849 );
850 }
851 }
852}