From 10166c58a23f65e23e5013dcf95a2d303ddee4bd Mon Sep 17 00:00:00 2001 From: Beast Date: Mon, 22 Jun 2026 11:49:48 +0800 Subject: [PATCH 1/5] feat: add version update feature - Auto detect new version and show on every command - Add update command to do self update --- Cargo.lock | 308 +++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 16 ++- README.md | 28 ++++ src/cli/mod.rs | 18 +++ src/cli/update.rs | 154 ++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 21 +++ src/version_check.rs | 199 ++++++++++++++++++++++++++++ 8 files changed, 731 insertions(+), 14 deletions(-) create mode 100644 src/cli/update.rs create mode 100644 src/version_check.rs diff --git a/Cargo.lock b/Cargo.lock index c882bd6..afce9a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -156,6 +156,15 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "argon2" version = "0.5.3" @@ -1015,15 +1024,14 @@ dependencies = [ [[package]] name = "console" -version = "0.15.11" +version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" dependencies = [ "encode_unicode", "libc", - "once_cell", "unicode-width", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1093,6 +1101,35 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -1127,6 +1164,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -1330,6 +1376,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "1.0.0" @@ -1453,6 +1510,15 @@ dependencies = [ "walkdir", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -1500,6 +1566,7 @@ dependencies = [ "ed25519", "serde", "sha2 0.10.9", + "signature", "subtle", "zeroize", ] @@ -1564,6 +1631,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "enum-ordinalize" version = "4.3.2" @@ -1686,6 +1762,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1713,6 +1799,17 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2471,14 +2568,14 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.17.11" +version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" dependencies = [ "console", - "number_prefix", "portable-atomic", "unicode-width", + "unit-prefix", "web-time", ] @@ -2909,6 +3006,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -2984,6 +3087,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -3122,12 +3226,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "number_prefix" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" - [[package]] name = "object" version = "0.37.3" @@ -3965,6 +4063,7 @@ dependencies = [ "reqwest", "rpassword", "rustls-webpki", + "self_update", "serde", "serde_json", "serial_test", @@ -3979,6 +4078,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -4235,7 +4343,10 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", + "futures-channel", "futures-core", + "futures-util", + "h2", "http", "http-body", "http-body-util", @@ -4712,6 +4823,43 @@ dependencies = [ "libc", ] +[[package]] +name = "self-replace" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7" +dependencies = [ + "fastrand", + "tempfile", + "windows-sys 0.52.0", +] + +[[package]] +name = "self_update" +version = "0.43.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6644febaa58f323b28f7321d04e24d0020d117c27619ab869d6abdf76be9aac6" +dependencies = [ + "either", + "flate2", + "http", + "indicatif", + "log", + "quick-xml", + "regex", + "reqwest", + "self-replace", + "semver", + "serde", + "serde_json", + "tar", + "tempfile", + "ureq", + "urlencoding", + "zip", + "zipsign-api", +] + [[package]] name = "semver" version = "1.0.27" @@ -4923,6 +5071,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simple-mermaid" version = "0.1.1" @@ -5064,6 +5218,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + [[package]] name = "soketto" version = "0.8.1" @@ -5738,6 +5903,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -6285,6 +6461,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + [[package]] name = "universal-hash" version = "0.5.1" @@ -6311,6 +6493,40 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "cookie_store", + "encoding_rs", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "socks", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -6323,6 +6539,18 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -7098,6 +7326,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yap" version = "0.12.0" @@ -7221,8 +7459,52 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", + "time", + "zopfli", +] + +[[package]] +name = "zipsign-api" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dba6063ff82cdbd9a765add16d369abe81e520f836054e997c2db217ceca40c0" +dependencies = [ + "base64", + "ed25519-dalek", + "thiserror 2.0.18", +] + +[[package]] +name = "zlib-rs" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977347db8caa080403f6b6b7c1cda9479a8e869316f7e13a59b19076a40f94e3" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml index bf69b50..8ca79e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,8 @@ thiserror = "2.0" # Terminal UI colored = "3.0" -indicatif = "0.17" +# Kept at 0.18 to share a single indicatif build with `self_update` (no duplicate). +indicatif = "0.18" # Additional utilities chrono = { version = "0.4", features = ["serde"] } @@ -57,6 +58,19 @@ qp-rusty-crystals-hdwallet = { version = "2.4.0" } blake3 = "1.8" reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } +# Self-update: download and replace the running binary from GitHub releases. +# Pinned to 0.43.x: it shares the same reqwest (0.12) and indicatif (0.18) as the +# rest of the crate, avoiding duplicate dependencies. The only change in 0.44 is a +# bump to reqwest 0.13, which would force a second reqwest build with no real gain. +self_update = { version = "0.43", default-features = false, features = [ + "reqwest", + "rustls", + "archive-tar", + "archive-zip", + "compression-flate2", + "compression-zip-deflate", +] } + # Force patched version of bytes (RUSTSEC-2026-0007) bytes = "1.11.1" diff --git a/README.md b/README.md index a6f35ac..54b9fc4 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,34 @@ cargo build --release # The binary will be available as `quantus` ``` +#### Updating + +The CLI checks GitHub for newer releases and notifies you when one is available +(the result is cached for a few hours, and the check is non-blocking and +best-effort). You can update in place without visiting the releases page: + +```bash +# Check whether a newer version is available (no install) +quantus update --check + +# Download and install the latest release for your platform +quantus update + +# Update without the confirmation prompt (useful in scripts) +quantus update --yes + +# Install a specific version +quantus update --version 1.5.0 +``` + +`quantus update` downloads the prebuilt binary for your platform from the +[GitHub releases](https://github.com/Quantus-Network/quantus-cli/releases) and +replaces the running executable. If the binary lives in a protected location +you may need to re-run with elevated privileges (e.g. `sudo quantus update`). + +To disable the automatic "new version available" notice, set the +`QUANTUS_NO_UPDATE_CHECK` environment variable to any value. + #### As a library Add to your `Cargo.toml`: diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 98cd06d..a468e08 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -26,6 +26,7 @@ pub mod tech_collective; pub mod tech_referenda; pub mod transfers; pub mod treasury; +pub mod update; pub mod wallet; pub mod wormhole; @@ -238,6 +239,21 @@ pub enum Commands { /// Show version information Version, + /// Update the CLI to the latest release + Update { + /// Only check whether a newer version is available (don't install) + #[arg(long)] + check: bool, + + /// Skip the confirmation prompt + #[arg(long, short = 'y')] + yes: bool, + + /// Install a specific version instead of the latest (e.g. "1.5.0") + #[arg(long)] + version: Option, + }, + /// Check compatibility with the connected node CompatibilityCheck, @@ -430,6 +446,8 @@ pub async fn execute_command( log_print!("CLI Version: Quantus CLI v{}", env!("CARGO_PKG_VERSION")); Ok(()) }, + Commands::Update { check, yes, version } => + update::handle_update_command(check, yes, version).await, Commands::CompatibilityCheck => handle_compatibility_check(node_url).await, Commands::Block(block_cmd) => block::handle_block_command(block_cmd, node_url).await, Commands::Wormhole(wormhole_cmd) => diff --git a/src/cli/update.rs b/src/cli/update.rs new file mode 100644 index 0000000..9ded37e --- /dev/null +++ b/src/cli/update.rs @@ -0,0 +1,154 @@ +//! Self-update command. +//! +//! Downloads the appropriate release archive from GitHub for the current +//! platform and replaces the running `quantus` binary in place. Cross-platform +//! binary replacement (including the Windows "can't overwrite a running exe" +//! case) is handled by the `self_update` crate. + +use crate::{error::QuantusError, log_print, log_success}; +use colored::Colorize; + +const REPO_OWNER: &str = "Quantus-Network"; +const REPO_NAME: &str = "quantus-cli"; +const BIN_NAME: &str = "quantus"; + +/// Identifier used to disambiguate the archive asset from the sibling +/// `sha256sums-*.txt` asset (both contain the target triple in their name). +#[cfg(target_os = "windows")] +const ASSET_IDENTIFIER: &str = ".zip"; +#[cfg(not(target_os = "windows"))] +const ASSET_IDENTIFIER: &str = ".tar.gz"; + +/// Run the self-update flow. +/// +/// * `check_only` - only report whether a newer version exists, don't install. +/// * `yes` - skip the interactive confirmation prompt. +/// * `version` - optional specific version to install (tag, with or without `v`). +pub async fn handle_update_command( + check_only: bool, + yes: bool, + version: Option, +) -> crate::error::Result<()> { + let current = env!("CARGO_PKG_VERSION"); + + log_print!("šŸ”„ {}", "Quantus CLI Self-Update".bright_cyan().bold()); + log_print!(" Current version: {}", current.bright_yellow()); + + // `self_update` is synchronous and does blocking I/O, so run it off the + // async runtime's worker threads. + let status = tokio::task::spawn_blocking(move || run_update(check_only, yes, version)) + .await + .map_err(|e| QuantusError::Generic(format!("Update task failed to run: {e}")))??; + + match status { + UpdateOutcome::AlreadyLatest(v) => { + log_success!("You are already on the latest version ({}).", v.bright_green()); + }, + UpdateOutcome::UpdateAvailable(v) => { + // `check_only` path: report and exit without installing. + log_print!(""); + log_print!( + "{} A newer version is available: {} → {}", + "ā¬†ļø".bright_yellow(), + current.dimmed(), + v.bright_green().bold() + ); + log_print!(" Run {} to install it.", "quantus update".bright_cyan()); + }, + UpdateOutcome::Updated(v) => { + log_print!(""); + log_success!("Updated to version {} šŸŽ‰", v.bright_green().bold()); + log_print!(" Restart any running sessions to use the new version."); + }, + } + + Ok(()) +} + +/// Result of an update attempt. +enum UpdateOutcome { + /// Already running the newest release. + AlreadyLatest(String), + /// A newer release exists (returned only in `check_only` mode). + UpdateAvailable(String), + /// The binary was replaced with this version. + Updated(String), +} + +/// Blocking implementation that talks to GitHub and replaces the binary. +fn run_update( + check_only: bool, + yes: bool, + version: Option, +) -> crate::error::Result { + let current = env!("CARGO_PKG_VERSION"); + + // In check-only mode we just look up the latest release and compare. + if check_only { + let release = self_update::backends::github::ReleaseList::configure() + .repo_owner(REPO_OWNER) + .repo_name(REPO_NAME) + .build() + .and_then(|list| list.fetch()) + .map_err(map_self_update_err)?; + + let latest = release + .first() + .ok_or_else(|| QuantusError::Generic("No releases found on GitHub.".to_string()))?; + + let latest_version = latest.version.trim_start_matches('v'); + if self_update::version::bump_is_greater(current, latest_version).unwrap_or(false) { + return Ok(UpdateOutcome::UpdateAvailable(latest_version.to_string())); + } + return Ok(UpdateOutcome::AlreadyLatest(current.to_string())); + } + + let mut builder = self_update::backends::github::Update::configure(); + builder + .repo_owner(REPO_OWNER) + .repo_name(REPO_NAME) + .bin_name(BIN_NAME) + // Archives extract to `quantus-cli-v{version}-{target}/quantus`. + // `{{ version }}` is substituted without the leading `v`, so it is + // added back as a literal here. + .bin_path_in_archive("quantus-cli-v{{ version }}-{{ target }}/{{ bin }}") + .identifier(ASSET_IDENTIFIER) + .show_download_progress(true) + .no_confirm(yes) + .current_version(current); + + if let Some(version) = version { + // Accept both `1.5.0` and `v1.5.0`; the release tags include the `v`. + let tag = + if version.starts_with('v') { version.clone() } else { format!("v{version}") }; + builder.target_version_tag(&tag); + } + + let status = builder + .build() + .map_err(map_self_update_err)? + .update() + .map_err(map_self_update_err)?; + + if status.updated() { + Ok(UpdateOutcome::Updated(status.version().to_string())) + } else { + Ok(UpdateOutcome::AlreadyLatest(status.version().to_string())) + } +} + +/// Convert a `self_update` error into a `QuantusError` with a friendly hint for +/// the common permission-denied case (e.g. binary installed under a path that +/// requires elevated privileges). +fn map_self_update_err(err: self_update::errors::Error) -> QuantusError { + let msg = err.to_string(); + if msg.contains("Permission denied") || msg.contains("Access is denied") { + QuantusError::Generic(format!( + "{msg}\nšŸ’” The CLI binary is in a protected location. Re-run with elevated \ + privileges (e.g. `sudo quantus update`) or reinstall manually from \ + https://github.com/Quantus-Network/quantus-cli/releases" + )) + } else { + QuantusError::Generic(format!("Self-update failed: {msg}")) + } +} diff --git a/src/lib.rs b/src/lib.rs index dfad76e..af8f9a9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ pub mod config; pub mod error; pub mod log; pub mod subsquid; +pub mod version_check; pub mod wallet; pub mod wormhole_lib; diff --git a/src/main.rs b/src/main.rs index b89571f..53300ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ mod config; mod error; mod log; mod subsquid; +mod version_check; mod wallet; mod wormhole_lib; @@ -76,6 +77,15 @@ async fn main() -> Result<(), QuantusError> { wait_for_transaction: cli.wait_for_transaction, }; + // Kick off the update check in the background so it runs concurrently with + // the command and doesn't add latency. It's best-effort and never fails. + // Skip it for the `update` command, which performs its own version check. + let update_check = if matches!(cli.command, Commands::Update { .. }) { + None + } else { + Some(tokio::spawn(version_check::notify_if_update_available())) + }; + // Execute the command with timing let start_time = std::time::Instant::now(); let result = @@ -87,12 +97,23 @@ async fn main() -> Result<(), QuantusError> { log_verbose!(""); log_verbose!("Command executed successfully!"); log_print!("ā±ļø Completed in {:.2}s", elapsed.as_secs_f64()); + finish_update_check(update_check).await; Ok(()) }, Err(e) => { log_error!("{}", e); log_print!("ā±ļø Failed after {:.2}s", elapsed.as_secs_f64()); + finish_update_check(update_check).await; std::process::exit(1); }, } } + +/// Wait for the background update check to finish so its notice (if any) is +/// printed after the command output. Bounded by a short timeout so a slow +/// network can never hold up the CLI. +async fn finish_update_check(handle: Option>) { + if let Some(handle) = handle { + let _ = tokio::time::timeout(std::time::Duration::from_secs(3), handle).await; + } +} diff --git a/src/version_check.rs b/src/version_check.rs new file mode 100644 index 0000000..d6f4d3f --- /dev/null +++ b/src/version_check.rs @@ -0,0 +1,199 @@ +//! Update notification module. +//! +//! Checks GitHub for the latest published release of the CLI and notifies the +//! user when a newer version is available. The result is cached on disk for a +//! short period so we don't query GitHub on every invocation (avoids latency +//! and API rate-limiting). +//! +//! The check is strictly best-effort: any network, parsing or filesystem error +//! is swallowed silently so it can never interfere with the actual command. + +use crate::log_print; +use colored::Colorize; +use serde::{Deserialize, Serialize}; +use std::{path::PathBuf, time::Duration}; + +/// GitHub API endpoint returning the latest (non-prerelease) release. +const LATEST_RELEASE_URL: &str = + "https://api.github.com/repos/Quantus-Network/quantus-cli/releases/latest"; + +/// Page users can visit to download a new release. +const RELEASES_PAGE_URL: &str = "https://github.com/Quantus-Network/quantus-cli/releases"; + +/// How long a cached result is considered fresh before we query GitHub again. +const CACHE_TTL: Duration = Duration::from_secs(60 * 60 * 4); // 4 hours + +/// Maximum time we allow the network request to take. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(3); + +/// Environment variable that, when set to any value, disables the update check. +const DISABLE_ENV: &str = "QUANTUS_NO_UPDATE_CHECK"; + +/// On-disk cache of the last update check. +#[derive(Debug, Serialize, Deserialize)] +struct UpdateCache { + /// Unix timestamp (seconds) of when the check was last performed. + last_checked: u64, + /// Latest version tag observed from GitHub (without a leading `v`). + latest_version: String, +} + +/// Minimal shape of the GitHub release response we care about. +#[derive(Debug, Deserialize)] +struct GithubRelease { + tag_name: String, +} + +/// Location of the cache file: `~/.quantus/update_check.json`. +fn cache_path() -> Option { + dirs::home_dir().map(|home| home.join(".quantus").join("update_check.json")) +} + +/// Current unix time in seconds. +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Strip a leading `v`/`V` from a version tag, returning the bare version. +fn normalize(version: &str) -> &str { + version.trim().strip_prefix(['v', 'V']).unwrap_or(version.trim()) +} + +/// Parse a semver-ish string into numeric `(major, minor, patch)` components. +/// Any pre-release/build suffix (after `-` or `+`) is ignored. +fn parse_version(version: &str) -> Option<(u64, u64, u64)> { + let core = normalize(version); + let core = core.split(['-', '+']).next().unwrap_or(core); + let mut parts = core.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next().unwrap_or("0").parse().ok()?; + let patch = parts.next().unwrap_or("0").parse().ok()?; + Some((major, minor, patch)) +} + +/// Returns `true` if `latest` is strictly newer than `current`. +fn is_newer(current: &str, latest: &str) -> bool { + match (parse_version(current), parse_version(latest)) { + (Some(c), Some(l)) => l > c, + // If we can't parse, fall back to a conservative string inequality so we + // don't nag users with a malformed comparison. + _ => false, + } +} + +/// Read the cache file if it exists and is well-formed. +fn read_cache() -> Option { + let path = cache_path()?; + let contents = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&contents).ok() +} + +/// Persist the cache file, creating the parent directory if needed. +fn write_cache(cache: &UpdateCache) { + let Some(path) = cache_path() else { return }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(contents) = serde_json::to_string_pretty(cache) { + let _ = std::fs::write(path, contents); + } +} + +/// Query GitHub for the latest release tag (without a leading `v`). +async fn fetch_latest_version() -> Option { + let client = reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + // GitHub requires a User-Agent header on all API requests. + .user_agent(concat!("quantus-cli/", env!("CARGO_PKG_VERSION"))) + .build() + .ok()?; + + let release = client + .get(LATEST_RELEASE_URL) + .header("Accept", "application/vnd.github+json") + .send() + .await + .ok()? + .error_for_status() + .ok()? + .json::() + .await + .ok()?; + + Some(normalize(&release.tag_name).to_string()) +} + +/// Resolve the latest known version, using the cache when it is still fresh and +/// otherwise querying GitHub and refreshing the cache. +async fn latest_version() -> Option { + if let Some(cache) = read_cache() { + if now_secs().saturating_sub(cache.last_checked) < CACHE_TTL.as_secs() { + return Some(cache.latest_version); + } + } + + let latest = fetch_latest_version().await?; + write_cache(&UpdateCache { last_checked: now_secs(), latest_version: latest.clone() }); + Some(latest) +} + +/// Perform the update check and print a notice when a newer version exists. +/// +/// This is best-effort and never returns an error: it is safe to call from any +/// command path. Honors the `QUANTUS_NO_UPDATE_CHECK` opt-out. +pub async fn notify_if_update_available() { + if std::env::var_os(DISABLE_ENV).is_some() { + return; + } + + let current = env!("CARGO_PKG_VERSION"); + let Some(latest) = latest_version().await else { return }; + + if is_newer(current, &latest) { + log_print!(""); + log_print!( + "{} A new version of Quantus CLI is available: {} → {}", + "ā¬†ļø".bright_yellow(), + current.dimmed(), + latest.bright_green().bold() + ); + log_print!(" Download it from {}", RELEASES_PAGE_URL.bright_cyan()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_strips_v_prefix() { + assert_eq!(normalize("v1.5.0"), "1.5.0"); + assert_eq!(normalize("V1.5.0"), "1.5.0"); + assert_eq!(normalize("1.5.0"), "1.5.0"); + assert_eq!(normalize(" v1.5.0 "), "1.5.0"); + } + + #[test] + fn parse_version_handles_suffixes() { + assert_eq!(parse_version("v1.5.0"), Some((1, 5, 0))); + assert_eq!(parse_version("1.5"), Some((1, 5, 0))); + assert_eq!(parse_version("2"), Some((2, 0, 0))); + assert_eq!(parse_version("1.5.0-beta.1"), Some((1, 5, 0))); + assert_eq!(parse_version("1.5.0+build"), Some((1, 5, 0))); + assert_eq!(parse_version("not-a-version"), None); + } + + #[test] + fn is_newer_compares_correctly() { + assert!(is_newer("1.4.0", "1.5.0")); + assert!(is_newer("1.5.0", "1.5.1")); + assert!(is_newer("1.5.0", "2.0.0")); + assert!(is_newer("1.5.0", "v1.6.0")); + assert!(!is_newer("1.5.0", "1.5.0")); + assert!(!is_newer("1.5.0", "1.4.9")); + assert!(!is_newer("1.5.0", "garbage")); + } +} From 963aa9136b56fc8dd806051346d518219f201b0b Mon Sep 17 00:00:00 2001 From: Beast Date: Mon, 22 Jun 2026 14:54:59 +0800 Subject: [PATCH 2/5] fix: error logging and blocking time --- src/main.rs | 29 ++++---- src/version_check.rs | 162 +++++++++++++++++++++++++++++++++---------- 2 files changed, 137 insertions(+), 54 deletions(-) diff --git a/src/main.rs b/src/main.rs index 53300ec..5ed6507 100644 --- a/src/main.rs +++ b/src/main.rs @@ -77,13 +77,19 @@ async fn main() -> Result<(), QuantusError> { wait_for_transaction: cli.wait_for_transaction, }; - // Kick off the update check in the background so it runs concurrently with - // the command and doesn't add latency. It's best-effort and never fails. - // Skip it for the `update` command, which performs its own version check. - let update_check = if matches!(cli.command, Commands::Update { .. }) { + // Warm the update-version cache in the background so it runs concurrently + // with the command and never adds latency: we never block the command on + // the network. The notice itself is printed (instantly, from cache) once + // the command finishes. It's best-effort and never fails. Skip it for the + // `update` command, which performs its own version check. + // + // The handle is intentionally detached: for slower commands the refresh + // finishes during execution and the notice can show on this same run; for + // near-instant commands it simply warms the cache for the next invocation. + let _update_refresh = if matches!(cli.command, Commands::Update { .. }) { None } else { - Some(tokio::spawn(version_check::notify_if_update_available())) + Some(tokio::spawn(version_check::refresh_cache_in_background())) }; // Execute the command with timing @@ -97,23 +103,14 @@ async fn main() -> Result<(), QuantusError> { log_verbose!(""); log_verbose!("Command executed successfully!"); log_print!("ā±ļø Completed in {:.2}s", elapsed.as_secs_f64()); - finish_update_check(update_check).await; + version_check::notify_if_update_available(); Ok(()) }, Err(e) => { log_error!("{}", e); log_print!("ā±ļø Failed after {:.2}s", elapsed.as_secs_f64()); - finish_update_check(update_check).await; + version_check::notify_if_update_available(); std::process::exit(1); }, } } - -/// Wait for the background update check to finish so its notice (if any) is -/// printed after the command output. Bounded by a short timeout so a slow -/// network can never hold up the CLI. -async fn finish_update_check(handle: Option>) { - if let Some(handle) = handle { - let _ = tokio::time::timeout(std::time::Duration::from_secs(3), handle).await; - } -} diff --git a/src/version_check.rs b/src/version_check.rs index d6f4d3f..c33e513 100644 --- a/src/version_check.rs +++ b/src/version_check.rs @@ -6,9 +6,18 @@ //! and API rate-limiting). //! //! The check is strictly best-effort: any network, parsing or filesystem error -//! is swallowed silently so it can never interfere with the actual command. +//! can never interfere with the actual command. To stay aligned with the +//! "fail early / always log" rule, these errors are not silenced outright — +//! they are downgraded to `log_verbose!` so `-v` surfaces exactly what happened +//! without spamming normal output. +//! +//! To avoid adding latency, the network fetch never blocks the command. The +//! latest version is resolved from an on-disk cache via [`refresh_cache_in_background`] +//! (spawned to run concurrently with the command), while [`notify_if_update_available`] +//! only consults that cache and prints instantly. The first run with a cold +//! cache shows nothing; a later run (once the cache is warm) shows the notice. -use crate::log_print; +use crate::{log_print, log_verbose}; use colored::Colorize; use serde::{Deserialize, Serialize}; use std::{path::PathBuf, time::Duration}; @@ -85,85 +94,162 @@ fn is_newer(current: &str, latest: &str) -> bool { } /// Read the cache file if it exists and is well-formed. +/// +/// A missing cache file is expected (e.g. on first run) and not logged. Any +/// other read/parse failure is surfaced via `log_verbose!`. fn read_cache() -> Option { let path = cache_path()?; - let contents = std::fs::read_to_string(path).ok()?; - serde_json::from_str(&contents).ok() + let contents = match std::fs::read_to_string(&path) { + Ok(contents) => contents, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None, + Err(e) => { + log_verbose!("update check: failed to read cache {}: {e}", path.display()); + return None; + }, + }; + match serde_json::from_str(&contents) { + Ok(cache) => Some(cache), + Err(e) => { + log_verbose!("update check: failed to parse cache {}: {e}", path.display()); + None + }, + } } /// Persist the cache file, creating the parent directory if needed. fn write_cache(cache: &UpdateCache) { - let Some(path) = cache_path() else { return }; + let Some(path) = cache_path() else { + log_verbose!("update check: could not determine cache path; skipping cache write"); + return; + }; if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); + if let Err(e) = std::fs::create_dir_all(parent) { + log_verbose!( + "update check: failed to create cache dir {}: {e}", + parent.display() + ); + return; + } } - if let Ok(contents) = serde_json::to_string_pretty(cache) { - let _ = std::fs::write(path, contents); + match serde_json::to_string_pretty(cache) { + Ok(contents) => { + if let Err(e) = std::fs::write(&path, contents) { + log_verbose!("update check: failed to write cache {}: {e}", path.display()); + } + }, + Err(e) => log_verbose!("update check: failed to serialize cache: {e}"), } } /// Query GitHub for the latest release tag (without a leading `v`). async fn fetch_latest_version() -> Option { - let client = reqwest::Client::builder() + let client = match reqwest::Client::builder() .timeout(REQUEST_TIMEOUT) // GitHub requires a User-Agent header on all API requests. .user_agent(concat!("quantus-cli/", env!("CARGO_PKG_VERSION"))) .build() - .ok()?; + { + Ok(client) => client, + Err(e) => { + log_verbose!("update check: failed to build HTTP client: {e}"); + return None; + }, + }; - let release = client + let response = match client .get(LATEST_RELEASE_URL) .header("Accept", "application/vnd.github+json") .send() .await - .ok()? - .error_for_status() - .ok()? - .json::() - .await - .ok()?; + { + Ok(response) => response, + Err(e) => { + log_verbose!("update check: request to GitHub failed: {e}"); + return None; + }, + }; - Some(normalize(&release.tag_name).to_string()) -} + let response = match response.error_for_status() { + Ok(response) => response, + Err(e) => { + log_verbose!("update check: GitHub returned an error status: {e}"); + return None; + }, + }; -/// Resolve the latest known version, using the cache when it is still fresh and -/// otherwise querying GitHub and refreshing the cache. -async fn latest_version() -> Option { - if let Some(cache) = read_cache() { - if now_secs().saturating_sub(cache.last_checked) < CACHE_TTL.as_secs() { - return Some(cache.latest_version); - } + match response.json::().await { + Ok(release) => Some(normalize(&release.tag_name).to_string()), + Err(e) => { + log_verbose!("update check: failed to parse GitHub response: {e}"); + None + }, } - - let latest = fetch_latest_version().await?; - write_cache(&UpdateCache { last_checked: now_secs(), latest_version: latest.clone() }); - Some(latest) } -/// Perform the update check and print a notice when a newer version exists. +/// Print an update notice when a *fresh* cached result already says a newer +/// version exists. /// -/// This is best-effort and never returns an error: it is safe to call from any -/// command path. Honors the `QUANTUS_NO_UPDATE_CHECK` opt-out. -pub async fn notify_if_update_available() { +/// This never performs network I/O — it only consults the on-disk cache — so it +/// is effectively instant and adds no latency to the command. The cache is +/// populated by [`refresh_cache_in_background`], which runs concurrently with +/// the command. When the cache is cold or stale we print nothing; a later +/// invocation (once the cache is warm) shows the notice. Honors the +/// `QUANTUS_NO_UPDATE_CHECK` opt-out. +pub fn notify_if_update_available() { if std::env::var_os(DISABLE_ENV).is_some() { return; } - let current = env!("CARGO_PKG_VERSION"); - let Some(latest) = latest_version().await else { return }; + let Some(cache) = read_cache() else { + log_verbose!("update check: no cached result yet; nothing to show"); + return; + }; + + if now_secs().saturating_sub(cache.last_checked) >= CACHE_TTL.as_secs() { + log_verbose!("update check: cached result is stale; refreshing in background"); + return; + } - if is_newer(current, &latest) { + let current = env!("CARGO_PKG_VERSION"); + if is_newer(current, &cache.latest_version) { log_print!(""); log_print!( "{} A new version of Quantus CLI is available: {} → {}", "ā¬†ļø".bright_yellow(), current.dimmed(), - latest.bright_green().bold() + cache.latest_version.bright_green().bold() ); log_print!(" Download it from {}", RELEASES_PAGE_URL.bright_cyan()); } } +/// Refresh the cached latest-version from GitHub when the cache is missing or +/// stale. +/// +/// Best-effort and designed to never block the command: it is meant to be +/// spawned and left to run concurrently so the *next* invocation has a warm +/// cache to display. Honors the `QUANTUS_NO_UPDATE_CHECK` opt-out. +pub async fn refresh_cache_in_background() { + if std::env::var_os(DISABLE_ENV).is_some() { + return; + } + + if let Some(cache) = read_cache() { + if now_secs().saturating_sub(cache.last_checked) < CACHE_TTL.as_secs() { + log_verbose!("update check: cache is fresh; skipping refresh"); + return; + } + } + + match fetch_latest_version().await { + Some(latest) => { + write_cache(&UpdateCache { last_checked: now_secs(), latest_version: latest }); + log_verbose!("update check: refreshed cached latest version"); + }, + None => log_verbose!("update check: could not refresh latest version"), + } +} + #[cfg(test)] mod tests { use super::*; From 750179a77a0c9751ac7bd63bd8d420b2dc0cd060 Mon Sep 17 00:00:00 2001 From: Beast Date: Mon, 22 Jun 2026 15:21:55 +0800 Subject: [PATCH 3/5] fix: DRY and can print notice in middle --- src/cli/update.rs | 74 +++++++++++++--------- src/main.rs | 17 +++-- src/version_check.rs | 146 ++++++++----------------------------------- 3 files changed, 78 insertions(+), 159 deletions(-) diff --git a/src/cli/update.rs b/src/cli/update.rs index 9ded37e..31632a6 100644 --- a/src/cli/update.rs +++ b/src/cli/update.rs @@ -75,6 +75,41 @@ enum UpdateOutcome { Updated(String), } +/// Build the shared `self_update` updater configuration. +/// +/// This is the single source of truth for how we talk to GitHub: both the +/// install flow and [`latest_stable_version`] derive from it, so the version a +/// check advertises can never disagree with the version an install resolves +/// (they both follow GitHub's `/releases/latest` semantics). +fn configure_updater() -> self_update::backends::github::UpdateBuilder { + let mut builder = self_update::backends::github::Update::configure(); + builder + .repo_owner(REPO_OWNER) + .repo_name(REPO_NAME) + .bin_name(BIN_NAME) + // Archives extract to `quantus-cli-v{version}-{target}/quantus`. + // `{{ version }}` is substituted without the leading `v`, so it is + // added back as a literal here. + .bin_path_in_archive("quantus-cli-v{{ version }}-{{ target }}/{{ bin }}") + .identifier(ASSET_IDENTIFIER) + .current_version(env!("CARGO_PKG_VERSION")); + builder +} + +/// Resolve the latest *stable* release version from GitHub (without a leading +/// `v`), using the same `/releases/latest` resolution as the install path. +/// +/// Blocking: `self_update` performs synchronous I/O, so call this off the async +/// runtime's worker threads (e.g. via `spawn_blocking`). +pub fn latest_stable_version() -> crate::error::Result { + let release = configure_updater() + .build() + .map_err(map_self_update_err)? + .get_latest_release() + .map_err(map_self_update_err)?; + Ok(release.version.trim_start_matches('v').to_string()) +} + /// Blocking implementation that talks to GitHub and replaces the binary. fn run_update( check_only: bool, @@ -83,44 +118,23 @@ fn run_update( ) -> crate::error::Result { let current = env!("CARGO_PKG_VERSION"); - // In check-only mode we just look up the latest release and compare. + // In check-only mode we just look up the latest release and compare. This + // uses the exact same resolution as the install path below, so a reported + // upgrade is always one that `quantus update` can actually install. if check_only { - let release = self_update::backends::github::ReleaseList::configure() - .repo_owner(REPO_OWNER) - .repo_name(REPO_NAME) - .build() - .and_then(|list| list.fetch()) - .map_err(map_self_update_err)?; - - let latest = release - .first() - .ok_or_else(|| QuantusError::Generic("No releases found on GitHub.".to_string()))?; - - let latest_version = latest.version.trim_start_matches('v'); - if self_update::version::bump_is_greater(current, latest_version).unwrap_or(false) { - return Ok(UpdateOutcome::UpdateAvailable(latest_version.to_string())); + let latest = latest_stable_version()?; + if self_update::version::bump_is_greater(current, &latest).unwrap_or(false) { + return Ok(UpdateOutcome::UpdateAvailable(latest)); } return Ok(UpdateOutcome::AlreadyLatest(current.to_string())); } - let mut builder = self_update::backends::github::Update::configure(); - builder - .repo_owner(REPO_OWNER) - .repo_name(REPO_NAME) - .bin_name(BIN_NAME) - // Archives extract to `quantus-cli-v{version}-{target}/quantus`. - // `{{ version }}` is substituted without the leading `v`, so it is - // added back as a literal here. - .bin_path_in_archive("quantus-cli-v{{ version }}-{{ target }}/{{ bin }}") - .identifier(ASSET_IDENTIFIER) - .show_download_progress(true) - .no_confirm(yes) - .current_version(current); + let mut builder = configure_updater(); + builder.show_download_progress(true).no_confirm(yes); if let Some(version) = version { // Accept both `1.5.0` and `v1.5.0`; the release tags include the `v`. - let tag = - if version.starts_with('v') { version.clone() } else { format!("v{version}") }; + let tag = if version.starts_with('v') { version } else { format!("v{version}") }; builder.target_version_tag(&tag); } diff --git a/src/main.rs b/src/main.rs index 5ed6507..e6c8824 100644 --- a/src/main.rs +++ b/src/main.rs @@ -79,14 +79,11 @@ async fn main() -> Result<(), QuantusError> { // Warm the update-version cache in the background so it runs concurrently // with the command and never adds latency: we never block the command on - // the network. The notice itself is printed (instantly, from cache) once - // the command finishes. It's best-effort and never fails. Skip it for the - // `update` command, which performs its own version check. - // - // The handle is intentionally detached: for slower commands the refresh - // finishes during execution and the notice can show on this same run; for - // near-instant commands it simply warms the cache for the next invocation. - let _update_refresh = if matches!(cli.command, Commands::Update { .. }) { + // the network. The notice itself is printed only after the command finishes + // (see `finish_update_check`), never racing it into the middle of the output. + // It's best-effort and never fails. Skip it for the `update` command, which + // performs its own version check. + let update_refresh = if matches!(cli.command, Commands::Update { .. }) { None } else { Some(tokio::spawn(version_check::refresh_cache_in_background())) @@ -103,13 +100,13 @@ async fn main() -> Result<(), QuantusError> { log_verbose!(""); log_verbose!("Command executed successfully!"); log_print!("ā±ļø Completed in {:.2}s", elapsed.as_secs_f64()); - version_check::notify_if_update_available(); + version_check::finish_update_check(update_refresh).await; Ok(()) }, Err(e) => { log_error!("{}", e); log_print!("ā±ļø Failed after {:.2}s", elapsed.as_secs_f64()); - version_check::notify_if_update_available(); + version_check::finish_update_check(update_refresh).await; std::process::exit(1); }, } diff --git a/src/version_check.rs b/src/version_check.rs index c33e513..672a204 100644 --- a/src/version_check.rs +++ b/src/version_check.rs @@ -16,24 +16,27 @@ //! (spawned to run concurrently with the command), while [`notify_if_update_available`] //! only consults that cache and prints instantly. The first run with a cold //! cache shows nothing; a later run (once the cache is warm) shows the notice. +//! +//! Both the fetch and the version comparison are delegated to the single shared +//! implementation in [`crate::cli::update`] (which wraps `self_update`), so the +//! background notice and the `quantus update` command can never disagree about +//! what "latest" means. use crate::{log_print, log_verbose}; use colored::Colorize; use serde::{Deserialize, Serialize}; use std::{path::PathBuf, time::Duration}; -/// GitHub API endpoint returning the latest (non-prerelease) release. -const LATEST_RELEASE_URL: &str = - "https://api.github.com/repos/Quantus-Network/quantus-cli/releases/latest"; - /// Page users can visit to download a new release. const RELEASES_PAGE_URL: &str = "https://github.com/Quantus-Network/quantus-cli/releases"; /// How long a cached result is considered fresh before we query GitHub again. const CACHE_TTL: Duration = Duration::from_secs(60 * 60 * 4); // 4 hours -/// Maximum time we allow the network request to take. -const REQUEST_TIMEOUT: Duration = Duration::from_secs(3); +/// How long we let the background refresh finish before printing the notice. +/// Bounds the wait at the end of a command so a slow network can never hold up +/// the CLI; the refresh has already been running concurrently with the command. +const REFRESH_GRACE: Duration = Duration::from_secs(3); /// Environment variable that, when set to any value, disables the update check. const DISABLE_ENV: &str = "QUANTUS_NO_UPDATE_CHECK"; @@ -47,12 +50,6 @@ struct UpdateCache { latest_version: String, } -/// Minimal shape of the GitHub release response we care about. -#[derive(Debug, Deserialize)] -struct GithubRelease { - tag_name: String, -} - /// Location of the cache file: `~/.quantus/update_check.json`. fn cache_path() -> Option { dirs::home_dir().map(|home| home.join(".quantus").join("update_check.json")) @@ -66,33 +63,6 @@ fn now_secs() -> u64 { .unwrap_or(0) } -/// Strip a leading `v`/`V` from a version tag, returning the bare version. -fn normalize(version: &str) -> &str { - version.trim().strip_prefix(['v', 'V']).unwrap_or(version.trim()) -} - -/// Parse a semver-ish string into numeric `(major, minor, patch)` components. -/// Any pre-release/build suffix (after `-` or `+`) is ignored. -fn parse_version(version: &str) -> Option<(u64, u64, u64)> { - let core = normalize(version); - let core = core.split(['-', '+']).next().unwrap_or(core); - let mut parts = core.split('.'); - let major = parts.next()?.parse().ok()?; - let minor = parts.next().unwrap_or("0").parse().ok()?; - let patch = parts.next().unwrap_or("0").parse().ok()?; - Some((major, minor, patch)) -} - -/// Returns `true` if `latest` is strictly newer than `current`. -fn is_newer(current: &str, latest: &str) -> bool { - match (parse_version(current), parse_version(latest)) { - (Some(c), Some(l)) => l > c, - // If we can't parse, fall back to a conservative string inequality so we - // don't nag users with a malformed comparison. - _ => false, - } -} - /// Read the cache file if it exists and is well-formed. /// /// A missing cache file is expected (e.g. on first run) and not logged. Any @@ -141,51 +111,6 @@ fn write_cache(cache: &UpdateCache) { } } -/// Query GitHub for the latest release tag (without a leading `v`). -async fn fetch_latest_version() -> Option { - let client = match reqwest::Client::builder() - .timeout(REQUEST_TIMEOUT) - // GitHub requires a User-Agent header on all API requests. - .user_agent(concat!("quantus-cli/", env!("CARGO_PKG_VERSION"))) - .build() - { - Ok(client) => client, - Err(e) => { - log_verbose!("update check: failed to build HTTP client: {e}"); - return None; - }, - }; - - let response = match client - .get(LATEST_RELEASE_URL) - .header("Accept", "application/vnd.github+json") - .send() - .await - { - Ok(response) => response, - Err(e) => { - log_verbose!("update check: request to GitHub failed: {e}"); - return None; - }, - }; - - let response = match response.error_for_status() { - Ok(response) => response, - Err(e) => { - log_verbose!("update check: GitHub returned an error status: {e}"); - return None; - }, - }; - - match response.json::().await { - Ok(release) => Some(normalize(&release.tag_name).to_string()), - Err(e) => { - log_verbose!("update check: failed to parse GitHub response: {e}"); - None - }, - } -} - /// Print an update notice when a *fresh* cached result already says a newer /// version exists. /// @@ -211,7 +136,7 @@ pub fn notify_if_update_available() { } let current = env!("CARGO_PKG_VERSION"); - if is_newer(current, &cache.latest_version) { + if self_update::version::bump_is_greater(current, &cache.latest_version).unwrap_or(false) { log_print!(""); log_print!( "{} A new version of Quantus CLI is available: {} → {}", @@ -241,45 +166,28 @@ pub async fn refresh_cache_in_background() { } } - match fetch_latest_version().await { - Some(latest) => { + // `self_update` does blocking I/O, so resolve the latest version off the + // async runtime's worker threads. + match tokio::task::spawn_blocking(crate::cli::update::latest_stable_version).await { + Ok(Ok(latest)) => { write_cache(&UpdateCache { last_checked: now_secs(), latest_version: latest }); log_verbose!("update check: refreshed cached latest version"); }, - None => log_verbose!("update check: could not refresh latest version"), + Ok(Err(e)) => log_verbose!("update check: could not refresh latest version: {e}"), + Err(e) => log_verbose!("update check: refresh task failed: {e}"), } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn normalize_strips_v_prefix() { - assert_eq!(normalize("v1.5.0"), "1.5.0"); - assert_eq!(normalize("V1.5.0"), "1.5.0"); - assert_eq!(normalize("1.5.0"), "1.5.0"); - assert_eq!(normalize(" v1.5.0 "), "1.5.0"); - } - - #[test] - fn parse_version_handles_suffixes() { - assert_eq!(parse_version("v1.5.0"), Some((1, 5, 0))); - assert_eq!(parse_version("1.5"), Some((1, 5, 0))); - assert_eq!(parse_version("2"), Some((2, 0, 0))); - assert_eq!(parse_version("1.5.0-beta.1"), Some((1, 5, 0))); - assert_eq!(parse_version("1.5.0+build"), Some((1, 5, 0))); - assert_eq!(parse_version("not-a-version"), None); - } - - #[test] - fn is_newer_compares_correctly() { - assert!(is_newer("1.4.0", "1.5.0")); - assert!(is_newer("1.5.0", "1.5.1")); - assert!(is_newer("1.5.0", "2.0.0")); - assert!(is_newer("1.5.0", "v1.6.0")); - assert!(!is_newer("1.5.0", "1.5.0")); - assert!(!is_newer("1.5.0", "1.4.9")); - assert!(!is_newer("1.5.0", "garbage")); +/// Wait for the background cache refresh to finish (bounded by [`REFRESH_GRACE`]), +/// then print the update notice. +/// +/// Awaiting the refresh first means the notice can appear on this same run once +/// the cache is warm, and — crucially — guarantees the notice is printed *after* +/// the command output rather than racing it into the middle. The wait is bounded +/// so a slow network can never hold up the CLI. +pub async fn finish_update_check(handle: Option>) { + if let Some(handle) = handle { + let _ = tokio::time::timeout(REFRESH_GRACE, handle).await; } + notify_if_update_available(); } From b3e14ebadfd9e0d38cb4f4633a16bdc0f919be4b Mon Sep 17 00:00:00 2001 From: Beast Date: Mon, 22 Jun 2026 15:22:26 +0800 Subject: [PATCH 4/5] chore: formatting --- src/version_check.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/version_check.rs b/src/version_check.rs index 672a204..1a5c96e 100644 --- a/src/version_check.rs +++ b/src/version_check.rs @@ -94,19 +94,15 @@ fn write_cache(cache: &UpdateCache) { }; if let Some(parent) = path.parent() { if let Err(e) = std::fs::create_dir_all(parent) { - log_verbose!( - "update check: failed to create cache dir {}: {e}", - parent.display() - ); + log_verbose!("update check: failed to create cache dir {}: {e}", parent.display()); return; } } match serde_json::to_string_pretty(cache) { - Ok(contents) => { + Ok(contents) => if let Err(e) = std::fs::write(&path, contents) { log_verbose!("update check: failed to write cache {}: {e}", path.display()); - } - }, + }, Err(e) => log_verbose!("update check: failed to serialize cache: {e}"), } } From 6891a90acfa7a8a381d251c6b0626b5e2742a658 Mon Sep 17 00:00:00 2001 From: Beast Date: Mon, 22 Jun 2026 15:43:59 +0800 Subject: [PATCH 5/5] chore: formatting --- Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8ca79e9..5acbbc1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,12 +63,12 @@ reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-feature # rest of the crate, avoiding duplicate dependencies. The only change in 0.44 is a # bump to reqwest 0.13, which would force a second reqwest build with no real gain. self_update = { version = "0.43", default-features = false, features = [ - "reqwest", - "rustls", "archive-tar", "archive-zip", "compression-flate2", "compression-zip-deflate", + "reqwest", + "rustls", ] } # Force patched version of bytes (RUSTSEC-2026-0007)