From 2cecde26f0e829f01414ef09f8e0e1da5dbe2660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 17 Apr 2026 18:43:26 +0200 Subject: [PATCH 1/6] feat(cli): silent auto-update on next run --- packages/cli/src/cli.ts | 13 +- packages/cli/src/telemetry/config.ts | 28 +++ packages/cli/src/utils/autoUpdate.test.ts | 221 ++++++++++++++++++ packages/cli/src/utils/autoUpdate.ts | 220 +++++++++++++++++ .../cli/src/utils/installerDetection.test.ts | 109 +++++++++ packages/cli/src/utils/installerDetection.ts | 150 ++++++++++++ 6 files changed, 739 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/utils/autoUpdate.test.ts create mode 100644 packages/cli/src/utils/autoUpdate.ts create mode 100644 packages/cli/src/utils/installerDetection.test.ts create mode 100644 packages/cli/src/utils/installerDetection.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 76b5dae54..7d6a83289 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -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); + } }); } diff --git a/packages/cli/src/telemetry/config.ts b/packages/cli/src/telemetry/config.ts index a0000c757..4e40bda6a 100644 --- a/packages/cli/src/telemetry/config.ts +++ b/packages/cli/src/telemetry/config.ts @@ -23,6 +23,32 @@ 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; + }; } const DEFAULT_CONFIG: HyperframesConfig = { @@ -58,6 +84,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; diff --git a/packages/cli/src/utils/autoUpdate.test.ts b/packages/cli/src/utils/autoUpdate.test.ts new file mode 100644 index 000000000..dd4d37f1d --- /dev/null +++ b/packages/cli/src/utils/autoUpdate.test.ts @@ -0,0 +1,221 @@ +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 }; + latestVersion?: string; +}; + +function setupMocks(opts: { + installer: { + kind: "npm" | "bun" | "pnpm" | "brew" | "skip"; + command: string | null; + }; + devMode?: boolean; + config?: ConfigShape; + env?: Record; +}): { + writeSpy: ReturnType; + spawnSpy: ReturnType; + 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)[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("node:fs"); + return { + ...actual, + mkdirSync: () => {}, + openSync: () => 99, + appendFileSync: () => {}, + }; + }); + + // 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(); + }); +}); diff --git a/packages/cli/src/utils/autoUpdate.ts b/packages/cli/src/utils/autoUpdate.ts new file mode 100644 index 000000000..77ba63945 --- /dev/null +++ b/packages/cli/src/utils/autoUpdate.ts @@ -0,0 +1,220 @@ +/** + * 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, writeFileSync } = require("node:fs"); + const CFG = ${JSON.stringify(configFile)}; + 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(CFG, JSON.stringify(cfg, null, 2) + "\\n", { mode: 0o600 }); } 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 && + config.completedUpdate.ok + ) { + 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, print one short line and clear + * the marker. Stays silent when no update has happened or the current run is + * non-TTY (JSON output, piped, CI). + */ +export function reportCompletedUpdate(): void { + if (process.env["HYPERFRAMES_NO_UPDATE_CHECK"] === "1") return; + + const config = readConfig(); + const done = config.completedUpdate; + if (!done) return; + + // Clear the marker regardless of whether we print — otherwise a non-TTY run + // would keep the flag around indefinitely and spam the next interactive run + // with stale news. + delete config.completedUpdate; + writeConfig(config); + + if (!process.stderr.isTTY) return; + + if (done.ok) { + process.stderr.write(` hyperframes auto-updated to v${done.version}\n\n`); + } else { + // 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`, + ); + } +} diff --git a/packages/cli/src/utils/installerDetection.test.ts b/packages/cli/src/utils/installerDetection.test.ts new file mode 100644 index 000000000..c2da03788 --- /dev/null +++ b/packages/cli/src/utils/installerDetection.test.ts @@ -0,0 +1,109 @@ +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 { + 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("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("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("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/); + }); +}); diff --git a/packages/cli/src/utils/installerDetection.ts b/packages/cli/src/utils/installerDetection.ts new file mode 100644 index 000000000..ed32baaab --- /dev/null +++ b/packages/cli/src/utils/installerDetection.ts @@ -0,0 +1,150 @@ +/** + * 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 { basename, dirname, sep } 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; + } +} + +/** True when running from a monorepo workspace link (pnpm/bun/yarn `dev:link`). */ +function isWorkspaceLink(realEntry: string): boolean { + // 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 realEntry.includes(`${sep}packages${sep}cli${sep}`); +} + +/** + * 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 { + // npm's npx caches into `/_npx//`; bun uses `bunx--…`. + return ( + realEntry.includes(`${sep}_npx${sep}`) || + realEntry.includes(`${sep}.npm${sep}_npx${sep}`) || + basename(dirname(realEntry)).startsWith("bunx-") + ); +} + +/** + * True when the binary was linked into Homebrew's install tree. Homebrew + * symlinks `/opt/homebrew/bin/hyperframes` into `…/Cellar/hyperframes//…` + * (or `/usr/local/Cellar/` on Intel). Either path wins the match. + */ +function isHomebrewInstall(realEntry: string): boolean { + return realEntry.includes(`${sep}Cellar${sep}hyperframes${sep}`); +} + +/** + * 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", + }; + } + + 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 (realEntry.includes(`${sep}.bun${sep}`)) { + 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 ( + realEntry.includes(`${sep}pnpm${sep}global${sep}`) || + realEntry.includes(`${sep}.pnpm${sep}`) + ) { + return { + kind: "pnpm", + installCommand: (version) => `pnpm add -g hyperframes@${version}`, + reason: `pnpm global install detected at ${realEntry}`, + }; + } + + // npm's default global prefix is `/lib/node_modules/hyperframes/…` + // where `` is `/usr/local` (macOS Intel), `/opt/homebrew` (Apple + // Silicon, non-brew-formula npm), or a user-configured directory. + if (realEntry.includes(`${sep}lib${sep}node_modules${sep}hyperframes${sep}`)) { + 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}`, + }; +} From 10aa43b1c92e01f51e40b61408b688821d0d522d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 17 Apr 2026 18:59:52 +0200 Subject: [PATCH 2/6] fix(cli): normalize env + typecheck in auto-update tests - autoUpdate.test.ts unsets CI / HYPERFRAMES_NO_AUTO_INSTALL / HYPERFRAMES_NO_UPDATE_CHECK per-test so GitHub Actions' CI=true env doesn't short-circuit the scheduling policy under test. - installerDetection.test.ts coerces the optional argv[1] restore to '' to satisfy strict TS on CI (process.argv[1] is typed as string; reading + assigning back hits the 'string | undefined' mismatch under exactOptionalPropertyTypes). --- packages/cli/src/utils/autoUpdate.test.ts | 8 ++++++++ packages/cli/src/utils/installerDetection.test.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/utils/autoUpdate.test.ts b/packages/cli/src/utils/autoUpdate.test.ts index dd4d37f1d..ab92a7b11 100644 --- a/packages/cli/src/utils/autoUpdate.test.ts +++ b/packages/cli/src/utils/autoUpdate.test.ts @@ -65,6 +65,14 @@ function setupMocks(opts: { }; }); + // 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)) { diff --git a/packages/cli/src/utils/installerDetection.test.ts b/packages/cli/src/utils/installerDetection.test.ts index c2da03788..efdd66697 100644 --- a/packages/cli/src/utils/installerDetection.test.ts +++ b/packages/cli/src/utils/installerDetection.test.ts @@ -33,7 +33,7 @@ async function detectWith(realPath: string | null): Promise { const mod = await import("./installerDetection.js"); const info = mod.detectInstaller(); - process.argv[1] = origArgv1; + process.argv[1] = origArgv1 ?? ""; return info; } From 763604124eb57c628be89aef598e33be64227c10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 20 Apr 2026 15:45:28 -0400 Subject: [PATCH 3/6] fix(cli): harden auto-update retries and pnpm detection --- packages/cli/src/utils/autoUpdate.test.ts | 33 +++++++++++++++++++ packages/cli/src/utils/autoUpdate.ts | 14 ++++---- .../cli/src/utils/installerDetection.test.ts | 8 +++++ packages/cli/src/utils/installerDetection.ts | 5 +-- 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/utils/autoUpdate.test.ts b/packages/cli/src/utils/autoUpdate.test.ts index ab92a7b11..a7ebb6878 100644 --- a/packages/cli/src/utils/autoUpdate.test.ts +++ b/packages/cli/src/utils/autoUpdate.test.ts @@ -226,4 +226,37 @@ describe("scheduleBackgroundInstall", () => { 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"); + }); }); diff --git a/packages/cli/src/utils/autoUpdate.ts b/packages/cli/src/utils/autoUpdate.ts index 77ba63945..9dd60d5ae 100644 --- a/packages/cli/src/utils/autoUpdate.ts +++ b/packages/cli/src/utils/autoUpdate.ts @@ -84,8 +84,9 @@ function launchDetachedInstall(installCommand: string, version: string): void { // 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, writeFileSync } = require("node:fs"); + 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) => { @@ -98,7 +99,10 @@ function launchDetachedInstall(installCommand: string, version: string): void { ...(err ? { error: String(stderr || err.message || "install failed").slice(-400) } : {}), }; delete cfg.pendingUpdate; - try { writeFileSync(CFG, JSON.stringify(cfg, null, 2) + "\\n", { mode: 0o600 }); } catch (e) {} + try { + writeFileSync(TMP, JSON.stringify(cfg, null, 2) + "\\n", { mode: 0o600 }); + renameSync(TMP, CFG); + } catch (e) {} }); `; @@ -161,11 +165,7 @@ export function scheduleBackgroundInstall(latestVersion: string, currentVersion: // 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 && - config.completedUpdate.ok - ) { + if (config.completedUpdate && config.completedUpdate.version === latestVersion) { return false; } diff --git a/packages/cli/src/utils/installerDetection.test.ts b/packages/cli/src/utils/installerDetection.test.ts index efdd66697..bb898686f 100644 --- a/packages/cli/src/utils/installerDetection.test.ts +++ b/packages/cli/src/utils/installerDetection.test.ts @@ -86,6 +86,14 @@ describe("detectInstaller", () => { 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"); diff --git a/packages/cli/src/utils/installerDetection.ts b/packages/cli/src/utils/installerDetection.ts index ed32baaab..2c63b4542 100644 --- a/packages/cli/src/utils/installerDetection.ts +++ b/packages/cli/src/utils/installerDetection.ts @@ -120,10 +120,7 @@ export function detectInstaller(): InstallerInfo { // 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 ( - realEntry.includes(`${sep}pnpm${sep}global${sep}`) || - realEntry.includes(`${sep}.pnpm${sep}`) - ) { + if (realEntry.includes(`${sep}pnpm${sep}global${sep}`)) { return { kind: "pnpm", installCommand: (version) => `pnpm add -g hyperframes@${version}`, From fd8d087a1a1471cf13963cf4f2eac6b804db3617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 20 Apr 2026 16:03:26 -0400 Subject: [PATCH 4/6] fix(tsconfig): resolve workspace packages from source --- packages/cli/tsconfig.json | 5 ++++- packages/studio/tsconfig.json | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 441b14aee..855ecc53f 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -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"], diff --git a/packages/studio/tsconfig.json b/packages/studio/tsconfig.json index f0d9032cf..f4b2eff2b 100644 --- a/packages/studio/tsconfig.json +++ b/packages/studio/tsconfig.json @@ -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, From fcb7cd249552531a73314537337632f686242f57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 20 Apr 2026 16:38:13 -0400 Subject: [PATCH 5/6] fix(cli): normalize installer path detection --- .../cli/src/utils/installerDetection.test.ts | 8 +++++ packages/cli/src/utils/installerDetection.ts | 29 +++++++++++++------ 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/utils/installerDetection.test.ts b/packages/cli/src/utils/installerDetection.test.ts index bb898686f..3175b1975 100644 --- a/packages/cli/src/utils/installerDetection.test.ts +++ b/packages/cli/src/utils/installerDetection.test.ts @@ -100,6 +100,14 @@ describe("detectInstaller", () => { 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 diff --git a/packages/cli/src/utils/installerDetection.ts b/packages/cli/src/utils/installerDetection.ts index 2c63b4542..9c69f4fcd 100644 --- a/packages/cli/src/utils/installerDetection.ts +++ b/packages/cli/src/utils/installerDetection.ts @@ -7,7 +7,7 @@ */ import { realpathSync } from "node:fs"; -import { basename, dirname, sep } from "node:path"; +import { posix } from "node:path"; export type InstallerKind = "npm" | "bun" | "pnpm" | "brew" | "skip"; @@ -35,12 +35,17 @@ function resolveEntry(): string | null { } } +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 realEntry.includes(`${sep}packages${sep}cli${sep}`); + return normalized.includes("/packages/cli/"); } /** @@ -49,11 +54,12 @@ function isWorkspaceLink(realEntry: string): boolean { * version on the next invocation anyway. */ function isEphemeralExec(realEntry: string): boolean { + const normalized = normalizePath(realEntry); // npm's npx caches into `/_npx//`; bun uses `bunx--…`. return ( - realEntry.includes(`${sep}_npx${sep}`) || - realEntry.includes(`${sep}.npm${sep}_npx${sep}`) || - basename(dirname(realEntry)).startsWith("bunx-") + normalized.includes("/_npx/") || + normalized.includes("/.npm/_npx/") || + posix.basename(posix.dirname(normalized)).startsWith("bunx-") ); } @@ -63,7 +69,7 @@ function isEphemeralExec(realEntry: string): boolean { * (or `/usr/local/Cellar/` on Intel). Either path wins the match. */ function isHomebrewInstall(realEntry: string): boolean { - return realEntry.includes(`${sep}Cellar${sep}hyperframes${sep}`); + return normalizePath(realEntry).includes("/Cellar/hyperframes/"); } /** @@ -80,6 +86,8 @@ export function detectInstaller(): InstallerInfo { }; } + const normalizedEntry = normalizePath(realEntry); + if (isWorkspaceLink(realEntry)) { return { kind: "skip", @@ -109,7 +117,7 @@ export function detectInstaller(): InstallerInfo { // bun's global install prefix is `~/.bun/install/global/node_modules/` and // the bin shim lives at `~/.bun/bin/`. Both paths contain `.bun`. - if (realEntry.includes(`${sep}.bun${sep}`)) { + if (normalizedEntry.includes("/.bun/")) { return { kind: "bun", installCommand: (version) => `bun add -g hyperframes@${version}`, @@ -120,7 +128,7 @@ export function detectInstaller(): InstallerInfo { // 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 (realEntry.includes(`${sep}pnpm${sep}global${sep}`)) { + if (normalizedEntry.includes("/pnpm/global/")) { return { kind: "pnpm", installCommand: (version) => `pnpm add -g hyperframes@${version}`, @@ -131,7 +139,10 @@ export function detectInstaller(): InstallerInfo { // npm's default global prefix is `/lib/node_modules/hyperframes/…` // where `` is `/usr/local` (macOS Intel), `/opt/homebrew` (Apple // Silicon, non-brew-formula npm), or a user-configured directory. - if (realEntry.includes(`${sep}lib${sep}node_modules${sep}hyperframes${sep}`)) { + if ( + normalizedEntry.includes("/lib/node_modules/hyperframes/") || + normalizedEntry.includes("/npm/node_modules/hyperframes/") + ) { return { kind: "npm", installCommand: (version) => `npm install -g hyperframes@${version}`, From e3368d9a4b31dcbe47715e7c5e2a768475a7a37a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 20 Apr 2026 17:56:04 -0400 Subject: [PATCH 6/6] fix(cli): persist failed auto-update markers --- packages/cli/src/telemetry/config.ts | 2 + packages/cli/src/utils/autoUpdate.test.ts | 45 ++++++++++++++++++++++- packages/cli/src/utils/autoUpdate.ts | 22 ++++++----- 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/telemetry/config.ts b/packages/cli/src/telemetry/config.ts index 4e40bda6a..6031ce886 100644 --- a/packages/cli/src/telemetry/config.ts +++ b/packages/cli/src/telemetry/config.ts @@ -48,6 +48,8 @@ export interface HyperframesConfig { 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; }; } diff --git a/packages/cli/src/utils/autoUpdate.test.ts b/packages/cli/src/utils/autoUpdate.test.ts index a7ebb6878..ec7fb16b7 100644 --- a/packages/cli/src/utils/autoUpdate.test.ts +++ b/packages/cli/src/utils/autoUpdate.test.ts @@ -8,7 +8,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; type ConfigShape = { pendingUpdate?: { version: string; command: string; startedAt: string }; - completedUpdate?: { version: string; ok: boolean; finishedAt: string }; + completedUpdate?: { version: string; ok: boolean; finishedAt: string; reported?: boolean }; latestVersion?: string; }; @@ -259,4 +259,47 @@ describe("scheduleBackgroundInstall", () => { 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, + }); + } + }); }); diff --git a/packages/cli/src/utils/autoUpdate.ts b/packages/cli/src/utils/autoUpdate.ts index 9dd60d5ae..5b21b00b6 100644 --- a/packages/cli/src/utils/autoUpdate.ts +++ b/packages/cli/src/utils/autoUpdate.ts @@ -189,9 +189,9 @@ export function scheduleBackgroundInstall(latestVersion: string, currentVersion: } /** - * If a previous run finished auto-installing, print one short line and clear - * the marker. Stays silent when no update has happened or the current run is - * non-TTY (JSON output, piped, CI). + * 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; @@ -200,17 +200,21 @@ export function reportCompletedUpdate(): void { const done = config.completedUpdate; if (!done) return; - // Clear the marker regardless of whether we print — otherwise a non-TTY run - // would keep the flag around indefinitely and spam the next interactive run - // with stale news. - delete config.completedUpdate; - writeConfig(config); + 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 { + } else if (!done.reported) { // Failed installs are surfaced once too — the user should know why the // auto-update didn't take. process.stderr.write(