forked from heygen-com/hyperframes
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcli.ts
More file actions
503 lines (463 loc) · 21.6 KB
/
Copy pathcli.ts
File metadata and controls
503 lines (463 loc) · 21.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
#!/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),
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<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;
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<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();