Files
hyperframes/packages/cli/src/cli.ts
T
Miguel Ángel 2734ee2c02 feat(cli): hyperframes history lists, undoes and restores a project's recorded changes (#4449)
* feat(cli): hyperframes history lists, undoes and restores a project's recorded changes

* fix(cli): an agent's history turn ends after 10 idle minutes on every path

A turn left open no longer takes the person's later edits: the engine
files a CLI turn's writes by the same idle rule as a preview window.
Studio claims take their paths back from an agent's window; refusals,
--json shapes, --limit and time parsing follow the review.

* refactor(cli): the project-history opener lives in its own module

The history command and other commands open a project's history the same
way: through a running preview, or directly under the engine's lock.

* docs(skills): the history loop lines sit in one marked trial block per skill

* fix(cli): a turn marker without its last write time has ended

Idle-limit tests set file times instead of racing the clock, and the
restore refusal test picks a ref no entry id starts with.

* docs(cli): the history entry shape comment names no other app

* refactor(cli): history reuses the busy error class and its who helper

* fix(cli): undo --who reverts the agent's whole last turn and nothing older

When the open turn recorded nothing, undo fell back to the agent's
earlier turn; when a mid-turn command split the turn, it reverted only
the newest part. The turn now keeps the entries it filed, and undo --who
reverts exactly those or refuses. Engine tests pin the write timing
rules, and a date that rolls over (Feb 31) is refused.

* fix(cli): an undo --who that meets a conflict leaves the whole turn as it was

It redoes the parts it already undid, lists the newer entries as they
are now, and offers --just-this and --back-to-before for the whole turn.
The last turn is recorded atomically before its marker is cleared.

* chore(cli): history keeps each file's comment share at or under main's

* fix(cli): history keeps log order past a turn's idle limit and undoes a turn's cut parts

* fix(cli): history drops unused imports and splits undo and reopen steps
2026-09-25 14:26:09 +00:00

535 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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, resolve } 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 { isDevMode } from "./utils/env.js";
import {
CliResultSignal,
CliRuntimeError,
CliUsageError,
consumeCommandResult,
registerRootExitCodeSanitizer,
registerRootExitRequester,
type CommandResult,
} from "./utils/commandResult.js";
const isHelp = process.argv.includes("--help") || process.argv.includes("-h");
// Runs before commands/preview.js is imported, so a missing or stale
// package is named here instead of crashing deep inside that import.
async function assertStudioWorkspaceBuilt(): Promise<void> {
if (!isDevMode()) return;
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const { checkStudioWorkspaceBuild, formatWorkspaceBuildProblems } =
await import("./utils/workspaceBuildCheck.js");
const problems = checkStudioWorkspaceBuild(repoRoot);
if (problems.length === 0) return;
console.error(formatWorkspaceBuildProblems(problems));
throw new CliRuntimeError("Studio workspace build check failed", {
exitCode: 1,
presented: true,
});
}
// ---------------------------------------------------------------------------
// 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),
"media-use": () => import("./commands/media-use.js").then((m) => m.default),
play: () => import("./commands/play.js").then((m) => m.default),
present: () => import("./commands/present.js").then((m) => m.default),
preview: () =>
assertStudioWorkspaceBuilt().then(() => 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),
timeline: () => import("./commands/timeline.js").then((m) => m.default),
history: () => import("./commands/history.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<void>) | 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<void> = 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<void> {
if (finalized) return;
finalized = true;
// Once the artifact has validated and been committed to disk, the run
// delivered — anything recorded as a failure after that is teardown noise.
// The uncaughtException / unhandledRejection handlers already consult
// isRenderSucceeded(), but a post-render throw that the command wrapper
// CATCHES never reaches them: it becomes an ordinary non-zero
// CommandResult, and a valid render is reported as a failure. Sanitizing
// here, once, is what those handlers cannot cover, and it keeps the exit
// code and the telemetry record from disagreeing about the same run.
const exitCode = isRenderSucceeded() ? 0 : result.exitCode;
commandFailed ||= exitCode !== 0;
await telemetryReady.catch(() => {});
_trackCommandResult?.({
command,
success: exitCode === 0 && commandSucceededForTelemetry(),
exitCode,
durationMs: Date.now() - commandStart,
runId,
});
await _flush?.().catch(() => {});
if (!hasJsonFlag) {
_printUpdateNotice?.();
_printStalePinNotice?.();
_printSkillsUpdateNotice?.();
}
process.exitCode = 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<T extends ArgsDef>(
cmd: CommandDef<T>,
parent?: CommandDef<T>,
): Promise<void> {
const { showUsage: impl } = await import("./help.js");
return impl(cmd as CommandDef, parent as CommandDef | undefined);
}
async function showRequestedUsage(): Promise<void> {
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<void> {
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();