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
333 crate::receipt::refresh_after_upgrade(latest);
338 Ok(())
339}
340
341fn report_channel_bookkeeping(channel: Channel) {
344 let Some(resync) = channel
347 .owns_its_files()
348 .then(|| channel.upgrade_command())
349 .flatten()
350 else {
351 return;
352 };
353 if channel.replaces_its_directory() {
354 output::print_info(&format!(
355 "The managed copy is now v{}. The copy {} installed was left exactly as it \
356 wrote it — replacing a file inside a versioned package directory only makes \
357 the manager and the disk disagree. Run `{resync}` to move that one forward \
358 too.",
359 constants::VERSION,
360 channel.label()
361 ));
362 } else {
363 output::print_info(&format!(
364 "The binaries are up to date. `{resync}` also updates that manager's own \
365 record of the version, which still reads v{}.",
366 constants::VERSION
367 ));
368 }
369}
370
371fn fetch_release_binary(version: &str) -> Result<Vec<u8>> {
384 let asset = constants::release_asset_name(version).with_context(|| {
385 format!(
386 "no published binary for {}-{}; upgrade through the channel that installed \
387 this copy instead",
388 std::env::consts::OS,
389 std::env::consts::ARCH
390 )
391 })?;
392 let base = format!("{}/v{version}/{asset}", constants::RELEASE_DOWNLOAD_BASE);
393
394 let expected = fetch_expected_hash(&format!("{base}.sha256"))?;
395 output::print_info(&format!("Downloading {asset} …"));
396 let bytes = fetch_bytes(&base)?;
397
398 let actual = {
399 use sha2::{Digest, Sha256};
400 use std::fmt::Write as _;
401 let mut h = Sha256::new();
402 h.update(&bytes);
403 h.finalize().iter().fold(String::new(), |mut s, b| {
406 let _ = write!(s, "{b:02x}");
407 s
408 })
409 };
410 if actual != expected {
411 anyhow::bail!(
412 "checksum mismatch for {asset}\n expected {expected}\n got {actual}\n\
413 The download was corrupted or tampered with; nothing was installed."
414 );
415 }
416
417 Ok(bytes)
418}
419
420fn install_bytes_at(bytes: &[u8], target: &Path) -> Result<()> {
426 let staging = target.with_extension("new");
430 if let Some(parent) = target.parent() {
431 fs::create_dir_all(parent).ok();
432 }
433 fs::write(&staging, bytes).with_context(|| format!("could not write {}", staging.display()))?;
434
435 #[cfg(unix)]
436 {
437 use std::os::unix::fs::PermissionsExt;
438 let _ = fs::set_permissions(&staging, fs::Permissions::from_mode(0o755));
440 }
441
442 replace_binary(&staging, target)
443}
444
445fn fetch_expected_hash(url: &str) -> Result<String> {
447 let body = String::from_utf8(fetch_bytes(url)?).context("the checksum sidecar was not text")?;
448 parse_sha256_sidecar(&body)
449}
450
451fn parse_sha256_sidecar(body: &str) -> Result<String> {
460 let hash = body
461 .split_whitespace()
462 .next()
463 .context("the checksum sidecar was empty")?
464 .to_ascii_lowercase();
465 if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
466 anyhow::bail!("the checksum sidecar did not contain a SHA-256 digest");
467 }
468 Ok(hash)
469}
470
471fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
472 let mut body = ureq::get(url)
473 .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
474 .config()
475 .timeout_global(Some(Duration::from_secs(
476 constants::UPDATE_DOWNLOAD_TIMEOUT_SECS,
477 )))
478 .build()
479 .call()
480 .with_context(|| format!("could not download {url}"))?;
481 let mut buf = Vec::new();
482 body.body_mut()
483 .as_reader()
484 .read_to_end(&mut buf)
485 .with_context(|| format!("could not read {url}"))?;
486 Ok(buf)
487}
488
489fn replace_binary(staged: &Path, target: &Path) -> Result<()> {
492 #[cfg(windows)]
496 let aside = {
497 let aside = target.with_extension("exe.old");
498 let _ = fs::remove_file(&aside);
499 target
500 .exists()
501 .then(|| fs::rename(target, &aside).ok().map(|_| aside))
502 .flatten()
503 };
504
505 match fs::rename(staged, target) {
506 Ok(()) => {
507 #[cfg(windows)]
508 if let Some(aside) = aside {
509 let _ = fs::remove_file(&aside);
510 }
511 Ok(())
512 }
513 Err(e) => {
514 let _ = fs::remove_file(staged);
515 #[cfg(windows)]
516 if let Some(aside) = aside
517 && !target.exists()
518 {
519 let _ = fs::rename(&aside, target);
522 }
523 Err(e).with_context(|| format!("could not install {}", target.display()))
524 }
525 }
526}
527
528fn spawn_channel_upgrade(channel: Channel) -> Result<()> {
531 let install_ps1 = format!("iwr -useb {} | iex", constants::INSTALL_PS1_URL);
532 let install_sh = format!("curl -fsSL {} | sh", constants::INSTALL_SH_URL);
533 let winget_id = constants::WINGET_PACKAGE_ID;
534 let argv: Vec<&str> = match channel {
535 Channel::Cargo => {
536 if crate::adapters::binary_available("cargo-binstall") {
539 vec!["cargo", "binstall", "dev-prune", "--force", "-y"]
540 } else {
541 vec!["cargo", "install", "dev-prune", "--force"]
542 }
543 }
544 Channel::Npm => vec!["npm", "install", "-g", "dev-prune@latest"],
545 Channel::UvTool => vec!["uv", "tool", "upgrade", "dev-prune"],
546 Channel::Pipx => vec!["pipx", "upgrade", "dev-prune"],
547 Channel::Pip => vec!["pip", "install", "--upgrade", "dev-prune"],
548 Channel::WinGet => vec![
553 "winget",
554 "upgrade",
555 "--id",
556 winget_id,
557 "--accept-package-agreements",
558 "--accept-source-agreements",
559 ],
560 Channel::Scoop => vec!["scoop", "update", "dev-prune"],
561 Channel::Homebrew => vec!["brew", "upgrade", "dev-prune"],
562 Channel::Installer => {
563 if cfg!(windows) {
564 vec!["powershell", "-NoProfile", "-Command", &install_ps1]
565 } else {
566 vec!["sh", "-c", &install_sh]
567 }
568 }
569 Channel::Unknown => {
570 output::print_warning(
571 "Could not tell which channel installed this binary, so nothing was \
572 changed. Upgrade it yourself with one of:",
573 );
574 print_upgrade_commands();
575 anyhow::bail!("unrecognised install channel");
576 }
577 };
578
579 output::print_info(&format!("Running: {}", argv.join(" ")));
580 let status = crate::spawn::command(crate::adapters::resolve_program(argv[0]))
581 .args(&argv[1..])
582 .status()
583 .with_context(|| format!("could not start `{}`", argv[0]))?;
584 if !status.success() {
585 anyhow::bail!("`{}` exited with {status}", argv.join(" "));
586 }
587 Ok(())
588}
589
590pub fn maybe_auto_update(registry: &Registry) {
603 if !registry.settings.auto_update
604 || crate::setup::offline_requested()
605 || crate::setup::no_auto_setup_requested()
606 {
607 return;
608 }
609 let Some(latest) = registry.latest_known_version.as_deref() else {
610 return;
611 };
612 if compare_versions(constants::VERSION, latest) != Some(Ordering::Less) {
613 return;
614 }
615
616 if registry.settings.version_lock {
620 println!();
621 output::print_info(&locked_notice(Some(latest)));
622 return;
623 }
624
625 let Ok(exe) = std::env::current_exe() else {
626 return;
627 };
628 let managed = crate::setup::managed_exe_path().ok();
629 let channel = Channel::detect_at(&exe, managed.as_deref());
630
631 if channel.replaces_its_directory() {
636 return;
637 }
638
639 println!();
640 output::print_info(&format!(
641 "Updating dev-prune v{} -> v{latest} …",
642 constants::VERSION
643 ));
644 match install_directly(latest, &exe, managed.as_deref(), channel) {
645 Ok(()) => {
646 output::print_success(&format!("dev-prune v{latest} installed."));
647 report_channel_bookkeeping(channel);
648 }
649 Err(e) => output::print_warning(&format!(
650 "Automatic update failed ({e:#}). Run `devp update --install` yourself, or \
651 `devp config set auto_update false` to stop trying."
652 )),
653 }
654}
655
656pub fn notify_if_outdated(registry: &mut Registry) -> bool {
662 if !registry.settings.update_check {
663 return false;
664 }
665
666 let interval = registry.settings.update_check_interval_days;
667 let due = registry
668 .last_update_check
669 .is_none_or(|last| Utc::now().signed_duration_since(last).num_days() >= interval);
670
671 if due {
672 let _ = refresh_latest(registry);
676 }
677
678 if let Some(latest) = registry.latest_known_version.as_deref()
679 && compare_versions(constants::VERSION, latest) == Some(Ordering::Less)
680 {
681 if registry.settings.version_lock {
682 output::print_info(&locked_notice(Some(latest)));
683 } else {
684 output::print_info(&format!(
685 "dev-prune v{latest} is out (you have v{}). `devp update` has the commands; \
686 `devp config set update_check false` silences this.",
687 constants::VERSION
688 ));
689 }
690 }
691
692 due
693}
694
695fn refresh_latest(registry: &mut Registry) -> Result<String> {
700 let result = latest_release(registry.settings.update_check_timeout_secs);
701 registry.last_update_check = Some(Utc::now());
702 let latest = result?;
703 registry.latest_known_version = Some(latest.clone());
704 Ok(latest)
705}
706
707fn report_comparison(latest: &str) {
709 let installed = constants::VERSION;
710 match compare_versions(installed, latest) {
711 Some(Ordering::Less) => {
712 output::print_warning(&format!(
713 "Latest release: v{latest} — an upgrade is available."
714 ));
715 }
716 Some(Ordering::Equal) => {
717 output::print_success(&format!(
718 "Latest release: v{latest} — you are up to date."
719 ));
720 }
721 Some(Ordering::Greater) => {
722 output::print_info(&format!(
724 "Latest release: v{latest} — your build is newer than the last published one."
725 ));
726 }
727 None => {
728 output::print_info(&format!(
729 "Latest release: v{latest} (could not compare it to v{installed})."
730 ));
731 }
732 }
733}
734
735fn latest_release(timeout_secs: u64) -> Result<String> {
740 if crate::setup::offline_requested() {
741 anyhow::bail!("{} is set", constants::ENV_OFFLINE);
742 }
743 let body = ureq::get(constants::LATEST_RELEASE_API_URL)
744 .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
745 .header("Accept", "application/vnd.github+json")
746 .config()
747 .timeout_global(Some(Duration::from_secs(timeout_secs.max(1))))
748 .build()
749 .call()
750 .context("request failed")?
751 .body_mut()
752 .read_to_string()
753 .context("could not read the response")?;
754
755 let json: serde_json::Value =
756 serde_json::from_str(&body).context("the response was not JSON")?;
757 let tag = json
758 .get("tag_name")
759 .and_then(|v| v.as_str())
760 .context("the response carried no tag_name")?;
761
762 Ok(tag.trim_start_matches('v').to_string())
763}
764
765pub(crate) fn compare_versions(a: &str, b: &str) -> Option<Ordering> {
771 let parse = |v: &str| -> Option<[u64; 3]> {
772 let core = v.split(['-', '+']).next()?;
773 let mut parts = core.split('.');
774 let out = [
775 parts.next()?.parse().ok()?,
776 parts.next()?.parse().ok()?,
777 parts.next()?.parse().ok()?,
778 ];
779 if parts.next().is_some() {
781 return None;
782 }
783 Some(out)
784 };
785 Some(parse(a)?.cmp(&parse(b)?))
786}
787
788#[cfg(test)]
789mod tests {
790 use super::*;
791 use chrono::Duration as ChronoDuration;
792
793 #[test]
794 fn orders_by_component_not_lexically() {
795 assert_eq!(compare_versions("1.9.0", "1.10.0"), Some(Ordering::Less));
797 assert_eq!(compare_versions("1.0.0", "1.0.0"), Some(Ordering::Equal));
798 assert_eq!(
799 compare_versions("2.0.0", "1.99.99"),
800 Some(Ordering::Greater)
801 );
802 }
803
804 #[test]
805 fn pre_release_suffixes_compare_by_their_core() {
806 assert_eq!(
807 compare_versions("1.0.0", "1.0.0-rc.1"),
808 Some(Ordering::Equal)
809 );
810 assert_eq!(
811 compare_versions("1.0.0+build7", "1.0.1"),
812 Some(Ordering::Less)
813 );
814 }
815
816 #[test]
817 fn unparseable_versions_report_no_answer_rather_than_a_wrong_one() {
818 assert_eq!(compare_versions("1.0", "1.0.0"), None);
819 assert_eq!(compare_versions("1.0.0.1", "1.0.0"), None);
820 assert_eq!(compare_versions("nightly", "1.0.0"), None);
821 }
822
823 #[test]
824 fn the_check_is_on_unless_the_user_turns_it_off() {
825 assert!(Registry::default().settings.update_check);
826 }
827
828 #[test]
829 fn a_disabled_check_touches_neither_the_network_nor_the_registry() {
830 let mut registry = Registry::default();
831 registry.settings.update_check = false;
832 assert!(!notify_if_outdated(&mut registry));
833 assert!(registry.last_update_check.is_none());
834 }
835
836 #[test]
837 fn auto_update_is_on_by_default_and_silent_with_nothing_to_install() {
838 let registry = Registry::default();
839 assert!(registry.settings.auto_update);
840 assert!(registry.latest_known_version.is_none());
844 maybe_auto_update(®istry);
845 }
846
847 #[test]
848 fn the_pin_is_off_until_somebody_asks_for_it() {
849 assert!(!Registry::default().settings.version_lock);
852 }
853
854 #[test]
855 fn the_refusal_names_the_version_it_is_holding_and_the_way_out() {
856 let notice = locked_notice(None);
860 assert!(notice.contains(constants::VERSION), "{notice}");
861 assert!(
862 notice.contains("devp config set version_lock false"),
863 "{notice}"
864 );
865 assert!(!notice.contains("is out"), "{notice}");
866 }
867
868 #[test]
869 fn a_known_release_is_named_in_the_refusal_that_withholds_it() {
870 let notice = locked_notice(Some("2.0.0"));
874 assert!(notice.contains("v2.0.0 is out"), "{notice}");
875 assert!(notice.contains(constants::VERSION), "{notice}");
876 }
877
878 #[test]
879 fn a_recent_check_is_not_repeated() {
880 let mut registry = Registry::default();
881 let stamp = Utc::now() - ChronoDuration::days(constants::UPDATE_CHECK_INTERVAL_DAYS - 1);
882 registry.last_update_check = Some(stamp);
883 assert!(!notify_if_outdated(&mut registry));
885 assert_eq!(registry.last_update_check, Some(stamp));
886 }
887
888 #[test]
889 fn the_asset_name_matches_what_the_release_workflow_builds() {
890 let name = constants::release_asset_name("1.4.0");
894 let expected = match (std::env::consts::OS, std::env::consts::ARCH) {
895 ("windows", "x86_64") => Some("dev-prune-v1.4.0-windows-x64.exe"),
896 ("windows", "aarch64") => Some("dev-prune-v1.4.0-windows-arm64.exe"),
897 ("windows", "x86") => Some("dev-prune-v1.4.0-windows-x86.exe"),
898 ("linux", "x86_64") => Some("dev-prune-v1.4.0-linux-x64"),
899 ("linux", "aarch64") => Some("dev-prune-v1.4.0-linux-arm64"),
900 ("macos", "x86_64") => Some("dev-prune-v1.4.0-darwin-x64"),
901 ("macos", "aarch64") => Some("dev-prune-v1.4.0-darwin-arm64"),
902 _ => None,
905 };
906 assert_eq!(name.as_deref(), expected);
907 }
908
909 #[test]
910 fn only_windows_has_a_32_bit_asset() {
911 let name = constants::release_asset_name("9.9.9");
914 if std::env::consts::ARCH == "x86" {
915 assert_eq!(name.is_some(), std::env::consts::OS == "windows");
916 }
917 }
918
919 #[test]
920 fn a_sidecar_is_read_as_the_first_field_of_sha256sum_format() {
921 let digest = "a".repeat(64);
922 assert_eq!(
923 parse_sha256_sidecar(&format!("{digest} dev-prune-v1.4.0-linux-x64\n")).unwrap(),
924 digest
925 );
926 assert_eq!(
929 parse_sha256_sidecar(&format!("{digest} asset.exe")).unwrap(),
930 digest
931 );
932 assert_eq!(
933 parse_sha256_sidecar(&format!("{} asset\r\n", digest.to_uppercase())).unwrap(),
934 digest,
935 "an upper-case digest must compare equal to the one we compute"
936 );
937 }
938
939 #[test]
940 fn anything_that_is_not_a_digest_is_refused_before_it_is_compared() {
941 for bad in [
944 "",
945 " ",
946 "<!DOCTYPE html>",
947 "not-a-hash asset",
948 &"a".repeat(63),
949 &"a".repeat(65),
950 &format!("{}g asset", "a".repeat(63)),
951 ] {
952 assert!(
953 parse_sha256_sidecar(bad).is_err(),
954 "{bad:?} must not be accepted as a digest"
955 );
956 }
957 }
958}