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
+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."));