Skip to content

Commit 5404126

Browse files
clockwork-labs-botbfopsclockwork-labs-bot
authored
CLI - Notify users if there's an update available (#4363)
## Summary Adds a version update check to the `spacetimedb-update` proxy, so users are notified when a newer version of SpacetimeDB is available. ## Changes Adds `crates/update/src/update_notice.rs` — a lightweight update check that runs in the proxy path before exec'ing the CLI: - **Cache-based**: Stores the last check result in `~/.spacetime/.update_check_cache`. Only hits the network once every 24 hours. - **Non-blocking on cache hit**: If the cache is fresh, it's a single file read — no network, no delay. - **Short timeout**: When the cache is stale, makes a single HTTP request to GitHub releases API with a 5-second timeout. Uses the same `SPACETIME_UPDATE_RELEASES_URL` env var as `spacetime version upgrade`. - **Best-effort**: Any failure (network, parse, file I/O) is silently ignored. The update check never interferes with the user's command. - **Uses semver**: Proper version comparison via the `semver` crate (already a dependency). ## Output When a newer version is available: ``` A new version of SpacetimeDB is available: v2.1.0 (current: v2.0.0) Run `spacetime version upgrade` to update. ``` # Testing - [x] I get a warning if my local version is less than 2.0.2 - [x] If I have a cached update check, I get the same error even if I have no network connection - [x] if the cache is old, the next command checks again - [x] if I'm not connected and the cache is stale, I'm still able to use the CLI --------- Co-authored-by: clockwork-labs-bot <clockwork-labs-bot@users.noreply.github.com> Co-authored-by: Zeke Foppa <bfops@users.noreply.github.com> Co-authored-by: clockwork-labs-bot <bot@clockworklabs.com> Co-authored-by: Zeke Foppa <196249+bfops@users.noreply.github.com>
1 parent 36a9481 commit 5404126

7 files changed

Lines changed: 182 additions & 30 deletions

File tree

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/update/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,14 @@ clap.workspace = true
2020
dialoguer = { workspace = true, default-features = false }
2121
flate2.workspace = true
2222
http-body-util = "0.1.2"
23+
colored.workspace = true
2324
indicatif.workspace = true
25+
log.workspace = true
2426
reqwest.workspace = true
2527
self-replace.workspace = true
2628
semver = { workspace = true, features = ["serde"] }
2729
serde.workspace = true
30+
serde_json.workspace = true
2831
tar.workspace = true
2932
tempfile.workspace = true
3033
tokio.workspace = true

crates/update/src/cli.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use std::process::ExitCode;
66

77
use spacetimedb_paths::{RootDir, SpacetimePaths};
88

9-
mod install;
9+
pub(crate) mod install;
1010
mod link;
1111
mod list;
1212
mod self_install;
@@ -88,7 +88,7 @@ enum VersionSubcommand {
8888
Link(link::Link),
8989
}
9090

91-
fn reqwest_client() -> anyhow::Result<reqwest::Client> {
91+
pub(crate) fn reqwest_client_builder() -> reqwest::ClientBuilder {
9292
let mut client = reqwest::Client::builder();
9393
#[cfg(feature = "github-token-auth")]
9494
{
@@ -101,10 +101,14 @@ fn reqwest_client() -> anyhow::Result<reqwest::Client> {
101101
}
102102
}
103103
client = client.user_agent(format!("SpacetimeDB CLI/{}", env!("CARGO_PKG_VERSION")));
104-
Ok(client.build()?)
104+
client
105105
}
106106

107-
fn tokio_block_on<Fut: Future>(fut: Fut) -> anyhow::Result<Fut::Output> {
107+
pub(crate) fn reqwest_client() -> anyhow::Result<reqwest::Client> {
108+
Ok(reqwest_client_builder().build()?)
109+
}
110+
111+
pub(crate) fn tokio_block_on<Fut: Future>(fut: Fut) -> anyhow::Result<Fut::Output> {
108112
Ok(tokio::runtime::Runtime::new()?.block_on(fut))
109113
}
110114

crates/update/src/cli/install.rs

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,37 @@ fn releases_url() -> String {
6161

6262
const MIRROR_BASE_URL: &str = "https://spacetimedb-client-binaries.nyc3.digitaloceanspaces.com";
6363

64+
/// Fetch the latest version tag from the mirror.
65+
///
66+
/// This is the single source of truth for mirror version resolution.
67+
async fn fetch_latest_version_from_mirror(client: &reqwest::Client) -> anyhow::Result<semver::Version> {
68+
let inner = async || {
69+
let url = format!("{MIRROR_BASE_URL}/latest-version");
70+
let tag = client.get(&url).send().await?.error_for_status()?.text().await?;
71+
let ver_str = tag.trim().strip_prefix('v').unwrap_or(tag.trim());
72+
semver::Version::parse(ver_str).context("Could not parse version")
73+
};
74+
inner().await.context("Could not fetch latest version from mirror")
75+
}
76+
77+
/// Fetch the latest release version from GitHub, falling back to the mirror.
78+
///
79+
/// Returns `None` if both sources are unreachable.
80+
pub(crate) async fn fetch_latest_release_version(client: &reqwest::Client) -> anyhow::Result<semver::Version> {
81+
// Try GitHub first.
82+
let url = format!("{}/latest", releases_url());
83+
if let Ok(resp) = client.get(&url).send().await
84+
&& resp.status().is_success()
85+
&& let Ok(release) = resp.json::<Release>().await
86+
&& let Ok(v) = release.version()
87+
{
88+
return Ok(v);
89+
}
90+
91+
// Fall back to mirror.
92+
fetch_latest_version_from_mirror(client).await
93+
}
94+
6495
pub(super) fn mirror_asset_url(version: &semver::Version, asset_name: &str) -> String {
6596
format!("{MIRROR_BASE_URL}/refs/tags/v{version}/{asset_name}")
6697
}
@@ -70,26 +101,12 @@ async fn mirror_release(
70101
version: Option<&semver::Version>,
71102
download_name: &str,
72103
) -> anyhow::Result<(semver::Version, Release)> {
73-
let tag = match version {
74-
Some(v) => format!("v{v}"),
75-
None => {
76-
let url = format!("{MIRROR_BASE_URL}/latest-version");
77-
client
78-
.get(&url)
79-
.send()
80-
.await?
81-
.error_for_status()?
82-
.text()
83-
.await?
84-
.trim()
85-
.to_owned()
86-
}
104+
let release_version = match version {
105+
Some(v) => v.clone(),
106+
None => fetch_latest_version_from_mirror(client).await?,
87107
};
88-
let ver_str = tag.strip_prefix('v').unwrap_or(&tag);
89-
let release_version =
90-
semver::Version::parse(ver_str).with_context(|| format!("Could not parse version from mirror: {tag}"))?;
91108
let release = Release {
92-
tag_name: tag.clone(),
109+
tag_name: format!("v{release_version}"),
93110
assets: vec![ReleaseAsset {
94111
name: download_name.to_owned(),
95112
browser_download_url: mirror_asset_url(&release_version, download_name),
@@ -244,13 +261,8 @@ pub(super) async fn available_releases(client: &reqwest::Client) -> anyhow::Resu
244261
.collect(),
245262
Err(_) => {
246263
eprintln!("GitHub unavailable, fetching latest version from mirror...");
247-
let url = format!("{MIRROR_BASE_URL}/latest-version");
248-
let tag = client.get(&url).send().await?.error_for_status()?.text().await?;
249-
let ver_str = tag.trim();
250-
let ver_str = ver_str.strip_prefix('v').unwrap_or(ver_str);
251-
semver::Version::parse(ver_str)
252-
.with_context(|| format!("Could not parse version from mirror: {ver_str}"))?;
253-
Ok(vec![ver_str.to_owned()])
264+
let version = fetch_latest_version_from_mirror(client).await?;
265+
Ok(vec![version.to_string()])
254266
}
255267
}
256268
}
@@ -262,7 +274,7 @@ pub(super) struct ReleaseAsset {
262274
}
263275

264276
#[derive(Deserialize)]
265-
pub(super) struct Release {
277+
pub struct Release {
266278
tag_name: String,
267279
pub(super) assets: Vec<ReleaseAsset>,
268280
}

crates/update/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use clap::Parser;
66

77
mod cli;
88
mod proxy;
9+
mod update_notice;
910

1011
fn main() -> anyhow::Result<ExitCode> {
1112
let mut args = std::env::args_os();

crates/update/src/proxy.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ pub(crate) fn run_cli(
3939
paths.cli_bin_dir.current_version_dir().spacetimedb_cli()
4040
};
4141

42+
// Check for updates before exec'ing the CLI. On Unix, exec replaces
43+
// the process so this is our only chance to print a notice.
44+
crate::update_notice::maybe_print_update_notice(paths.cli_config_dir.as_ref());
45+
4246
let mut cmd = Command::new(&cli_path);
4347
cmd.args(&args);
4448
#[cfg(unix)]

crates/update/src/update_notice.rs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
//! Lightweight update notice check for the proxy path.
2+
//!
3+
//! Before exec'ing the CLI, we check a cache file to see if a newer version
4+
//! is available. If the cache is stale (>24h), we do a quick HTTP check with
5+
//! a short timeout to refresh it. The notice is printed to stderr.
6+
7+
use std::path::{Path, PathBuf};
8+
use std::time::{Duration, SystemTime, UNIX_EPOCH};
9+
10+
use colored::Colorize;
11+
12+
use crate::cli::install::fetch_latest_release_version;
13+
14+
/// How long to cache the update check result.
15+
const CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
16+
const UPDATE_CHECK_TIMEOUT: Duration = Duration::from_secs(2);
17+
18+
/// Cache file name.
19+
const CACHE_FILENAME: &str = ".update_check_cache";
20+
21+
#[derive(serde::Serialize, serde::Deserialize, Default)]
22+
struct Cache {
23+
/// Unix timestamp of the last successful check.
24+
last_check_secs: u64,
25+
/// The latest version string (without "v" prefix).
26+
latest_version: String,
27+
}
28+
29+
impl Cache {
30+
fn read(config_dir: &Path) -> Option<Self> {
31+
let contents = std::fs::read_to_string(Self::path(config_dir)).ok()?;
32+
serde_json::from_str(&contents).ok()
33+
}
34+
35+
fn write(&self, config_dir: &Path) {
36+
if let Ok(json) = serde_json::to_string(self) {
37+
let _ = std::fs::write(Self::path(config_dir), json);
38+
}
39+
}
40+
41+
fn path(config_dir: &Path) -> PathBuf {
42+
config_dir.join(CACHE_FILENAME)
43+
}
44+
}
45+
46+
fn now_secs() -> u64 {
47+
SystemTime::now()
48+
.duration_since(UNIX_EPOCH)
49+
.unwrap_or_default()
50+
.as_secs()
51+
}
52+
53+
/// Resolve the latest version, using the cache if fresh or fetching from the network.
54+
///
55+
/// On success, updates the cache. On network failure, leaves the cache unchanged
56+
/// so we retry on the next invocation.
57+
fn latest_version_or_cached(config_dir: &Path) -> Option<semver::Version> {
58+
let cache = Cache::read(config_dir);
59+
let now = now_secs();
60+
61+
// Cache is fresh — use it.
62+
if let Some(ref cache) = cache
63+
&& now.saturating_sub(cache.last_check_secs) < CHECK_INTERVAL.as_secs()
64+
{
65+
return semver::Version::parse(&cache.latest_version).ok();
66+
}
67+
68+
// Cache is stale or missing — fetch from network.
69+
let client = crate::cli::reqwest_client_builder()
70+
.timeout(UPDATE_CHECK_TIMEOUT)
71+
.build()
72+
.ok()?;
73+
74+
let latest = crate::cli::tokio_block_on(async { fetch_latest_release_version(&client).await }).flatten();
75+
76+
match latest {
77+
Ok(version) => {
78+
Cache {
79+
last_check_secs: now,
80+
latest_version: version.to_string(),
81+
}
82+
.write(config_dir);
83+
Some(version)
84+
}
85+
Err(e) => {
86+
log::debug!("Failed to fetch latest version from network; will retry next invocation: {e}");
87+
// Don't update cache — retry next time.
88+
// Fall back to stale cache if available.
89+
cache.and_then(|c| semver::Version::parse(&c.latest_version).ok())
90+
}
91+
}
92+
}
93+
94+
/// Check for updates and print a notice to stderr if a newer version is available.
95+
///
96+
/// This is designed to be called from the proxy path before exec'ing the CLI.
97+
/// It reads a cache file to avoid hitting the network on every invocation.
98+
/// If the cache is stale, it makes a quick HTTP request (with timeout) to refresh.
99+
///
100+
/// `config_dir` should be the SpacetimeDB config directory (e.g. `~/.spacetime`).
101+
#[allow(clippy::disallowed_macros)]
102+
pub(crate) fn maybe_print_update_notice(config_dir: &Path) {
103+
let current = env!("CARGO_PKG_VERSION");
104+
let current = match semver::Version::parse(current) {
105+
Ok(v) => v,
106+
Err(e) => {
107+
log::debug!("Failed to parse current version: {e}");
108+
return;
109+
}
110+
};
111+
112+
let latest = match latest_version_or_cached(config_dir) {
113+
Some(v) => v,
114+
None => return,
115+
};
116+
117+
if latest > current {
118+
eprintln!(
119+
"{}",
120+
format!("A new version of SpacetimeDB is available: v{latest} (current: v{current})").yellow()
121+
);
122+
eprintln!("Run `spacetime version upgrade` to update.");
123+
eprintln!();
124+
}
125+
}

0 commit comments

Comments
 (0)