From ad4ee266c7722de6b03c1e296590c030b2dec823 Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Fri, 20 Feb 2026 13:34:03 -0500 Subject: [PATCH 1/5] Move CLI version check to spacetimedb-update proxy (#1822) Instead of duplicating release-checking logic in spacetimedb-cli, add the update notice to spacetimedb-update's proxy path. This runs before exec'ing the CLI, so users see the notice regardless of which CLI command they run. Uses the same GitHub releases API and mirror URL as spacetime version upgrade. Caches the result for 24 hours to avoid network overhead on every invocation. Network checks use a 5s timeout and are best-effort (failures are silently ignored). Closes #1822 --- crates/update/Cargo.toml | 1 + crates/update/src/main.rs | 1 + crates/update/src/proxy.rs | 4 + crates/update/src/update_notice.rs | 140 +++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+) create mode 100644 crates/update/src/update_notice.rs diff --git a/crates/update/Cargo.toml b/crates/update/Cargo.toml index 385c5c0c02a..9d108fb820f 100644 --- a/crates/update/Cargo.toml +++ b/crates/update/Cargo.toml @@ -25,6 +25,7 @@ reqwest.workspace = true self-replace.workspace = true semver = { workspace = true, features = ["serde"] } serde.workspace = true +serde_json.workspace = true tar.workspace = true tempfile.workspace = true tokio.workspace = true diff --git a/crates/update/src/main.rs b/crates/update/src/main.rs index bfead942d40..98a5bbbdd7f 100644 --- a/crates/update/src/main.rs +++ b/crates/update/src/main.rs @@ -6,6 +6,7 @@ use clap::Parser; mod cli; mod proxy; +mod update_notice; fn main() -> anyhow::Result { let mut args = std::env::args_os(); diff --git a/crates/update/src/proxy.rs b/crates/update/src/proxy.rs index 51296ee85f4..f80293fa8f0 100644 --- a/crates/update/src/proxy.rs +++ b/crates/update/src/proxy.rs @@ -39,6 +39,10 @@ pub(crate) fn run_cli( paths.cli_bin_dir.current_version_dir().spacetimedb_cli() }; + // Check for updates before exec'ing the CLI. On Unix, exec replaces + // the process so this is our only chance to print a notice. + crate::update_notice::maybe_print_update_notice(paths.cli_config_dir.as_ref()); + let mut cmd = Command::new(&cli_path); cmd.args(&args); #[cfg(unix)] diff --git a/crates/update/src/update_notice.rs b/crates/update/src/update_notice.rs new file mode 100644 index 00000000000..fc3e0d95cc9 --- /dev/null +++ b/crates/update/src/update_notice.rs @@ -0,0 +1,140 @@ +//! Lightweight update notice check for the proxy path. +//! +//! Before exec'ing the CLI, we check a cache file to see if a newer version +//! is available. If the cache is stale (>24h), we do a quick HTTP check with +//! a short timeout to refresh it. The notice is printed to stderr. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// How long to cache the update check result. +const CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); + +/// HTTP timeout for the version check. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); + +/// Cache file name. +const CACHE_FILENAME: &str = ".update_check_cache"; + +const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[derive(serde::Serialize, serde::Deserialize, Default)] +struct Cache { + /// Unix timestamp of the last successful check. + last_check_secs: u64, + /// The latest version string (without "v" prefix), if known. + latest_version: Option, +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn cache_path(config_dir: &Path) -> PathBuf { + config_dir.join(CACHE_FILENAME) +} + +fn read_cache(path: &Path) -> Option { + let contents = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&contents).ok() +} + +fn write_cache(path: &Path, cache: &Cache) { + if let Ok(json) = serde_json::to_string(cache) { + let _ = std::fs::write(path, json); + } +} + +/// Fetch the latest release version tag from GitHub, using the same release URL +/// infrastructure as `spacetime version upgrade`. +async fn fetch_latest_version(client: &reqwest::Client) -> Option { + let releases_url = std::env::var("SPACETIME_UPDATE_RELEASES_URL") + .unwrap_or_else(|_| "https://api.github.com/repos/clockworklabs/SpacetimeDB/releases".to_owned()); + let url = format!("{releases_url}/latest"); + + let resp = client.get(&url).send().await.ok()?; + if !resp.status().is_success() { + return None; + } + + #[derive(serde::Deserialize)] + struct Release { + tag_name: String, + } + + let release: Release = resp.json().await.ok()?; + let version = release.tag_name.strip_prefix('v').unwrap_or(&release.tag_name); + Some(version.to_owned()) +} + +/// Check for updates and print a notice to stderr if a newer version is available. +/// +/// This is designed to be called from the proxy path before exec'ing the CLI. +/// It reads a cache file to avoid hitting the network on every invocation. +/// If the cache is stale, it makes a quick HTTP request (with timeout) to refresh. +/// +/// `config_dir` should be the SpacetimeDB config directory (e.g. `~/.spacetime`). +pub(crate) fn maybe_print_update_notice(config_dir: &Path) { + // Best-effort: never let a failed update check interfere with the user's command. + let _ = check_and_notify(config_dir); +} + +fn check_and_notify(config_dir: &Path) -> Option<()> { + let path = cache_path(config_dir); + let cache = read_cache(&path).unwrap_or_default(); + let now = now_secs(); + + let current = semver::Version::parse(CURRENT_VERSION).ok()?; + + if now.saturating_sub(cache.last_check_secs) < CHECK_INTERVAL.as_secs() { + // Cache is fresh — just check the cached version. + if let Some(ref latest_str) = cache.latest_version { + if let Ok(latest) = semver::Version::parse(latest_str) { + if latest > current { + print_notice(¤t, &latest); + } + } + } + return Some(()); + } + + // Cache is stale — do a quick network check. + let latest_str = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .ok()? + .block_on(async { + let client = reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .user_agent(format!("SpacetimeDB CLI/{CURRENT_VERSION}")) + .build() + .ok()?; + fetch_latest_version(&client).await + }); + + // Update the cache regardless of whether we got a result. + let new_cache = Cache { + last_check_secs: now, + latest_version: latest_str.clone(), + }; + write_cache(&path, &new_cache); + + if let Some(ref latest_str) = latest_str { + if let Ok(latest) = semver::Version::parse(latest_str) { + if latest > current { + print_notice(¤t, &latest); + } + } + } + + Some(()) +} + +fn print_notice(current: &semver::Version, latest: &semver::Version) { + eprintln!("\x1b[33mA new version of SpacetimeDB is available: v{latest} (current: v{current})\x1b[0m"); + eprintln!("Run `spacetime version upgrade` to update."); + eprintln!(); +} From c992e835d3d769e415420ad2a7024fdb2ce56d97 Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Fri, 20 Feb 2026 13:50:09 -0500 Subject: [PATCH 2/5] fix: allow disallowed_macros for CLI update notice (eprintln) --- crates/update/src/update_notice.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/update/src/update_notice.rs b/crates/update/src/update_notice.rs index fc3e0d95cc9..a795ba4f0da 100644 --- a/crates/update/src/update_notice.rs +++ b/crates/update/src/update_notice.rs @@ -133,6 +133,7 @@ fn check_and_notify(config_dir: &Path) -> Option<()> { Some(()) } +#[allow(clippy::disallowed_macros)] fn print_notice(current: &semver::Version, latest: &semver::Version) { eprintln!("\x1b[33mA new version of SpacetimeDB is available: v{latest} (current: v{current})\x1b[0m"); eprintln!("Run `spacetime version upgrade` to update."); From 82e3f059460854c7e4df90597cb32c387dac7b56 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 20 Feb 2026 11:04:10 -0800 Subject: [PATCH 3/5] [bot/cli-version-check]: update cargo lock --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index 8fdb8396928..d2186a5326b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8561,6 +8561,7 @@ dependencies = [ "self-replace", "semver", "serde", + "serde_json", "spacetimedb-paths", "tar", "tempfile", From bbec30ec9a872fd0244a335ee3c4adf32b66c108 Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Tue, 24 Feb 2026 13:37:29 -0500 Subject: [PATCH 4/5] refactor: reuse shared version-fetch logic in update_notice Address review comments from Zeke: 1. Extract fetch_latest_release_version() in install.rs as pub(crate), reuse it from update_notice.rs instead of duplicating the GitHub API call and URL construction. 2. Factor out notify_if_newer() and fetch_latest_version_cached() to eliminate the duplicated compare-and-print block between the cache-fresh and cache-stale paths. --- Cargo.lock | 1 + crates/update/src/cli.rs | 2 +- crates/update/src/cli/install.rs | 38 ++++++++++++++++-- crates/update/src/update_notice.rs | 62 ++++++++++++------------------ 4 files changed, 62 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8fdb8396928..d2186a5326b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8561,6 +8561,7 @@ dependencies = [ "self-replace", "semver", "serde", + "serde_json", "spacetimedb-paths", "tar", "tempfile", diff --git a/crates/update/src/cli.rs b/crates/update/src/cli.rs index 05a69d4c06d..3b2faab7006 100644 --- a/crates/update/src/cli.rs +++ b/crates/update/src/cli.rs @@ -6,7 +6,7 @@ use std::process::ExitCode; use spacetimedb_paths::{RootDir, SpacetimePaths}; -mod install; +pub(crate) mod install; mod link; mod list; mod self_install; diff --git a/crates/update/src/cli/install.rs b/crates/update/src/cli/install.rs index bc02273ee29..8746c70c963 100644 --- a/crates/update/src/cli/install.rs +++ b/crates/update/src/cli/install.rs @@ -54,11 +54,43 @@ pub(super) fn make_progress_bar() -> ProgressBar { pb } -fn releases_url() -> String { +pub(crate) fn releases_url() -> String { std::env::var("SPACETIME_UPDATE_RELEASES_URL") .unwrap_or_else(|_| "https://api.github.com/repos/clockworklabs/SpacetimeDB/releases".to_owned()) } +/// Fetch the latest release version from GitHub, falling back to the mirror. +/// +/// Returns `None` if both sources are unreachable. +pub(crate) async fn fetch_latest_release_version(client: &reqwest::Client) -> Option { + // Try GitHub first. + let url = format!("{}/latest", releases_url()); + if let Ok(resp) = client.get(&url).send().await { + if resp.status().is_success() { + if let Ok(release) = resp.json::().await { + if let Ok(v) = release.version() { + return Some(v); + } + } + } + } + + // Fall back to mirror. + let mirror_url = format!("{MIRROR_BASE_URL}/latest-version"); + let tag = client + .get(&mirror_url) + .send() + .await + .ok()? + .error_for_status() + .ok()? + .text() + .await + .ok()?; + let ver_str = tag.trim().strip_prefix('v').unwrap_or(tag.trim()); + semver::Version::parse(ver_str).ok() +} + const MIRROR_BASE_URL: &str = "https://spacetimedb-client-binaries.nyc3.digitaloceanspaces.com"; pub(super) fn mirror_asset_url(version: &semver::Version, asset_name: &str) -> String { @@ -262,13 +294,13 @@ pub(super) struct ReleaseAsset { } #[derive(Deserialize)] -pub(super) struct Release { +pub(crate) struct Release { tag_name: String, pub(super) assets: Vec, } impl Release { - fn version(&self) -> anyhow::Result { + pub(crate) fn version(&self) -> anyhow::Result { let ver = self.tag_name.strip_prefix('v').unwrap_or(&self.tag_name); Ok(semver::Version::parse(ver)?) } diff --git a/crates/update/src/update_notice.rs b/crates/update/src/update_notice.rs index a795ba4f0da..603d93be89c 100644 --- a/crates/update/src/update_notice.rs +++ b/crates/update/src/update_notice.rs @@ -7,6 +7,8 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use crate::cli::install::fetch_latest_release_version; + /// How long to cache the update check result. const CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); @@ -48,26 +50,13 @@ fn write_cache(path: &Path, cache: &Cache) { } } -/// Fetch the latest release version tag from GitHub, using the same release URL -/// infrastructure as `spacetime version upgrade`. -async fn fetch_latest_version(client: &reqwest::Client) -> Option { - let releases_url = std::env::var("SPACETIME_UPDATE_RELEASES_URL") - .unwrap_or_else(|_| "https://api.github.com/repos/clockworklabs/SpacetimeDB/releases".to_owned()); - let url = format!("{releases_url}/latest"); - - let resp = client.get(&url).send().await.ok()?; - if !resp.status().is_success() { - return None; - } - - #[derive(serde::Deserialize)] - struct Release { - tag_name: String, +/// If `latest` is newer than `current`, print an update notice to stderr. +fn notify_if_newer(current: &semver::Version, latest_str: &str) { + if let Ok(latest) = semver::Version::parse(latest_str) { + if latest > *current { + print_notice(current, &latest); + } } - - let release: Release = resp.json().await.ok()?; - let version = release.tag_name.strip_prefix('v').unwrap_or(&release.tag_name); - Some(version.to_owned()) } /// Check for updates and print a notice to stderr if a newer version is available. @@ -92,17 +81,24 @@ fn check_and_notify(config_dir: &Path) -> Option<()> { if now.saturating_sub(cache.last_check_secs) < CHECK_INTERVAL.as_secs() { // Cache is fresh — just check the cached version. if let Some(ref latest_str) = cache.latest_version { - if let Ok(latest) = semver::Version::parse(latest_str) { - if latest > current { - print_notice(¤t, &latest); - } - } + notify_if_newer(¤t, latest_str); } return Some(()); } // Cache is stale — do a quick network check. - let latest_str = tokio::runtime::Builder::new_current_thread() + let latest_version = fetch_latest_version_cached(&path, now); + + if let Some(ref latest) = latest_version { + notify_if_newer(¤t, &latest.to_string()); + } + + Some(()) +} + +/// Fetch the latest version (with timeout) and update the cache file. +fn fetch_latest_version_cached(cache_path: &Path, now: u64) -> Option { + let latest = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .ok()? @@ -112,25 +108,17 @@ fn check_and_notify(config_dir: &Path) -> Option<()> { .user_agent(format!("SpacetimeDB CLI/{CURRENT_VERSION}")) .build() .ok()?; - fetch_latest_version(&client).await + fetch_latest_release_version(&client).await }); // Update the cache regardless of whether we got a result. let new_cache = Cache { last_check_secs: now, - latest_version: latest_str.clone(), + latest_version: latest.as_ref().map(|v| v.to_string()), }; - write_cache(&path, &new_cache); + write_cache(cache_path, &new_cache); - if let Some(ref latest_str) = latest_str { - if let Ok(latest) = semver::Version::parse(latest_str) { - if latest > current { - print_notice(¤t, &latest); - } - } - } - - Some(()) + latest } #[allow(clippy::disallowed_macros)] From 1301057731fc92b6ae7df59238167d3655789ddb Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Tue, 24 Feb 2026 13:44:29 -0500 Subject: [PATCH 5/5] =?UTF-8?q?refactor:=20address=20review=20=E2=80=94=20?= =?UTF-8?q?deduplicate=20mirror=20fallback,=20fix=20cache=20bugs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Extract fetch_latest_version_from_mirror() as single source of truth for mirror version resolution. Reuse it in: - fetch_latest_release_version() (GitHub → mirror fallback) - mirror_release() (version=None case) - available_releases() (GitHub-unavailable fallback) 2. Fix cache behavior when fetch fails: don't save cache with latest_version=None (would silently skip checks for 24h). 3. Fix cache behavior when cached latest_version is None: fall through to re-check rather than returning early. --- crates/update/src/cli/install.rs | 72 +++++++++++++----------------- crates/update/src/update_notice.rs | 44 +++++++++--------- 2 files changed, 56 insertions(+), 60 deletions(-) diff --git a/crates/update/src/cli/install.rs b/crates/update/src/cli/install.rs index 8746c70c963..dbe62a0039a 100644 --- a/crates/update/src/cli/install.rs +++ b/crates/update/src/cli/install.rs @@ -59,6 +59,27 @@ pub(crate) fn releases_url() -> String { .unwrap_or_else(|_| "https://api.github.com/repos/clockworklabs/SpacetimeDB/releases".to_owned()) } +const MIRROR_BASE_URL: &str = "https://spacetimedb-client-binaries.nyc3.digitaloceanspaces.com"; + +/// Fetch the latest version tag from the mirror. +/// +/// This is the single source of truth for mirror version resolution. +pub(crate) async fn fetch_latest_version_from_mirror(client: &reqwest::Client) -> Option { + let url = format!("{MIRROR_BASE_URL}/latest-version"); + let tag = client + .get(&url) + .send() + .await + .ok()? + .error_for_status() + .ok()? + .text() + .await + .ok()?; + let ver_str = tag.trim().strip_prefix('v').unwrap_or(tag.trim()); + semver::Version::parse(ver_str).ok() +} + /// Fetch the latest release version from GitHub, falling back to the mirror. /// /// Returns `None` if both sources are unreachable. @@ -76,23 +97,9 @@ pub(crate) async fn fetch_latest_release_version(client: &reqwest::Client) -> Op } // Fall back to mirror. - let mirror_url = format!("{MIRROR_BASE_URL}/latest-version"); - let tag = client - .get(&mirror_url) - .send() - .await - .ok()? - .error_for_status() - .ok()? - .text() - .await - .ok()?; - let ver_str = tag.trim().strip_prefix('v').unwrap_or(tag.trim()); - semver::Version::parse(ver_str).ok() + fetch_latest_version_from_mirror(client).await } -const MIRROR_BASE_URL: &str = "https://spacetimedb-client-binaries.nyc3.digitaloceanspaces.com"; - pub(super) fn mirror_asset_url(version: &semver::Version, asset_name: &str) -> String { format!("{MIRROR_BASE_URL}/refs/tags/v{version}/{asset_name}") } @@ -102,26 +109,14 @@ async fn mirror_release( version: Option<&semver::Version>, download_name: &str, ) -> anyhow::Result<(semver::Version, Release)> { - let tag = match version { - Some(v) => format!("v{v}"), - None => { - let url = format!("{MIRROR_BASE_URL}/latest-version"); - client - .get(&url) - .send() - .await? - .error_for_status()? - .text() - .await? - .trim() - .to_owned() - } + let release_version = match version { + Some(v) => v.clone(), + None => fetch_latest_version_from_mirror(client) + .await + .context("Could not fetch latest version from mirror")?, }; - let ver_str = tag.strip_prefix('v').unwrap_or(&tag); - let release_version = - semver::Version::parse(ver_str).with_context(|| format!("Could not parse version from mirror: {tag}"))?; let release = Release { - tag_name: tag.clone(), + tag_name: format!("v{release_version}"), assets: vec![ReleaseAsset { name: download_name.to_owned(), browser_download_url: mirror_asset_url(&release_version, download_name), @@ -276,13 +271,10 @@ pub(super) async fn available_releases(client: &reqwest::Client) -> anyhow::Resu .collect(), Err(_) => { eprintln!("GitHub unavailable, fetching latest version from mirror..."); - let url = format!("{MIRROR_BASE_URL}/latest-version"); - let tag = client.get(&url).send().await?.error_for_status()?.text().await?; - let ver_str = tag.trim(); - let ver_str = ver_str.strip_prefix('v').unwrap_or(ver_str); - semver::Version::parse(ver_str) - .with_context(|| format!("Could not parse version from mirror: {ver_str}"))?; - Ok(vec![ver_str.to_owned()]) + let version = fetch_latest_version_from_mirror(client) + .await + .context("Could not fetch latest version from mirror")?; + Ok(vec![version.to_string()]) } } } diff --git a/crates/update/src/update_notice.rs b/crates/update/src/update_notice.rs index 603d93be89c..bdaccffe140 100644 --- a/crates/update/src/update_notice.rs +++ b/crates/update/src/update_notice.rs @@ -78,27 +78,40 @@ fn check_and_notify(config_dir: &Path) -> Option<()> { let current = semver::Version::parse(CURRENT_VERSION).ok()?; + // Cache is fresh and has a known latest version — use it. if now.saturating_sub(cache.last_check_secs) < CHECK_INTERVAL.as_secs() { - // Cache is fresh — just check the cached version. if let Some(ref latest_str) = cache.latest_version { notify_if_newer(¤t, latest_str); + return Some(()); } - return Some(()); + // Cache is fresh but latest_version is None (previous fetch failed). + // Fall through to re-check rather than silently skipping for 24h. } - // Cache is stale — do a quick network check. - let latest_version = fetch_latest_version_cached(&path, now); - - if let Some(ref latest) = latest_version { - notify_if_newer(¤t, &latest.to_string()); + // Cache is stale or empty — do a quick network check. + let latest_version = fetch_latest_version_now(); + + match latest_version { + Some(ref latest) => { + // Successful fetch — save to cache and compare. + let new_cache = Cache { + last_check_secs: now, + latest_version: Some(latest.to_string()), + }; + write_cache(&path, &new_cache); + notify_if_newer(¤t, &latest.to_string()); + } + None => { + // Fetch failed — don't update the cache so we retry next invocation. + } } Some(()) } -/// Fetch the latest version (with timeout) and update the cache file. -fn fetch_latest_version_cached(cache_path: &Path, now: u64) -> Option { - let latest = tokio::runtime::Builder::new_current_thread() +/// Fetch the latest version from GitHub/mirror with a short timeout. +fn fetch_latest_version_now() -> Option { + tokio::runtime::Builder::new_current_thread() .enable_all() .build() .ok()? @@ -109,16 +122,7 @@ fn fetch_latest_version_cached(cache_path: &Path, now: u64) -> Option