Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
308 changes: 295 additions & 13 deletions Cargo.lock

Large diffs are not rendered by default.

16 changes: 15 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand All @@ -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"

Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
18 changes: 18 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<String>,
},

/// Check compatibility with the connected node
CompatibilityCheck,

Expand Down Expand Up @@ -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) =>
Expand Down
168 changes: 168 additions & 0 deletions src/cli/update.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
) -> 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<String> {
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<String>,
) -> crate::error::Result<UpdateOutcome> {
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()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check-only uses wrong latest

Medium Severity

quantus update --check treats the first entry from ReleaseList::fetch() as “latest”, which can be a prerelease or any newest tag on the full releases list. Background checks and self_update’s install path follow GitHub’s /releases/latest (stable) semantics, so --check can report an upgrade that quantus update will not perform.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 10166c5. Configure here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dewabisma this one is true positive

}

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}"))
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
15 changes: 15 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ mod config;
mod error;
mod log;
mod subsquid;
mod version_check;
mod wallet;
mod wormhole_lib;

Expand Down Expand Up @@ -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 =
Expand All @@ -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);
},
}
Expand Down
Loading
Loading