From b5a4dd136c00a332bd2b188c6a35ed33a6374693 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:58:02 -0700 Subject: [PATCH 1/6] =?UTF-8?q?feat(upgrade):=20gate=20commands=20on=20a?= =?UTF-8?q?=20new=20release=20and=20rename=20update=E2=86=92upgrade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a newer release is available, the running CLI may be incompatible with the API. Detect this before dispatching an API-touching command and require the user to upgrade first: - New `update::enforce_latest_or_exit()` runs before command dispatch. On an interactive terminal, if a newer release exists it prompts to upgrade. Declining (or Ctrl-D) exits without running the command; accepting upgrades in place and exits so the user re-runs on the new binary. - Exempts commands that don't hit the API (`completions`, bare help) and the upgrader itself. - No-op for non-interactive/CI sessions (no TTY, --no-input, or HOTDATA_NO_UPDATE_CHECK), so scripts and pipelines are never blocked. - Status messages go to stderr so they never pollute redirected stdout; the explicit upgrade command still exits non-zero on failure. Also renames the `update` subcommand to `upgrade`, drops the old post-command background notice, and removes the now-unused decline-suppression cache field. --- src/command.rs | 4 +- src/main.rs | 27 ++++----- src/update.rs | 149 ++++++++++++++++++++++++++++++++----------------- 3 files changed, 113 insertions(+), 67 deletions(-) diff --git a/src/command.rs b/src/command.rs index d90cbd80..38a96196 100644 --- a/src/command.rs +++ b/src/command.rs @@ -230,8 +230,8 @@ pub enum Commands { shell: ShellChoice, }, - /// Update the hotdata CLI to the latest release - Update, + /// Upgrade the hotdata CLI to the latest release + Upgrade, } #[derive(Clone, clap::ValueEnum)] diff --git a/src/main.rs b/src/main.rs index 2c79fafc..2ac3355a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -141,15 +141,19 @@ fn main() { skill::maybe_auto_update_after_cli_upgrade(); } - // Kick off the update check in the background so it runs concurrently - // with the command. We join and print after the command finishes so the - // notice always appears at the bottom of the output. Skipped during - // `hotdata update` itself so it doesn't talk over the updater's output. - let update_handle = if !matches!(&cli.command, Some(Commands::Update)) { - update::spawn_update_check() - } else { - None - }; + // A newer release may be incompatible with the API, so gate API-touching + // commands behind an up-to-date check: prompt to upgrade and, on decline + // or a failed upgrade, exit *without* running the command. Exempt the + // commands that don't hit the API (bare help, completions) and the + // upgrader itself. No-op for non-interactive/CI sessions, so automation is + // never blocked (see `update::should_check`). + let gate_update = !matches!( + &cli.command, + None | Some(Commands::Upgrade) | Some(Commands::Completions { .. }) + ); + if gate_update { + update::enforce_latest_or_exit(); + } match cli.command { None => { @@ -832,12 +836,9 @@ fn main() { let mut cmd = Cli::command(); generate(shell, &mut cmd, "hotdata", &mut std::io::stdout()); } - Commands::Update => update::run_update(), + Commands::Upgrade => update::run_upgrade(), }, } - - // Print update notice after command output (joined from background thread). - update::maybe_print_update_notice(update_handle); } pub fn get_styles() -> clap::builder::Styles { diff --git a/src/update.rs b/src/update.rs index f3b9dac8..c7b0c4eb 100644 --- a/src/update.rs +++ b/src/update.rs @@ -131,48 +131,69 @@ fn should_check() -> bool { && std::env::var_os("HOTDATA_NO_UPDATE_CHECK").is_none() } -/// How long `maybe_print_update_notice` will wait for the background thread -/// before giving up. In practice the thread finishes well within this window -/// because `fetch_latest_version` has its own 5-second HTTP timeout and cache -/// hits resolve in microseconds. -const NOTICE_WAIT_MS: u64 = 6_000; - -/// Spawn a background thread that checks for a newer release. Returns a -/// channel receiver that `maybe_print_update_notice` can poll after the -/// command runs. No-op (returns None) when stderr isn't a TTY, `--no-input` -/// is set, or `HOTDATA_NO_UPDATE_CHECK` is set. -pub fn spawn_update_check() -> Option>> { +/// Called before dispatching an API-touching command. A newer release may be +/// incompatible with the API, so when one is available we require the user to +/// upgrade before continuing: prompt, and on decline — or a failed upgrade — +/// exit *without* running the command. A successful upgrade also exits, because +/// the still-running process is the old binary; the user re-runs to pick up the +/// new one. +/// +/// No-op when the update check is disabled or the session isn't an interactive +/// terminal (see `should_check`), so scripts and CI are never blocked. The +/// check is synchronous because its result gates the command, but it resolves +/// in microseconds on a cache hit and only hits the network once per ~24h. +pub fn enforce_latest_or_exit() { if !should_check() { - return None; + return; } - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let _ = tx.send(cached_latest_if_newer()); - }); - Some(rx) -} - -/// Poll the receiver returned by `spawn_update_check` and print a one-line -/// notice if a newer release was found. Call this *after* the command has -/// produced its own output so the notice appears at the bottom. -pub fn maybe_print_update_notice(rx: Option>>) { - let Some(rx) = rx else { return }; - let Ok(Some(latest)) = rx.recv_timeout(Duration::from_millis(NOTICE_WAIT_MS)) else { + let Some(latest) = cached_latest_if_newer() else { return; }; - eprintln!( + + use std::io::Write; + eprint!( "{}", format!( - "\nA new version of hotdata is available (v{CURRENT_VERSION} → v{latest}). Run: hotdata update" + "\nA new version of hotdata is available (v{CURRENT_VERSION} → v{latest}).\nThis version may be incompatible with the API. Upgrade now? [Y/n] " ) .yellow() ); + let _ = std::io::stderr().flush(); + + // `read_line` returns `Ok(0)` on EOF (e.g. Ctrl-D). Treat EOF and read + // errors as "no" so an accidental Ctrl-D doesn't trigger an upgrade. + let mut input = String::new(); + let confirmed = match std::io::stdin().read_line(&mut input) { + Ok(0) | Err(_) => false, + Ok(_) => { + let answer = input.trim(); + answer.is_empty() || answer.eq_ignore_ascii_case("y") + } + }; + + if !confirmed { + eprintln!( + "{}", + "Upgrade required to continue. Run 'hotdata upgrade' when ready.".red() + ); + std::process::exit(1); + } + + if let Err(e) = update_to(&latest) { + eprintln!("{}", format!("error: upgrade failed: {e}").red()); + std::process::exit(1); + } + + eprintln!("{}", "Re-run your command to continue.".green()); + std::process::exit(0); } -/// Try to run `brew upgrade ` directly. Falls back to printing the -/// manual instruction if `brew` is not on PATH or the command fails. -fn run_homebrew_upgrade() { - println!("Updating via Homebrew..."); +/// Try to run `brew upgrade ` directly. Status goes to stderr so it +/// never lands in a caller's redirected stdout. Returns an error (including +/// the manual fallback command) instead of exiting, so the caller decides +/// whether a failure is fatal. +fn run_homebrew_upgrade() -> Result<(), String> { + eprintln!("Updating via Homebrew..."); // Locate `brew` — prefer the common install paths so the upgrade works // even if the user's shell profile hasn't been sourced in this context. @@ -188,9 +209,9 @@ fn run_homebrew_upgrade() { .copied(); let Some(brew_bin) = brew else { - eprintln!("{}", "brew not found — run manually:".yellow()); - println!(" {}", format!("brew upgrade {HOMEBREW_FORMULA}").cyan()); - return; + return Err(format!( + "brew not found — run manually: brew upgrade {HOMEBREW_FORMULA}" + )); }; let status = std::process::Command::new(brew_bin) @@ -206,17 +227,12 @@ fn run_homebrew_upgrade() { latest_version: v.to_string(), }); } + Ok(()) } - Ok(s) => { - eprintln!("{}", format!("brew upgrade exited with status {s}").red()); - std::process::exit(s.code().unwrap_or(1)); - } - Err(e) => { - eprintln!("{}", format!("error running brew: {e}").red()); - eprintln!("Run manually:"); - println!(" {}", format!("brew upgrade {HOMEBREW_FORMULA}").cyan()); - std::process::exit(1); - } + Ok(s) => Err(format!("brew upgrade exited with status {s}")), + Err(e) => Err(format!( + "error running brew: {e} — run manually: brew upgrade {HOMEBREW_FORMULA}" + )), } } @@ -228,15 +244,18 @@ fn which_brew() -> bool { .unwrap_or(false) } -pub fn run_update() { +pub fn run_upgrade() { let current = Version::parse(CURRENT_VERSION).expect("invalid package version"); if detect_install_method() == InstallMethod::Homebrew { - run_homebrew_upgrade(); + if let Err(e) = run_homebrew_upgrade() { + eprintln!("{}", format!("error: {e}").red()); + std::process::exit(1); + } return; } - println!("Checking for updates..."); + eprintln!("Checking for updates..."); let latest = match fetch_latest_version() { Ok(v) => v, Err(e) => { @@ -249,7 +268,7 @@ pub fn run_update() { }; if latest <= current { - println!("Already up to date (v{current})."); + eprintln!("Already up to date (v{current})."); // Refresh cache so the notice goes away. write_cache(&UpdateCheckCache { checked_at: now_secs(), @@ -258,24 +277,39 @@ pub fn run_update() { return; } - println!("Updating from v{current} to v{latest}..."); - if let Err(e) = perform_update(&latest) { + if let Err(e) = update_to(&latest) { eprintln!("{}", format!("error: update failed: {e}").red()); std::process::exit(1); } - println!("{}", format!("Updated to v{latest}.").green()); +} + +/// Upgrade the running binary to a known-newer `latest`, dispatching on the +/// install method. Shared by the explicit `hotdata upgrade` command and the +/// pre-command upgrade gate. Returns an error instead of exiting so each +/// caller decides whether a failure is fatal; status goes to stderr so it +/// never pollutes a caller's redirected stdout. On success the new version +/// takes effect on the next invocation. +fn update_to(latest: &Version) -> Result<(), String> { + if detect_install_method() == InstallMethod::Homebrew { + return run_homebrew_upgrade(); + } + + eprintln!("Updating from v{CURRENT_VERSION} to v{latest}..."); + perform_update(latest)?; + eprintln!("{}", format!("Updated to v{latest}.").green()); // Install/update skills to match the new binary. The tarball URL is built // from `latest` (not CURRENT_VERSION) because the old binary is still // running at this point — we want the skills for the version we just // downloaded, not the one we replaced. - crate::skill::install_for_version(&latest); + crate::skill::install_for_version(latest); // Bust the cache so the notice clears on the next run. write_cache(&UpdateCheckCache { checked_at: now_secs(), latest_version: latest.to_string(), }); + Ok(()) } /// Download the cargo-dist tar.xz asset for the running target, unpack it, @@ -356,4 +390,15 @@ mod tests { let m = detect_install_method(); assert!(matches!(m, InstallMethod::Homebrew | InstallMethod::Other)); } + + #[test] + fn cache_deserializes_ignoring_unknown_fields() { + // A cache file written by an interim build (which carried an extra + // `declined_version` field) must still load after that field was + // removed — serde ignores unknown keys by default. + let json = r#"{"checked_at":123,"latest_version":"0.6.0","declined_version":"0.7.0"}"#; + let cache: UpdateCheckCache = serde_json::from_str(json).unwrap(); + assert_eq!(cache.checked_at, 123); + assert_eq!(cache.latest_version, "0.6.0"); + } } From d860bd865a714c3d316f9951f58a975fc9467578 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:02:06 -0700 Subject: [PATCH 2/6] docs(skills): document hotdata upgrade command and the pre-command upgrade gate --- skills/hotdata/SKILL.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/skills/hotdata/SKILL.md b/skills/hotdata/SKILL.md index 081776f7..41a7fda5 100644 --- a/skills/hotdata/SKILL.md +++ b/skills/hotdata/SKILL.md @@ -291,6 +291,16 @@ hotdata completions Writes completion script for the chosen shell to stdout (redirect into your shell’s completion path as usual). +### Upgrade (`upgrade`) + +``` +hotdata upgrade +``` + +Upgrades the CLI in place to the latest release (`brew upgrade` for Homebrew installs, otherwise a direct binary download), refreshing bundled skills to match. After a successful upgrade, re-run your command. + +A newer release can be incompatible with the API, so in an **interactive terminal** the CLI checks for a new release before running any API-touching command and prompts to upgrade. Declining (or `Ctrl-D`) exits without running the command — `hotdata upgrade` is then required to continue. The check is a **no-op in non-interactive sessions** (no TTY, `--no-input`, or `HOTDATA_NO_UPDATE_CHECK` set), so typical agent and CI usage is never blocked; set `HOTDATA_NO_UPDATE_CHECK=1` to disable it entirely. + ### Auth ``` hotdata auth login # Browser-based login (same as: hotdata auth) From 0345e5e8558d12fc6af629d3daf91425819a155d Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:05:07 -0700 Subject: [PATCH 3/6] docs: fix stale 'update' references (command index + install_for_version comment) --- skills/hotdata/SKILL.md | 2 +- src/skill.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/hotdata/SKILL.md b/skills/hotdata/SKILL.md index 41a7fda5..da60b44d 100644 --- a/skills/hotdata/SKILL.md +++ b/skills/hotdata/SKILL.md @@ -72,7 +72,7 @@ Catalog, skill decision tree, epic flows (onboard, chain, retrieval), and manage ## Available Commands -Top-level subcommands (each detailed below): **`auth`**, **`query`**, **`workspaces`**, **`connections`**, **`databases`**, **`tables`**, **`skills`**, **`results`**, **`jobs`**, **`indexes`**, **`embedding-providers`**, **`search`**, **`queries`**, **`context`**, **`usage`**, **`completions`**, **`update`**. Search, indexes (bm25/vector), and embedding providers are documented in **`hotdata-search`**; query history, results, Chain, and OLAP patterns in **`hotdata-analytics`**. +Top-level subcommands (each detailed below): **`auth`**, **`query`**, **`workspaces`**, **`connections`**, **`databases`**, **`tables`**, **`skills`**, **`results`**, **`jobs`**, **`indexes`**, **`embedding-providers`**, **`search`**, **`queries`**, **`context`**, **`usage`**, **`completions`**, **`upgrade`**. Search, indexes (bm25/vector), and embedding providers are documented in **`hotdata-search`**; query history, results, Chain, and OLAP patterns in **`hotdata-analytics`**. Global CLI options: **`--api-key`**, **`-v` / `--version`**, **`-h` / `--help`**, **`--no-input`** (disable interactive prompts; commands that require input will error instead — useful in CI or non-TTY environments). Hidden developer flag: **`--debug`** (verbose HTTP logs). diff --git a/src/skill.rs b/src/skill.rs index e5f989cd..1fafad28 100644 --- a/src/skill.rs +++ b/src/skill.rs @@ -247,7 +247,7 @@ fn download_and_extract_from_url(url: &str) -> Result<(), String> { Ok(()) } -/// Download and install skills for `version`. Called from `run_update()` after +/// Download and install skills for `version`. Called from `update_to()` after /// the binary has been atomically swapped so that skills match the new CLI on /// first use. Uses the release tarball URL for `version` (not `CURRENT_VERSION`, /// which is still the old binary at call time). Skips silently when managed by From 941f25e93a26fa08f27bdb07286c76f414877dbf Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:08:43 -0700 Subject: [PATCH 4/6] fix(upgrade): accept 'yes' (not just 'y') at the upgrade prompt --- src/update.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/update.rs b/src/update.rs index c7b0c4eb..5a876118 100644 --- a/src/update.rs +++ b/src/update.rs @@ -167,7 +167,7 @@ pub fn enforce_latest_or_exit() { Ok(0) | Err(_) => false, Ok(_) => { let answer = input.trim(); - answer.is_empty() || answer.eq_ignore_ascii_case("y") + answer.is_empty() || answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") } }; From 06b644b60984c5b810e05deb69fd985e27d664fc Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:13:07 -0700 Subject: [PATCH 5/6] style: cargo fmt the upgrade-prompt confirmation expression --- src/update.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/update.rs b/src/update.rs index 5a876118..baa03cd4 100644 --- a/src/update.rs +++ b/src/update.rs @@ -167,7 +167,9 @@ pub fn enforce_latest_or_exit() { Ok(0) | Err(_) => false, Ok(_) => { let answer = input.trim(); - answer.is_empty() || answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") + answer.is_empty() + || answer.eq_ignore_ascii_case("y") + || answer.eq_ignore_ascii_case("yes") } }; From 3c162e9156910b48b3a64d8bc558ae0e75bd9a1c Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:18:34 -0700 Subject: [PATCH 6/6] fix(upgrade): mention HOTDATA_NO_UPDATE_CHECK escape hatch in gate messages Avoids locking an offline user out of every command when a fresh cache records a newer version but the network is unavailable (decline exits 1, and accepting also fails in update_to). Surface the bypass in both the decline and failed-upgrade messages. --- src/update.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/update.rs b/src/update.rs index baa03cd4..b5c255e1 100644 --- a/src/update.rs +++ b/src/update.rs @@ -176,13 +176,18 @@ pub fn enforce_latest_or_exit() { if !confirmed { eprintln!( "{}", - "Upgrade required to continue. Run 'hotdata upgrade' when ready.".red() + "Upgrade required to continue. Run 'hotdata upgrade' when ready, or set HOTDATA_NO_UPDATE_CHECK=1 to bypass this check (e.g. when offline).".red() ); std::process::exit(1); } if let Err(e) = update_to(&latest) { eprintln!("{}", format!("error: upgrade failed: {e}").red()); + eprintln!( + "{}", + "If you can't upgrade right now, set HOTDATA_NO_UPDATE_CHECK=1 to bypass this check." + .dark_grey() + ); std::process::exit(1); }