mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
feat(cli): silent auto-update on next run (#306)
## Summary
Today users have to run `hyperframes upgrade` (or the right install command for their package manager) to get a new release — we ship fixes but they don't reach the install until the user remembers. This PR borrows the Claude Code model: detect the update on run N, install it in a detached background child, surface one line ("hyperframes auto-updated to vX.Y.Z") on run N+1. The user's current command never blocks, never prompts, never sees an install stream.
## Flow across two runs
```
Run N → checkForUpdate() sees latest > current → spawn detached
child running `npm install -g hyperframes@X` (or bun /
pnpm / brew equivalent). Parent exits immediately.
(between) → detached child installs, writes completedUpdate into
~/.hyperframes/config.json, clears pendingUpdate.
Run N+1 → reportCompletedUpdate() prints one line and clears the
marker. User is on the new version.
```
## Installer detection
Walks `realpathSync(process.argv[1])` against each package manager's well-known global prefix. Wrong guesses are biased toward `skip` — we'd rather miss an auto-update than clobber a Homebrew install with npm.
| Resolved entry path contains | Detected as | Install command |
|---|---|---|
| `…/Cellar/hyperframes/<v>/…` | `brew` | `brew upgrade hyperframes` |
| `…/.bun/…` | `bun` | `bun add -g hyperframes@<v>` |
| `…/pnpm/global/…` or `…/.pnpm/…` | `pnpm` | `pnpm add -g hyperframes@<v>` |
| `…/lib/node_modules/hyperframes/…` | `npm` | `npm install -g hyperframes@<v>` |
| `…/packages/cli/…` (workspace link) | `skip` | (no-op) |
| `…/_npx/…`, `…/bunx-…/…` | `skip` | (no-op) |
| Anything else | `skip` | (no-op) |
## Guardrails
- **Never auto-update across a major version.** The existing banner still nudges the user to run `hyperframes upgrade` explicitly.
- **Skip on CI, non-TTY, dev mode,** npx / bunx / workspace link, or any install layout the detector doesn't recognize.
- **`HYPERFRAMES_NO_AUTO_INSTALL=1`** disables the install without silencing the notice banner.
- **`HYPERFRAMES_NO_UPDATE_CHECK=1`** silences both (existing knob).
- **Fresh pending install (<10 min old)** prevents re-launch on every invocation.
- **Installer stdout + stderr go to `~/.hyperframes/auto-update.log`** for postmortem — the terminal stays clean.
- **Failed installs are surfaced once** with a prompt to run `hyperframes upgrade` manually.
## What changed
| File | Role |
|---|---|
| `packages/cli/src/utils/installerDetection.ts` | Classifies the running install → npm \| bun \| pnpm \| brew \| skip, with the right install command. |
| `packages/cli/src/utils/autoUpdate.ts` | `scheduleBackgroundInstall` + `reportCompletedUpdate`. Spawns a detached `node -e "..."` child that runs the install and writes the outcome back to the config, then `unref()`s so the parent exits immediately. |
| `packages/cli/src/telemetry/config.ts` | `pendingUpdate` + `completedUpdate` fields on the config schema. |
| `packages/cli/src/cli.ts` | Wires `reportCompletedUpdate()` at startup and `scheduleBackgroundInstall()` after `checkForUpdate()` resolves. |
## Verification
### Unit tests — 19 / 19 pass (full CLI suite 115 / 115)
- `installerDetection.test.ts` — 9 cases, one per layout (workspace, npx, bunx, brew, bun, pnpm, npm, unknown, unresolved).
- `autoUpdate.test.ts` — 10 scheduling-policy cases:
- Minor/patch → schedules + writes pendingUpdate
- Major bump → **does not** schedule
- Dev mode → skipped
- `CI=1` → skipped
- `HYPERFRAMES_NO_AUTO_INSTALL=1` → skipped
- Unknown installer → skipped
- Already-on-latest → skipped
- Fresh pending install → de-duplicated
- Stale pending install (>10 min) → supersedes
- Previous run already completed this version → skipped
Unit tests mock `spawn` and the installer — they verify the **policy**, not the real detached-child path.
### Live end-to-end smoke test (on this Mac, real processes)
To validate the parts the unit tests can't — actual detached spawn, real config writeback, banner surfacing in a fresh subsequent process — I wired a smoke script that exercises the exact same code path `autoUpdate.ts` uses, but with `echo …` as the "install command" so nothing global gets touched.
**Steps exercised:**
1. Backed up the user's real `~/.hyperframes/config.json`.
2. Wrote a `pendingUpdate` marker for version `0.4.99` (like `scheduleBackgroundInstall` does).
3. Spawned the **exact same detached `node -e "..."` child** the real scheduler produces, with the install command replaced by `echo 'faux install for 0.4.99'`.
4. The parent `unref()`d and continued; 800 ms later the parent re-read `config.json`.
5. Ran `reportCompletedUpdate()` in a **fresh subprocess** (via `bunx tsx -e ...`) to match the real "Run N+1" conditions, capturing its stderr.
6. Asserted the marker was cleared.
7. Restored the original config on exit.
**Observed output:**
```
[setup] Backed up config to /Users/miguel/.hyperframes/config.json.smoke-backup
[setup] Wrote pendingUpdate for v0.4.99
[spawn] Detached child pid=49469
[after] completedUpdate = {"version":"0.4.99","ok":true,"finishedAt":"2026-04-17T17:26:50.115Z"}
[after] pendingUpdate = (cleared)
✓ detached spawn + writeback verified
[banner-subprocess] stderr: "hyperframes auto-updated to v0.4.99"
✓ banner fired in fresh process + marker cleared
ALL CHECKS PASSED ✓
[cleanup] Config restored
```
**What this proves:**
| Claim | Evidence |
|---|---|
| Detached spawn works (doesn't block the parent) | `[spawn] pid=49469` logged, parent continued immediately |
| Detached child is process-independent | Parent exited its own work while child ran `exec(CMD)` |
| Child writes correct config shape | `completedUpdate = { version: "0.4.99", ok: true, finishedAt: … }` |
| Child clears the pending marker | `pendingUpdate = (cleared)` |
| Banner fires only in a fresh process | Subprocess stderr = `"hyperframes auto-updated to v0.4.99"` |
| Banner message format | Matches the copy in `autoUpdate.ts:reportCompletedUpdate` exactly |
| Marker clears after banner | Second file read shows `completedUpdate` absent |
Both the original test-plan checkboxes (fresh install, `HYPERFRAMES_NO_AUTO_INSTALL=1`, `CI=1`) are covered by either the unit-test suite or this smoke test — the scheduling-policy gates are unit-tested under `CI=true`, and the real detached-spawn path is smoke-tested above.
### What's still worth doing
- **Physical installer test on a real `npm i -g` / `brew` / `bun add -g` environment** — the smoke test above replaces the install command with `echo`, so we've never actually seen npm/bun/brew run the real command. That's the one remaining unknown. Worth one manual run on the maintainer's machine before cutting v0.4.4.
## Test plan
- [x] `bunx vitest run` on `packages/cli` — 115 / 115 pass (incl. 19 new)
- [x] `tsc --noEmit` clean
- [x] `tsup` build clean
- [x] **Live e2e smoke test** exercising the real detached spawn + config writeback + fresh-process banner (output above)
- [x] CI green on this branch (Typecheck, Test, Test: runtime contract, Build, Lint, Format)
- [ ] One manual run on a physical `npm i -g hyperframes@0.4.2` install to confirm the real `npm install -g hyperframes@0.4.3` command actually runs when `autoUpdate.ts` delegates to it (the smoke test stopped short of executing `npm`)
## Notes
- Independent of any version bump — ship whenever.
- The existing `checkForUpdate` + `printUpdateNotice` still work unchanged; this PR adds a second stage that *applies* the update rather than just telling the user about it.
- `hyperframes upgrade` still exists and is still the right command for explicit upgrades (especially major-version jumps).
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Silent, lazy auto-update — Claude-Code-style.
|
||||
*
|
||||
* Flow across two runs of `hyperframes`:
|
||||
*
|
||||
* Run N → check registry, see latest > current, spawn detached
|
||||
* installer child, write `pendingUpdate` marker. Exit normally
|
||||
* without waiting. User's command is unaffected.
|
||||
* (between) → detached child runs the installer, writes the outcome to
|
||||
* `completedUpdate`, clears `pendingUpdate`.
|
||||
* Run N+1 → detect `completedUpdate`, print one short line, clear the
|
||||
* marker. The user is now on the new version.
|
||||
*
|
||||
* Guardrails:
|
||||
* - Never auto-update across major versions. The user opts in explicitly
|
||||
* via `hyperframes upgrade`.
|
||||
* - Skip on CI, non-TTY, dev mode, unknown installer, ephemeral exec (npx),
|
||||
* or when `HYPERFRAMES_NO_AUTO_INSTALL` / `HYPERFRAMES_NO_UPDATE_CHECK`
|
||||
* is set.
|
||||
* - If a previous install is still in flight (less than 10 min old), don't
|
||||
* re-launch.
|
||||
* - Installer output is redirected to `~/.hyperframes/auto-update.log` for
|
||||
* postmortem; the user's terminal stays clean.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { appendFileSync, mkdirSync, openSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { compareVersions } from "compare-versions";
|
||||
import { readConfig, writeConfig } from "../telemetry/config.js";
|
||||
import { isDevMode } from "./env.js";
|
||||
import { detectInstaller } from "./installerDetection.js";
|
||||
|
||||
const CONFIG_DIR = join(homedir(), ".hyperframes");
|
||||
const LOG_FILE = join(CONFIG_DIR, "auto-update.log");
|
||||
/** An install that hasn't finished after this many ms is considered stuck. */
|
||||
const PENDING_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
function isAutoInstallDisabled(): boolean {
|
||||
if (isDevMode()) return true;
|
||||
if (process.env["CI"] === "true" || process.env["CI"] === "1") return true;
|
||||
if (process.env["HYPERFRAMES_NO_UPDATE_CHECK"] === "1") return true;
|
||||
if (process.env["HYPERFRAMES_NO_AUTO_INSTALL"] === "1") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Parse a semver-ish string's major number; returns NaN for pre-releases etc. */
|
||||
function majorOf(version: string): number {
|
||||
const match = /^(\d+)\./.exec(version);
|
||||
return match?.[1] ? Number.parseInt(match[1], 10) : Number.NaN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quietly log a diagnostic line to `auto-update.log`. Never throws — a bad
|
||||
* file write must not take down the CLI.
|
||||
*/
|
||||
function log(line: string): void {
|
||||
try {
|
||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
appendFileSync(LOG_FILE, `${new Date().toISOString()} ${line}\n`, { mode: 0o600 });
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a detached child to run the install command. Stdout/stderr land in
|
||||
* the log file; the child is `unref()`d so the parent exits immediately
|
||||
* regardless of install duration.
|
||||
*
|
||||
* The child is responsible for writing `completedUpdate` to the config when
|
||||
* it finishes — we express that by running a small inline Node command after
|
||||
* the install that edits the config file in place. Keeps the whole thing to
|
||||
* one spawned process with no extra binary to distribute.
|
||||
*/
|
||||
function launchDetachedInstall(installCommand: string, version: string): void {
|
||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
const configFile = join(CONFIG_DIR, "config.json");
|
||||
|
||||
// The child script:
|
||||
// 1. Runs the install command, capturing exit code + stderr tail.
|
||||
// 2. Rewrites the config file with completedUpdate, clears pendingUpdate.
|
||||
// We shell out to `node -e` so we don't need to ship a separate file.
|
||||
const nodeScript = `
|
||||
const { exec } = require("node:child_process");
|
||||
const { readFileSync, renameSync, writeFileSync } = require("node:fs");
|
||||
const CFG = ${JSON.stringify(configFile)};
|
||||
const TMP = \`\${CFG}.tmp\`;
|
||||
const VERSION = ${JSON.stringify(version)};
|
||||
const CMD = ${JSON.stringify(installCommand)};
|
||||
exec(CMD, { windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (err, _stdout, stderr) => {
|
||||
let cfg = {};
|
||||
try { cfg = JSON.parse(readFileSync(CFG, "utf-8")); } catch (e) {}
|
||||
cfg.completedUpdate = {
|
||||
version: VERSION,
|
||||
ok: !err,
|
||||
finishedAt: new Date().toISOString(),
|
||||
...(err ? { error: String(stderr || err.message || "install failed").slice(-400) } : {}),
|
||||
};
|
||||
delete cfg.pendingUpdate;
|
||||
try {
|
||||
writeFileSync(TMP, JSON.stringify(cfg, null, 2) + "\\n", { mode: 0o600 });
|
||||
renameSync(TMP, CFG);
|
||||
} catch (e) {}
|
||||
});
|
||||
`;
|
||||
|
||||
const out = openSync(LOG_FILE, "a", 0o600);
|
||||
const child = spawn(process.execPath, ["-e", nodeScript], {
|
||||
detached: true,
|
||||
stdio: ["ignore", out, out],
|
||||
windowsHide: true,
|
||||
env: { ...process.env, HYPERFRAMES_NO_UPDATE_CHECK: "1", HYPERFRAMES_NO_AUTO_INSTALL: "1" },
|
||||
});
|
||||
child.unref();
|
||||
log(`[launch] pid=${child.pid ?? "?"} cmd=${installCommand} version=${version}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* If a new version is available and policy allows, kick off a detached
|
||||
* installer. Returns whether an install was spawned (for tests).
|
||||
*/
|
||||
export function scheduleBackgroundInstall(latestVersion: string, currentVersion: string): boolean {
|
||||
if (isAutoInstallDisabled()) return false;
|
||||
if (!latestVersion || !currentVersion) return false;
|
||||
|
||||
let cmp: number;
|
||||
try {
|
||||
cmp = compareVersions(latestVersion, currentVersion);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (cmp <= 0) return false;
|
||||
|
||||
// Major-version jumps carry breaking-change risk. Don't silent-install;
|
||||
// the existing `printUpdateNotice` banner nudges the user to run
|
||||
// `hyperframes upgrade` explicitly.
|
||||
const latestMajor = majorOf(latestVersion);
|
||||
const currentMajor = majorOf(currentVersion);
|
||||
if (Number.isFinite(latestMajor) && Number.isFinite(currentMajor) && latestMajor > currentMajor) {
|
||||
log(`[skip] major-bump ${currentVersion} -> ${latestVersion}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const installer = detectInstaller();
|
||||
if (installer.kind === "skip") {
|
||||
log(`[skip] ${installer.reason}`);
|
||||
return false;
|
||||
}
|
||||
const installCommand = installer.installCommand(latestVersion);
|
||||
if (!installCommand) return false;
|
||||
|
||||
const config = readConfig();
|
||||
|
||||
// Don't re-launch if a previous install is still fresh. Treat anything
|
||||
// over PENDING_TIMEOUT_MS as stuck and let the next run supersede it.
|
||||
if (config.pendingUpdate) {
|
||||
const startedAt = Date.parse(config.pendingUpdate.startedAt);
|
||||
const age = Number.isFinite(startedAt) ? Date.now() - startedAt : Number.POSITIVE_INFINITY;
|
||||
if (age < PENDING_TIMEOUT_MS && config.pendingUpdate.version === latestVersion) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if the previous completed outcome is already for this version and
|
||||
// hasn't been surfaced yet — that run already did the work.
|
||||
if (config.completedUpdate && config.completedUpdate.version === latestVersion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
config.pendingUpdate = {
|
||||
version: latestVersion,
|
||||
command: installCommand,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
writeConfig(config);
|
||||
|
||||
try {
|
||||
launchDetachedInstall(installCommand, latestVersion);
|
||||
return true;
|
||||
} catch (err) {
|
||||
log(`[error] spawn failed: ${String(err)}`);
|
||||
const rollback = readConfig();
|
||||
delete rollback.pendingUpdate;
|
||||
writeConfig(rollback);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If a previous run finished auto-installing, surface the outcome once.
|
||||
* Successful installs are cleared immediately; failed installs stay marked so
|
||||
* the scheduler can avoid retrying the same version on every invocation.
|
||||
*/
|
||||
export function reportCompletedUpdate(): void {
|
||||
if (process.env["HYPERFRAMES_NO_UPDATE_CHECK"] === "1") return;
|
||||
|
||||
const config = readConfig();
|
||||
const done = config.completedUpdate;
|
||||
if (!done) return;
|
||||
|
||||
if (done.ok) {
|
||||
delete config.completedUpdate;
|
||||
writeConfig(config);
|
||||
} else if (!done.reported) {
|
||||
config.completedUpdate = { ...done, reported: true };
|
||||
writeConfig(config);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!process.stderr.isTTY) return;
|
||||
|
||||
if (done.ok) {
|
||||
process.stderr.write(` hyperframes auto-updated to v${done.version}\n\n`);
|
||||
} else if (!done.reported) {
|
||||
// Failed installs are surfaced once too — the user should know why the
|
||||
// auto-update didn't take.
|
||||
process.stderr.write(
|
||||
` hyperframes auto-update to v${done.version} failed. Run \`hyperframes upgrade\` to retry.\n\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user