#!/usr/bin/env node // ── EPIPE suppression (must run before ANY stdout/stderr write) ──────────── // When the CLI runs inside a piped agent environment (Claude Code, Codex, // Cursor, etc.), the reader may close the pipe before we finish writing. // Node treats EPIPE on stdout/stderr as an uncaughtException, which crashes // the process. This is a normal lifecycle event — suppress it. // // commandFailed must be declared here (before the handlers) so the EPIPE // stream-error path can set it before process.exit(0). The telemetry exit // handler reads this flag to determine success/failure — an EPIPE that // interrupts a command should NOT score as success:true, but one that // arrives after the render artifact was validated is the normal agent-pipe // teardown and must stay success:true (see handleStreamEpipe). let commandFailed = false; for (const stream of [process.stdout, process.stderr]) { stream.on("error", (err) => { if ((err as NodeJS.ErrnoException).code === "EPIPE") { handleStreamEpipe(); } }); } // ── Worker entry path bootstrap (must run before any producer/engine load) ── // The shaderTransitionWorkerPool lives in the producer package and resolves // its worker entry by probing for a sibling `.js` file next to // `import.meta.url`. When this CLI is bundled by tsup, the producer code is // inlined into `cli.js`, but `import.meta.url` resolves to the producer's // own dist path (NOT cli.js) on some module-graph layouts — so the sibling // probe lands in a directory that does not contain the bundled worker. // We emit the worker entry next to cli.js (see tsup.config.ts) and tell // the pool where to find it via the published env-var override. import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { existsSync } from "node:fs"; (() => { const here = dirname(fileURLToPath(import.meta.url)); const shader = join(here, "shaderTransitionWorker.js"); if (!process.env.HF_SHADER_WORKER_ENTRY && existsSync(shader)) { process.env.HF_SHADER_WORKER_ENTRY = shader; } })(); // ── Fast-path exits ───────────────────────────────────────────────────────── // Check --version before importing anything heavy. This makes // `hyperframes --version` near-instant (~10ms vs ~80ms). import { VERSION } from "./version.js"; const argv = process.argv.slice(2); const commandArg = argv[0]; const rootVersionRequested = commandArg === "--version" || commandArg === "-V" || (commandArg === undefined && (argv.includes("--version") || argv.includes("-V"))); if (rootVersionRequested) { console.log(VERSION); process.exit(0); } // ── Load .env from CWD ───────────────────────────────────────────────────── // Agents run from the project directory where .env holds API keys (Gemini, // HeyGen, ElevenLabs). Load it automatically so they don't need `source .env`. try { const { readFileSync } = await import("node:fs"); const { resolve } = await import("node:path"); const envPath = resolve(process.cwd(), ".env"); const envContent = readFileSync(envPath, "utf-8"); for (const rawLine of envContent.split("\n")) { let line = rawLine.trim(); if (!line || line.startsWith("#")) continue; // Tolerate `export FOO=bar` (common in dotfile-style .env files). if (line.startsWith("export ")) line = line.slice(7).trim(); const eqIdx = line.indexOf("="); if (eqIdx < 1) continue; const key = line.slice(0, eqIdx).trim(); let val = line.slice(eqIdx + 1).trim(); if (val.startsWith('"') || val.startsWith("'")) { // Quoted value: take until the matching closing quote; leave the rest. // Anything after a closing quote (including `# comment`) is dropped. const quote = val.charAt(0); const end = val.indexOf(quote, 1); if (end > 0) val = val.slice(1, end); else val = val.slice(1); // unterminated quote — best-effort, strip opener } else { // Unquoted value: strip inline `# comment` (requires whitespace before # // to avoid eating `pass#word` style values). const commentMatch = val.match(/\s+#/); if (commentMatch?.index !== undefined) val = val.slice(0, commentMatch.index).trim(); } if (key && !(key in process.env)) process.env[key] = val; } } catch { /* .env not present — fine, env vars may be set another way */ } // ── Lazy imports ──────────────────────────────────────────────────────────── // Telemetry, update checks, and heavy modules are imported only when needed. // For --help we skip telemetry entirely. import { defineCommand, runCommand } from "citty"; import type { ArgsDef, CommandDef } from "citty"; import { getRunId } from "./telemetry/runId.js"; import { reportCommandFailure, trackCommandFailures } from "./utils/command-failure-tracking.js"; import { isRenderSucceeded } from "./utils/render-success-state.js"; import { resolveCommandUsage } from "./utils/commandUsageResolution.js"; import { CliResultSignal, CliRuntimeError, CliUsageError, consumeCommandResult, registerRootExitCodeSanitizer, registerRootExitRequester, type CommandResult, } from "./utils/commandResult.js"; const isHelp = process.argv.includes("--help") || process.argv.includes("-h"); // --------------------------------------------------------------------------- // CLI definition — all commands are lazy-loaded via dynamic import() // --------------------------------------------------------------------------- const commandLoaders = { init: () => import("./commands/init.js").then((m) => m.default), add: () => import("./commands/add.js").then((m) => m.default), catalog: () => import("./commands/catalog.js").then((m) => m.default), play: () => import("./commands/play.js").then((m) => m.default), present: () => import("./commands/present.js").then((m) => m.default), preview: () => import("./commands/preview.js").then((m) => m.default), publish: () => import("./commands/publish.js").then((m) => m.default), render: () => import("./commands/render.js").then((m) => m.default), lint: () => import("./commands/lint.js").then((m) => m.default), check: () => import("./commands/check.js").then((m) => m.default), beats: () => import("./commands/beats.js").then((m) => m.default), "normalize-audio": () => import("./commands/normalize-audio.js").then((m) => m.default), inspect: () => import("./commands/inspect.js").then((m) => m.default), keyframes: () => import("./commands/keyframes.js").then((m) => m.default), layout: () => import("./commands/layout.js").then((m) => m.default), info: () => import("./commands/info.js").then((m) => m.default), compositions: () => import("./commands/compositions.js").then((m) => m.default), benchmark: () => import("./commands/benchmark.js").then((m) => m.default), browser: () => import("./commands/browser.js").then((m) => m.default), "remove-background": () => import("./commands/remove-background.js").then((m) => m.default), transcribe: () => import("./commands/transcribe.js").then((m) => m.default), tts: () => import("./commands/tts.js").then((m) => m.default), docs: () => import("./commands/docs.js").then((m) => m.default), doctor: () => import("./commands/doctor.js").then((m) => m.default), upgrade: () => import("./commands/upgrade.js").then((m) => m.default), skills: () => import("./commands/skills.js").then((m) => m.default), feedback: () => import("./commands/feedback.js").then((m) => m.default), telemetry: () => import("./commands/telemetry.js").then((m) => m.default), events: () => import("./commands/events.js").then((m) => m.default), validate: () => import("./commands/validate.js").then((m) => m.default), snapshot: () => import("./commands/snapshot.js").then((m) => m.default), "media-treatment": () => import("./commands/media-treatment.js").then((m) => m.mediaTreatmentCommand), "grade-compare": () => import("./commands/grade-compare.js").then((m) => m.default), compare: () => import("./commands/compare.js").then((m) => m.default), capture: () => import("./commands/capture.js").then((m) => m.default), lambda: () => import("./commands/lambda.js").then((m) => m.default), cloudrun: () => import("./commands/cloudrun.js").then((m) => m.default), cloud: () => import("./commands/cloud.js").then((m) => m.default), auth: () => import("./commands/auth.js").then((m) => m.default), figma: () => import("./commands/figma.js").then((m) => m.default), }; const subCommands = Object.fromEntries( Object.entries(commandLoaders).map(([name, load]) => [name, trackCommandFailures(load)]), ); const main = defineCommand({ meta: { name: "hyperframes", version: VERSION, description: "Create and render HTML video compositions", }, subCommands, }); // --------------------------------------------------------------------------- // Telemetry — lazy-loaded, captured references for exit handlers // --------------------------------------------------------------------------- const cliCommandArg = process.argv[2]; // Explicit annotation breaks a type cycle: `subCommands` references `command` // (in the failure reporter) and `command` references `subCommands` (the `in` // check), so its type can't be inferred from its own initializer. const command: string = cliCommandArg && cliCommandArg in subCommands ? cliCommandArg : "unknown"; const hasJsonFlag = process.argv.includes("--json"); // Captured references — populated when the lazy imports resolve. // Used in exit handlers where dynamic import() is unsafe (beforeExit loops, // exit handler is synchronous-only). let _flush: (() => Promise) | undefined; let _flushSync: (() => void) | undefined; let _trackCliError: | ((props: { error_name: string; error_message: string; stack_trace?: string; command?: string; kind: "uncaught_exception" | "unhandled_rejection" | "command_error"; }) => void) | undefined; let _trackCommandResult: | ((props: { command: string; success: boolean; exitCode: number; durationMs: number; runId?: string; }) => void) | undefined; let _printUpdateNotice: (() => void) | undefined; let _printStalePinNotice: (() => void) | undefined; let _printSkillsUpdateNotice: (() => void) | undefined; let telemetryReady: Promise = Promise.resolve(); // `events` is a telemetry-internal beacon: it self-tracks + self-flushes, so it // skips the per-command wrapper (no duplicate cli_command, no first-run notice // printed into a skill's captured output). if (!isHelp && command !== "telemetry" && command !== "events" && command !== "unknown") { telemetryReady = import("./telemetry/index.js").then((mod) => { _flush = mod.flush; _flushSync = mod.flushSync; _trackCliError = mod.trackCliError; _trackCommandResult = mod.trackCommandResult; mod.showTelemetryNotice(); mod.trackCommand(command, runId); if (mod.shouldTrack()) mod.incrementCommandCount(); }); } // `events` skips the update check too — a skill-usage beacon must not add // network latency or trigger a background self-upgrade on the calling skill. // `telemetry` skips it because update metadata must never race the command // that changes the user's telemetry preference. // `skills` is excluded from the SKILLS nudge for the same reason `upgrade` is // excluded from the self-update notice: a command that is itself actively // checking/reconciling skills (`skills check`, `skills update`) must not also // tell the user to go run `skills update` — that's either redundant (it just // did) or, worse, misleading (it printed a stale nudge count from the last // cached check while reporting fresh results of its own). if ( !isHelp && !hasJsonFlag && command !== "upgrade" && command !== "events" && command !== "telemetry" && command !== "skills" ) { // Report any completed auto-install from the previous run first, before // kicking off the next check — so the user sees "updated to vX" once and // we don't over-print. import("./utils/autoUpdate.js").then((mod) => mod.reportCompletedUpdate()).catch(() => {}); import("./utils/updateCheck.js").then(async (mod) => { _printUpdateNotice = mod.printUpdateNotice; _printStalePinNotice = mod.printStalePinNotice; const result = await mod.checkForUpdate().catch(() => null); if (result?.updateAvailable) { const auto = await import("./utils/autoUpdate.js").catch(() => null); auto?.scheduleBackgroundInstall(result.latest, result.current); } }); // Skills freshness nudge — same gating as the CLI self-update notice. The // check is cached (24h) and best-effort: it never blocks or fails the command. import("./utils/skillsUpdateCheck.js").then(async (mod) => { _printSkillsUpdateNotice = mod.printSkillsUpdateNotice; await mod.checkSkillsForUpdate().catch(() => null); }); } const commandStart = Date.now(); const runId = getRunId(); let finalized = false; // Root-only lifecycle fan-in: telemetry, notices, flushing, then exit code. // fallow-ignore-next-line complexity async function finalizeCli(result: CommandResult): Promise { if (finalized) return; finalized = true; commandFailed ||= result.exitCode !== 0; await telemetryReady.catch(() => {}); _trackCommandResult?.({ command, success: result.exitCode === 0 && commandSucceededForTelemetry(), exitCode: result.exitCode, durationMs: Date.now() - commandStart, runId, }); await _flush?.().catch(() => {}); if (!hasJsonFlag) { _printUpdateNotice?.(); _printStalePinNotice?.(); _printSkillsUpdateNotice?.(); } process.exitCode = result.exitCode; } registerRootExitRequester((exitCode) => { void finalizeCli({ exitCode, kind: exitCode === 0 ? "success" : "runtime_error", presented: true, }).finally(() => process.exit(exitCode)); }); registerRootExitCodeSanitizer(() => { if (process.exitCode !== undefined && process.exitCode !== 0) { process.exitCode = 0; } }); // Sync-only: exit handlers cannot await promises or drain microtasks. // _trackCommandResult / _trackCliError are captured references resolved // at init time, so they're callable synchronously here. process.on( "exit", // fallow-ignore-next-line complexity (code) => { if (!finalized) { _trackCommandResult?.({ command, success: code === 0 && commandSucceededForTelemetry(), exitCode: code, durationMs: Date.now() - commandStart, runId, }); } // Unconditional — `finalized` only means finalizeCli STARTED its awaited // flush(). A process.exit() racing that flush (the EPIPE path under agent // pipes) kills the in-flight request, and gating this fallback behind // `finalized` silently dropped the still-queued events — the 0.7.65 // render_complete regression. flushSync() is safe to over-call: an empty // queue is a no-op, and event uuids make re-sends idempotent. _flushSync?.(); }, ); // Report a CLI error event to telemetry. Extracted from the process-error // handlers so their bodies stay simple linear branches (see fallow CRAP // scoring — arrow handlers with inline telemetry calls tip past threshold). function emitCliErrorEvent(kind: "uncaught_exception" | "unhandled_rejection", error: Error): void { _trackCliError?.({ error_name: error.name, error_message: error.message, stack_trace: error.stack, command, kind, }); } // Handle a post-artifact-validated throw: record the diagnostic, emit the // telemetry event, but do NOT mark the run as failed. Field signals: // ts=1784169760, ts=1784171150, ts=1784172467 (all win32/x64, CLI 0.7.58, // ffmpeg=no, 1080x1920, valid MP4s on disk). // The render is valid — a worker teardown / browser shutdown / stray // subprocess stream error after `renderSucceeded` was set must not flip // exit code or telemetry success to failure. function reportPostRenderTerminationEvent( label: "uncaughtException" | "unhandledRejection", kind: "uncaught_exception" | "unhandled_rejection", error: Error, ): void { process.stderr.write( ` [hyperframes] Post-render ${label} (render already succeeded): ${error.message}\n`, ); emitCliErrorEvent(kind, error); } // Terminate the process after a post-artifact-validated throw. Wraps // report + flush + exit(0) so the caller arrow handler doesn't accumulate // optional-chain branches (fallow CRAP scoring on the arrow tips past // threshold otherwise). function exitAfterPostRenderTermination( label: "uncaughtException" | "unhandledRejection", kind: "uncaught_exception" | "unhandled_rejection", error: Error, ): never { reportPostRenderTerminationEvent(label, kind, error); _flushSync?.(); process.exit(0); } // A closed pipe (EPIPE) is the NORMAL teardown when the CLI runs under a // piped agent (Claude Code, Codex, …) — the reader may stop consuming as // soon as it has what it needs. Exit cleanly, but only score the run as a // failure when the pipe died BEFORE the render artifact was validated: // unconditionally setting `commandFailed = true` here marked every piped // successful render as success:false (0.7.65–0.7.90). Delivery of anything // still queued (render_complete's eager flush() dies with the process) is // owned by the unconditional flushSync() in the `exit` handler below. function handleStreamEpipe(): never { if (!isRenderSucceeded()) commandFailed = true; process.exit(0); } // Success gate for the cli_command_result telemetry field. `commandFailed` // can be set by pre-artifact noise — a stray unhandledRejection mid-render, // or an EPIPE that fires before validation on a run that still completes. // Once the render artifact has been validated (`isRenderSucceeded()`), that // earlier noise must not score the run as a failure: the run delivered. // Genuine failures keep a non-zero exit code and are caught by the // `exitCode === 0 &&` half of the expression at both call sites. function commandSucceededForTelemetry(): boolean { return !commandFailed || isRenderSucceeded(); } // Terminate the process after a genuine CLI failure — mark commandFailed, // emit telemetry, flush, exit(1). Same rationale as above: keeps the arrow // handler linear so fallow CRAP stays under threshold. function exitAfterCliFailure( kind: "uncaught_exception" | "unhandled_rejection", error: Error, ): never { commandFailed = true; emitCliErrorEvent(kind, error); _flushSync?.(); process.exit(1); } process.on("uncaughtException", (error) => { if ((error as NodeJS.ErrnoException).code === "EPIPE") { handleStreamEpipe(); } // Post-artifact-validated shutdown throws must not turn a valid render // into an exit-1 "no final error message" failure. The render command // sets `renderSucceeded` right after the producer resolves and the // artifact is committed. if (isRenderSucceeded()) { exitAfterPostRenderTermination("uncaughtException", "uncaught_exception", error); } exitAfterCliFailure("uncaught_exception", error); }); // unhandledRejection does not call process.exit() — Node may continue // running if the rejection is non-fatal (e.g. a fire-and-forget promise). // The exit handler above will still fire with the real exit code. process.on("unhandledRejection", (reason) => { const error = reason instanceof Error ? reason : new Error(String(reason)); // Same rationale as the uncaughtException branch above: a stray promise // rejection during post-artifact-validated cleanup must not mark a valid // render as failed. `commandFailed` gates the success:true telemetry // field — keep it false when the render actually succeeded. if (isRenderSucceeded()) { reportPostRenderTerminationEvent("unhandledRejection", "unhandled_rejection", error); return; } commandFailed = true; process.exitCode = 1; emitCliErrorEvent("unhandled_rejection", error); }); // Lazy-load help renderer — avoids allocating help data on non-help invocations async function showUsage( cmd: CommandDef, parent?: CommandDef, ): Promise { const { showUsage: impl } = await import("./help.js"); return impl(cmd as CommandDef, parent as CommandDef | undefined); } async function showRequestedUsage(): Promise { const requested = await resolveCommandUsage(main as CommandDef, argv); return showUsage(requested.command, requested.parent); } function commandResultForError(error: unknown): CommandResult { if (error instanceof CliResultSignal) return error.result; if (error instanceof CliUsageError || error instanceof CliRuntimeError) return error.result; return { exitCode: 1, kind: "runtime_error" }; } // Root-only command boundary; keeping every result path here prevents modules // from bypassing output, telemetry, or finalizers. // fallow-ignore-next-line complexity async function executeCli(): Promise { let result: CommandResult = { exitCode: 0, kind: "success" }; try { if (isHelp) await showRequestedUsage(); else await runCommand(main, { rawArgs: argv }); } catch (error) { result = commandResultForError(error); if (!(error instanceof CliResultSignal)) { commandFailed = true; await reportCommandFailure(command, error); const typed = error instanceof CliUsageError || error instanceof CliRuntimeError; if (error instanceof CliUsageError && !error.result.presented) await showRequestedUsage(); if (!typed || !error.result.presented) { console.error(error instanceof Error ? error.message : String(error)); } } } finally { const pending = consumeCommandResult(); if (pending.exitCode !== 0 || result.exitCode === 0) result = pending; await finalizeCli(result); } } await executeCli();