Skip to content

Commit b250d0a

Browse files
authored
feat(upgrade): gate commands on a new release; rename update→upgrade (#178)
* feat(upgrade): gate commands on a new release and rename update→upgrade 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. * docs(skills): document hotdata upgrade command and the pre-command upgrade gate * docs: fix stale 'update' references (command index + install_for_version comment) * fix(upgrade): accept 'yes' (not just 'y') at the upgrade prompt * style: cargo fmt the upgrade-prompt confirmation expression * 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. --------- Co-authored-by: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com>
1 parent a5ecd87 commit b250d0a

5 files changed

Lines changed: 132 additions & 69 deletions

File tree

‎skills/hotdata/SKILL.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ Catalog, skill decision tree, epic flows (onboard, chain, retrieval), and manage
7272

7373
## Available Commands
7474

75-
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`**.
75+
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`**.
7676

7777
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).
7878

@@ -324,6 +324,16 @@ hotdata completions <bash|zsh|fish>
324324

325325
Writes completion script for the chosen shell to stdout (redirect into your shell’s completion path as usual).
326326

327+
### Upgrade (`upgrade`)
328+
329+
```
330+
hotdata upgrade
331+
```
332+
333+
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.
334+
335+
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.
336+
327337
### Auth
328338
```
329339
hotdata auth login # Browser-based login (same as: hotdata auth)

‎src/command.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,8 +230,8 @@ pub enum Commands {
230230
shell: ShellChoice,
231231
},
232232

233-
/// Update the hotdata CLI to the latest release
234-
Update,
233+
/// Upgrade the hotdata CLI to the latest release
234+
Upgrade,
235235
}
236236

237237
#[derive(Clone, clap::ValueEnum)]

‎src/main.rs‎

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -141,15 +141,19 @@ fn main() {
141141
skill::maybe_auto_update_after_cli_upgrade();
142142
}
143143

144-
// Kick off the update check in the background so it runs concurrently
145-
// with the command. We join and print after the command finishes so the
146-
// notice always appears at the bottom of the output. Skipped during
147-
// `hotdata update` itself so it doesn't talk over the updater's output.
148-
let update_handle = if !matches!(&cli.command, Some(Commands::Update)) {
149-
update::spawn_update_check()
150-
} else {
151-
None
152-
};
144+
// A newer release may be incompatible with the API, so gate API-touching
145+
// commands behind an up-to-date check: prompt to upgrade and, on decline
146+
// or a failed upgrade, exit *without* running the command. Exempt the
147+
// commands that don't hit the API (bare help, completions) and the
148+
// upgrader itself. No-op for non-interactive/CI sessions, so automation is
149+
// never blocked (see `update::should_check`).
150+
let gate_update = !matches!(
151+
&cli.command,
152+
None | Some(Commands::Upgrade) | Some(Commands::Completions { .. })
153+
);
154+
if gate_update {
155+
update::enforce_latest_or_exit();
156+
}
153157

154158
match cli.command {
155159
None => {
@@ -848,12 +852,9 @@ fn main() {
848852
let mut cmd = Cli::command();
849853
generate(shell, &mut cmd, "hotdata", &mut std::io::stdout());
850854
}
851-
Commands::Update => update::run_update(),
855+
Commands::Upgrade => update::run_upgrade(),
852856
},
853857
}
854-
855-
// Print update notice after command output (joined from background thread).
856-
update::maybe_print_update_notice(update_handle);
857858
}
858859

859860
pub fn get_styles() -> clap::builder::Styles {

‎src/skill.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ fn download_and_extract_from_url(url: &str) -> Result<(), String> {
247247
Ok(())
248248
}
249249

250-
/// Download and install skills for `version`. Called from `run_update()` after
250+
/// Download and install skills for `version`. Called from `update_to()` after
251251
/// the binary has been atomically swapped so that skills match the new CLI on
252252
/// first use. Uses the release tarball URL for `version` (not `CURRENT_VERSION`,
253253
/// which is still the old binary at call time). Skips silently when managed by

‎src/update.rs‎

Lines changed: 104 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -131,48 +131,76 @@ fn should_check() -> bool {
131131
&& std::env::var_os("HOTDATA_NO_UPDATE_CHECK").is_none()
132132
}
133133

134-
/// How long `maybe_print_update_notice` will wait for the background thread
135-
/// before giving up. In practice the thread finishes well within this window
136-
/// because `fetch_latest_version` has its own 5-second HTTP timeout and cache
137-
/// hits resolve in microseconds.
138-
const NOTICE_WAIT_MS: u64 = 6_000;
139-
140-
/// Spawn a background thread that checks for a newer release. Returns a
141-
/// channel receiver that `maybe_print_update_notice` can poll after the
142-
/// command runs. No-op (returns None) when stderr isn't a TTY, `--no-input`
143-
/// is set, or `HOTDATA_NO_UPDATE_CHECK` is set.
144-
pub fn spawn_update_check() -> Option<std::sync::mpsc::Receiver<Option<Version>>> {
134+
/// Called before dispatching an API-touching command. A newer release may be
135+
/// incompatible with the API, so when one is available we require the user to
136+
/// upgrade before continuing: prompt, and on decline — or a failed upgrade —
137+
/// exit *without* running the command. A successful upgrade also exits, because
138+
/// the still-running process is the old binary; the user re-runs to pick up the
139+
/// new one.
140+
///
141+
/// No-op when the update check is disabled or the session isn't an interactive
142+
/// terminal (see `should_check`), so scripts and CI are never blocked. The
143+
/// check is synchronous because its result gates the command, but it resolves
144+
/// in microseconds on a cache hit and only hits the network once per ~24h.
145+
pub fn enforce_latest_or_exit() {
145146
if !should_check() {
146-
return None;
147+
return;
147148
}
148-
let (tx, rx) = std::sync::mpsc::channel();
149-
std::thread::spawn(move || {
150-
let _ = tx.send(cached_latest_if_newer());
151-
});
152-
Some(rx)
153-
}
154-
155-
/// Poll the receiver returned by `spawn_update_check` and print a one-line
156-
/// notice if a newer release was found. Call this *after* the command has
157-
/// produced its own output so the notice appears at the bottom.
158-
pub fn maybe_print_update_notice(rx: Option<std::sync::mpsc::Receiver<Option<Version>>>) {
159-
let Some(rx) = rx else { return };
160-
let Ok(Some(latest)) = rx.recv_timeout(Duration::from_millis(NOTICE_WAIT_MS)) else {
149+
let Some(latest) = cached_latest_if_newer() else {
161150
return;
162151
};
163-
eprintln!(
152+
153+
use std::io::Write;
154+
eprint!(
164155
"{}",
165156
format!(
166-
"\nA new version of hotdata is available (v{CURRENT_VERSION} → v{latest}). Run: hotdata update"
157+
"\nA new version of hotdata is available (v{CURRENT_VERSION} → v{latest}).\nThis version may be incompatible with the API. Upgrade now? [Y/n] "
167158
)
168159
.yellow()
169160
);
161+
let _ = std::io::stderr().flush();
162+
163+
// `read_line` returns `Ok(0)` on EOF (e.g. Ctrl-D). Treat EOF and read
164+
// errors as "no" so an accidental Ctrl-D doesn't trigger an upgrade.
165+
let mut input = String::new();
166+
let confirmed = match std::io::stdin().read_line(&mut input) {
167+
Ok(0) | Err(_) => false,
168+
Ok(_) => {
169+
let answer = input.trim();
170+
answer.is_empty()
171+
|| answer.eq_ignore_ascii_case("y")
172+
|| answer.eq_ignore_ascii_case("yes")
173+
}
174+
};
175+
176+
if !confirmed {
177+
eprintln!(
178+
"{}",
179+
"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()
180+
);
181+
std::process::exit(1);
182+
}
183+
184+
if let Err(e) = update_to(&latest) {
185+
eprintln!("{}", format!("error: upgrade failed: {e}").red());
186+
eprintln!(
187+
"{}",
188+
"If you can't upgrade right now, set HOTDATA_NO_UPDATE_CHECK=1 to bypass this check."
189+
.dark_grey()
190+
);
191+
std::process::exit(1);
192+
}
193+
194+
eprintln!("{}", "Re-run your command to continue.".green());
195+
std::process::exit(0);
170196
}
171197

172-
/// Try to run `brew upgrade <formula>` directly. Falls back to printing the
173-
/// manual instruction if `brew` is not on PATH or the command fails.
174-
fn run_homebrew_upgrade() {
175-
println!("Updating via Homebrew...");
198+
/// Try to run `brew upgrade <formula>` directly. Status goes to stderr so it
199+
/// never lands in a caller's redirected stdout. Returns an error (including
200+
/// the manual fallback command) instead of exiting, so the caller decides
201+
/// whether a failure is fatal.
202+
fn run_homebrew_upgrade() -> Result<(), String> {
203+
eprintln!("Updating via Homebrew...");
176204

177205
// Locate `brew` — prefer the common install paths so the upgrade works
178206
// even if the user's shell profile hasn't been sourced in this context.
@@ -188,9 +216,9 @@ fn run_homebrew_upgrade() {
188216
.copied();
189217

190218
let Some(brew_bin) = brew else {
191-
eprintln!("{}", "brew not found — run manually:".yellow());
192-
println!(" {}", format!("brew upgrade {HOMEBREW_FORMULA}").cyan());
193-
return;
219+
return Err(format!(
220+
"brew not found — run manually: brew upgrade {HOMEBREW_FORMULA}"
221+
));
194222
};
195223

196224
let status = std::process::Command::new(brew_bin)
@@ -206,17 +234,12 @@ fn run_homebrew_upgrade() {
206234
latest_version: v.to_string(),
207235
});
208236
}
237+
Ok(())
209238
}
210-
Ok(s) => {
211-
eprintln!("{}", format!("brew upgrade exited with status {s}").red());
212-
std::process::exit(s.code().unwrap_or(1));
213-
}
214-
Err(e) => {
215-
eprintln!("{}", format!("error running brew: {e}").red());
216-
eprintln!("Run manually:");
217-
println!(" {}", format!("brew upgrade {HOMEBREW_FORMULA}").cyan());
218-
std::process::exit(1);
219-
}
239+
Ok(s) => Err(format!("brew upgrade exited with status {s}")),
240+
Err(e) => Err(format!(
241+
"error running brew: {e} — run manually: brew upgrade {HOMEBREW_FORMULA}"
242+
)),
220243
}
221244
}
222245

@@ -228,15 +251,18 @@ fn which_brew() -> bool {
228251
.unwrap_or(false)
229252
}
230253

231-
pub fn run_update() {
254+
pub fn run_upgrade() {
232255
let current = Version::parse(CURRENT_VERSION).expect("invalid package version");
233256

234257
if detect_install_method() == InstallMethod::Homebrew {
235-
run_homebrew_upgrade();
258+
if let Err(e) = run_homebrew_upgrade() {
259+
eprintln!("{}", format!("error: {e}").red());
260+
std::process::exit(1);
261+
}
236262
return;
237263
}
238264

239-
println!("Checking for updates...");
265+
eprintln!("Checking for updates...");
240266
let latest = match fetch_latest_version() {
241267
Ok(v) => v,
242268
Err(e) => {
@@ -249,7 +275,7 @@ pub fn run_update() {
249275
};
250276

251277
if latest <= current {
252-
println!("Already up to date (v{current}).");
278+
eprintln!("Already up to date (v{current}).");
253279
// Refresh cache so the notice goes away.
254280
write_cache(&UpdateCheckCache {
255281
checked_at: now_secs(),
@@ -258,24 +284,39 @@ pub fn run_update() {
258284
return;
259285
}
260286

261-
println!("Updating from v{current} to v{latest}...");
262-
if let Err(e) = perform_update(&latest) {
287+
if let Err(e) = update_to(&latest) {
263288
eprintln!("{}", format!("error: update failed: {e}").red());
264289
std::process::exit(1);
265290
}
266-
println!("{}", format!("Updated to v{latest}.").green());
291+
}
292+
293+
/// Upgrade the running binary to a known-newer `latest`, dispatching on the
294+
/// install method. Shared by the explicit `hotdata upgrade` command and the
295+
/// pre-command upgrade gate. Returns an error instead of exiting so each
296+
/// caller decides whether a failure is fatal; status goes to stderr so it
297+
/// never pollutes a caller's redirected stdout. On success the new version
298+
/// takes effect on the next invocation.
299+
fn update_to(latest: &Version) -> Result<(), String> {
300+
if detect_install_method() == InstallMethod::Homebrew {
301+
return run_homebrew_upgrade();
302+
}
303+
304+
eprintln!("Updating from v{CURRENT_VERSION} to v{latest}...");
305+
perform_update(latest)?;
306+
eprintln!("{}", format!("Updated to v{latest}.").green());
267307

268308
// Install/update skills to match the new binary. The tarball URL is built
269309
// from `latest` (not CURRENT_VERSION) because the old binary is still
270310
// running at this point — we want the skills for the version we just
271311
// downloaded, not the one we replaced.
272-
crate::skill::install_for_version(&latest);
312+
crate::skill::install_for_version(latest);
273313

274314
// Bust the cache so the notice clears on the next run.
275315
write_cache(&UpdateCheckCache {
276316
checked_at: now_secs(),
277317
latest_version: latest.to_string(),
278318
});
319+
Ok(())
279320
}
280321

281322
/// Download the cargo-dist tar.xz asset for the running target, unpack it,
@@ -356,4 +397,15 @@ mod tests {
356397
let m = detect_install_method();
357398
assert!(matches!(m, InstallMethod::Homebrew | InstallMethod::Other));
358399
}
400+
401+
#[test]
402+
fn cache_deserializes_ignoring_unknown_fields() {
403+
// A cache file written by an interim build (which carried an extra
404+
// `declined_version` field) must still load after that field was
405+
// removed — serde ignores unknown keys by default.
406+
let json = r#"{"checked_at":123,"latest_version":"0.6.0","declined_version":"0.7.0"}"#;
407+
let cache: UpdateCheckCache = serde_json::from_str(json).unwrap();
408+
assert_eq!(cache.checked_at, 123);
409+
assert_eq!(cache.latest_version, "0.6.0");
410+
}
359411
}

0 commit comments

Comments
 (0)