Skip to content
Open
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/update/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/update/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
80 changes: 52 additions & 28 deletions crates/update/src/cli/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<semver::Version> {
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<semver::Version> {
// 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::<Release>().await {
if let Ok(v) = release.version() {
return Some(v);
}
}
}
}

// Fall back to mirror.
fetch_latest_version_from_mirror(client).await
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

there is existing code in this same file that fetches the latest release, has a fallback mirror, etc. That code should be factored together with this function so we do not duplicate this logic.


pub(super) fn mirror_asset_url(version: &semver::Version, asset_name: &str) -> String {
format!("{MIRROR_BASE_URL}/refs/tags/v{version}/{asset_name}")
}
Expand All @@ -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),
Expand Down Expand Up @@ -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()])
}
}
}
Expand All @@ -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<ReleaseAsset>,
}

impl Release {
fn version(&self) -> anyhow::Result<semver::Version> {
pub(crate) fn version(&self) -> anyhow::Result<semver::Version> {
let ver = self.tag_name.strip_prefix('v').unwrap_or(&self.tag_name);
Ok(semver::Version::parse(ver)?)
}
Expand Down
1 change: 1 addition & 0 deletions crates/update/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use clap::Parser;

mod cli;
mod proxy;
mod update_notice;

fn main() -> anyhow::Result<ExitCode> {
let mut args = std::env::args_os();
Expand Down
4 changes: 4 additions & 0 deletions crates/update/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
133 changes: 133 additions & 0 deletions crates/update/src/update_notice.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
Copy link
Collaborator

Choose a reason for hiding this comment

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

does this need to be an Option anymore?

}

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<Cache> {
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 {
Copy link
Collaborator

Choose a reason for hiding this comment

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

this seems entirely incorrect. if we cached a broken thing, we just silently skip the update check?

notify_if_newer(&current, 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(&current, &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<semver::Version> {
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!();
}
Loading