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:
Miguel Ángel
2026-04-21 00:05:04 +02:00
committed by GitHub
parent c4bcc52f3b
commit 5db554c7b6
8 changed files with 861 additions and 4 deletions
+11 -2
View File
@@ -82,9 +82,18 @@ if (!isHelp && command !== "telemetry" && command !== "unknown") {
}
if (!isHelp && !hasJsonFlag && command !== "upgrade") {
import("./utils/updateCheck.js").then((mod) => {
// Report any completed auto-install from the previous run first, before
// kicking off the next check — so the user sees "updated to vX" once and
// we don't over-print.
import("./utils/autoUpdate.js").then((mod) => mod.reportCompletedUpdate()).catch(() => {});
import("./utils/updateCheck.js").then(async (mod) => {
_printUpdateNotice = mod.printUpdateNotice;
mod.checkForUpdate().catch(() => {});
const result = await mod.checkForUpdate().catch(() => null);
if (result?.updateAvailable) {
const auto = await import("./utils/autoUpdate.js").catch(() => null);
auto?.scheduleBackgroundInstall(result.latest, result.current);
}
});
}
+30
View File
@@ -23,6 +23,34 @@ export interface HyperframesConfig {
lastUpdateCheck?: string;
/** Latest version found on npm */
latestVersion?: string;
/**
* Auto-update marker. Set when a background install is spawned so a
* subsequent run can skip re-triggering it. Cleared once
* `completedUpdate` captures the outcome.
*/
pendingUpdate?: {
/** Version being installed. */
version: string;
/** Install command being run, for debug logging. */
command: string;
/** ISO timestamp of when the background install was launched. */
startedAt: string;
};
/**
* Outcome of the last completed auto-update, written by the detached
* installer. Surfaced once in the next invocation and then cleared.
*/
completedUpdate?: {
version: string;
/** Whether the install succeeded. */
ok: boolean;
/** ISO timestamp of when the installer finished. */
finishedAt: string;
/** Non-empty when `ok === false` — the installer's stderr tail. */
error?: string;
/** True after the result has been surfaced once to the user. */
reported?: boolean;
};
}
const DEFAULT_CONFIG: HyperframesConfig = {
@@ -58,6 +86,8 @@ export function readConfig(): HyperframesConfig {
commandCount: parsed.commandCount ?? DEFAULT_CONFIG.commandCount,
lastUpdateCheck: parsed.lastUpdateCheck,
latestVersion: parsed.latestVersion,
pendingUpdate: parsed.pendingUpdate,
completedUpdate: parsed.completedUpdate,
};
cachedConfig = config;
+305
View File
@@ -0,0 +1,305 @@
import { afterEach, describe, expect, it, vi } from "vitest";
/**
* These tests exercise the policy — when a background install should or
* shouldn't be scheduled — without ever spawning a real child process. The
* `launchDetachedInstall` path is mocked out via vi.mock on node:child_process.
*/
type ConfigShape = {
pendingUpdate?: { version: string; command: string; startedAt: string };
completedUpdate?: { version: string; ok: boolean; finishedAt: string; reported?: boolean };
latestVersion?: string;
};
function setupMocks(opts: {
installer: {
kind: "npm" | "bun" | "pnpm" | "brew" | "skip";
command: string | null;
};
devMode?: boolean;
config?: ConfigShape;
env?: Record<string, string | undefined>;
}): {
writeSpy: ReturnType<typeof vi.fn>;
spawnSpy: ReturnType<typeof vi.fn>;
config: ConfigShape;
} {
vi.resetModules();
const config = { ...(opts.config ?? {}) };
const writeSpy = vi.fn((next: ConfigShape) => {
Object.assign(config, next);
// writeConfig is given a full replacement — mirror that by pruning keys
// that disappeared.
for (const k of Object.keys(config)) {
if (!(k in next)) delete (config as Record<string, unknown>)[k];
}
});
vi.doMock("../telemetry/config.js", () => ({
readConfig: () => ({ ...config }),
writeConfig: writeSpy,
}));
vi.doMock("./env.js", () => ({ isDevMode: () => !!opts.devMode }));
vi.doMock("./installerDetection.js", () => ({
detectInstaller: () => ({
kind: opts.installer.kind,
installCommand: () => opts.installer.command,
reason: "test",
}),
}));
const spawnSpy = vi.fn(() => ({
pid: 42,
unref: () => {},
}));
vi.doMock("node:child_process", () => ({ spawn: spawnSpy }));
vi.doMock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return {
...actual,
mkdirSync: () => {},
openSync: () => 99,
appendFileSync: () => {},
};
});
// Clear any env knobs that would otherwise bypass the scheduling policy
// before the test runs. Critical for CI, where GitHub Actions always sets
// CI=true and would cause every scheduling assertion to fail false-negative.
// Tests that specifically want one of these set pass it via opts.env.
delete process.env["CI"];
delete process.env["HYPERFRAMES_NO_AUTO_INSTALL"];
delete process.env["HYPERFRAMES_NO_UPDATE_CHECK"];
// Apply env overrides, remembering originals for afterEach cleanup.
if (opts.env) {
for (const [k, v] of Object.entries(opts.env)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
}
return { writeSpy, spawnSpy, config };
}
const ORIGINAL_ENV = { ...process.env };
describe("scheduleBackgroundInstall", () => {
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
vi.doUnmock("../telemetry/config.js");
vi.doUnmock("./env.js");
vi.doUnmock("./installerDetection.js");
vi.doUnmock("node:child_process");
vi.doUnmock("node:fs");
vi.resetModules();
});
it("schedules an install when a newer minor/patch is available", async () => {
const { spawnSpy, writeSpy, config } = setupMocks({
installer: { kind: "npm", command: "npm install -g hyperframes@0.4.4" },
});
const { scheduleBackgroundInstall } = await import("./autoUpdate.js");
const scheduled = scheduleBackgroundInstall("0.4.4", "0.4.3");
expect(scheduled).toBe(true);
expect(spawnSpy).toHaveBeenCalledOnce();
expect(writeSpy).toHaveBeenCalled();
expect(config.pendingUpdate?.version).toBe("0.4.4");
expect(config.pendingUpdate?.command).toBe("npm install -g hyperframes@0.4.4");
});
it("does NOT schedule across a major-version jump", async () => {
const { spawnSpy } = setupMocks({
installer: { kind: "npm", command: "npm install -g hyperframes@1.0.0" },
});
const { scheduleBackgroundInstall } = await import("./autoUpdate.js");
expect(scheduleBackgroundInstall("1.0.0", "0.4.3")).toBe(false);
expect(spawnSpy).not.toHaveBeenCalled();
});
it("skips in dev mode", async () => {
const { spawnSpy } = setupMocks({
installer: { kind: "npm", command: "npm install -g hyperframes@0.4.4" },
devMode: true,
});
const { scheduleBackgroundInstall } = await import("./autoUpdate.js");
expect(scheduleBackgroundInstall("0.4.4", "0.4.3")).toBe(false);
expect(spawnSpy).not.toHaveBeenCalled();
});
it("skips when CI=1", async () => {
const { spawnSpy } = setupMocks({
installer: { kind: "npm", command: "npm install -g hyperframes@0.4.4" },
env: { CI: "1" },
});
const { scheduleBackgroundInstall } = await import("./autoUpdate.js");
expect(scheduleBackgroundInstall("0.4.4", "0.4.3")).toBe(false);
expect(spawnSpy).not.toHaveBeenCalled();
});
it("skips when HYPERFRAMES_NO_AUTO_INSTALL=1", async () => {
const { spawnSpy } = setupMocks({
installer: { kind: "npm", command: "npm install -g hyperframes@0.4.4" },
env: { HYPERFRAMES_NO_AUTO_INSTALL: "1" },
});
const { scheduleBackgroundInstall } = await import("./autoUpdate.js");
expect(scheduleBackgroundInstall("0.4.4", "0.4.3")).toBe(false);
expect(spawnSpy).not.toHaveBeenCalled();
});
it("skips when the installer kind is unknown", async () => {
const { spawnSpy } = setupMocks({
installer: { kind: "skip", command: null },
});
const { scheduleBackgroundInstall } = await import("./autoUpdate.js");
expect(scheduleBackgroundInstall("0.4.4", "0.4.3")).toBe(false);
expect(spawnSpy).not.toHaveBeenCalled();
});
it("skips when already up to date", async () => {
const { spawnSpy } = setupMocks({
installer: { kind: "npm", command: "npm install -g hyperframes@0.4.3" },
});
const { scheduleBackgroundInstall } = await import("./autoUpdate.js");
expect(scheduleBackgroundInstall("0.4.3", "0.4.3")).toBe(false);
expect(spawnSpy).not.toHaveBeenCalled();
});
it("does not re-launch while a fresh pending install exists for the same version", async () => {
const { spawnSpy } = setupMocks({
installer: { kind: "npm", command: "npm install -g hyperframes@0.4.4" },
config: {
pendingUpdate: {
version: "0.4.4",
command: "npm install -g hyperframes@0.4.4",
startedAt: new Date().toISOString(),
},
},
});
const { scheduleBackgroundInstall } = await import("./autoUpdate.js");
expect(scheduleBackgroundInstall("0.4.4", "0.4.3")).toBe(false);
expect(spawnSpy).not.toHaveBeenCalled();
});
it("re-launches when a stale pending install is older than the timeout", async () => {
const longAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); // 1h ago
const { spawnSpy } = setupMocks({
installer: { kind: "npm", command: "npm install -g hyperframes@0.4.4" },
config: {
pendingUpdate: {
version: "0.4.4",
command: "npm install -g hyperframes@0.4.4",
startedAt: longAgo,
},
},
});
const { scheduleBackgroundInstall } = await import("./autoUpdate.js");
expect(scheduleBackgroundInstall("0.4.4", "0.4.3")).toBe(true);
expect(spawnSpy).toHaveBeenCalledOnce();
});
it("skips when the previous run already completed this version successfully", async () => {
const { spawnSpy } = setupMocks({
installer: { kind: "npm", command: "npm install -g hyperframes@0.4.4" },
config: {
completedUpdate: {
version: "0.4.4",
ok: true,
finishedAt: new Date().toISOString(),
},
},
});
const { scheduleBackgroundInstall } = await import("./autoUpdate.js");
expect(scheduleBackgroundInstall("0.4.4", "0.4.3")).toBe(false);
expect(spawnSpy).not.toHaveBeenCalled();
});
it("skips when the previous run already failed this version", async () => {
const { spawnSpy } = setupMocks({
installer: { kind: "npm", command: "npm install -g hyperframes@0.4.4" },
config: {
completedUpdate: {
version: "0.4.4",
ok: false,
finishedAt: new Date().toISOString(),
},
},
});
const { scheduleBackgroundInstall } = await import("./autoUpdate.js");
expect(scheduleBackgroundInstall("0.4.4", "0.4.3")).toBe(false);
expect(spawnSpy).not.toHaveBeenCalled();
});
it("writes completed updates atomically in the detached child script", async () => {
const { spawnSpy } = setupMocks({
installer: { kind: "npm", command: "npm install -g hyperframes@0.4.4" },
});
const { scheduleBackgroundInstall } = await import("./autoUpdate.js");
expect(scheduleBackgroundInstall("0.4.4", "0.4.3")).toBe(true);
expect(spawnSpy).toHaveBeenCalledOnce();
const spawnArgs = spawnSpy.mock.calls[0]?.[1];
expect(Array.isArray(spawnArgs)).toBe(true);
expect(spawnArgs?.[0]).toBe("-e");
expect(spawnArgs?.[1]).toContain("renameSync");
expect(spawnArgs?.[1]).toContain(".tmp");
});
it("surfaces failed installs once but still blocks retries for the same version", async () => {
const { spawnSpy, config } = setupMocks({
installer: { kind: "npm", command: "npm install -g hyperframes@0.4.4" },
config: {
completedUpdate: {
version: "0.4.4",
ok: false,
finishedAt: new Date().toISOString(),
},
},
});
const { reportCompletedUpdate, scheduleBackgroundInstall } = await import("./autoUpdate.js");
const stderrWrite = vi.spyOn(process.stderr, "write").mockReturnValue(true);
const originalIsTTY = process.stderr.isTTY;
Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true });
try {
reportCompletedUpdate();
expect(stderrWrite).toHaveBeenCalledWith(
expect.stringContaining("hyperframes auto-update to v0.4.4 failed"),
);
expect(config.completedUpdate).toMatchObject({
version: "0.4.4",
ok: false,
reported: true,
});
stderrWrite.mockClear();
reportCompletedUpdate();
expect(stderrWrite).not.toHaveBeenCalled();
expect(scheduleBackgroundInstall("0.4.4", "0.4.3")).toBe(false);
expect(spawnSpy).not.toHaveBeenCalled();
} finally {
stderrWrite.mockRestore();
Object.defineProperty(process.stderr, "isTTY", {
value: originalIsTTY,
configurable: true,
});
}
});
});
+224
View File
@@ -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`,
);
}
}
@@ -0,0 +1,125 @@
import { afterEach, describe, expect, it, vi } from "vitest";
// The module inspects `process.argv[1]` + `realpathSync`. We stub both so
// each test describes a hypothetical install layout without touching the
// filesystem.
type InstallerInfo =
(typeof import("./installerDetection.js"))["detectInstaller"] extends () => infer R ? R : never;
async function detectWith(realPath: string | null): Promise<InstallerInfo> {
vi.resetModules();
const origArgv1 = process.argv[1];
if (realPath === null) {
process.argv[1] = "";
} else {
// argv[1] doesn't matter — realpathSync is what gets checked after the
// resolver runs. Set it to the unresolved form and stub fs.realpathSync
// to return the scenario's resolved path.
process.argv[1] = "/not/used/at/runtime";
}
vi.doMock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return {
...actual,
realpathSync:
realPath === null
? () => {
throw new Error("simulated unresolved path");
}
: () => realPath,
};
});
const mod = await import("./installerDetection.js");
const info = mod.detectInstaller();
process.argv[1] = origArgv1 ?? "";
return info;
}
describe("detectInstaller", () => {
afterEach(() => {
vi.doUnmock("node:fs");
vi.resetModules();
});
it("classifies workspace link as skip (monorepo dev)", async () => {
const info = await detectWith("/Users/dev/hyperframes-oss/packages/cli/dist/cli.js");
expect(info.kind).toBe("skip");
expect(info.reason).toContain("workspace");
expect(info.installCommand("0.4.4")).toBeNull();
});
it("classifies npx _npx cache path as skip", async () => {
const info = await detectWith(
"/Users/me/.npm/_npx/abc123/node_modules/hyperframes/dist/cli.js",
);
expect(info.kind).toBe("skip");
expect(info.reason.toLowerCase()).toContain("ephemeral");
});
it("classifies bunx temp dir as skip", async () => {
const info = await detectWith("/var/folders/tmp/bunx-501-hyperframes/entry.js");
expect(info.kind).toBe("skip");
expect(info.reason.toLowerCase()).toContain("ephemeral");
});
it("detects Homebrew install", async () => {
const info = await detectWith("/opt/homebrew/Cellar/hyperframes/0.4.3/bin/hyperframes");
expect(info.kind).toBe("brew");
expect(info.installCommand("0.4.4")).toBe("brew upgrade hyperframes");
});
it("detects bun global install", async () => {
const info = await detectWith(
"/Users/me/.bun/install/global/node_modules/hyperframes/dist/cli.js",
);
expect(info.kind).toBe("bun");
expect(info.installCommand("0.4.4")).toBe("bun add -g hyperframes@0.4.4");
});
it("detects pnpm global install (Library/pnpm path)", async () => {
const info = await detectWith(
"/Users/me/Library/pnpm/global/5/node_modules/hyperframes/dist/cli.js",
);
expect(info.kind).toBe("pnpm");
expect(info.installCommand("0.4.4")).toBe("pnpm add -g hyperframes@0.4.4");
});
it("treats pnpm project-local installs as unknown layouts", async () => {
const info = await detectWith(
"/path/to/project/node_modules/.pnpm/hyperframes@0.4.3/node_modules/hyperframes/dist/cli.js",
);
expect(info.kind).toBe("skip");
expect(info.installCommand("0.4.4")).toBeNull();
});
it("detects npm global install", async () => {
const info = await detectWith("/usr/local/lib/node_modules/hyperframes/dist/cli.js");
expect(info.kind).toBe("npm");
expect(info.installCommand("0.4.4")).toBe("npm install -g hyperframes@0.4.4");
});
it("detects npm global install on Windows", async () => {
const info = await detectWith(
"C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\hyperframes\\dist\\cli.js",
);
expect(info.kind).toBe("npm");
expect(info.installCommand("0.4.4")).toBe("npm install -g hyperframes@0.4.4");
});
it("returns skip when the entry cannot be resolved", async () => {
const info = await detectWith(null);
// realpathSync throws → reason is "could not resolve" OR the path itself
// (the fallback returns the unresolved argv[1]); either way the kind is
// skip-or-unknown which we treat as skip downstream.
expect(info.kind).toBe("skip");
expect(info.installCommand("0.4.4")).toBeNull();
});
it("returns skip for an unknown install layout", async () => {
const info = await detectWith("/some/random/path/hyperframes");
expect(info.kind).toBe("skip");
expect(info.reason).toMatch(/Unknown install layout/);
});
});
@@ -0,0 +1,158 @@
/**
* 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}`,
};
}
+4 -1
View File
@@ -3,12 +3,15 @@
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"baseUrl": ".",
"paths": {
"@hyperframes/producer": ["../producer/src/index.ts"]
},
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true
},
"include": ["src"],
+4 -1
View File
@@ -3,6 +3,10 @@
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"baseUrl": ".",
"paths": {
"@hyperframes/player": ["../player/src/hyperframes-player.ts"]
},
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
@@ -12,7 +16,6 @@
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"types": ["vite/client"],
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,