diff --git a/Cargo.lock b/Cargo.lock index 8af1de0f1f4..88c47f1ddaf 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/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/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..dbe62a0039a 100644 --- a/crates/update/src/cli/install.rs +++ b/crates/update/src/cli/install.rs @@ -54,13 +54,52 @@ 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()) } 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. +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. + fetch_latest_version_from_mirror(client).await +} + pub(super) fn mirror_asset_url(version: &semver::Version, asset_name: &str) -> String { format!("{MIRROR_BASE_URL}/refs/tags/v{version}/{asset_name}") } @@ -70,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), @@ -244,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()]) } } } @@ -262,13 +286,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/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..bdaccffe140 --- /dev/null +++ b/crates/update/src/update_notice.rs @@ -0,0 +1,133 @@ +//! 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}; + +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); + +/// 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); + } +} + +/// 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); + } + } +} + +/// 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()?; + + // Cache is fresh and has a known latest version — use it. + if now.saturating_sub(cache.last_check_secs) < CHECK_INTERVAL.as_secs() { + if let Some(ref latest_str) = cache.latest_version { + notify_if_newer(¤t, latest_str); + 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 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 from GitHub/mirror with a short timeout. +fn fetch_latest_version_now() -> Option { + 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_release_version(&client).await + }) +} + +#[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."); + eprintln!(); +}