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..5acbbc1 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 = [ + "archive-tar", + "archive-zip", + "compression-flate2", + "compression-zip-deflate", + "reqwest", + "rustls", +] } + # 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..31632a6 --- /dev/null +++ b/src/cli/update.rs @@ -0,0 +1,168 @@ +//! 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), +} + +/// 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, + 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. 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 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 = 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 } 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..e6c8824 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,18 @@ async fn main() -> Result<(), QuantusError> { wait_for_transaction: cli.wait_for_transaction, }; + // 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 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())) + }; + // Execute the command with timing let start_time = std::time::Instant::now(); let result = @@ -87,11 +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::finish_update_check(update_refresh).await; Ok(()) }, Err(e) => { log_error!("{}", e); log_print!("ā±ļø Failed after {:.2}s", elapsed.as_secs_f64()); + version_check::finish_update_check(update_refresh).await; std::process::exit(1); }, } diff --git a/src/version_check.rs b/src/version_check.rs new file mode 100644 index 0000000..1a5c96e --- /dev/null +++ b/src/version_check.rs @@ -0,0 +1,189 @@ +//! 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 +//! 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. +//! +//! 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}; + +/// 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 + +/// 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"; + +/// 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, +} + +/// 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) +} + +/// 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 = 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 { + log_verbose!("update check: could not determine cache path; skipping cache write"); + return; + }; + 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()); + return; + } + } + 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}"), + } +} + +/// Print an update notice when a *fresh* cached result already says a newer +/// version exists. +/// +/// 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 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; + } + + let current = env!("CARGO_PKG_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: {} → {}", + "ā¬†ļø".bright_yellow(), + current.dimmed(), + 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; + } + } + + // `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"); + }, + Ok(Err(e)) => log_verbose!("update check: could not refresh latest version: {e}"), + Err(e) => log_verbose!("update check: refresh task failed: {e}"), + } +} + +/// 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(); +}