diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 4c013ea..e24cd6d 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -8,6 +8,7 @@ repos:
rev: v6.0.0
hooks:
- id: end-of-file-fixer
+ exclude: ^docs/cli-commands\.md$
- id: trailing-whitespace
args:
- --markdown-linebreak-ext=md
diff --git a/Cargo.lock b/Cargo.lock
index a021f17..40786d6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2086,6 +2086,7 @@ dependencies = [
"regex",
"reqwest",
"ricochet-core",
+ "self-replace",
"serde",
"serde_json",
"serde_yaml",
@@ -2308,6 +2309,17 @@ 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 = "semver"
version = "1.0.27"
diff --git a/Cargo.toml b/Cargo.toml
index 524e29c..3a8afb5 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -49,13 +49,14 @@ ulid = "1.1"
unicode-icons = "2.0"
url = { version = "2.5", features = ["serde"] }
urlencoding = "2.1"
+self-replace = "1"
+tempfile = "3.8"
walkdir = "2.5"
webbrowser = "1.0"
[dev-dependencies]
mockito = "1.5"
serial_test = "3"
-tempfile = "3.8"
tokio-test = "0.4"
# Optimize dependencies even in dev mode for better test performance
diff --git a/docs/cli-commands.md b/docs/cli-commands.md
index e048a61..0373371 100644
--- a/docs/cli-commands.md
+++ b/docs/cli-commands.md
@@ -18,6 +18,7 @@ This document contains the help content for the `ricochet` command-line program.
* [`ricochet servers add`↴](#ricochet-servers-add)
* [`ricochet servers remove`↴](#ricochet-servers-remove)
* [`ricochet servers set-default`↴](#ricochet-servers-set-default)
+* [`ricochet self-update`↴](#ricochet-self-update)
## `ricochet`
@@ -36,6 +37,7 @@ Ricochet CLI
* `config` — Show configuration
* `init` — Initialize a new Ricochet deployment
* `servers` — Manage configured Ricochet servers
+* `self-update` — Update the ricochet CLI to the latest version
###### **Options:**
@@ -231,6 +233,18 @@ Set the default server
+## `ricochet self-update`
+
+Update the ricochet CLI to the latest version
+
+**Usage:** `ricochet self-update [OPTIONS]`
+
+###### **Options:**
+
+* `-f`, `--force` — Force reinstall even if already on the latest version
+
+
+
diff --git a/src/commands/auth/auth_unit_test.rs b/src/commands/auth/auth_unit_test.rs
index ec05e27..8404368 100644
--- a/src/commands/auth/auth_unit_test.rs
+++ b/src/commands/auth/auth_unit_test.rs
@@ -285,6 +285,7 @@ mod tests {
servers,
default_server: Some("prod".to_string()),
default_format: Some("table".to_string()),
+ skip_update_check: None,
}
}
diff --git a/src/commands/auth/login.rs b/src/commands/auth/login.rs
index 919ce40..3cac730 100644
--- a/src/commands/auth/login.rs
+++ b/src/commands/auth/login.rs
@@ -84,9 +84,7 @@ pub async fn login(
"{} Headless environment detected (no display server). Using manual key entry.",
"ℹ".bright_cyan()
);
- println!(
- "Create an API key in your server's web UI and paste it below.\n"
- );
+ println!("Create an API key in your server's web UI and paste it below.\n");
let key = Password::new()
.with_prompt("Enter API key (starts with 'rico_')")
diff --git a/src/commands/mod.rs b/src/commands/mod.rs
index 2381b77..c6581d0 100644
--- a/src/commands/mod.rs
+++ b/src/commands/mod.rs
@@ -6,3 +6,4 @@ pub mod init;
pub mod invoke;
pub mod list;
pub mod servers;
+pub mod update;
diff --git a/src/commands/update.rs b/src/commands/update.rs
new file mode 100644
index 0000000..479f303
--- /dev/null
+++ b/src/commands/update.rs
@@ -0,0 +1,231 @@
+use crate::update;
+use anyhow::{Context, Result};
+use colored::Colorize;
+use flate2::read::GzDecoder;
+use indicatif::{ProgressBar, ProgressStyle};
+use std::io::Read;
+
+const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
+
+/// Determine the download URL for the current platform.
+fn download_url(version: &str) -> Result {
+ #[cfg(any(
+ all(target_os = "linux", target_arch = "x86_64"),
+ all(target_os = "linux", target_arch = "aarch64"),
+ all(target_os = "windows", target_arch = "x86_64"),
+ ))]
+ let base = format!(
+ "https://github.com/ricochet-rs/cli/releases/download/v{}",
+ version
+ );
+
+ #[cfg(target_os = "macos")]
+ let base = format!(
+ "https://hel1.your-objectstorage.com/ricochet-cli/v{}",
+ version
+ );
+
+ #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
+ return Ok(format!("{}/ricochet-{}-linux-x86_64.tar.gz", base, version));
+
+ #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
+ return Ok(format!(
+ "{}/ricochet-{}-linux-aarch64.tar.gz",
+ base, version
+ ));
+
+ #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
+ return Ok(format!(
+ "{}/ricochet-{}-windows-x86_64.exe.tar.gz",
+ base, version
+ ));
+
+ #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
+ return Ok(format!("{}/ricochet-{}-darwin-arm64.tar.gz", base, version));
+
+ #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
+ return Ok(format!(
+ "{}/ricochet-{}-darwin-x86_64.tar.gz",
+ base, version
+ ));
+
+ #[cfg(not(any(
+ all(target_os = "linux", target_arch = "x86_64"),
+ all(target_os = "linux", target_arch = "aarch64"),
+ all(target_os = "windows", target_arch = "x86_64"),
+ all(target_os = "macos", target_arch = "aarch64"),
+ all(target_os = "macos", target_arch = "x86_64"),
+ )))]
+ anyhow::bail!(
+ "self-update is not supported on this platform.\n Install manually: https://github.com/ricochet-rs/cli/releases"
+ );
+}
+
+/// The binary name inside the tarball for the current platform.
+/// macOS bottles have a different structure: ricochet/{version}/bin/ricochet
+fn binary_name_in_tarball(version: &str) -> Result {
+ #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
+ return Ok(format!("ricochet-{}-linux-x86_64", version));
+
+ #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
+ return Ok(format!("ricochet-{}-linux-aarch64", version));
+
+ #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
+ return Ok(format!("ricochet-{}-windows-x86_64.exe", version));
+
+ // macOS bottles: the binary is at ricochet/{version}/bin/ricochet inside the tarball
+ #[cfg(target_os = "macos")]
+ return Ok(format!("ricochet/{}/bin/ricochet", version));
+
+ #[cfg(not(any(
+ all(target_os = "linux", target_arch = "x86_64"),
+ all(target_os = "linux", target_arch = "aarch64"),
+ all(target_os = "windows", target_arch = "x86_64"),
+ target_os = "macos",
+ )))]
+ {
+ let _ = version;
+ anyhow::bail!("self-update is not supported on this platform");
+ }
+}
+
+pub async fn self_update(force: bool) -> Result<()> {
+ println!("Checking for updates...");
+
+ let latest = update::fetch_latest_version()
+ .await
+ .context("Failed to fetch latest version from GitHub")?;
+
+ if !update::is_newer(CURRENT_VERSION, &latest) && !force {
+ println!(
+ "{} Already on the latest version: {}",
+ "✓".green().bold(),
+ CURRENT_VERSION.bright_cyan()
+ );
+ return Ok(());
+ }
+
+ if !update::is_newer(CURRENT_VERSION, &latest) {
+ println!(
+ "Reinstalling current version {} (--force)",
+ latest.bright_cyan()
+ );
+ } else {
+ println!(
+ "Updating {} -> {}",
+ CURRENT_VERSION.dimmed(),
+ latest.green().bold()
+ );
+ }
+
+ let url = download_url(&latest)?;
+
+ let client = reqwest::Client::builder()
+ .user_agent(concat!("ricochet-cli/", env!("CARGO_PKG_VERSION")))
+ .timeout(std::time::Duration::from_secs(120))
+ .build()?;
+
+ let spinner = ProgressBar::new_spinner();
+ spinner.set_style(
+ ProgressStyle::default_spinner()
+ .template("{spinner:.green} {msg}")
+ .unwrap(),
+ );
+ spinner.set_message(format!("Downloading v{}...", latest));
+ spinner.enable_steady_tick(std::time::Duration::from_millis(80));
+
+ let tarball_bytes = client
+ .get(&url)
+ .send()
+ .await
+ .context("Failed to download release tarball")?
+ .error_for_status()
+ .context("Download failed (server returned error)")?
+ .bytes()
+ .await
+ .context("Failed to read download response")?;
+
+ spinner.finish_and_clear();
+ println!("{} Downloaded ({} bytes)", "✓".green(), tarball_bytes.len());
+
+ let binary_path = binary_name_in_tarball(&latest)?;
+ let extracted_bytes = extract_binary_from_tarball(&tarball_bytes, &binary_path)
+ .with_context(|| format!("Failed to extract '{}' from tarball", binary_path))?;
+
+ // Write extracted binary to a temp file, then use self_replace to atomically swap it in.
+ // self_replace handles platform quirks like Windows locking the running executable.
+ let tmp_dir = tempfile::tempdir().context("Failed to create temp directory")?;
+ let tmp_path = tmp_dir.path().join("ricochet-new");
+ std::fs::write(&tmp_path, &extracted_bytes)
+ .context("Failed to write new binary to temp file")?;
+
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))
+ .context("Failed to set executable permissions")?;
+ }
+
+ self_replace::self_replace(&tmp_path)
+ .context("Failed to replace the ricochet binary. You may need elevated permissions.")?;
+
+ // Update the cache: reset failure counter and record the new version
+ let _ = update::save_cache(&update::UpdateCache {
+ last_checked: chrono::Utc::now(),
+ latest_version: latest.clone(),
+ consecutive_failures: 0,
+ });
+
+ // Re-enable update checks if they were auto-disabled due to previous failures
+ if let Ok(mut config) = crate::config::Config::load()
+ && config.skip_update_check == Some(true)
+ {
+ config.skip_update_check = None;
+ let _ = config.save();
+ }
+
+ println!(
+ "\n{} Successfully updated to v{}",
+ "✓".green().bold(),
+ latest.bright_cyan()
+ );
+ println!(
+ "Release notes: {}",
+ update::release_notes_url(&latest).bright_cyan()
+ );
+
+ Ok(())
+}
+
+fn extract_binary_from_tarball(tarball: &[u8], binary_path: &str) -> Result> {
+ use std::io::Cursor;
+
+ let cursor = Cursor::new(tarball);
+ let gz = GzDecoder::new(cursor);
+ let mut archive = tar::Archive::new(gz);
+
+ for entry in archive.entries().context("Failed to read tar entries")? {
+ let mut entry = entry.context("Failed to read tar entry")?;
+ let path = entry.path().context("Failed to read entry path")?;
+ let path_str = path.to_string_lossy();
+
+ // Match against full path (for macOS bottles: ricochet/0.4.0/bin/ricochet)
+ // or just the filename (for Linux/Windows flat tarballs)
+ let matches = path_str == binary_path
+ || path
+ .file_name()
+ .and_then(|n| n.to_str())
+ .map(|n| n == binary_path)
+ .unwrap_or(false);
+
+ if matches {
+ let mut bytes = Vec::new();
+ entry
+ .read_to_end(&mut bytes)
+ .context("Failed to read binary from tar")?;
+ return Ok(bytes);
+ }
+ }
+
+ anyhow::bail!("Binary '{}' not found in tarball", binary_path)
+}
diff --git a/src/config.rs b/src/config.rs
index 3bce046..621b6f4 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -38,6 +38,11 @@ pub struct Config {
pub default_server: Option,
pub default_format: Option,
+
+ /// When set to true, disables the periodic background update check.
+ /// This is set automatically when the GitHub API is repeatedly unreachable.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub skip_update_check: Option,
}
impl Default for Config {
@@ -56,6 +61,7 @@ impl Default for Config {
servers,
default_server: Some("default".to_string()),
default_format: Some("table".to_string()),
+ skip_update_check: None,
}
}
}
@@ -78,6 +84,7 @@ impl Config {
servers,
default_server: Some("default".to_string()),
default_format: Some("table".to_string()),
+ skip_update_check: None,
}
}
}
@@ -309,6 +316,7 @@ mod tests {
servers,
default_server: Some("prod".to_string()),
default_format: Some("table".to_string()),
+ skip_update_check: None,
}
}
@@ -367,6 +375,7 @@ mod tests {
servers: HashMap::new(),
default_server: None,
default_format: Some("table".to_string()),
+ skip_update_check: None,
};
let url = Url::parse("https://first.server.com").unwrap();
@@ -491,6 +500,7 @@ mod tests {
servers: HashMap::new(),
default_server: None,
default_format: Some("table".to_string()),
+ skip_update_check: None,
};
let servers = config.list_servers();
@@ -534,6 +544,7 @@ mod tests {
servers: HashMap::new(),
default_server: None,
default_format: Some("table".to_string()),
+ skip_update_check: None,
};
let result = config.resolve_server(None);
@@ -628,6 +639,7 @@ mod tests {
servers: HashMap::new(),
default_server: None,
default_format: Some("table".to_string()),
+ skip_update_check: None,
};
config.migrate_v1_config();
@@ -650,6 +662,7 @@ mod tests {
servers: HashMap::new(),
default_server: None,
default_format: Some("table".to_string()),
+ skip_update_check: None,
};
config.migrate_v1_config();
@@ -733,6 +746,7 @@ mod tests {
servers: HashMap::new(),
default_server: None,
default_format: Some("table".to_string()),
+ skip_update_check: None,
};
assert_eq!(config.default_server(), None);
diff --git a/src/lib.rs b/src/lib.rs
index e8d78f6..f55f927 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,6 +1,7 @@
pub mod client;
pub mod commands;
pub mod config;
+pub mod update;
pub mod utils;
#[derive(clap::ValueEnum, Clone, Debug, Copy)]
diff --git a/src/main.rs b/src/main.rs
index e6028d8..a9256c3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,6 +1,6 @@
use anyhow::Result;
use clap::{Parser, Subcommand};
-use ricochet_cli::{OutputFormat, commands, config::Config};
+use ricochet_cli::{OutputFormat, commands, config::Config, update};
#[derive(Parser)]
#[command(name = "ricochet")]
@@ -111,6 +111,12 @@ enum Commands {
#[command(subcommand)]
command: ServersCommands,
},
+ /// Update the ricochet CLI to the latest version
+ SelfUpdate {
+ /// Force reinstall even if already on the latest version
+ #[arg(short = 'f', long)]
+ force: bool,
+ },
/// Generate markdown documentation (hidden command)
#[command(hide = true)]
GenerateDocs,
@@ -237,6 +243,9 @@ async fn main() -> Result<()> {
commands::servers::set_default(&mut config, name)?;
}
},
+ Some(Commands::SelfUpdate { force }) => {
+ commands::update::self_update(force).await?;
+ }
Some(Commands::GenerateDocs) => {
let markdown = clap_markdown::help_markdown::();
println!("{}", markdown);
@@ -248,5 +257,15 @@ async fn main() -> Result<()> {
}
}
+ // Background update check and notification.
+ // Skipped for Homebrew installs, when RICOCHET_NO_UPDATE_CHECK is set,
+ // or when skip_update_check is set in config (auto-set after repeated failures).
+ update::maybe_notify_update(&config);
+ if let Some(handle) = update::trigger_background_check(&config) {
+ // Give the background check a short window to complete before exiting.
+ // If it doesn't finish in time, the cache will be written on the next run.
+ let _ = tokio::time::timeout(std::time::Duration::from_secs(5), handle).await;
+ }
+
Ok(())
}
diff --git a/src/update.rs b/src/update.rs
new file mode 100644
index 0000000..6847b74
--- /dev/null
+++ b/src/update.rs
@@ -0,0 +1,288 @@
+//! Background update checking and notification.
+//!
+//! Checks for new CLI versions via the GitHub Releases API every 24 hours and
+//! notifies the user via stderr. Skipped when the binary lives in /opt/homebrew/bin
+//! (Homebrew manages updates there), when RICOCHET_NO_UPDATE_CHECK is set, or when
+//! skip_update_check is set in the config (auto-set after repeated GitHub API failures).
+
+use anyhow::{Context, Result};
+use serde::{Deserialize, Serialize};
+use std::path::PathBuf;
+
+use crate::config::Config;
+
+const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
+const GITHUB_API_URL: &str = "https://api.github.com/repos/ricochet-rs/cli/releases/latest";
+const RELEASE_NOTES_BASE: &str = "https://github.com/ricochet-rs/cli/releases/tag";
+const CHECK_INTERVAL_SECS: u64 = 60 * 60 * 24; // 24 hours
+const MAX_CONSECUTIVE_FAILURES: u32 = 3;
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct UpdateCache {
+ pub last_checked: chrono::DateTime,
+ pub latest_version: String,
+ #[serde(default)]
+ pub consecutive_failures: u32,
+}
+
+#[derive(Debug, Deserialize)]
+struct GitHubRelease {
+ tag_name: String,
+}
+
+/// Returns true if update checks should be suppressed.
+fn should_suppress_checks(config: &Config) -> bool {
+ if config.skip_update_check == Some(true) {
+ return true;
+ }
+ if std::env::var("RICOCHET_NO_UPDATE_CHECK")
+ .map(|v| !v.is_empty())
+ .unwrap_or(false)
+ {
+ return true;
+ }
+ if std::env::var("CI").is_ok() {
+ return true;
+ }
+ if let Ok(exe) = std::env::current_exe()
+ && let Some(parent) = exe.parent()
+ && parent.starts_with("/opt/homebrew")
+ {
+ return true;
+ }
+ false
+}
+
+pub fn cache_path() -> Result {
+ let cache_dir = dirs::cache_dir().context("Failed to get cache directory")?;
+ Ok(cache_dir.join("ricochet").join("update-check.json"))
+}
+
+pub fn load_cache() -> Option {
+ let path = cache_path().ok()?;
+ let content = std::fs::read_to_string(path).ok()?;
+ serde_json::from_str(&content).ok()
+}
+
+pub fn save_cache(cache: &UpdateCache) -> Result<()> {
+ let path = cache_path()?;
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent).context("Failed to create cache directory")?;
+ }
+ let content =
+ serde_json::to_string_pretty(cache).context("Failed to serialize update cache")?;
+ std::fs::write(&path, content).context("Failed to write update cache")?;
+ Ok(())
+}
+
+pub fn release_notes_url(version: &str) -> String {
+ format!("{}/v{}", RELEASE_NOTES_BASE, version)
+}
+
+/// Fetch the latest release version string (without leading 'v') from GitHub.
+pub async fn fetch_latest_version() -> Result {
+ let client = reqwest::Client::builder()
+ .user_agent(concat!("ricochet-cli/", env!("CARGO_PKG_VERSION")))
+ .timeout(std::time::Duration::from_secs(10))
+ .build()?;
+
+ let release: GitHubRelease = client
+ .get(GITHUB_API_URL)
+ .send()
+ .await
+ .context("Failed to contact GitHub API")?
+ .error_for_status()
+ .context("GitHub API returned error")?
+ .json()
+ .await
+ .context("Failed to parse GitHub API response")?;
+
+ Ok(release.tag_name.trim_start_matches('v').to_string())
+}
+
+/// Returns true if `candidate` is a newer version than `current`.
+pub fn is_newer(current: &str, candidate: &str) -> bool {
+ fn parse(v: &str) -> Option<(u64, u64, u64)> {
+ let v = v.split('-').next()?;
+ let parts: Vec = v.split('.').filter_map(|p| p.parse().ok()).collect();
+ if parts.len() >= 3 {
+ Some((parts[0], parts[1], parts[2]))
+ } else {
+ None
+ }
+ }
+ match (parse(current), parse(candidate)) {
+ (Some(c), Some(n)) => n > c,
+ _ => false,
+ }
+}
+
+/// Background task: fetch latest version and save to cache.
+/// On success, resets the failure counter. On failure, increments it.
+/// After MAX_CONSECUTIVE_FAILURES, auto-disables update checks in the config
+/// and notifies the user via stderr.
+pub async fn check_for_update() -> Option {
+ let previous_failures = load_cache().map(|c| c.consecutive_failures).unwrap_or(0);
+
+ match fetch_latest_version().await {
+ Ok(latest) => {
+ let cache = UpdateCache {
+ last_checked: chrono::Utc::now(),
+ latest_version: latest.clone(),
+ consecutive_failures: 0,
+ };
+ let _ = save_cache(&cache);
+ if is_newer(CURRENT_VERSION, &latest) {
+ Some(latest)
+ } else {
+ None
+ }
+ }
+ Err(_) => {
+ let failures = previous_failures + 1;
+ let cache = UpdateCache {
+ last_checked: chrono::Utc::now(),
+ latest_version: load_cache()
+ .map(|c| c.latest_version)
+ .unwrap_or_else(|| CURRENT_VERSION.to_string()),
+ consecutive_failures: failures,
+ };
+ let _ = save_cache(&cache);
+
+ if failures >= MAX_CONSECUTIVE_FAILURES {
+ disable_update_checks();
+ }
+
+ None
+ }
+ }
+}
+
+/// Disable update checks by setting skip_update_check in the config file,
+/// and inform the user via stderr.
+fn disable_update_checks() {
+ use colored::Colorize;
+
+ if let Ok(mut config) = Config::load() {
+ config.skip_update_check = Some(true);
+ let _ = config.save();
+ }
+
+ eprintln!(
+ "\n{} Automatic update checks have been disabled after {} consecutive failures reaching GitHub.\n To re-enable, set {} in your config file or run:\n {}",
+ "notice:".yellow().bold(),
+ MAX_CONSECUTIVE_FAILURES,
+ "skip_update_check = false".bright_cyan(),
+ "ricochet self-update".bright_cyan(),
+ );
+}
+
+/// Print a one-line stderr notice if a newer version is recorded in the cache.
+/// Reads the on-disk cache synchronously — no network call.
+pub fn maybe_notify_update(config: &Config) {
+ if should_suppress_checks(config) {
+ return;
+ }
+ use colored::Colorize;
+ let Some(cache) = load_cache() else { return };
+ if is_newer(CURRENT_VERSION, &cache.latest_version) {
+ eprintln!(
+ "\n{} A new version of ricochet is available: {} -> {}\n Update with: {}\n Release notes: {}",
+ "notice:".yellow().bold(),
+ CURRENT_VERSION.dimmed(),
+ cache.latest_version.green().bold(),
+ "ricochet self-update".bright_cyan(),
+ release_notes_url(&cache.latest_version).dimmed(),
+ );
+ }
+}
+
+/// If the last update check was more than 24h ago (or never), spawn a background
+/// tokio task to fetch the latest version and refresh the cache.
+/// Returns the JoinHandle so the caller can await it with a timeout.
+pub fn trigger_background_check(config: &Config) -> Option> {
+ if should_suppress_checks(config) {
+ return None;
+ }
+
+ let should_check = match load_cache() {
+ None => true,
+ Some(cache) => {
+ let age = chrono::Utc::now()
+ .signed_duration_since(cache.last_checked)
+ .num_seconds()
+ .unsigned_abs();
+ age >= CHECK_INTERVAL_SECS
+ }
+ };
+
+ if should_check {
+ Some(tokio::spawn(async {
+ let _ = check_for_update().await;
+ }))
+ } else {
+ None
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_is_newer_basic() {
+ assert!(is_newer("0.3.0", "0.4.0"));
+ assert!(is_newer("0.3.0", "0.3.1"));
+ assert!(is_newer("0.3.0", "1.0.0"));
+ assert!(!is_newer("0.3.0", "0.3.0"));
+ assert!(!is_newer("0.4.0", "0.3.0"));
+ }
+
+ #[test]
+ fn test_is_newer_double_digit() {
+ assert!(is_newer("0.9.0", "0.10.0"));
+ assert!(!is_newer("0.10.0", "0.9.0"));
+ }
+
+ #[test]
+ fn test_is_newer_with_prerelease() {
+ assert!(is_newer("0.3.0-abc1234", "0.4.0"));
+ assert!(!is_newer("0.4.0", "0.3.0-abc1234"));
+ }
+
+ #[test]
+ fn test_is_newer_unparseable_returns_false() {
+ assert!(!is_newer("0.3.0", "garbage"));
+ assert!(!is_newer("garbage", "0.4.0"));
+ assert!(!is_newer("garbage", "also-garbage"));
+ }
+
+ #[test]
+ fn test_release_notes_url() {
+ assert_eq!(
+ release_notes_url("0.4.0"),
+ "https://github.com/ricochet-rs/cli/releases/tag/v0.4.0"
+ );
+ }
+
+ #[test]
+ fn test_cache_roundtrip_with_failures() {
+ let cache = UpdateCache {
+ last_checked: chrono::Utc::now(),
+ latest_version: "0.4.0".to_string(),
+ consecutive_failures: 2,
+ };
+ let json = serde_json::to_string(&cache).unwrap();
+ let loaded: UpdateCache = serde_json::from_str(&json).unwrap();
+ assert_eq!(loaded.consecutive_failures, 2);
+ assert_eq!(loaded.latest_version, "0.4.0");
+ }
+
+ #[test]
+ fn test_cache_deserializes_without_failures_field() {
+ // Backward compat: old cache files won't have consecutive_failures
+ let json = r#"{"last_checked":"2026-02-20T00:00:00Z","latest_version":"0.3.0"}"#;
+ let loaded: UpdateCache = serde_json::from_str(json).unwrap();
+ assert_eq!(loaded.consecutive_failures, 0);
+ }
+}
diff --git a/tests/deploy_test.rs b/tests/deploy_test.rs
index 9243a4b..56f5cf2 100644
--- a/tests/deploy_test.rs
+++ b/tests/deploy_test.rs
@@ -34,6 +34,7 @@ mod deploy_tests {
servers,
default_server: Some("prod".to_string()),
default_format: Some("table".to_string()),
+ skip_update_check: None,
}
}
diff --git a/tests/servers_test.rs b/tests/servers_test.rs
index dfc742f..3b0c060 100644
--- a/tests/servers_test.rs
+++ b/tests/servers_test.rs
@@ -71,6 +71,7 @@ fn create_multi_server_config() -> Config {
servers,
default_server: Some("prod".to_string()),
default_format: Some("table".to_string()),
+ skip_update_check: None,
}
}
@@ -91,6 +92,7 @@ mod servers_tests {
servers: HashMap::new(),
default_server: None,
default_format: Some("table".to_string()),
+ skip_update_check: None,
};
let url = Url::parse("https://new.server.com").unwrap();
@@ -140,6 +142,7 @@ mod servers_tests {
servers: HashMap::new(),
default_server: None,
default_format: Some("table".to_string()),
+ skip_update_check: None,
};
// Add first server
@@ -416,6 +419,7 @@ mod servers_tests {
servers: HashMap::new(),
default_server: None,
default_format: Some("table".to_string()),
+ skip_update_check: None,
};
config.add_server(
@@ -494,6 +498,7 @@ mod servers_tests {
servers: HashMap::new(),
default_server: None,
default_format: Some("table".to_string()),
+ skip_update_check: None,
};
let result = config.resolve_server(None);