mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
## 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).
159 lines
5.2 KiB
TypeScript
159 lines
5.2 KiB
TypeScript
/**
|
|
* Detect how the running `hyperframes` binary was installed so auto-update can
|
|
* re-use the same installer. Getting this wrong means either silently failing
|
|
* to update or clobbering a Homebrew install with npm, so the classifier is
|
|
* deliberately conservative — when unsure we return `skip` and leave the user
|
|
* in charge.
|
|
*/
|
|
|
|
import { realpathSync } from "node:fs";
|
|
import { posix } from "node:path";
|
|
|
|
export type InstallerKind = "npm" | "bun" | "pnpm" | "brew" | "skip";
|
|
|
|
export interface InstallerInfo {
|
|
kind: InstallerKind;
|
|
/** Full command to install the given version, or null when `kind === "skip"`. */
|
|
installCommand: (version: string) => string | null;
|
|
/** Human-readable reason for debug logging / doctor output. */
|
|
reason: string;
|
|
}
|
|
|
|
/**
|
|
* `process.argv[1]` points at the CLI entry script but on global installs the
|
|
* entry is usually a shim in a `bin/` dir that symlinks to the real install
|
|
* under `lib/node_modules/`. Resolve through the symlink so the classifier
|
|
* sees the canonical install prefix.
|
|
*/
|
|
function resolveEntry(): string | null {
|
|
const entry = process.argv[1];
|
|
if (!entry) return null;
|
|
try {
|
|
return realpathSync(entry);
|
|
} catch {
|
|
return entry;
|
|
}
|
|
}
|
|
|
|
function normalizePath(path: string): string {
|
|
return path.replaceAll("\\", "/");
|
|
}
|
|
|
|
/** True when running from a monorepo workspace link (pnpm/bun/yarn `dev:link`). */
|
|
function isWorkspaceLink(realEntry: string): boolean {
|
|
const normalized = normalizePath(realEntry);
|
|
// Resolved path lands inside the repo, typically .../packages/cli/...
|
|
// A real global install never contains `/packages/` because npm publish
|
|
// collapses the package into a flat tarball.
|
|
return normalized.includes("/packages/cli/");
|
|
}
|
|
|
|
/**
|
|
* True when invoked via `npx hyperframes` / `bunx hyperframes`. These don't
|
|
* persist an install, so auto-update is a no-op — the user gets the latest
|
|
* version on the next invocation anyway.
|
|
*/
|
|
function isEphemeralExec(realEntry: string): boolean {
|
|
const normalized = normalizePath(realEntry);
|
|
// npm's npx caches into `<prefix>/_npx/<hash>/`; bun uses `bunx-<uid>-…`.
|
|
return (
|
|
normalized.includes("/_npx/") ||
|
|
normalized.includes("/.npm/_npx/") ||
|
|
posix.basename(posix.dirname(normalized)).startsWith("bunx-")
|
|
);
|
|
}
|
|
|
|
/**
|
|
* True when the binary was linked into Homebrew's install tree. Homebrew
|
|
* symlinks `/opt/homebrew/bin/hyperframes` into `…/Cellar/hyperframes/<v>/…`
|
|
* (or `/usr/local/Cellar/` on Intel). Either path wins the match.
|
|
*/
|
|
function isHomebrewInstall(realEntry: string): boolean {
|
|
return normalizePath(realEntry).includes("/Cellar/hyperframes/");
|
|
}
|
|
|
|
/**
|
|
* Classify the install by walking the resolved entry path against each
|
|
* package manager's well-known global prefix signature.
|
|
*/
|
|
export function detectInstaller(): InstallerInfo {
|
|
const realEntry = resolveEntry();
|
|
if (!realEntry) {
|
|
return {
|
|
kind: "skip",
|
|
installCommand: () => null,
|
|
reason: "Could not resolve process entry path",
|
|
};
|
|
}
|
|
|
|
const normalizedEntry = normalizePath(realEntry);
|
|
|
|
if (isWorkspaceLink(realEntry)) {
|
|
return {
|
|
kind: "skip",
|
|
installCommand: () => null,
|
|
reason: "Running from a workspace link (monorepo dev)",
|
|
};
|
|
}
|
|
|
|
if (isEphemeralExec(realEntry)) {
|
|
return {
|
|
kind: "skip",
|
|
installCommand: () => null,
|
|
reason: "Running via ephemeral exec (npx / bunx)",
|
|
};
|
|
}
|
|
|
|
if (isHomebrewInstall(realEntry)) {
|
|
return {
|
|
kind: "brew",
|
|
// Updating a brew formula isn't a straight `install`; the formula needs
|
|
// to have been published. Defer to `brew upgrade` which is a no-op if
|
|
// the tap hasn't caught up.
|
|
installCommand: () => "brew upgrade hyperframes",
|
|
reason: `Homebrew install detected at ${realEntry}`,
|
|
};
|
|
}
|
|
|
|
// bun's global install prefix is `~/.bun/install/global/node_modules/` and
|
|
// the bin shim lives at `~/.bun/bin/`. Both paths contain `.bun`.
|
|
if (normalizedEntry.includes("/.bun/")) {
|
|
return {
|
|
kind: "bun",
|
|
installCommand: (version) => `bun add -g hyperframes@${version}`,
|
|
reason: `bun global install detected at ${realEntry}`,
|
|
};
|
|
}
|
|
|
|
// pnpm's global prefix is typically `~/Library/pnpm/global/5/node_modules/`
|
|
// on macOS or `~/.local/share/pnpm/global/…` on Linux. `pnpm` wins when the
|
|
// path contains `/pnpm/global/` regardless of platform.
|
|
if (normalizedEntry.includes("/pnpm/global/")) {
|
|
return {
|
|
kind: "pnpm",
|
|
installCommand: (version) => `pnpm add -g hyperframes@${version}`,
|
|
reason: `pnpm global install detected at ${realEntry}`,
|
|
};
|
|
}
|
|
|
|
// npm's default global prefix is `<prefix>/lib/node_modules/hyperframes/…`
|
|
// where `<prefix>` is `/usr/local` (macOS Intel), `/opt/homebrew` (Apple
|
|
// Silicon, non-brew-formula npm), or a user-configured directory.
|
|
if (
|
|
normalizedEntry.includes("/lib/node_modules/hyperframes/") ||
|
|
normalizedEntry.includes("/npm/node_modules/hyperframes/")
|
|
) {
|
|
return {
|
|
kind: "npm",
|
|
installCommand: (version) => `npm install -g hyperframes@${version}`,
|
|
reason: `npm global install detected at ${realEntry}`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
kind: "skip",
|
|
installCommand: () => null,
|
|
reason: `Unknown install layout at ${realEntry}`,
|
|
};
|
|
}
|