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() {
106 println!(" Upgrade with whichever channel you installed from:");
107 println!(" cargo binstall dev-prune --force");
108 println!(" cargo install dev-prune --force");
109 println!(" npm install -g dev-prune@latest");
110 println!(" uv tool upgrade dev-prune / pipx upgrade dev-prune");
111 println!(" winget upgrade --id {}", constants::WINGET_PACKAGE_ID);
112 println!(" scoop update dev-prune / brew upgrade dev-prune");
113 println!(" curl -fsSL {} | sh", constants::INSTALL_SH_URL);
114 println!(" iwr -useb {} | iex", constants::INSTALL_PS1_URL);
115}
116
117fn run_install() -> Result<()> {
137 output::print_header("dev-prune self-update");
138
139 if crate::setup::offline_requested() {
140 anyhow::bail!(
141 "{} is set — an install needs the network by definition.",
142 constants::ENV_OFFLINE
143 );
144 }
145
146 let mut registry = Registry::load()?;
150 let latest = refresh_latest(&mut registry)?;
151 let _ = registry.save();
152 if compare_versions(constants::VERSION, &latest) != Some(Ordering::Less) {
153 output::print_success(&format!(
154 "v{} is already the latest release — nothing to install.",
155 constants::VERSION
156 ));
157 return Ok(());
158 }
159 output::print_info(&format!("Upgrading v{} -> v{latest} …", constants::VERSION));
160
161 let exe = std::env::current_exe().context("could not locate the running binary")?;
162 let managed = crate::setup::managed_exe_path().ok();
163 let channel = Channel::detect_at(&exe, managed.as_deref());
164
165 match install_directly(&latest, &exe, managed.as_deref(), channel) {
166 Ok(()) => {
167 output::print_success(&format!("dev-prune v{latest} installed."));
168 report_channel_bookkeeping(channel);
169 output::print_info(
170 "The scheduled pass was not interrupted: it runs the managed copy, which \
171 was replaced by atomic rename, so a pass already in flight keeps the \
172 image it loaded and the next one picks up the new binary.",
173 );
174 return Ok(());
175 }
176 Err(e) => output::print_warning(&format!(
177 "Direct download did not work ({e:#}).\nFalling back to the channel that \
178 installed this copy."
179 )),
180 }
181
182 #[cfg(windows)]
187 let aside = {
188 let aside = exe.with_extension("exe.old");
189 let _ = fs::remove_file(&aside);
190 fs::rename(&exe, &aside).ok().map(|_| aside)
191 };
192
193 let result = spawn_channel_upgrade(channel);
194
195 #[cfg(windows)]
196 if let Some(aside) = aside {
197 if result.is_ok() {
198 let _ = fs::remove_file(&aside);
201 } else if !exe.exists() {
202 let _ = fs::rename(&aside, &exe);
205 }
206 }
207 result?;
208
209 output::print_success(&format!("dev-prune v{latest} installed."));
210 output::print_info(
211 "The scheduled pass was not interrupted: it runs the managed copy, which \
212 refreshes itself from the new binary on its next run.",
213 );
214 Ok(())
215}
216
217fn install_directly(
230 latest: &str,
231 exe: &Path,
232 managed: Option<&Path>,
233 channel: Channel,
234) -> Result<()> {
235 let bytes = fetch_release_binary(latest)?;
236 let primary = managed.unwrap_or(exe);
237 install_bytes_at(&bytes, primary)?;
238
239 let mut also: Vec<PathBuf> = Vec::new();
243 if let Some(dir) = primary.parent() {
244 also.push(dir.join(if cfg!(windows) { "devp.exe" } else { "devp" }));
245 }
246 if primary != exe && exe.is_file() && !channel.replaces_its_directory() {
253 also.push(exe.to_path_buf());
254 }
255 for path in also {
256 if path == primary {
257 continue;
258 }
259 if let Err(e) = install_bytes_at(&bytes, &path) {
260 output::print_warning(&format!(
261 "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.",
262 path.display()
263 ));
264 }
265 }
266
267 crate::daemon::refresh_hidden_twin();
270 Ok(())
271}
272
273fn report_channel_bookkeeping(channel: Channel) {
276 let Some(resync) = channel
279 .owns_its_files()
280 .then(|| channel.upgrade_command())
281 .flatten()
282 else {
283 return;
284 };
285 if channel.replaces_its_directory() {
286 output::print_info(&format!(
287 "The managed copy is now v{}. The copy {} installed was left exactly as it \
288 wrote it — replacing a file inside a versioned package directory only makes \
289 the manager and the disk disagree. Run `{resync}` to move that one forward \
290 too.",
291 constants::VERSION,
292 channel.label()
293 ));
294 } else {
295 output::print_info(&format!(
296 "The binaries are up to date. `{resync}` also updates that manager's own \
297 record of the version, which still reads v{}.",
298 constants::VERSION
299 ));
300 }
301}
302
303fn fetch_release_binary(version: &str) -> Result<Vec<u8>> {
316 let asset = constants::release_asset_name(version).with_context(|| {
317 format!(
318 "no published binary for {}-{}; upgrade through the channel that installed \
319 this copy instead",
320 std::env::consts::OS,
321 std::env::consts::ARCH
322 )
323 })?;
324 let base = format!("{}/v{version}/{asset}", constants::RELEASE_DOWNLOAD_BASE);
325
326 let expected = fetch_expected_hash(&format!("{base}.sha256"))?;
327 output::print_info(&format!("Downloading {asset} …"));
328 let bytes = fetch_bytes(&base)?;
329
330 let actual = {
331 use sha2::{Digest, Sha256};
332 use std::fmt::Write as _;
333 let mut h = Sha256::new();
334 h.update(&bytes);
335 h.finalize().iter().fold(String::new(), |mut s, b| {
338 let _ = write!(s, "{b:02x}");
339 s
340 })
341 };
342 if actual != expected {
343 anyhow::bail!(
344 "checksum mismatch for {asset}\n expected {expected}\n got {actual}\n\
345 The download was corrupted or tampered with; nothing was installed."
346 );
347 }
348
349 Ok(bytes)
350}
351
352fn install_bytes_at(bytes: &[u8], target: &Path) -> Result<()> {
358 let staging = target.with_extension("new");
362 if let Some(parent) = target.parent() {
363 fs::create_dir_all(parent).ok();
364 }
365 fs::write(&staging, bytes).with_context(|| format!("could not write {}", staging.display()))?;
366
367 #[cfg(unix)]
368 {
369 use std::os::unix::fs::PermissionsExt;
370 let _ = fs::set_permissions(&staging, fs::Permissions::from_mode(0o755));
372 }
373
374 replace_binary(&staging, target)
375}
376
377fn fetch_expected_hash(url: &str) -> Result<String> {
379 let body = String::from_utf8(fetch_bytes(url)?).context("the checksum sidecar was not text")?;
380 parse_sha256_sidecar(&body)
381}
382
383fn parse_sha256_sidecar(body: &str) -> Result<String> {
392 let hash = body
393 .split_whitespace()
394 .next()
395 .context("the checksum sidecar was empty")?
396 .to_ascii_lowercase();
397 if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
398 anyhow::bail!("the checksum sidecar did not contain a SHA-256 digest");
399 }
400 Ok(hash)
401}
402
403fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
404 let mut body = ureq::get(url)
405 .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
406 .config()
407 .timeout_global(Some(Duration::from_secs(
408 constants::UPDATE_DOWNLOAD_TIMEOUT_SECS,
409 )))
410 .build()
411 .call()
412 .with_context(|| format!("could not download {url}"))?;
413 let mut buf = Vec::new();
414 body.body_mut()
415 .as_reader()
416 .read_to_end(&mut buf)
417 .with_context(|| format!("could not read {url}"))?;
418 Ok(buf)
419}
420
421fn replace_binary(staged: &Path, target: &Path) -> Result<()> {
424 #[cfg(windows)]
428 let aside = {
429 let aside = target.with_extension("exe.old");
430 let _ = fs::remove_file(&aside);
431 target
432 .exists()
433 .then(|| fs::rename(target, &aside).ok().map(|_| aside))
434 .flatten()
435 };
436
437 match fs::rename(staged, target) {
438 Ok(()) => {
439 #[cfg(windows)]
440 if let Some(aside) = aside {
441 let _ = fs::remove_file(&aside);
442 }
443 Ok(())
444 }
445 Err(e) => {
446 let _ = fs::remove_file(staged);
447 #[cfg(windows)]
448 if let Some(aside) = aside
449 && !target.exists()
450 {
451 let _ = fs::rename(&aside, target);
454 }
455 Err(e).with_context(|| format!("could not install {}", target.display()))
456 }
457 }
458}
459
460fn spawn_channel_upgrade(channel: Channel) -> Result<()> {
463 let install_ps1 = format!("iwr -useb {} | iex", constants::INSTALL_PS1_URL);
464 let install_sh = format!("curl -fsSL {} | sh", constants::INSTALL_SH_URL);
465 let winget_id = constants::WINGET_PACKAGE_ID;
466 let argv: Vec<&str> = match channel {
467 Channel::Cargo => {
468 if crate::adapters::binary_available("cargo-binstall") {
471 vec!["cargo", "binstall", "dev-prune", "--force", "-y"]
472 } else {
473 vec!["cargo", "install", "dev-prune", "--force"]
474 }
475 }
476 Channel::Npm => vec!["npm", "install", "-g", "dev-prune@latest"],
477 Channel::UvTool => vec!["uv", "tool", "upgrade", "dev-prune"],
478 Channel::Pipx => vec!["pipx", "upgrade", "dev-prune"],
479 Channel::Pip => vec!["pip", "install", "--upgrade", "dev-prune"],
480 Channel::WinGet => vec![
485 "winget",
486 "upgrade",
487 "--id",
488 winget_id,
489 "--accept-package-agreements",
490 "--accept-source-agreements",
491 ],
492 Channel::Scoop => vec!["scoop", "update", "dev-prune"],
493 Channel::Homebrew => vec!["brew", "upgrade", "dev-prune"],
494 Channel::Installer => {
495 if cfg!(windows) {
496 vec!["powershell", "-NoProfile", "-Command", &install_ps1]
497 } else {
498 vec!["sh", "-c", &install_sh]
499 }
500 }
501 Channel::Unknown => {
502 output::print_warning(
503 "Could not tell which channel installed this binary, so nothing was \
504 changed. Upgrade it yourself with one of:",
505 );
506 print_upgrade_commands();
507 anyhow::bail!("unrecognised install channel");
508 }
509 };
510
511 output::print_info(&format!("Running: {}", argv.join(" ")));
512 let status = crate::spawn::command(crate::adapters::resolve_program(argv[0]))
513 .args(&argv[1..])
514 .status()
515 .with_context(|| format!("could not start `{}`", argv[0]))?;
516 if !status.success() {
517 anyhow::bail!("`{}` exited with {status}", argv.join(" "));
518 }
519 Ok(())
520}
521
522pub fn maybe_auto_update(registry: &Registry) {
528 if !registry.settings.auto_update
529 || crate::setup::offline_requested()
530 || crate::setup::no_auto_setup_requested()
531 {
532 return;
533 }
534 let Some(latest) = registry.latest_known_version.as_deref() else {
535 return;
536 };
537 if compare_versions(constants::VERSION, latest) != Some(Ordering::Less) {
538 return;
539 }
540 println!();
541 if let Err(e) = run_install() {
542 output::print_warning(&format!(
543 "Automatic update failed ({e}). Run `devp update --install` yourself, or \
544 `devp config set auto_update false` to stop trying."
545 ));
546 }
547}
548
549pub fn notify_if_outdated(registry: &mut Registry) -> bool {
555 if !registry.settings.update_check {
556 return false;
557 }
558
559 let interval = registry.settings.update_check_interval_days;
560 let due = registry
561 .last_update_check
562 .is_none_or(|last| Utc::now().signed_duration_since(last).num_days() >= interval);
563
564 if due {
565 let _ = refresh_latest(registry);
569 }
570
571 if let Some(latest) = registry.latest_known_version.as_deref()
572 && compare_versions(constants::VERSION, latest) == Some(Ordering::Less)
573 {
574 output::print_info(&format!(
575 "dev-prune v{latest} is out (you have v{}). `devp update` has the commands; \
576 `devp config set update_check false` silences this.",
577 constants::VERSION
578 ));
579 }
580
581 due
582}
583
584fn refresh_latest(registry: &mut Registry) -> Result<String> {
589 let result = latest_release(registry.settings.update_check_timeout_secs);
590 registry.last_update_check = Some(Utc::now());
591 let latest = result?;
592 registry.latest_known_version = Some(latest.clone());
593 Ok(latest)
594}
595
596fn report_comparison(latest: &str) {
598 let installed = constants::VERSION;
599 match compare_versions(installed, latest) {
600 Some(Ordering::Less) => {
601 output::print_warning(&format!(
602 "Latest release: v{latest} — an upgrade is available."
603 ));
604 }
605 Some(Ordering::Equal) => {
606 output::print_success(&format!(
607 "Latest release: v{latest} — you are up to date."
608 ));
609 }
610 Some(Ordering::Greater) => {
611 output::print_info(&format!(
613 "Latest release: v{latest} — your build is newer than the last published one."
614 ));
615 }
616 None => {
617 output::print_info(&format!(
618 "Latest release: v{latest} (could not compare it to v{installed})."
619 ));
620 }
621 }
622}
623
624fn latest_release(timeout_secs: u64) -> Result<String> {
629 if crate::setup::offline_requested() {
630 anyhow::bail!("{} is set", constants::ENV_OFFLINE);
631 }
632 let body = ureq::get(constants::LATEST_RELEASE_API_URL)
633 .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
634 .header("Accept", "application/vnd.github+json")
635 .config()
636 .timeout_global(Some(Duration::from_secs(timeout_secs.max(1))))
637 .build()
638 .call()
639 .context("request failed")?
640 .body_mut()
641 .read_to_string()
642 .context("could not read the response")?;
643
644 let json: serde_json::Value =
645 serde_json::from_str(&body).context("the response was not JSON")?;
646 let tag = json
647 .get("tag_name")
648 .and_then(|v| v.as_str())
649 .context("the response carried no tag_name")?;
650
651 Ok(tag.trim_start_matches('v').to_string())
652}
653
654pub(crate) fn compare_versions(a: &str, b: &str) -> Option<Ordering> {
660 let parse = |v: &str| -> Option<[u64; 3]> {
661 let core = v.split(['-', '+']).next()?;
662 let mut parts = core.split('.');
663 let out = [
664 parts.next()?.parse().ok()?,
665 parts.next()?.parse().ok()?,
666 parts.next()?.parse().ok()?,
667 ];
668 if parts.next().is_some() {
670 return None;
671 }
672 Some(out)
673 };
674 Some(parse(a)?.cmp(&parse(b)?))
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680 use chrono::Duration as ChronoDuration;
681
682 #[test]
683 fn orders_by_component_not_lexically() {
684 assert_eq!(compare_versions("1.9.0", "1.10.0"), Some(Ordering::Less));
686 assert_eq!(compare_versions("1.0.0", "1.0.0"), Some(Ordering::Equal));
687 assert_eq!(
688 compare_versions("2.0.0", "1.99.99"),
689 Some(Ordering::Greater)
690 );
691 }
692
693 #[test]
694 fn pre_release_suffixes_compare_by_their_core() {
695 assert_eq!(
696 compare_versions("1.0.0", "1.0.0-rc.1"),
697 Some(Ordering::Equal)
698 );
699 assert_eq!(
700 compare_versions("1.0.0+build7", "1.0.1"),
701 Some(Ordering::Less)
702 );
703 }
704
705 #[test]
706 fn unparseable_versions_report_no_answer_rather_than_a_wrong_one() {
707 assert_eq!(compare_versions("1.0", "1.0.0"), None);
708 assert_eq!(compare_versions("1.0.0.1", "1.0.0"), None);
709 assert_eq!(compare_versions("nightly", "1.0.0"), None);
710 }
711
712 #[test]
713 fn the_check_is_on_unless_the_user_turns_it_off() {
714 assert!(Registry::default().settings.update_check);
715 }
716
717 #[test]
718 fn a_disabled_check_touches_neither_the_network_nor_the_registry() {
719 let mut registry = Registry::default();
720 registry.settings.update_check = false;
721 assert!(!notify_if_outdated(&mut registry));
722 assert!(registry.last_update_check.is_none());
723 }
724
725 #[test]
726 fn auto_update_is_off_by_default_and_silent_when_off() {
727 let registry = Registry::default();
728 assert!(!registry.settings.auto_update);
729 maybe_auto_update(®istry);
731 }
732
733 #[test]
734 fn a_recent_check_is_not_repeated() {
735 let mut registry = Registry::default();
736 let stamp = Utc::now() - ChronoDuration::days(constants::UPDATE_CHECK_INTERVAL_DAYS - 1);
737 registry.last_update_check = Some(stamp);
738 assert!(!notify_if_outdated(&mut registry));
740 assert_eq!(registry.last_update_check, Some(stamp));
741 }
742
743 #[test]
744 fn the_asset_name_matches_what_the_release_workflow_builds() {
745 let name = constants::release_asset_name("1.4.0");
749 let expected = match (std::env::consts::OS, std::env::consts::ARCH) {
750 ("windows", "x86_64") => Some("dev-prune-v1.4.0-windows-x64.exe"),
751 ("windows", "aarch64") => Some("dev-prune-v1.4.0-windows-arm64.exe"),
752 ("windows", "x86") => Some("dev-prune-v1.4.0-windows-x86.exe"),
753 ("linux", "x86_64") => Some("dev-prune-v1.4.0-linux-x64"),
754 ("linux", "aarch64") => Some("dev-prune-v1.4.0-linux-arm64"),
755 ("macos", "x86_64") => Some("dev-prune-v1.4.0-darwin-x64"),
756 ("macos", "aarch64") => Some("dev-prune-v1.4.0-darwin-arm64"),
757 _ => None,
760 };
761 assert_eq!(name.as_deref(), expected);
762 }
763
764 #[test]
765 fn only_windows_has_a_32_bit_asset() {
766 let name = constants::release_asset_name("9.9.9");
769 if std::env::consts::ARCH == "x86" {
770 assert_eq!(name.is_some(), std::env::consts::OS == "windows");
771 }
772 }
773
774 #[test]
775 fn a_sidecar_is_read_as_the_first_field_of_sha256sum_format() {
776 let digest = "a".repeat(64);
777 assert_eq!(
778 parse_sha256_sidecar(&format!("{digest} dev-prune-v1.4.0-linux-x64\n")).unwrap(),
779 digest
780 );
781 assert_eq!(
784 parse_sha256_sidecar(&format!("{digest} asset.exe")).unwrap(),
785 digest
786 );
787 assert_eq!(
788 parse_sha256_sidecar(&format!("{} asset\r\n", digest.to_uppercase())).unwrap(),
789 digest,
790 "an upper-case digest must compare equal to the one we compute"
791 );
792 }
793
794 #[test]
795 fn anything_that_is_not_a_digest_is_refused_before_it_is_compared() {
796 for bad in [
799 "",
800 " ",
801 "<!DOCTYPE html>",
802 "not-a-hash asset",
803 &"a".repeat(63),
804 &"a".repeat(65),
805 &format!("{}g asset", "a".repeat(63)),
806 ] {
807 assert!(
808 parse_sha256_sidecar(bad).is_err(),
809 "{bad:?} must not be accepted as a digest"
810 );
811 }
812 }
813}