Files
hyperframes/packages/cli/src/utils/updateCheck.ts
T
Miguel Ángel 693c385508 fix(cli): commands exit right away even when the network or DNS is broken (#4604)
* fix(cli): commands exit without waiting on update checks or telemetry

The update and skills checks run in a detached child that refreshes the
caches for the next run; the parent only reads the caches. finalizeCli no
longer awaits the telemetry flush: the exit handler flushSync() delivers
what is queued. The events beacon, feedback and figma error paths stop
awaiting flush() too.

* fix(cli): retry a failed background check at most once an hour

Before starting the background worker, the CLI stamps a per-check attempt
time and skips a check it attempted within the hour, so a dead network no
longer starts a worker on every command. The stale-pin notice writes onto a
fresh read, detached spawns set windowsHide, and a test ties the worker path
to its build entry.

* fix(cli): a failed-check stamp dated in the future no longer silences update checks
2026-09-28 02:22:12 +00:00

254 lines
9.5 KiB
TypeScript

import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { compareVersions } from "compare-versions";
import { readConfig, readConfigFresh, writeConfig } from "../telemetry/config.js";
import { VERSION } from "../version.js";
import { isDevMode } from "./env.js";
import { hostAnswers } from "./hostAnswers.js";
import { detectInstaller } from "./installerDetection.js";
import { readPinnedHyperframesVersions } from "./projectPin.js";
import { isSafeVersion } from "./safeVersion.js";
export { isSafeVersion } from "./safeVersion.js";
const NPM_REGISTRY_URL = "https://registry.npmjs.org/hyperframes/latest";
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
const FETCH_TIMEOUT_MS = 3000;
/** Returns true if `a` is newer than `b` per semver (handles alpha, beta, rc). */
function isNewerSemver(a: string, b: string): boolean {
try {
return compareVersions(a, b) > 0;
} catch {
return a !== b;
}
}
export interface UpdateCheckResult {
current: string;
latest: string;
updateAvailable: boolean;
}
export interface UpdateMeta {
version: string;
latestVersion?: string;
updateAvailable: boolean;
/** Present (and true) only for commands superseded by `check`; absent otherwise. */
deprecated?: boolean;
}
/**
* Check npm registry for the latest version. Uses a 24h cache to avoid
* hitting the registry on every invocation.
*
* @param force - Skip the cache, opt-outs and DNS probe: the caller waits for the registry
*/
export async function checkForUpdate(force?: boolean): Promise<UpdateCheckResult> {
const config = readConfig();
if (!force && !updateCheckDue(config)) return fallbackResult(config.latestVersion);
try {
if (!force && !(await hostAnswers(new URL(NPM_REGISTRY_URL).hostname))) {
return fallbackResult(config.latestVersion);
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
let data: { version?: unknown };
try {
const res = await fetch(NPM_REGISTRY_URL, {
signal: controller.signal,
headers: { Connection: "close" },
});
if (!res.ok) return fallbackResult(config.latestVersion);
data = (await res.json()) as { version?: unknown };
} finally {
clearTimeout(timeout);
}
// 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;
// The registry request can take seconds. Merge its two metadata fields
// into a fresh snapshot rather than writing the object captured before
// the network call: the stale object previously reverted a concurrent
// `telemetry disable` back to the default `true`.
const freshConfig = readConfigFresh();
freshConfig.lastUpdateCheck = new Date().toISOString();
freshConfig.latestVersion = latest;
writeConfig(freshConfig);
return { current: VERSION, latest, updateAvailable: isNewerSemver(latest, VERSION) };
} catch {
return fallbackResult(config.latestVersion);
}
}
/** Whether the background check should ask the registry: not opted out, and no fresh safe cache. */
export function updateCheckDue(config = readConfig()): boolean {
if (updateCheckDisabled()) return false;
if (!config.lastUpdateCheck || !config.latestVersion || !isSafeVersion(config.latestVersion)) {
return true;
}
const fresh = Date.now() - new Date(config.lastUpdateCheck).getTime() < CHECK_INTERVAL_MS;
return !fresh;
}
export function cachedUpdateCheck(): UpdateCheckResult {
return fallbackResult(readConfig().latestVersion);
}
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: safeCached ?? VERSION,
updateAvailable: safeCached ? isNewerSemver(safeCached, VERSION) : false,
};
}
/**
* Synchronous read from cache — for _meta envelope on --json commands.
* Never fetches. Returns what the last background check found.
*/
export function getUpdateMeta(): UpdateMeta {
const config = readConfig();
return {
version: VERSION,
latestVersion: config.latestVersion,
updateAvailable: config.latestVersion ? isNewerSemver(config.latestVersion, VERSION) : false,
};
}
/**
* Wrap a JSON payload with the _meta version envelope.
* Use this in all --json command outputs for consistent agent-friendly metadata.
*
* Pass `{ deprecated: true }` from a command superseded by `check` (validate,
* inspect, layout) to add `_meta.deprecated: true`; every other call site is
* unaffected — the key is only ever added, never set to `false`.
*/
export function withMeta<T extends object>(
data: T,
options?: { deprecated?: boolean },
): T & { _meta: UpdateMeta } {
const meta = getUpdateMeta();
if (options?.deprecated) meta.deprecated = true;
return { ...data, _meta: meta };
}
/**
* One-line deprecation notice for a command superseded by `check`. Always
* writes to stderr (never stdout), so a --json invocation's stdout stays
* pure, parseable JSON. Call once per invocation, before the command's own
* output.
*/
export function printDeprecationNotice(command: string): void {
process.stderr.write(
`'hyperframes ${command}' is deprecated and will be removed in a future release. Use 'hyperframes check' instead.\n`,
);
}
/** True when the update check is off: dev mode, CI, or the HYPERFRAMES_NO_UPDATE_CHECK opt-out. */
export function updateCheckDisabled(): boolean {
if (isDevMode()) return true;
if (process.env["CI"] === "true" || process.env["CI"] === "1") return true;
return process.env["HYPERFRAMES_NO_UPDATE_CHECK"] === "1";
}
/**
* True when update / freshness notices should stay silent: the check is off or stderr is not a
* terminal. Shared with the skills freshness notice so both honour the same gating.
*/
export function updateNoticesSuppressed(): boolean {
return updateCheckDisabled() || !process.stderr.isTTY;
}
/**
* Print update notice to stderr if a newer version is available.
* Skipped in CI, non-TTY, dev mode, or when HYPERFRAMES_NO_UPDATE_CHECK is set.
*/
export function printUpdateNotice(): void {
if (updateNoticesSuppressed()) return;
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: ${command}\n\n`,
);
}
const STALE_PIN_THROTTLE_MS = 24 * 60 * 60 * 1000;
/**
* Actionable notice when the running CLI is older than the project's pin (every
* run), or when the pin is older than the latest release (once/24h per install).
* Unlike printUpdateNotice this DOES fire on non-TTY (agents render with piped
* stderr), but never under --json/CI/dev/opt-out: the whole cli.ts update block
* is skipped for --json, so a JSON stdout stays clean regardless.
*/
export function printStalePinNotice(cwd: string = process.cwd()): void {
if (updateCheckDisabled()) return;
let scripts: Record<string, string> = {};
try {
const pkgPath = join(cwd, "package.json");
if (!existsSync(pkgPath)) return;
scripts = (JSON.parse(readFileSync(pkgPath, "utf-8")).scripts ?? {}) as Record<string, string>;
} catch {
return;
}
const pins = readPinnedHyperframesVersions(scripts);
// A CLI older than the pin (e.g. a stale npx cache) misjudges every run, so this is never throttled.
const newerPins = pins.filter((v) => isNewerSemver(v, VERSION)).sort(compareVersions);
if (newerPins.length > 0) {
process.stderr.write(
`\n This is hyperframes ${VERSION}, but this project pins hyperframes@${newerPins.join(", ")}.\n` +
` Run it through the project's npm scripts, or npx hyperframes@${newerPins.at(-1)}.\n\n`,
);
return;
}
const latest = getUpdateMeta().latestVersion;
if (!latest || !isSafeVersion(latest)) return;
const stale = pins.filter((v) => {
try {
return compareVersions(latest, v) > 0;
} catch {
return false;
}
});
if (stale.length === 0) return;
const last = readConfig().lastStalePinNoticeAt ?? 0;
if (Date.now() - last < STALE_PIN_THROTTLE_MS) return;
const config = readConfigFresh();
config.lastStalePinNoticeAt = Date.now();
writeConfig(config);
process.stderr.write(
`\n This project pins hyperframes@${stale.join(", ")} (latest ${latest}).\n` +
` Bump it: npx hyperframes@latest upgrade --project\n\n`,
);
}