mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 00:56:23 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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."));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user