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 let mut registry = Registry::load().ok();
49
50 if offline {
51 output::print_info("Skipping the release check because `--offline` was passed.");
52 } else if let Some(reg) = registry.as_mut() {
53 if reg.settings.update_check {
54 match refresh_latest(reg) {
57 Ok(latest) => report_comparison(&latest),
58 Err(e) => output::print_warning(&format!(
61 "Could not reach the release API ({e}). The upgrade commands below still apply."
62 )),
63 }
64 let _ = reg.save();
65 } else {
66 output::print_info(
67 "The release check is off (`devp config set update_check true` re-enables it).",
68 );
69 }
70 }
71
72 println!();
73 println!(" Latest releases: {}", constants::RELEASES_URL);
74 println!();
75
76 if registry.is_some_and(|r| r.settings.version_lock) {
80 output::print_info(&locked_notice(None));
81 } else {
82 print_upgrade_commands();
83 }
84
85 Ok(())
86}
87
88pub(crate) fn locked_notice(latest: Option<&str>) -> String {
97 let head = match latest {
98 Some(latest) => format!("dev-prune v{latest} is out. "),
99 None => String::new(),
100 };
101 format!(
102 "{head}`version_lock` is on, so this copy stays at v{}. \
103 `devp config set version_lock false` releases it.",
104 constants::VERSION
105 )
106}
107
108pub fn check_now(registry: &mut Registry) -> bool {
117 if !registry.settings.update_check {
118 return false;
119 }
120
121 match refresh_latest(registry) {
122 Ok(latest) => {
123 report_comparison(&latest);
124 if compare_versions(constants::VERSION, &latest) == Some(Ordering::Less) {
125 print_upgrade_commands();
126 }
127 }
128 Err(e) => output::print_info(&format!("Could not check for a newer release ({e}).")),
130 }
131 true
132}
133
134fn print_upgrade_commands() {
142 let channel = Channel::detect();
143 match channel.upgrade_command() {
144 Some(command) => {
145 println!(" Installed with {} — upgrade with:", channel.label());
146 println!(" {command}");
147 println!();
148 println!(" Or `devp update --install` to let dev-prune do it for you.");
149 }
150 None => {
154 println!(" This copy is not in a location any install channel owns, so there");
155 println!(" is no package manager to name. Replace it in place with:");
156 println!(" devp update --install");
157 println!();
158 println!(" Or install through a channel, which keeps it upgradeable:");
159 println!(" cargo binstall dev-prune --force");
160 println!(" cargo install dev-prune --force");
161 println!(" npm install -g dev-prune@latest");
162 println!(" uv tool upgrade dev-prune / pipx upgrade dev-prune");
163 println!(" winget upgrade {}", constants::WINGET_PACKAGE_ID);
164 println!(" scoop update dev-prune / brew upgrade dev-prune");
165 println!(" curl -fsSL {} | sh", constants::INSTALL_SH_URL);
166 println!(" iwr -useb {} | iex", constants::INSTALL_PS1_URL);
167 }
168 }
169}
170
171fn run_install() -> Result<()> {
191 output::print_header("dev-prune self-update");
192
193 let mut registry = Registry::load()?;
194
195 if registry.settings.version_lock {
198 anyhow::bail!("{}", locked_notice(None));
199 }
200
201 if crate::setup::offline_requested() {
202 anyhow::bail!(
203 "{} is set — an install needs the network by definition.",
204 constants::ENV_OFFLINE
205 );
206 }
207
208 let latest = refresh_latest(&mut registry)?;
212 let _ = registry.save();
213 if compare_versions(constants::VERSION, &latest) != Some(Ordering::Less) {
214 output::print_success(&format!(
215 "v{} is already the latest release — nothing to install.",
216 constants::VERSION
217 ));
218 return Ok(());
219 }
220 output::print_info(&format!("Upgrading v{} -> v{latest} …", constants::VERSION));
221
222 let exe = std::env::current_exe().context("could not locate the running binary")?;
223 let managed = crate::setup::managed_exe_path().ok();
224 let channel = Channel::detect_at(&exe, managed.as_deref());
225
226 match install_directly(&latest, &exe, managed.as_deref(), channel) {
227 Ok(()) => {
228 output::print_success(&format!("dev-prune v{latest} installed."));
229 report_channel_bookkeeping(channel);
230 output::print_info(
231 "The scheduled pass was not interrupted: it runs the managed copy, which \
232 was replaced by atomic rename, so a pass already in flight keeps the \
233 image it loaded and the next one picks up the new binary.",
234 );
235 return Ok(());
236 }
237 Err(e) => output::print_warning(&format!(
238 "Direct download did not work ({e:#}).\nFalling back to the channel that \
239 installed this copy."
240 )),
241 }
242
243 #[cfg(windows)]
248 let aside = {
249 let aside = exe.with_extension("exe.old");
250 let _ = fs::remove_file(&aside);
251 fs::rename(&exe, &aside).ok().map(|_| aside)
252 };
253
254 let result = spawn_channel_upgrade(channel);
255
256 #[cfg(windows)]
257 if let Some(aside) = aside {
258 if result.is_ok() {
259 let _ = fs::remove_file(&aside);
262 } else if !exe.exists() {
263 let _ = fs::rename(&aside, &exe);
266 }
267 }
268 result?;
269
270 output::print_success(&format!("dev-prune v{latest} installed."));
271 output::print_info(
272 "The scheduled pass was not interrupted: it runs the managed copy, which \
273 refreshes itself from the new binary on its next run.",
274 );
275 Ok(())
276}
277
278fn install_directly(
291 latest: &str,
292 exe: &Path,
293 managed: Option<&Path>,
294 channel: Channel,
295) -> Result<()> {
296 let bytes = fetch_release_binary(latest)?;
297 let primary = managed.unwrap_or(exe);
298 install_bytes_at(&bytes, primary)?;
299
300 let mut also: Vec<PathBuf> = Vec::new();
304 if let Some(dir) = primary.parent() {
305 also.push(dir.join(if cfg!(windows) { "devp.exe" } else { "devp" }));
306 }
307 if primary != exe && exe.is_file() && !channel.replaces_its_directory() {
314 also.push(exe.to_path_buf());
315 }
316 for path in also {
317 if path == primary {
318 continue;
319 }
320 if let Err(e) = install_bytes_at(&bytes, &path) {
321 output::print_warning(&format!(
322 "The managed copy is now v{latest}, but {} could not be replaced ({e:#}). Until it \
323 is, that copy runs the previous version whenever it is the one invoked.",
324 path.display()
325 ));
326 }
327 }
328
329 crate::daemon::refresh_hidden_twin();
332 Ok(())
333}
334
335fn report_channel_bookkeeping(channel: Channel) {
338 let Some(resync) = channel
341 .owns_its_files()
342 .then(|| channel.upgrade_command())
343 .flatten()
344 else {
345 return;
346 };
347 if channel.replaces_its_directory() {
348 output::print_info(&format!(
349 "The managed copy is now v{}. The copy {} installed was left exactly as it \
350 wrote it — replacing a file inside a versioned package directory only makes \
351 the manager and the disk disagree. Run `{resync}` to move that one forward \
352 too.",
353 constants::VERSION,
354 channel.label()
355 ));
356 } else {
357 output::print_info(&format!(
358 "The binaries are up to date. `{resync}` also updates that manager's own \
359 record of the version, which still reads v{}.",
360 constants::VERSION
361 ));
362 }
363}
364
365fn fetch_release_binary(version: &str) -> Result<Vec<u8>> {
378 let asset = constants::release_asset_name(version).with_context(|| {
379 format!(
380 "no published binary for {}-{}; upgrade through the channel that installed \
381 this copy instead",
382 std::env::consts::OS,
383 std::env::consts::ARCH
384 )
385 })?;
386 let base = format!("{}/v{version}/{asset}", constants::RELEASE_DOWNLOAD_BASE);
387
388 let expected = fetch_expected_hash(&format!("{base}.sha256"))?;
389 output::print_info(&format!("Downloading {asset} …"));
390 let bytes = fetch_bytes(&base)?;
391
392 let actual = {
393 use sha2::{Digest, Sha256};
394 use std::fmt::Write as _;
395 let mut h = Sha256::new();
396 h.update(&bytes);
397 h.finalize().iter().fold(String::new(), |mut s, b| {
400 let _ = write!(s, "{b:02x}");
401 s
402 })
403 };
404 if actual != expected {
405 anyhow::bail!(
406 "checksum mismatch for {asset}\n expected {expected}\n got {actual}\n\
407 The download was corrupted or tampered with; nothing was installed."
408 );
409 }
410
411 Ok(bytes)
412}
413
414fn install_bytes_at(bytes: &[u8], target: &Path) -> Result<()> {
420 let staging = target.with_extension("new");
424 if let Some(parent) = target.parent() {
425 fs::create_dir_all(parent).ok();
426 }
427 fs::write(&staging, bytes).with_context(|| format!("could not write {}", staging.display()))?;
428
429 #[cfg(unix)]
430 {
431 use std::os::unix::fs::PermissionsExt;
432 let _ = fs::set_permissions(&staging, fs::Permissions::from_mode(0o755));
434 }
435
436 replace_binary(&staging, target)
437}
438
439fn fetch_expected_hash(url: &str) -> Result<String> {
441 let body = String::from_utf8(fetch_bytes(url)?).context("the checksum sidecar was not text")?;
442 parse_sha256_sidecar(&body)
443}
444
445fn parse_sha256_sidecar(body: &str) -> Result<String> {
454 let hash = body
455 .split_whitespace()
456 .next()
457 .context("the checksum sidecar was empty")?
458 .to_ascii_lowercase();
459 if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
460 anyhow::bail!("the checksum sidecar did not contain a SHA-256 digest");
461 }
462 Ok(hash)
463}
464
465fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
466 let mut body = ureq::get(url)
467 .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
468 .config()
469 .timeout_global(Some(Duration::from_secs(
470 constants::UPDATE_DOWNLOAD_TIMEOUT_SECS,
471 )))
472 .build()
473 .call()
474 .with_context(|| format!("could not download {url}"))?;
475 let mut buf = Vec::new();
476 body.body_mut()
477 .as_reader()
478 .read_to_end(&mut buf)
479 .with_context(|| format!("could not read {url}"))?;
480 Ok(buf)
481}
482
483fn replace_binary(staged: &Path, target: &Path) -> Result<()> {
486 #[cfg(windows)]
490 let aside = {
491 let aside = target.with_extension("exe.old");
492 let _ = fs::remove_file(&aside);
493 target
494 .exists()
495 .then(|| fs::rename(target, &aside).ok().map(|_| aside))
496 .flatten()
497 };
498
499 match fs::rename(staged, target) {
500 Ok(()) => {
501 #[cfg(windows)]
502 if let Some(aside) = aside {
503 let _ = fs::remove_file(&aside);
504 }
505 Ok(())
506 }
507 Err(e) => {
508 let _ = fs::remove_file(staged);
509 #[cfg(windows)]
510 if let Some(aside) = aside
511 && !target.exists()
512 {
513 let _ = fs::rename(&aside, target);
516 }
517 Err(e).with_context(|| format!("could not install {}", target.display()))
518 }
519 }
520}
521
522fn spawn_channel_upgrade(channel: Channel) -> Result<()> {
525 let install_ps1 = format!("iwr -useb {} | iex", constants::INSTALL_PS1_URL);
526 let install_sh = format!("curl -fsSL {} | sh", constants::INSTALL_SH_URL);
527 let winget_id = constants::WINGET_PACKAGE_ID;
528 let argv: Vec<&str> = match channel {
529 Channel::Cargo => {
530 if crate::adapters::binary_available("cargo-binstall") {
533 vec!["cargo", "binstall", "dev-prune", "--force", "-y"]
534 } else {
535 vec!["cargo", "install", "dev-prune", "--force"]
536 }
537 }
538 Channel::Npm => vec!["npm", "install", "-g", "dev-prune@latest"],
539 Channel::UvTool => vec!["uv", "tool", "upgrade", "dev-prune"],
540 Channel::Pipx => vec!["pipx", "upgrade", "dev-prune"],
541 Channel::Pip => vec!["pip", "install", "--upgrade", "dev-prune"],
542 Channel::WinGet => vec![
547 "winget",
548 "upgrade",
549 "--id",
550 winget_id,
551 "--accept-package-agreements",
552 "--accept-source-agreements",
553 ],
554 Channel::Scoop => vec!["scoop", "update", "dev-prune"],
555 Channel::Homebrew => vec!["brew", "upgrade", "dev-prune"],
556 Channel::Installer => {
557 if cfg!(windows) {
558 vec!["powershell", "-NoProfile", "-Command", &install_ps1]
559 } else {
560 vec!["sh", "-c", &install_sh]
561 }
562 }
563 Channel::Unknown => {
564 output::print_warning(
565 "Could not tell which channel installed this binary, so nothing was \
566 changed. Upgrade it yourself with one of:",
567 );
568 print_upgrade_commands();
569 anyhow::bail!("unrecognised install channel");
570 }
571 };
572
573 output::print_info(&format!("Running: {}", argv.join(" ")));
574 let status = crate::spawn::command(crate::adapters::resolve_program(argv[0]))
575 .args(&argv[1..])
576 .status()
577 .with_context(|| format!("could not start `{}`", argv[0]))?;
578 if !status.success() {
579 anyhow::bail!("`{}` exited with {status}", argv.join(" "));
580 }
581 Ok(())
582}
583
584pub fn maybe_auto_update(registry: &Registry) {
597 if !registry.settings.auto_update
598 || crate::setup::offline_requested()
599 || crate::setup::no_auto_setup_requested()
600 {
601 return;
602 }
603 let Some(latest) = registry.latest_known_version.as_deref() else {
604 return;
605 };
606 if compare_versions(constants::VERSION, latest) != Some(Ordering::Less) {
607 return;
608 }
609
610 if registry.settings.version_lock {
614 println!();
615 output::print_info(&locked_notice(Some(latest)));
616 return;
617 }
618
619 let Ok(exe) = std::env::current_exe() else {
620 return;
621 };
622 let managed = crate::setup::managed_exe_path().ok();
623 let channel = Channel::detect_at(&exe, managed.as_deref());
624
625 if channel.replaces_its_directory() {
630 return;
631 }
632
633 println!();
634 output::print_info(&format!(
635 "Updating dev-prune v{} -> v{latest} …",
636 constants::VERSION
637 ));
638 match install_directly(latest, &exe, managed.as_deref(), channel) {
639 Ok(()) => {
640 output::print_success(&format!("dev-prune v{latest} installed."));
641 report_channel_bookkeeping(channel);
642 }
643 Err(e) => output::print_warning(&format!(
644 "Automatic update failed ({e:#}). Run `devp update --install` yourself, or \
645 `devp config set auto_update false` to stop trying."
646 )),
647 }
648}
649
650pub fn notify_if_outdated(registry: &mut Registry) -> bool {
656 if !registry.settings.update_check {
657 return false;
658 }
659
660 let interval = registry.settings.update_check_interval_days;
661 let due = registry
662 .last_update_check
663 .is_none_or(|last| Utc::now().signed_duration_since(last).num_days() >= interval);
664
665 if due {
666 let _ = refresh_latest(registry);
670 }
671
672 if let Some(latest) = registry.latest_known_version.as_deref()
673 && compare_versions(constants::VERSION, latest) == Some(Ordering::Less)
674 {
675 if registry.settings.version_lock {
676 output::print_info(&locked_notice(Some(latest)));
677 } else {
678 output::print_info(&format!(
679 "dev-prune v{latest} is out (you have v{}). `devp update` has the commands; \
680 `devp config set update_check false` silences this.",
681 constants::VERSION
682 ));
683 }
684 }
685
686 due
687}
688
689fn refresh_latest(registry: &mut Registry) -> Result<String> {
694 let result = latest_release(registry.settings.update_check_timeout_secs);
695 registry.last_update_check = Some(Utc::now());
696 let latest = result?;
697 registry.latest_known_version = Some(latest.clone());
698 Ok(latest)
699}
700
701fn report_comparison(latest: &str) {
703 let installed = constants::VERSION;
704 match compare_versions(installed, latest) {
705 Some(Ordering::Less) => {
706 output::print_warning(&format!(
707 "Latest release: v{latest} — an upgrade is available."
708 ));
709 }
710 Some(Ordering::Equal) => {
711 output::print_success(&format!(
712 "Latest release: v{latest} — you are up to date."
713 ));
714 }
715 Some(Ordering::Greater) => {
716 output::print_info(&format!(
718 "Latest release: v{latest} — your build is newer than the last published one."
719 ));
720 }
721 None => {
722 output::print_info(&format!(
723 "Latest release: v{latest} (could not compare it to v{installed})."
724 ));
725 }
726 }
727}
728
729fn latest_release(timeout_secs: u64) -> Result<String> {
734 if crate::setup::offline_requested() {
735 anyhow::bail!("{} is set", constants::ENV_OFFLINE);
736 }
737 let body = ureq::get(constants::LATEST_RELEASE_API_URL)
738 .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
739 .header("Accept", "application/vnd.github+json")
740 .config()
741 .timeout_global(Some(Duration::from_secs(timeout_secs.max(1))))
742 .build()
743 .call()
744 .context("request failed")?
745 .body_mut()
746 .read_to_string()
747 .context("could not read the response")?;
748
749 let json: serde_json::Value =
750 serde_json::from_str(&body).context("the response was not JSON")?;
751 let tag = json
752 .get("tag_name")
753 .and_then(|v| v.as_str())
754 .context("the response carried no tag_name")?;
755
756 Ok(tag.trim_start_matches('v').to_string())
757}
758
759pub(crate) fn compare_versions(a: &str, b: &str) -> Option<Ordering> {
765 let parse = |v: &str| -> Option<[u64; 3]> {
766 let core = v.split(['-', '+']).next()?;
767 let mut parts = core.split('.');
768 let out = [
769 parts.next()?.parse().ok()?,
770 parts.next()?.parse().ok()?,
771 parts.next()?.parse().ok()?,
772 ];
773 if parts.next().is_some() {
775 return None;
776 }
777 Some(out)
778 };
779 Some(parse(a)?.cmp(&parse(b)?))
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785 use chrono::Duration as ChronoDuration;
786
787 #[test]
788 fn orders_by_component_not_lexically() {
789 assert_eq!(compare_versions("1.9.0", "1.10.0"), Some(Ordering::Less));
791 assert_eq!(compare_versions("1.0.0", "1.0.0"), Some(Ordering::Equal));
792 assert_eq!(
793 compare_versions("2.0.0", "1.99.99"),
794 Some(Ordering::Greater)
795 );
796 }
797
798 #[test]
799 fn pre_release_suffixes_compare_by_their_core() {
800 assert_eq!(
801 compare_versions("1.0.0", "1.0.0-rc.1"),
802 Some(Ordering::Equal)
803 );
804 assert_eq!(
805 compare_versions("1.0.0+build7", "1.0.1"),
806 Some(Ordering::Less)
807 );
808 }
809
810 #[test]
811 fn unparseable_versions_report_no_answer_rather_than_a_wrong_one() {
812 assert_eq!(compare_versions("1.0", "1.0.0"), None);
813 assert_eq!(compare_versions("1.0.0.1", "1.0.0"), None);
814 assert_eq!(compare_versions("nightly", "1.0.0"), None);
815 }
816
817 #[test]
818 fn the_check_is_on_unless_the_user_turns_it_off() {
819 assert!(Registry::default().settings.update_check);
820 }
821
822 #[test]
823 fn a_disabled_check_touches_neither_the_network_nor_the_registry() {
824 let mut registry = Registry::default();
825 registry.settings.update_check = false;
826 assert!(!notify_if_outdated(&mut registry));
827 assert!(registry.last_update_check.is_none());
828 }
829
830 #[test]
831 fn auto_update_is_on_by_default_and_silent_with_nothing_to_install() {
832 let registry = Registry::default();
833 assert!(registry.settings.auto_update);
834 assert!(registry.latest_known_version.is_none());
838 maybe_auto_update(®istry);
839 }
840
841 #[test]
842 fn the_pin_is_off_until_somebody_asks_for_it() {
843 assert!(!Registry::default().settings.version_lock);
846 }
847
848 #[test]
849 fn the_refusal_names_the_version_it_is_holding_and_the_way_out() {
850 let notice = locked_notice(None);
854 assert!(notice.contains(constants::VERSION), "{notice}");
855 assert!(
856 notice.contains("devp config set version_lock false"),
857 "{notice}"
858 );
859 assert!(!notice.contains("is out"), "{notice}");
860 }
861
862 #[test]
863 fn a_known_release_is_named_in_the_refusal_that_withholds_it() {
864 let notice = locked_notice(Some("2.0.0"));
868 assert!(notice.contains("v2.0.0 is out"), "{notice}");
869 assert!(notice.contains(constants::VERSION), "{notice}");
870 }
871
872 #[test]
873 fn a_recent_check_is_not_repeated() {
874 let mut registry = Registry::default();
875 let stamp = Utc::now() - ChronoDuration::days(constants::UPDATE_CHECK_INTERVAL_DAYS - 1);
876 registry.last_update_check = Some(stamp);
877 assert!(!notify_if_outdated(&mut registry));
879 assert_eq!(registry.last_update_check, Some(stamp));
880 }
881
882 #[test]
883 fn the_asset_name_matches_what_the_release_workflow_builds() {
884 let name = constants::release_asset_name("1.4.0");
888 let expected = match (std::env::consts::OS, std::env::consts::ARCH) {
889 ("windows", "x86_64") => Some("dev-prune-v1.4.0-windows-x64.exe"),
890 ("windows", "aarch64") => Some("dev-prune-v1.4.0-windows-arm64.exe"),
891 ("windows", "x86") => Some("dev-prune-v1.4.0-windows-x86.exe"),
892 ("linux", "x86_64") => Some("dev-prune-v1.4.0-linux-x64"),
893 ("linux", "aarch64") => Some("dev-prune-v1.4.0-linux-arm64"),
894 ("macos", "x86_64") => Some("dev-prune-v1.4.0-darwin-x64"),
895 ("macos", "aarch64") => Some("dev-prune-v1.4.0-darwin-arm64"),
896 _ => None,
899 };
900 assert_eq!(name.as_deref(), expected);
901 }
902
903 #[test]
904 fn only_windows_has_a_32_bit_asset() {
905 let name = constants::release_asset_name("9.9.9");
908 if std::env::consts::ARCH == "x86" {
909 assert_eq!(name.is_some(), std::env::consts::OS == "windows");
910 }
911 }
912
913 #[test]
914 fn a_sidecar_is_read_as_the_first_field_of_sha256sum_format() {
915 let digest = "a".repeat(64);
916 assert_eq!(
917 parse_sha256_sidecar(&format!("{digest} dev-prune-v1.4.0-linux-x64\n")).unwrap(),
918 digest
919 );
920 assert_eq!(
923 parse_sha256_sidecar(&format!("{digest} asset.exe")).unwrap(),
924 digest
925 );
926 assert_eq!(
927 parse_sha256_sidecar(&format!("{} asset\r\n", digest.to_uppercase())).unwrap(),
928 digest,
929 "an upper-case digest must compare equal to the one we compute"
930 );
931 }
932
933 #[test]
934 fn anything_that_is_not_a_digest_is_refused_before_it_is_compared() {
935 for bad in [
938 "",
939 " ",
940 "<!DOCTYPE html>",
941 "not-a-hash asset",
942 &"a".repeat(63),
943 &"a".repeat(65),
944 &format!("{}g asset", "a".repeat(63)),
945 ] {
946 assert!(
947 parse_sha256_sidecar(bad).is_err(),
948 "{bad:?} must not be accepted as a digest"
949 );
950 }
951 }
952}