feat(cli): add version check system with agent-friendly output

- New `updateCheck.ts` utility: cached npm registry check (24h TTL),
  sync `getUpdateMeta()` for _meta envelope, `printUpdateNotice()` for
  passive stderr banner
- `upgrade --check --json`: machine-readable version check for AI agents
  Returns { current, latest, updateAvailable }
- `_meta` envelope on all --json commands (info, lint, benchmark,
  compositions): includes version, latestVersion, updateAvailable
- `doctor` shows version check as first row
- Passive update notice on stderr after command completes (skipped in
  CI, non-TTY, --json, --quiet)
- Background check fires on startup (non-blocking, populates cache)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
James
2026-03-27 01:41:34 +00:00
co-authored by Claude Opus 4.6
parent 101ff633ca
commit cd15c68a2e
9 changed files with 181 additions and 37 deletions
+11
View File
@@ -10,6 +10,7 @@ import {
trackCommand,
incrementCommandCount,
} from "./telemetry/index.js";
import { checkForUpdate, printUpdateNotice } from "./utils/updateCheck.js";
// ---------------------------------------------------------------------------
// CLI definition
@@ -59,9 +60,19 @@ if (command !== "telemetry" && command !== "unknown" && !isHelpOrVersion) {
}
}
// Fire background update check (non-blocking, populates cache for printUpdateNotice)
const hasJsonFlag = process.argv.includes("--json");
if (!isHelpOrVersion && !hasJsonFlag && command !== "upgrade") {
checkForUpdate().catch(() => {});
}
// Async flush for normal exit (beforeExit fires when the event loop drains)
process.on("beforeExit", () => {
flush().catch(() => {});
// Print update notice after command output (stderr, skipped in CI/non-TTY)
if (!hasJsonFlag) {
printUpdateNotice();
}
});
// Sync flush for process.exit() calls (exit event only allows synchronous code)
+13 -10
View File
@@ -6,6 +6,7 @@ import { loadProducer } from "../utils/producer.js";
import { c } from "../ui/colors.js";
import { formatBytes, formatDuration, errorBox } from "../ui/format.js";
import * as clack from "@clack/prompts";
import { withMeta } from "../utils/updateCheck.js";
interface BenchmarkConfig {
label: string;
@@ -151,16 +152,18 @@ export default defineCommand({
if (jsonOutput) {
console.log(
JSON.stringify(
results.map((r) => ({
config: r.config.label,
fps: r.config.fps,
quality: r.config.quality,
workers: r.config.workers,
avgTimeMs: r.avgTime,
avgSizeBytes: r.avgSize,
failures: r.failures,
runs: r.runs,
})),
withMeta({
results: results.map((r) => ({
config: r.config.label,
fps: r.config.fps,
quality: r.config.quality,
workers: r.config.workers,
avgTimeMs: r.avgTime,
avgSizeBytes: r.avgSize,
failures: r.failures,
runs: r.runs,
})),
}),
null,
2,
),
+2 -1
View File
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
import { c } from "../ui/colors.js";
import { ensureDOMParser } from "../utils/dom.js";
import { resolveProject } from "../utils/project.js";
import { withMeta } from "../utils/updateCheck.js";
interface CompositionInfo {
id: string;
@@ -79,7 +80,7 @@ export default defineCommand({
}
if (args.json) {
console.log(JSON.stringify(compositions, null, 2));
console.log(JSON.stringify(withMeta({ compositions }), null, 2));
return;
}
+15
View File
@@ -3,6 +3,8 @@ import { execSync } from "node:child_process";
import { c } from "../ui/colors.js";
import { findBrowser } from "../browser/manager.js";
import { findFFmpeg } from "../browser/ffmpeg.js";
import { VERSION } from "../version.js";
import { getUpdateMeta } from "../utils/updateCheck.js";
interface Check {
name: string;
@@ -84,6 +86,18 @@ function checkDockerRunning(): CheckResult {
}
}
function checkVersion(): CheckResult {
const meta = getUpdateMeta();
if (meta.updateAvailable && meta.latestVersion) {
return {
ok: false,
detail: `${VERSION} \u2192 ${meta.latestVersion} available`,
hint: "Run: hyperframes upgrade",
};
}
return { ok: true, detail: `${VERSION} (latest)` };
}
function checkNode(): CheckResult {
return { ok: true, detail: `${process.version} (${process.platform} ${process.arch})` };
}
@@ -97,6 +111,7 @@ export default defineCommand({
console.log();
const checks: Check[] = [
{ name: "Version", run: checkVersion },
{ name: "Node.js", run: checkNode },
{ name: "FFmpeg", run: checkFFmpeg },
{ name: "FFprobe", run: checkFFprobe },
+3 -2
View File
@@ -6,6 +6,7 @@ import { c } from "../ui/colors.js";
import { formatBytes, label } from "../ui/format.js";
import { ensureDOMParser } from "../utils/dom.js";
import { resolveProject } from "../utils/project.js";
import { withMeta } from "../utils/updateCheck.js";
function totalSize(dir: string): number {
let total = 0;
@@ -52,7 +53,7 @@ export default defineCommand({
if (args.json) {
console.log(
JSON.stringify(
{
withMeta({
name: project.name,
resolution: parsed.resolution,
width: parsed.resolution === "portrait" ? 1080 : 1920,
@@ -62,7 +63,7 @@ export default defineCommand({
tracks: tracks.size,
types: typeCounts,
size,
},
}),
null,
2,
),
+2 -1
View File
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
import { lintHyperframeHtml } from "@hyperframes/core/lint";
import { c } from "../ui/colors.js";
import { resolveProject } from "../utils/project.js";
import { withMeta } from "../utils/updateCheck.js";
export default defineCommand({
meta: { name: "lint", description: "Validate a composition for common mistakes" },
@@ -16,7 +17,7 @@ export default defineCommand({
const result = lintHyperframeHtml(html, { filePath: project.indexPath });
if (args.json) {
console.log(JSON.stringify(result, null, 2));
console.log(JSON.stringify(withMeta(result), null, 2));
process.exit(result.ok ? 0 : 1);
}
+20 -23
View File
@@ -2,52 +2,49 @@ import { defineCommand } from "citty";
import * as clack from "@clack/prompts";
import { c } from "../ui/colors.js";
import { VERSION } from "../version.js";
import { checkForUpdate, withMeta } from "../utils/updateCheck.js";
export default defineCommand({
meta: { name: "upgrade", description: "Check for updates and show upgrade instructions" },
args: {
yes: { type: "boolean", alias: "y", description: "Show upgrade commands without prompting" },
check: { type: "boolean", description: "Check for updates and exit (no prompt)" },
json: { type: "boolean", description: "Output as JSON", default: false },
},
async run({ args }) {
const autoYes = args.yes === true;
const useJson = args.json === true;
const checkOnly = args.check === true;
// JSON mode: always force-check and output structured data
if (useJson) {
const result = await checkForUpdate(true);
console.log(JSON.stringify(withMeta(result), null, 2));
return;
}
const autoYes = args.yes === true;
clack.intro(c.bold("hyperframes upgrade"));
const s = clack.spinner();
s.start("Checking for updates...");
let latest: string;
try {
const res = await fetch("https://registry.npmjs.org/hyperframes/latest");
if (!res.ok) {
s.stop("Could not check for updates");
clack.outro(c.dim("Package not yet published to npm."));
return;
}
const data = (await res.json()) as { version?: string };
latest = data.version ?? VERSION;
} catch {
s.stop("Could not check for updates");
clack.outro(c.dim("Network error. Check your connection."));
return;
}
const result = await checkForUpdate(true);
if (latest === VERSION) {
if (result.latest === result.current) {
s.stop(c.success("Already up to date"));
clack.outro(`${c.success("")} ${c.bold("v" + VERSION)}`);
clack.outro(`${c.success("\u25C7")} ${c.bold("v" + VERSION)}`);
return;
}
s.stop("Update available");
console.log();
console.log(` ${c.dim("Current:")} ${c.bold("v" + VERSION)}`);
console.log(` ${c.dim("Latest:")} ${c.bold(c.accent("v" + latest))}`);
console.log(` ${c.dim("Current:")} ${c.bold("v" + result.current)}`);
console.log(` ${c.dim("Latest:")} ${c.bold(c.accent("v" + result.latest))}`);
console.log();
if (checkOnly) {
clack.outro(c.accent("Update available: v" + latest));
clack.outro(c.accent("Update available: v" + result.latest));
return;
}
@@ -63,9 +60,9 @@ export default defineCommand({
}
console.log();
console.log(` ${c.accent("npm install -g hyperframes@" + latest)}`);
console.log(` ${c.accent("npm install -g hyperframes@" + result.latest)}`);
console.log(` ${c.dim("or")}`);
console.log(` ${c.accent("npx hyperframes@" + latest + " --version")}`);
console.log(` ${c.accent("npx hyperframes@" + result.latest + " --version")}`);
console.log();
clack.outro(c.success("Run one of the commands above to upgrade."));
+6
View File
@@ -19,6 +19,10 @@ export interface HyperframesConfig {
telemetryNoticeShown: boolean;
/** Total CLI command invocations (for engagement prompts) */
commandCount: number;
/** ISO timestamp of the last npm registry version check */
lastUpdateCheck?: string;
/** Latest version found on npm */
latestVersion?: string;
}
const DEFAULT_CONFIG: HyperframesConfig = {
@@ -52,6 +56,8 @@ export function readConfig(): HyperframesConfig {
anonymousId: parsed.anonymousId || randomUUID(),
telemetryNoticeShown: parsed.telemetryNoticeShown ?? DEFAULT_CONFIG.telemetryNoticeShown,
commandCount: parsed.commandCount ?? DEFAULT_CONFIG.commandCount,
lastUpdateCheck: parsed.lastUpdateCheck,
latestVersion: parsed.latestVersion,
};
cachedConfig = config;
+109
View File
@@ -0,0 +1,109 @@
import { readConfig, writeConfig } from "../telemetry/config.js";
import { VERSION } from "../version.js";
import { isDevMode } from "./env.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;
export interface UpdateCheckResult {
current: string;
latest: string;
updateAvailable: boolean;
}
export interface UpdateMeta {
version: string;
latestVersion?: string;
updateAvailable: boolean;
}
/**
* Check npm registry for the latest version. Uses a 24h cache to avoid
* hitting the registry on every invocation.
*
* @param force - Skip cache and fetch fresh data
*/
export async function checkForUpdate(force?: boolean): Promise<UpdateCheckResult> {
const config = readConfig();
const now = Date.now();
if (!force && config.lastUpdateCheck && config.latestVersion) {
const lastCheck = new Date(config.lastUpdateCheck).getTime();
if (now - lastCheck < CHECK_INTERVAL_MS) {
return {
current: VERSION,
latest: config.latestVersion,
updateAvailable: config.latestVersion !== VERSION,
};
}
}
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const res = await fetch(NPM_REGISTRY_URL, { signal: controller.signal });
clearTimeout(timeout);
if (!res.ok) return fallbackResult(config.latestVersion);
const data = (await res.json()) as { version?: string };
const latest = data.version ?? VERSION;
config.lastUpdateCheck = new Date().toISOString();
config.latestVersion = latest;
writeConfig(config);
return { current: VERSION, latest, updateAvailable: latest !== VERSION };
} catch {
return fallbackResult(config.latestVersion);
}
}
function fallbackResult(cachedLatest?: string): UpdateCheckResult {
return {
current: VERSION,
latest: cachedLatest ?? VERSION,
updateAvailable: cachedLatest ? cachedLatest !== 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 ? 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.
*/
export function withMeta<T extends object>(data: T): T & { _meta: UpdateMeta } {
return { ...data, _meta: getUpdateMeta() };
}
/**
* 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 (isDevMode()) return;
if (process.env["CI"] === "true" || process.env["CI"] === "1") return;
if (!process.stderr.isTTY) return;
if (process.env["HYPERFRAMES_NO_UPDATE_CHECK"] === "1") return;
const meta = getUpdateMeta();
if (!meta.updateAvailable || !meta.latestVersion) return;
process.stderr.write(
`\n Update available: ${meta.version} \u2192 ${meta.latestVersion}\n` +
` Run: npx hyperframes@latest\n\n`,
);
}