mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +00:00
* 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
254 lines
9.5 KiB
TypeScript
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`,
|
|
);
|
|
}
|