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::channel::Channel;
36use crate::config::Registry;
37use crate::constants;
38use crate::output;
39
40pub fn run(offline: bool, install: bool) -> Result<()> {
41 if install {
42 return run_install();
43 }
44 output::print_header("dev-prune version & upgrade");
45
46 output::print_info(&format!("Installed version: v{}", constants::VERSION));
47
48 if offline {
49 output::print_info("Skipping the release check because `--offline` was passed.");
50 } else if let Ok(mut registry) = Registry::load() {
51 if registry.settings.update_check {
52 match refresh_latest(&mut registry) {
55 Ok(latest) => report_comparison(&latest),
56 Err(e) => output::print_warning(&format!(
59 "Could not reach the release API ({e}). The upgrade commands below still apply."
60 )),
61 }
62 let _ = registry.save();
63 } else {
64 output::print_info(
65 "The release check is off (`devp config set update_check true` re-enables it).",
66 );
67 }
68 }
69
70 println!();
71 println!(" Latest releases: {}", constants::RELEASES_URL);
72 println!();
73 print_upgrade_commands();
74
75 Ok(())
76}
77
78pub fn check_now(registry: &mut Registry) -> bool {
87 if !registry.settings.update_check {
88 return false;
89 }
90
91 match refresh_latest(registry) {
92 Ok(latest) => {
93 report_comparison(&latest);
94 if compare_versions(constants::VERSION, &latest) == Some(Ordering::Less) {
95 print_upgrade_commands();
96 }
97 }
98 Err(e) => output::print_info(&format!("Could not check for a newer release ({e}).")),
100 }
101 true
102}
103
104fn print_upgrade_commands() {
112 let channel = Channel::detect();
113 match channel.upgrade_command() {
114 Some(command) => {
115 println!(" Installed with {} — upgrade with:", channel.label());
116 println!(" {command}");
117 println!();
118 println!(" Or `devp update --install` to let dev-prune do it for you.");
119 }
120 None => {
124 println!(" This copy is not in a location any install channel owns, so there");
125 println!(" is no package manager to name. Replace it in place with:");
126 println!(" devp update --install");
127 println!();
128 println!(" Or install through a channel, which keeps it upgradeable:");
129 println!(" cargo binstall dev-prune --force");
130 println!(" cargo install dev-prune --force");
131 println!(" npm install -g dev-prune@latest");
132 println!(" uv tool upgrade dev-prune / pipx upgrade dev-prune");
133 println!(" winget upgrade {}", constants::WINGET_PACKAGE_ID);
134 println!(" scoop update dev-prune / brew upgrade dev-prune");
135 println!(" curl -fsSL {} | sh", constants::INSTALL_SH_URL);
136 println!(" iwr -useb {} | iex", constants::INSTALL_PS1_URL);
137 }
138 }
139}
140
141fn run_install() -> Result<()> {
161 output::print_header("dev-prune self-update");
162
163 if crate::setup::offline_requested() {
164 anyhow::bail!(
165 "{} is set — an install needs the network by definition.",
166 constants::ENV_OFFLINE
167 );
168 }
169
170 let mut registry = Registry::load()?;
174 let latest = refresh_latest(&mut registry)?;
175 let _ = registry.save();
176 if compare_versions(constants::VERSION, &latest) != Some(Ordering::Less) {
177 output::print_success(&format!(
178 "v{} is already the latest release — nothing to install.",
179 constants::VERSION
180 ));
181 return Ok(());
182 }
183 output::print_info(&format!("Upgrading v{} -> v{latest} …", constants::VERSION));
184
185 let exe = std::env::current_exe().context("could not locate the running binary")?;
186 let managed = crate::setup::managed_exe_path().ok();
187 let channel = Channel::detect_at(&exe, managed.as_deref());
188
189 match install_directly(&latest, &exe, managed.as_deref(), channel) {
190 Ok(()) => {
191 output::print_success(&format!("dev-prune v{latest} installed."));
192 report_channel_bookkeeping(channel);
193 output::print_info(
194 "The scheduled pass was not interrupted: it runs the managed copy, which \
195 was replaced by atomic rename, so a pass already in flight keeps the \
196 image it loaded and the next one picks up the new binary.",
197 );
198 return Ok(());
199 }
200 Err(e) => output::print_warning(&format!(
201 "Direct download did not work ({e:#}).\nFalling back to the channel that \
202 installed this copy."
203 )),
204 }
205
206 #[cfg(windows)]
211 let aside = {
212 let aside = exe.with_extension("exe.old");
213 let _ = fs::remove_file(&aside);
214 fs::rename(&exe, &aside).ok().map(|_| aside)
215 };
216
217 let result = spawn_channel_upgrade(channel);
218
219 #[cfg(windows)]
220 if let Some(aside) = aside {
221 if result.is_ok() {
222 let _ = fs::remove_file(&aside);
225 } else if !exe.exists() {
226 let _ = fs::rename(&aside, &exe);
229 }
230 }
231 result?;
232
233 output::print_success(&format!("dev-prune v{latest} installed."));
234 output::print_info(
235 "The scheduled pass was not interrupted: it runs the managed copy, which \
236 refreshes itself from the new binary on its next run.",
237 );
238 Ok(())
239}
240
241fn install_directly(
254 latest: &str,
255 exe: &Path,
256 managed: Option<&Path>,
257 channel: Channel,
258) -> Result<()> {
259 let bytes = fetch_release_binary(latest)?;
260 let primary = managed.unwrap_or(exe);
261 install_bytes_at(&bytes, primary)?;
262
263 let mut also: Vec<PathBuf> = Vec::new();
267 if let Some(dir) = primary.parent() {
268 also.push(dir.join(if cfg!(windows) { "devp.exe" } else { "devp" }));
269 }
270 if primary != exe && exe.is_file() && !channel.replaces_its_directory() {
277 also.push(exe.to_path_buf());
278 }
279 for path in also {
280 if path == primary {
281 continue;
282 }
283 if let Err(e) = install_bytes_at(&bytes, &path) {
284 output::print_warning(&format!(
285 "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.",
286 path.display()
287 ));
288 }
289 }
290
291 crate::daemon::refresh_hidden_twin();
294 Ok(())
295}
296
297fn report_channel_bookkeeping(channel: Channel) {
300 let Some(resync) = channel
303 .owns_its_files()
304 .then(|| channel.upgrade_command())
305 .flatten()
306 else {
307 return;
308 };
309 if channel.replaces_its_directory() {
310 output::print_info(&format!(
311 "The managed copy is now v{}. The copy {} installed was left exactly as it \
312 wrote it — replacing a file inside a versioned package directory only makes \
313 the manager and the disk disagree. Run `{resync}` to move that one forward \
314 too.",
315 constants::VERSION,
316 channel.label()
317 ));
318 } else {
319 output::print_info(&format!(
320 "The binaries are up to date. `{resync}` also updates that manager's own \
321 record of the version, which still reads v{}.",
322 constants::VERSION
323 ));
324 }
325}
326
327fn fetch_release_binary(version: &str) -> Result<Vec<u8>> {
340 let asset = constants::release_asset_name(version).with_context(|| {
341 format!(
342 "no published binary for {}-{}; upgrade through the channel that installed \
343 this copy instead",
344 std::env::consts::OS,
345 std::env::consts::ARCH
346 )
347 })?;
348 let base = format!("{}/v{version}/{asset}", constants::RELEASE_DOWNLOAD_BASE);
349
350 let expected = fetch_expected_hash(&format!("{base}.sha256"))?;
351 output::print_info(&format!("Downloading {asset} …"));
352 let bytes = fetch_bytes(&base)?;
353
354 let actual = {
355 use sha2::{Digest, Sha256};
356 use std::fmt::Write as _;
357 let mut h = Sha256::new();
358 h.update(&bytes);
359 h.finalize().iter().fold(String::new(), |mut s, b| {
362 let _ = write!(s, "{b:02x}");
363 s
364 })
365 };
366 if actual != expected {
367 anyhow::bail!(
368 "checksum mismatch for {asset}\n expected {expected}\n got {actual}\n\
369 The download was corrupted or tampered with; nothing was installed."
370 );
371 }
372
373 Ok(bytes)
374}
375
376fn install_bytes_at(bytes: &[u8], target: &Path) -> Result<()> {
382 let staging = target.with_extension("new");
386 if let Some(parent) = target.parent() {
387 fs::create_dir_all(parent).ok();
388 }
389 fs::write(&staging, bytes).with_context(|| format!("could not write {}", staging.display()))?;
390
391 #[cfg(unix)]
392 {
393 use std::os::unix::fs::PermissionsExt;
394 let _ = fs::set_permissions(&staging, fs::Permissions::from_mode(0o755));
396 }
397
398 replace_binary(&staging, target)
399}
400
401fn fetch_expected_hash(url: &str) -> Result<String> {
403 let body = String::from_utf8(fetch_bytes(url)?).context("the checksum sidecar was not text")?;
404 parse_sha256_sidecar(&body)
405}
406
407fn parse_sha256_sidecar(body: &str) -> Result<String> {
416 let hash = body
417 .split_whitespace()
418 .next()
419 .context("the checksum sidecar was empty")?
420 .to_ascii_lowercase();
421 if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
422 anyhow::bail!("the checksum sidecar did not contain a SHA-256 digest");
423 }
424 Ok(hash)
425}
426
427fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
428 let mut body = ureq::get(url)
429 .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
430 .config()
431 .timeout_global(Some(Duration::from_secs(
432 constants::UPDATE_DOWNLOAD_TIMEOUT_SECS,
433 )))
434 .build()
435 .call()
436 .with_context(|| format!("could not download {url}"))?;
437 let mut buf = Vec::new();
438 body.body_mut()
439 .as_reader()
440 .read_to_end(&mut buf)
441 .with_context(|| format!("could not read {url}"))?;
442 Ok(buf)
443}
444
445fn replace_binary(staged: &Path, target: &Path) -> Result<()> {
448 #[cfg(windows)]
452 let aside = {
453 let aside = target.with_extension("exe.old");
454 let _ = fs::remove_file(&aside);
455 target
456 .exists()
457 .then(|| fs::rename(target, &aside).ok().map(|_| aside))
458 .flatten()
459 };
460
461 match fs::rename(staged, target) {
462 Ok(()) => {
463 #[cfg(windows)]
464 if let Some(aside) = aside {
465 let _ = fs::remove_file(&aside);
466 }
467 Ok(())
468 }
469 Err(e) => {
470 let _ = fs::remove_file(staged);
471 #[cfg(windows)]
472 if let Some(aside) = aside
473 && !target.exists()
474 {
475 let _ = fs::rename(&aside, target);
478 }
479 Err(e).with_context(|| format!("could not install {}", target.display()))
480 }
481 }
482}
483
484fn spawn_channel_upgrade(channel: Channel) -> Result<()> {
487 let install_ps1 = format!("iwr -useb {} | iex", constants::INSTALL_PS1_URL);
488 let install_sh = format!("curl -fsSL {} | sh", constants::INSTALL_SH_URL);
489 let winget_id = constants::WINGET_PACKAGE_ID;
490 let argv: Vec<&str> = match channel {
491 Channel::Cargo => {
492 if crate::adapters::binary_available("cargo-binstall") {
495 vec!["cargo", "binstall", "dev-prune", "--force", "-y"]
496 } else {
497 vec!["cargo", "install", "dev-prune", "--force"]
498 }
499 }
500 Channel::Npm => vec!["npm", "install", "-g", "dev-prune@latest"],
501 Channel::UvTool => vec!["uv", "tool", "upgrade", "dev-prune"],
502 Channel::Pipx => vec!["pipx", "upgrade", "dev-prune"],
503 Channel::Pip => vec!["pip", "install", "--upgrade", "dev-prune"],
504 Channel::WinGet => vec![
509 "winget",
510 "upgrade",
511 "--id",
512 winget_id,
513 "--accept-package-agreements",
514 "--accept-source-agreements",
515 ],
516 Channel::Scoop => vec!["scoop", "update", "dev-prune"],
517 Channel::Homebrew => vec!["brew", "upgrade", "dev-prune"],
518 Channel::Installer => {
519 if cfg!(windows) {
520 vec!["powershell", "-NoProfile", "-Command", &install_ps1]
521 } else {
522 vec!["sh", "-c", &install_sh]
523 }
524 }
525 Channel::Unknown => {
526 output::print_warning(
527 "Could not tell which channel installed this binary, so nothing was \
528 changed. Upgrade it yourself with one of:",
529 );
530 print_upgrade_commands();
531 anyhow::bail!("unrecognised install channel");
532 }
533 };
534
535 output::print_info(&format!("Running: {}", argv.join(" ")));
536 let status = crate::spawn::command(crate::adapters::resolve_program(argv[0]))
537 .args(&argv[1..])
538 .status()
539 .with_context(|| format!("could not start `{}`", argv[0]))?;
540 if !status.success() {
541 anyhow::bail!("`{}` exited with {status}", argv.join(" "));
542 }
543 Ok(())
544}
545
546pub fn maybe_auto_update(registry: &Registry) {
559 if !registry.settings.auto_update
560 || crate::setup::offline_requested()
561 || crate::setup::no_auto_setup_requested()
562 {
563 return;
564 }
565 let Some(latest) = registry.latest_known_version.as_deref() else {
566 return;
567 };
568 if compare_versions(constants::VERSION, latest) != Some(Ordering::Less) {
569 return;
570 }
571
572 let Ok(exe) = std::env::current_exe() else {
573 return;
574 };
575 let managed = crate::setup::managed_exe_path().ok();
576 let channel = Channel::detect_at(&exe, managed.as_deref());
577
578 if channel.replaces_its_directory() {
583 return;
584 }
585
586 println!();
587 output::print_info(&format!(
588 "Updating dev-prune v{} -> v{latest} …",
589 constants::VERSION
590 ));
591 match install_directly(latest, &exe, managed.as_deref(), channel) {
592 Ok(()) => {
593 output::print_success(&format!("dev-prune v{latest} installed."));
594 report_channel_bookkeeping(channel);
595 }
596 Err(e) => output::print_warning(&format!(
597 "Automatic update failed ({e:#}). Run `devp update --install` yourself, or \
598 `devp config set auto_update false` to stop trying."
599 )),
600 }
601}
602
603pub fn notify_if_outdated(registry: &mut Registry) -> bool {
609 if !registry.settings.update_check {
610 return false;
611 }
612
613 let interval = registry.settings.update_check_interval_days;
614 let due = registry
615 .last_update_check
616 .is_none_or(|last| Utc::now().signed_duration_since(last).num_days() >= interval);
617
618 if due {
619 let _ = refresh_latest(registry);
623 }
624
625 if let Some(latest) = registry.latest_known_version.as_deref()
626 && compare_versions(constants::VERSION, latest) == Some(Ordering::Less)
627 {
628 output::print_info(&format!(
629 "dev-prune v{latest} is out (you have v{}). `devp update` has the commands; \
630 `devp config set update_check false` silences this.",
631 constants::VERSION
632 ));
633 }
634
635 due
636}
637
638fn refresh_latest(registry: &mut Registry) -> Result<String> {
643 let result = latest_release(registry.settings.update_check_timeout_secs);
644 registry.last_update_check = Some(Utc::now());
645 let latest = result?;
646 registry.latest_known_version = Some(latest.clone());
647 Ok(latest)
648}
649
650fn report_comparison(latest: &str) {
652 let installed = constants::VERSION;
653 match compare_versions(installed, latest) {
654 Some(Ordering::Less) => {
655 output::print_warning(&format!(
656 "Latest release: v{latest} — an upgrade is available."
657 ));
658 }
659 Some(Ordering::Equal) => {
660 output::print_success(&format!(
661 "Latest release: v{latest} — you are up to date."
662 ));
663 }
664 Some(Ordering::Greater) => {
665 output::print_info(&format!(
667 "Latest release: v{latest} — your build is newer than the last published one."
668 ));
669 }
670 None => {
671 output::print_info(&format!(
672 "Latest release: v{latest} (could not compare it to v{installed})."
673 ));
674 }
675 }
676}
677
678fn latest_release(timeout_secs: u64) -> Result<String> {
683 if crate::setup::offline_requested() {
684 anyhow::bail!("{} is set", constants::ENV_OFFLINE);
685 }
686 let body = ureq::get(constants::LATEST_RELEASE_API_URL)
687 .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
688 .header("Accept", "application/vnd.github+json")
689 .config()
690 .timeout_global(Some(Duration::from_secs(timeout_secs.max(1))))
691 .build()
692 .call()
693 .context("request failed")?
694 .body_mut()
695 .read_to_string()
696 .context("could not read the response")?;
697
698 let json: serde_json::Value =
699 serde_json::from_str(&body).context("the response was not JSON")?;
700 let tag = json
701 .get("tag_name")
702 .and_then(|v| v.as_str())
703 .context("the response carried no tag_name")?;
704
705 Ok(tag.trim_start_matches('v').to_string())
706}
707
708pub(crate) fn compare_versions(a: &str, b: &str) -> Option<Ordering> {
714 let parse = |v: &str| -> Option<[u64; 3]> {
715 let core = v.split(['-', '+']).next()?;
716 let mut parts = core.split('.');
717 let out = [
718 parts.next()?.parse().ok()?,
719 parts.next()?.parse().ok()?,
720 parts.next()?.parse().ok()?,
721 ];
722 if parts.next().is_some() {
724 return None;
725 }
726 Some(out)
727 };
728 Some(parse(a)?.cmp(&parse(b)?))
729}
730
731#[cfg(test)]
732mod tests {
733 use super::*;
734 use chrono::Duration as ChronoDuration;
735
736 #[test]
737 fn orders_by_component_not_lexically() {
738 assert_eq!(compare_versions("1.9.0", "1.10.0"), Some(Ordering::Less));
740 assert_eq!(compare_versions("1.0.0", "1.0.0"), Some(Ordering::Equal));
741 assert_eq!(
742 compare_versions("2.0.0", "1.99.99"),
743 Some(Ordering::Greater)
744 );
745 }
746
747 #[test]
748 fn pre_release_suffixes_compare_by_their_core() {
749 assert_eq!(
750 compare_versions("1.0.0", "1.0.0-rc.1"),
751 Some(Ordering::Equal)
752 );
753 assert_eq!(
754 compare_versions("1.0.0+build7", "1.0.1"),
755 Some(Ordering::Less)
756 );
757 }
758
759 #[test]
760 fn unparseable_versions_report_no_answer_rather_than_a_wrong_one() {
761 assert_eq!(compare_versions("1.0", "1.0.0"), None);
762 assert_eq!(compare_versions("1.0.0.1", "1.0.0"), None);
763 assert_eq!(compare_versions("nightly", "1.0.0"), None);
764 }
765
766 #[test]
767 fn the_check_is_on_unless_the_user_turns_it_off() {
768 assert!(Registry::default().settings.update_check);
769 }
770
771 #[test]
772 fn a_disabled_check_touches_neither_the_network_nor_the_registry() {
773 let mut registry = Registry::default();
774 registry.settings.update_check = false;
775 assert!(!notify_if_outdated(&mut registry));
776 assert!(registry.last_update_check.is_none());
777 }
778
779 #[test]
780 fn auto_update_is_on_by_default_and_silent_with_nothing_to_install() {
781 let registry = Registry::default();
782 assert!(registry.settings.auto_update);
783 assert!(registry.latest_known_version.is_none());
787 maybe_auto_update(®istry);
788 }
789
790 #[test]
791 fn a_recent_check_is_not_repeated() {
792 let mut registry = Registry::default();
793 let stamp = Utc::now() - ChronoDuration::days(constants::UPDATE_CHECK_INTERVAL_DAYS - 1);
794 registry.last_update_check = Some(stamp);
795 assert!(!notify_if_outdated(&mut registry));
797 assert_eq!(registry.last_update_check, Some(stamp));
798 }
799
800 #[test]
801 fn the_asset_name_matches_what_the_release_workflow_builds() {
802 let name = constants::release_asset_name("1.4.0");
806 let expected = match (std::env::consts::OS, std::env::consts::ARCH) {
807 ("windows", "x86_64") => Some("dev-prune-v1.4.0-windows-x64.exe"),
808 ("windows", "aarch64") => Some("dev-prune-v1.4.0-windows-arm64.exe"),
809 ("windows", "x86") => Some("dev-prune-v1.4.0-windows-x86.exe"),
810 ("linux", "x86_64") => Some("dev-prune-v1.4.0-linux-x64"),
811 ("linux", "aarch64") => Some("dev-prune-v1.4.0-linux-arm64"),
812 ("macos", "x86_64") => Some("dev-prune-v1.4.0-darwin-x64"),
813 ("macos", "aarch64") => Some("dev-prune-v1.4.0-darwin-arm64"),
814 _ => None,
817 };
818 assert_eq!(name.as_deref(), expected);
819 }
820
821 #[test]
822 fn only_windows_has_a_32_bit_asset() {
823 let name = constants::release_asset_name("9.9.9");
826 if std::env::consts::ARCH == "x86" {
827 assert_eq!(name.is_some(), std::env::consts::OS == "windows");
828 }
829 }
830
831 #[test]
832 fn a_sidecar_is_read_as_the_first_field_of_sha256sum_format() {
833 let digest = "a".repeat(64);
834 assert_eq!(
835 parse_sha256_sidecar(&format!("{digest} dev-prune-v1.4.0-linux-x64\n")).unwrap(),
836 digest
837 );
838 assert_eq!(
841 parse_sha256_sidecar(&format!("{digest} asset.exe")).unwrap(),
842 digest
843 );
844 assert_eq!(
845 parse_sha256_sidecar(&format!("{} asset\r\n", digest.to_uppercase())).unwrap(),
846 digest,
847 "an upper-case digest must compare equal to the one we compute"
848 );
849 }
850
851 #[test]
852 fn anything_that_is_not_a_digest_is_refused_before_it_is_compared() {
853 for bad in [
856 "",
857 " ",
858 "<!DOCTYPE html>",
859 "not-a-hash asset",
860 &"a".repeat(63),
861 &"a".repeat(65),
862 &format!("{}g asset", "a".repeat(63)),
863 ] {
864 assert!(
865 parse_sha256_sidecar(bad).is_err(),
866 "{bad:?} must not be accepted as a digest"
867 );
868 }
869 }
870}