fix(cli): upgrade and update notice use the detected install method

* fix(cli): upgrade + update-notice use the detected install method

hyperframes upgrade hardcoded 'npm install -g', so bun/pnpm/brew users either
saw it fail or silently got a shadowed npm copy while their real (older) binary
kept running. Route the install through detectInstaller() via a new
installInvocation() argv helper; for skip kinds (ephemeral npx/bunx,
project-local, unknown) print 'npx hyperframes@latest' instead of guessing.

The passive update notice now shows the detected manager's command too. Semver
safety guard consolidated into a shared isSafeVersion(). Suppression gates and
the background auto-update flow are unchanged.

* test(cli): pin the shell:false contract of the --yes install path

Export runDetectedInstall and add a mocked-execFileSync test asserting the
detected manager binary is spawned with the exact installInvocation argv,
{stdio:inherit, shell:false}, and that an install failure sets a non-zero exit
code without throwing. Addresses review nit on the untested --yes path.

* fix(cli): guard the registry version at the boundary; execFile the auto-installer

Security (addresses review): a poisoned registry data.version (e.g.
'1.2.3; rm -rf /') was cached unvalidated and flowed into the background
auto-updater, which ran it via exec() -- a shell -- so a registry compromise
meant RCE on the next CLI run. isSafeVersion only covered the two touched
consumers (upgrade, notice), not this third sibling (scheduleBackgroundInstall).

- Guard at the registry boundary in checkForUpdate: only a strict-semver STRING
  is trusted; a non-string or metachar-bearing data.version is never cached and
  falls back to the last known-good version. The cache-read and fallback paths
  re-validate too, so a pre-existing poisoned cache can't leak through. One gate
  closes all three consumers and any future one; per-consumer checks stay as
  defense in depth.
- The detached auto-installer now runs via execFile(bin, args, shell:false),
  reusing installInvocation, matching the interactive runDetectedInstall path --
  the shell is gone from that path entirely.

Tests: reject poisoned / non-string registry version (never cached); accept a
valid semver.
This commit is contained in:
Miguel Ángel
2026-07-07 18:40:43 -04:00
committed by GitHub
parent 4a36655b2b
commit 4b3c73d941
8 changed files with 469 additions and 62 deletions
+57
View File
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it, vi } from "vitest";
/**
* Pins the security-relevant contract of the `--yes` install path: the detected
* manager binary is spawned via execFileSync with `shell: false` and the exact
* argv from installInvocation — no shell, so a version can never be re-parsed
* as shell syntax. installInvocation's argv correctness is covered separately
* in installerDetection.test.ts; this locks how it's executed.
*/
describe("runDetectedInstall", () => {
afterEach(() => {
vi.doUnmock("node:child_process");
vi.resetModules();
});
it("spawns the manager binary with shell:false and inherited stdio", async () => {
const execSpy = vi.fn();
vi.resetModules();
vi.doMock("node:child_process", () => ({ execFileSync: execSpy }));
const { runDetectedInstall } = await import("./upgrade.js");
runDetectedInstall(
{ bin: "bun", args: ["add", "-g", "hyperframes@1.2.3"] },
"bun add -g hyperframes@1.2.3",
"1.2.3",
);
expect(execSpy).toHaveBeenCalledTimes(1);
expect(execSpy).toHaveBeenCalledWith("bun", ["add", "-g", "hyperframes@1.2.3"], {
stdio: "inherit",
shell: false,
});
});
it("sets a non-zero exit code when the install fails, without throwing", async () => {
const execSpy = vi.fn(() => {
throw new Error("install boom");
});
vi.resetModules();
vi.doMock("node:child_process", () => ({ execFileSync: execSpy }));
const { runDetectedInstall } = await import("./upgrade.js");
const original = process.exitCode;
try {
expect(() =>
runDetectedInstall(
{ bin: "npm", args: ["install", "-g", "hyperframes@1.2.3"] },
"npm install -g hyperframes@1.2.3",
"1.2.3",
),
).not.toThrow();
expect(process.exitCode).toBe(1);
} finally {
process.exitCode = original;
}
});
});
+96 -46
View File
@@ -10,7 +10,13 @@ export const examples: Example[] = [
["Upgrade non-interactively", "hyperframes upgrade --yes"],
];
import { VERSION } from "../version.js";
import { checkForUpdate, withMeta } from "../utils/updateCheck.js";
import {
checkForUpdate,
withMeta,
isSafeVersion,
type UpdateCheckResult,
} from "../utils/updateCheck.js";
import { detectInstaller, installInvocation } from "../utils/installerDetection.js";
export default defineCommand({
meta: { name: "upgrade", description: "Check for updates and show upgrade instructions" },
@@ -19,6 +25,7 @@ export default defineCommand({
check: { type: "boolean", description: "Check for updates and exit (no prompt)" },
json: { type: "boolean", description: "Output as JSON", default: false },
},
// fallow-ignore-next-line complexity
async run({ args }) {
const useJson = args.json === true;
const checkOnly = args.check === true;
@@ -56,53 +63,96 @@ export default defineCommand({
return;
}
if (!autoYes) {
const shouldUpgrade = await clack.confirm({
message: "Upgrade now?",
});
if (clack.isCancel(shouldUpgrade) || !shouldUpgrade) {
clack.outro(c.dim("Skipped."));
return;
}
}
// Reject anything that isn't a strict semver-shaped string before it reaches
// the install command. A poisoned npm registry response could otherwise put
// shell metacharacters into `result.latest`; rejecting up front means the
// version flows through execFile (and the displayed command) as an opaque
// token, not something the shell might re-parse.
const SAFE_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
if (!SAFE_VERSION.test(result.latest)) {
clack.outro(c.dim("Refusing to install: unexpected version string from npm registry."));
process.exitCode = 1;
if (!autoYes && !(await confirmUpgrade())) {
clack.outro(c.dim("Skipped."));
return;
}
const installArgs = ["install", "-g", `hyperframes@${result.latest}`];
const installCmd = `npm ${installArgs.join(" ")}`;
if (autoYes) {
console.log();
console.log(` ${c.dim("Running:")} ${c.accent(installCmd)}`);
console.log();
try {
// execFileSync with shell:false — the version is now provably safe per
// SAFE_VERSION above, but keep the no-shell call so future edits can't
// regress the shell-injection surface area.
execFileSync("npm", installArgs, { stdio: "inherit", shell: false });
clack.outro(c.success(`Upgraded to v${result.latest}`));
} catch {
clack.outro(c.dim("Install failed. Try running manually:"));
console.log(` ${c.accent(installCmd)}`);
process.exitCode = 1;
}
} else {
console.log();
console.log(` ${c.accent(installCmd)}`);
console.log(` ${c.dim("or")}`);
console.log(` ${c.accent("npx hyperframes@" + result.latest + " --version")}`);
console.log();
clack.outro(c.success("Run one of the commands above to upgrade."));
}
applyUpgrade(result, autoYes);
},
});
/** Interactive "Upgrade now?" prompt; false on decline or cancel. */
async function confirmUpgrade(): Promise<boolean> {
const shouldUpgrade = await clack.confirm({ message: "Upgrade now?" });
return !clack.isCancel(shouldUpgrade) && shouldUpgrade === true;
}
/**
* Show (or, with `autoYes`, run) the upgrade for the user's ACTUAL install
* method — not a hardcoded `npm install -g`, which fails or silently shadows a
* bun/pnpm/brew install. Extracted from `run` to keep that handler simple.
*/
// fallow-ignore-next-line complexity
function applyUpgrade(result: UpdateCheckResult, autoYes: boolean): void {
// Reject anything that isn't a strict semver before it reaches a command. A
// poisoned npm registry response could otherwise put shell metacharacters
// into `result.latest`; the guard means the version flows through execFile
// (and the displayed command) as an opaque token. Shared with the update
// notice via isSafeVersion.
if (!isSafeVersion(result.latest)) {
clack.outro(c.dim("Refusing to install: unexpected version string from npm registry."));
process.exitCode = 1;
return;
}
const installer = detectInstaller();
const invocation = installInvocation(installer.kind, result.latest);
const displayCmd = installer.installCommand(result.latest);
const npxFallback = `npx hyperframes@${result.latest}`;
// Undetectable / ephemeral (npx, bunx) / project-local / workspace: don't
// guess a manager command; point at the universal npx fallback instead.
if (!invocation || !displayCmd) {
printNpxFallback(installer.reason, npxFallback, autoYes);
return;
}
if (!autoYes) {
printManualCommands(displayCmd, npxFallback);
return;
}
runDetectedInstall(invocation, displayCmd, result.latest);
}
function printNpxFallback(reason: string, npxFallback: string, autoYes: boolean): void {
console.log();
if (autoYes) {
console.log(
` ${c.dim("Couldn't detect a global install to upgrade")} ${c.dim("(" + reason + ")")}`,
);
}
console.log(` ${c.accent(npxFallback)}`);
console.log();
clack.outro(c.success("Run the command above to use the latest version."));
}
function printManualCommands(displayCmd: string, npxFallback: string): void {
console.log();
console.log(` ${c.accent(displayCmd)}`);
console.log(` ${c.dim("or")}`);
console.log(` ${c.accent(npxFallback)}`);
console.log();
clack.outro(c.success("Run one of the commands above to upgrade."));
}
export function runDetectedInstall(
invocation: { bin: string; args: string[] },
displayCmd: string,
version: string,
): void {
console.log();
console.log(` ${c.dim("Running:")} ${c.accent(displayCmd)}`);
console.log();
try {
// shell:false — version is provably safe per isSafeVersion above; keep the
// no-shell call so future edits can't regress the injection surface.
execFileSync(invocation.bin, invocation.args, { stdio: "inherit", shell: false });
clack.outro(c.success(`Upgraded to v${version}`));
} catch {
clack.outro(c.dim("Install failed. Try running manually:"));
console.log(` ${c.accent(displayCmd)}`);
process.exitCode = 1;
}
}
@@ -48,6 +48,10 @@ function setupMocks(opts: {
installCommand: () => opts.installer.command,
reason: "test",
}),
// scheduleBackgroundInstall now also derives the argv form; mirror the real
// helper (null for skip, a {bin,args} pair otherwise).
installInvocation: (kind: string, version: string) =>
kind === "skip" ? null : { bin: kind, args: ["add", "-g", `hyperframes@${version}`] },
}));
const spawnSpy = vi.fn(() => ({
+23 -10
View File
@@ -30,7 +30,11 @@ 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";
import {
detectInstaller,
installInvocation,
type InstallInvocation,
} from "./installerDetection.js";
const CONFIG_DIR = join(homedir(), ".hyperframes");
const LOG_FILE = join(CONFIG_DIR, "auto-update.log");
@@ -74,22 +78,30 @@ function log(line: string): void {
* 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 {
function launchDetachedInstall(
invocation: InstallInvocation,
displayCommand: 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.
// 1. Runs the install via execFile (bin + argv, NO shell) so a version
// string can never be re-interpreted as shell syntax — structural
// symmetry with the interactive `runDetectedInstall` path.
// 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.
// We run it through `node -e` so we don't need to ship a separate file. Bin
// and args are embedded as JSON literals (data, not code).
const nodeScript = `
const { exec } = require("node:child_process");
const { execFile } = 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) => {
const BIN = ${JSON.stringify(invocation.bin)};
const ARGS = ${JSON.stringify(invocation.args)};
execFile(BIN, ARGS, { windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (err, _stdout, stderr) => {
let cfg = {};
try { cfg = JSON.parse(readFileSync(CFG, "utf-8")); } catch (e) {}
cfg.completedUpdate = {
@@ -114,7 +126,7 @@ function launchDetachedInstall(installCommand: string, version: string): void {
env: { ...process.env, HYPERFRAMES_NO_UPDATE_CHECK: "1", HYPERFRAMES_NO_AUTO_INSTALL: "1" },
});
child.unref();
log(`[launch] pid=${child.pid ?? "?"} cmd=${installCommand} version=${version}`);
log(`[launch] pid=${child.pid ?? "?"} cmd=${displayCommand} version=${version}`);
}
/**
@@ -149,7 +161,8 @@ export function scheduleBackgroundInstall(latestVersion: string, currentVersion:
return false;
}
const installCommand = installer.installCommand(latestVersion);
if (!installCommand) return false;
const invocation = installInvocation(installer.kind, latestVersion);
if (!installCommand || !invocation) return false;
const config = readConfig();
@@ -177,7 +190,7 @@ export function scheduleBackgroundInstall(latestVersion: string, currentVersion:
writeConfig(config);
try {
launchDetachedInstall(installCommand, latestVersion);
launchDetachedInstall(invocation, installCommand, latestVersion);
return true;
} catch (err) {
log(`[error] spawn failed: ${String(err)}`);
@@ -123,3 +123,36 @@ describe("detectInstaller", () => {
expect(info.reason).toMatch(/Unknown install layout/);
});
});
import { installInvocation } from "./installerDetection.js";
describe("installInvocation", () => {
it("returns the npm global argv for kind npm", () => {
expect(installInvocation("npm", "1.2.3")).toEqual({
bin: "npm",
args: ["install", "-g", "hyperframes@1.2.3"],
});
});
it("returns bun/pnpm add -g argv for those managers", () => {
expect(installInvocation("bun", "1.2.3")).toEqual({
bin: "bun",
args: ["add", "-g", "hyperframes@1.2.3"],
});
expect(installInvocation("pnpm", "1.2.3")).toEqual({
bin: "pnpm",
args: ["add", "-g", "hyperframes@1.2.3"],
});
});
it("returns a version-less brew upgrade for kind brew", () => {
expect(installInvocation("brew", "1.2.3")).toEqual({
bin: "brew",
args: ["upgrade", "hyperframes"],
});
});
it("returns null for kind skip (ephemeral / project-local / unknown)", () => {
expect(installInvocation("skip", "1.2.3")).toBeNull();
});
});
@@ -156,3 +156,34 @@ export function detectInstaller(): InstallerInfo {
reason: `Unknown install layout at ${realEntry}`,
};
}
/** Argv-shaped install command for a no-shell `execFile`. */
export interface InstallInvocation {
bin: string;
args: string[];
}
/**
* The argv form of {@link InstallerInfo.installCommand}, kept next to the
* detector so the command we *run* (execFile, no shell) and the command we
* *display* (installCommand string) can never drift. Returns `null` for `skip`
* kinds (ephemeral npx/bunx, workspace links, project-local, unknown layouts):
* the caller must print a manual instruction rather than run a guessed command
* (running the wrong manager is worse than running nothing).
*/
export function installInvocation(kind: InstallerKind, version: string): InstallInvocation | null {
switch (kind) {
case "npm":
return { bin: "npm", args: ["install", "-g", `hyperframes@${version}`] };
case "bun":
return { bin: "bun", args: ["add", "-g", `hyperframes@${version}`] };
case "pnpm":
return { bin: "pnpm", args: ["add", "-g", `hyperframes@${version}`] };
case "brew":
// brew has no per-version install; `brew upgrade` moves to the tap's
// current formula (a no-op if the tap hasn't caught up).
return { bin: "brew", args: ["upgrade", "hyperframes"] };
case "skip":
return null;
}
}
+177
View File
@@ -0,0 +1,177 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { isSafeVersion } from "./updateCheck.js";
describe("isSafeVersion", () => {
it("accepts strict semver, incl. prerelease/build metadata", () => {
expect(isSafeVersion("1.2.3")).toBe(true);
expect(isSafeVersion("0.7.28")).toBe(true);
expect(isSafeVersion("1.2.3-beta.1")).toBe(true);
expect(isSafeVersion("1.2.3+build.5")).toBe(true);
});
it("rejects anything that could carry shell metacharacters or isn't semver", () => {
expect(isSafeVersion("")).toBe(false);
expect(isSafeVersion("latest")).toBe(false);
expect(isSafeVersion("1.2")).toBe(false);
expect(isSafeVersion("1.2.3; rm -rf /")).toBe(false);
expect(isSafeVersion("1.2.3 && curl evil")).toBe(false);
expect(isSafeVersion("$(whoami)")).toBe(false);
});
});
/**
* Drive printUpdateNotice under controlled mocks. isDevMode() is true under
* vitest (the module path ends in .ts), which would suppress the notice, so we
* mock ./env.js. detectInstaller and readConfig are mocked to pick the branch.
*/
async function noticeWith(opts: {
installerCommand: string | null;
latestVersion?: string;
isTTY?: boolean;
env?: Record<string, string | undefined>;
}): Promise<string> {
vi.resetModules();
vi.doMock("./env.js", () => ({ isDevMode: () => false }));
vi.doMock("./installerDetection.js", () => ({
detectInstaller: () => ({
kind: opts.installerCommand ? "npm" : "skip",
installCommand: () => opts.installerCommand,
reason: "test",
}),
}));
vi.doMock("../telemetry/config.js", () => ({
readConfig: () => ({ latestVersion: opts.latestVersion ?? "9.9.9" }),
writeConfig: () => {},
}));
const origEnv = { ...process.env };
for (const [k, v] of Object.entries(opts.env ?? {})) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
// Default to a non-CI interactive terminal unless the test overrides env.
if (!("CI" in (opts.env ?? {}))) delete process.env["CI"];
const origTTY = process.stderr.isTTY;
Object.defineProperty(process.stderr, "isTTY", {
value: opts.isTTY ?? true,
configurable: true,
});
const writes: string[] = [];
const origWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = ((chunk: unknown) => {
writes.push(String(chunk));
return true;
}) as typeof process.stderr.write;
try {
const mod = await import("./updateCheck.js");
mod.printUpdateNotice();
} finally {
process.stderr.write = origWrite;
Object.defineProperty(process.stderr, "isTTY", { value: origTTY, configurable: true });
process.env = origEnv;
}
return writes.join("");
}
describe("printUpdateNotice — install-method-aware command", () => {
afterEach(() => {
vi.doUnmock("./env.js");
vi.doUnmock("./installerDetection.js");
vi.doUnmock("../telemetry/config.js");
vi.resetModules();
});
it("shows the detected manager's command for an owned global install", async () => {
const out = await noticeWith({ installerCommand: "brew upgrade hyperframes" });
expect(out).toContain("Update available");
expect(out).toContain("brew upgrade hyperframes");
expect(out).not.toContain("npx hyperframes@latest");
});
it("falls back to npx hyperframes@latest when the install method is skip/unknown", async () => {
const out = await noticeWith({ installerCommand: null });
expect(out).toContain("npx hyperframes@latest");
});
it("is suppressed on a non-TTY stderr", async () => {
const out = await noticeWith({ installerCommand: "brew upgrade hyperframes", isTTY: false });
expect(out).toBe("");
});
it("is suppressed in CI", async () => {
const out = await noticeWith({
installerCommand: "brew upgrade hyperframes",
env: { CI: "true" },
});
expect(out).toBe("");
});
it("is suppressed by the HYPERFRAMES_NO_UPDATE_CHECK opt-out", async () => {
const out = await noticeWith({
installerCommand: "brew upgrade hyperframes",
env: { HYPERFRAMES_NO_UPDATE_CHECK: "1" },
});
expect(out).toBe("");
});
});
/**
* The registry-boundary guard: a poisoned or non-string data.version must
* never be cached, because it flows into the auto-updater's install command.
* This closes the injection class for every downstream consumer at one point.
*/
async function checkWith(registryVersion: unknown): Promise<{
latest: string;
wroteVersion: string | undefined;
}> {
vi.resetModules();
const writes: Array<Record<string, unknown>> = [];
vi.doMock("../telemetry/config.js", () => ({
readConfig: () => ({}),
writeConfig: (c: Record<string, unknown>) => writes.push({ ...c }),
}));
const origFetch = globalThis.fetch;
globalThis.fetch = (async () => ({
ok: true,
json: async () => ({ version: registryVersion }),
})) as unknown as typeof fetch;
try {
const mod = await import("./updateCheck.js");
const result = await mod.checkForUpdate(true);
const lastWrite = writes.at(-1);
return {
latest: result.latest,
wroteVersion: lastWrite ? (lastWrite["latestVersion"] as string | undefined) : undefined,
};
} finally {
globalThis.fetch = origFetch;
}
}
describe("checkForUpdate — registry boundary guard", () => {
afterEach(() => {
vi.doUnmock("../telemetry/config.js");
vi.resetModules();
});
it("caches and returns a valid semver from the registry", async () => {
const { latest, wroteVersion } = await checkWith("9.9.9");
expect(latest).toBe("9.9.9");
expect(wroteVersion).toBe("9.9.9");
});
it("rejects a version carrying shell metacharacters (no cache, no surface)", async () => {
const { latest, wroteVersion } = await checkWith("1.2.3; rm -rf /");
expect(latest).not.toContain(";");
expect(latest).not.toBe("1.2.3; rm -rf /");
expect(wroteVersion).toBeUndefined(); // never written to config
});
it("rejects a non-string data.version", async () => {
const { latest, wroteVersion } = await checkWith({ evil: true });
expect(typeof latest).toBe("string");
expect(wroteVersion).toBeUndefined();
});
});
+48 -6
View File
@@ -2,6 +2,20 @@ import { compareVersions } from "compare-versions";
import { readConfig, writeConfig } from "../telemetry/config.js";
import { VERSION } from "../version.js";
import { isDevMode } from "./env.js";
import { detectInstaller } from "./installerDetection.js";
/**
* True when `v` is a strict semver-shaped string. Registry-supplied versions
* flow into commands that are displayed AND executed (the `upgrade` command and
* the background auto-installer both run them), so a poisoned `latest` carrying
* shell metacharacters must never reach them. This is enforced at the registry
* boundary in `checkForUpdate` — an unsafe `data.version` is never cached — so
* every consumer (notice, upgrade, background auto-install, and any future one)
* is covered by this single gate; the per-consumer checks are defense in depth.
*/
export function isSafeVersion(v: string): boolean {
return /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(v);
}
const NPM_REGISTRY_URL = "https://registry.npmjs.org/hyperframes/latest";
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
@@ -38,7 +52,14 @@ export async function checkForUpdate(force?: boolean): Promise<UpdateCheckResult
const config = readConfig();
const now = Date.now();
if (!force && config.lastUpdateCheck && config.latestVersion) {
// Also guard the cache read: a cache written before this boundary guard
// existed could hold an unsafe latestVersion — re-validate before trusting it.
if (
!force &&
config.lastUpdateCheck &&
config.latestVersion &&
isSafeVersion(config.latestVersion)
) {
const lastCheck = new Date(config.lastUpdateCheck).getTime();
if (now - lastCheck < CHECK_INTERVAL_MS) {
return {
@@ -60,8 +81,17 @@ export async function checkForUpdate(force?: boolean): Promise<UpdateCheckResult
if (!res.ok) return fallbackResult(config.latestVersion);
const data = (await res.json()) as { version?: string };
const latest = data.version ?? VERSION;
const data = (await res.json()) as { version?: unknown };
// Registry boundary guard: only a strict-semver STRING is trusted. This
// value is cached and later flows into an install command that the
// background auto-updater executes, so a poisoned or non-string
// data.version (e.g. "1.2.3; rm -rf /") must never be persisted. Reject it
// and fall back to the last known-good version. Closes the injection class
// for every consumer at one point.
if (typeof data.version !== "string" || !isSafeVersion(data.version)) {
return fallbackResult(config.latestVersion);
}
const latest = data.version;
config.lastUpdateCheck = new Date().toISOString();
config.latestVersion = latest;
@@ -74,10 +104,13 @@ export async function checkForUpdate(force?: boolean): Promise<UpdateCheckResult
}
function fallbackResult(cachedLatest?: string): UpdateCheckResult {
// Only surface a cached version we can prove is safe — a pre-existing
// poisoned cache must not leak through the fallback path either.
const safeCached = cachedLatest && isSafeVersion(cachedLatest) ? cachedLatest : undefined;
return {
current: VERSION,
latest: cachedLatest ?? VERSION,
updateAvailable: cachedLatest ? isNewerSemver(cachedLatest, VERSION) : false,
latest: safeCached ?? VERSION,
updateAvailable: safeCached ? isNewerSemver(safeCached, VERSION) : false,
};
}
@@ -125,8 +158,17 @@ export function printUpdateNotice(): void {
const meta = getUpdateMeta();
if (!meta.updateAvailable || !meta.latestVersion) return;
// Show the command that updates *this* install: the detected package
// manager's upgrade for owned global installs (npm/bun/pnpm/brew), and the
// universal `npx hyperframes@latest` for ephemeral/unknown installs (where a
// manager command wouldn't apply). detectInstaller() only runs here, after
// the suppression + update-available gates, so it adds no cost to normal runs.
const safeLatest = isSafeVersion(meta.latestVersion);
const managerCommand = safeLatest ? detectInstaller().installCommand(meta.latestVersion) : null;
const command = managerCommand ?? "npx hyperframes@latest";
process.stderr.write(
`\n Update available: ${meta.version} \u2192 ${meta.latestVersion}\n` +
` Run: npx hyperframes@latest\n\n`,
` Run: ${command}\n\n`,
);
}