Files
hyperframes/packages/cli/src/help.ts
T
James Russo e90ad2da61 feat(cli): add hyperframes lambda deploy/render/progress/destroy (#910)
* feat(cli): add hyperframes lambda deploy/render/progress/destroy

Wraps the @hyperframes/aws-lambda SDK + the Phase 6a SAM template behind
a single CLI surface so an end-to-end render is three commands instead
of the ~8 manual bun+sam+aws steps the smoke script does today:

  hyperframes lambda deploy
  hyperframes lambda render ./my-project --width 1920 --height 1080 --wait
  hyperframes lambda destroy

Subcommands:
  - deploy:        build handler.zip + sam-deploy + persist stack outputs
                   to <cwd>/.hyperframes/lambda-stack-<name>.json
  - sites create:  pre-upload a project to S3 with a stable content hash
                   so re-renders skip the tar+PUT pass
  - render:        start a Step Functions execution; --wait blocks and
                   streams per-chunk progress + accrued cost
  - progress:      one-shot snapshot — status, frames, cost breakdown,
                   errors. Accepts renderId or executionArn
  - destroy:       sam-delete + drop the local state file (S3 bucket
                   is Retain'd by the template; documented in --help
                   and in docs/packages/cli.mdx)

To keep @sparticuz/chromium out of the CLI's transitive deps, this also
adds a dedicated ./sdk subpath export to @hyperframes/aws-lambda; the
CLI imports from @hyperframes/aws-lambda/sdk exclusively. The existing
. barrel still re-exports both handler + SDK for adopters who want one
entry point.

Defaults are deliberately cost-conservative for first-time users:
--concurrency=8 (low enough to never surprise) and --memory=10240 (the
common case; documented for adopters who want to tune down).

Tests: 5 unit tests on the state-file round-trip. CLI integration
against sam local invoke is part of the upcoming PR 6.6 (lambda-local
regression harness).

* refactor(cli): /simplify pass on the lambda command group

Two small cleanups on top of the lambda CLI:

  - Replace parseFormat / parseCodec / parseQuality / parseChromeSource
    (four near-identical helpers) with a single generic parseEnum() +
    typed const-tuple lookups. The four callers now read as one-line
    arrow functions that lift the allowed values out of the function
    body so they're easy to extend.

  - DEFAULT_STACK_NAME was const-declared then re-exported at the
    bottom of state.ts; just mark the const export inline.

No behavior changes. All CLI tests still pass.

* fix(cli): keep @hyperframes/aws-lambda external in the tsup bundle

esbuild can't bundle @hyperframes/aws-lambda's transitive AWS SDK
deps (@aws-sdk/* + @smithy/*) cleanly into a node binary — the
SDK's .browser.js conditional re-exports break the resolver:

  ESM Build failed
    No matching export in "splitStream.browser.js" for import
    "splitStream" (and ~10 similar errors)

Mark aws-lambda as `external` so esbuild doesn't follow it, and
move it from devDependencies to dependencies so the published CLI
can resolve it from node_modules at runtime. The lambda subverb
files dynamic-import only on `hyperframes lambda *` invocation, so
the CLI cold-start cost is unchanged.

The install-size hit (AWS SDK + @sparticuz/chromium ≈ 200 MiB) is
documented as a v1 tradeoff; a future split into a lambda-sdk-only
subpackage can pare this back.

* fix(cli): address PR review on lambda CLI

Two blockers + four important items from Vai's review:

  - `--memory` was parsed and recorded in the local state file but
    never forwarded to `sam deploy` as a parameter override. Worse,
    `progress.ts` then read the *recorded* value for cost math, so
    `--memory 5120` produced wrong cost numbers downstream. Thread
    `LambdaMemoryMb` through samDeploy's --parameter-overrides.

  - `--profile` was only consumed by deploy / destroy. render and
    progress fell back to the default credentials chain — a user
    with `--profile prod` would silently render against their
    default account (wrong-account billing footgun). Set
    `process.env.AWS_PROFILE` (and `AWS_REGION`) in the dispatcher
    before any subverb runs; the AWS SDK reads them natively, so
    render / progress / sites all benefit without each subverb
    threading the flag through the SDK call.

  - `--profile` + destroy now also reads `process.env.AWS_PROFILE`
    as a fallback (matching deploy's existing env fallback).

  - `--wait --json` printed both the start handle AND the final
    progress snapshot, producing two concatenated JSON blobs that
    `jq` rejected. Now emits a single document: handle (without
    --wait) OR final progress (with --wait).

  - Negative integers on `--width` / `--height` / `--chunk-size` /
    `--max-parallel-chunks` / `--memory` / `--concurrency` now fail
    loudly via a new `parsePositiveInt` wrapper instead of flowing
    into the SDK and producing opaque AWS validation errors mid-
    render.

  - `DEFAULT_STACK_NAME` is now centralized to the literal
    `"hyperframes-default"` and consumed from one place. Previously
    the value was assembled as `hyperframes-${"default"}` in three
    sites and hardcoded as `"hyperframes-default"` in a fourth.
    `requireStack`'s hint now matches the dispatcher's default.

The faked `SiteHandle` for `--site-id` keeps the documented
placeholder fields but also surfaces `bucketName` (from PR 909's
extended SiteHandle interface), matching the SDK contract.

All CLI unit tests + the full bundler build still pass.

* fix(cli): keep aws-lambda out of CLI runtime deps

The "Smoke: global install" CI step packs the CLI via `npm pack` and
installs it globally via `npm install -g <tgz>`. npm doesn't understand
the workspace: protocol, so a runtime `dependencies` entry of
`@hyperframes/aws-lambda: workspace:*` blows up with:

  npm error code EUNSUPPORTEDPROTOCOL
  npm error Unsupported URL Type "workspace:": workspace:*

(pnpm rewrites workspace:* on publish; npm pack doesn't.)

Three changes to unblock the smoke + keep the published CLI install
small for users who don't deploy to Lambda:

  - Move `@hyperframes/aws-lambda` from CLI's `dependencies` back to
    `devDependencies`. It's already external in tsup.config.ts; the
    bundle references it via runtime resolution only.

  - Convert the static `import { … } from "@hyperframes/aws-lambda/sdk"`
    in sites.ts / render.ts / progress.ts to `await import()` inside
    each function. tsup with `splitting: false` was inlining those
    static imports at the top of the bundle, which made Node eagerly
    resolve them at CLI startup (MODULE_NOT_FOUND before any lambda
    subcommand even runs). Dynamic imports stay dynamic in the bundle.

  - Add a friendly missing-module check in the lambda dispatcher.
    When a user runs `hyperframes lambda deploy / render / sites /
    progress / destroy` without aws-lambda installed, they now see:

      @hyperframes/aws-lambda is not installed.
      The `hyperframes lambda deploy` command needs it at runtime.
      Install it alongside the CLI:
        npm install -g @hyperframes/aws-lambda

Verified locally: pack + global install + `hyperframes init --example
blank` now succeeds end-to-end (was the same scenario the CI smoke job
runs).
2026-05-17 13:06:00 -04:00

170 lines
6.2 KiB
TypeScript

/**
* Custom help renderer for the hyperframes CLI.
*
* Root-level: grouped command categories + examples.
* Subcommands: citty's standard USAGE/ARGUMENTS/OPTIONS + appended examples.
*/
import { renderUsage } from "citty";
import type { CommandDef } from "citty";
import { c } from "./ui/colors.js";
import { VERSION } from "./version.js";
// ── Root-level command groups ──────────────────────────────────────────────
interface Group {
title: string;
commands: [name: string, description: string][];
}
const GROUPS: Group[] = [
{
title: "Getting Started",
commands: [
["init", "Scaffold a new composition project"],
["add", "Install a block or component from the registry"],
["capture", "Capture a website for video production"],
["catalog", "Browse and install blocks and components"],
["preview", "Start the studio for previewing compositions"],
["publish", "Upload a project and get a stable public URL"],
["render", "Render a composition to MP4 or WebM"],
],
},
{
title: "Project",
commands: [
["lint", "Validate a composition for common mistakes"],
["inspect", "Inspect rendered visual layout across the timeline"],
["snapshot", "Capture key frames as PNG screenshots for visual verification"],
["info", "Print project metadata"],
["compositions", "List all compositions in a project"],
["docs", "View inline documentation in the terminal"],
],
},
{
title: "Tooling",
commands: [
[
"benchmark",
"Render with preset fps/quality/worker configs and compare speed and file size",
],
["browser", "Manage the Chrome browser used for rendering"],
["doctor", "Check system dependencies and environment"],
["upgrade", "Check for updates and show upgrade instructions"],
],
},
{
title: "Deploy",
commands: [["lambda", "Deploy and drive distributed renders on AWS Lambda"]],
},
{
title: "AI & Integrations",
commands: [
["skills", "Install HyperFrames and GSAP skills for AI coding tools"],
[
"transcribe",
"Transcribe audio/video to word-level timestamps, or import an existing transcript",
],
["tts", "Generate speech audio from text using a local AI model (Kokoro-82M)"],
["remove-background", "Remove background from a video or image to produce transparent media"],
],
},
{
title: "Settings",
commands: [["telemetry", "Manage anonymous usage telemetry"]],
},
];
// ── Root-level examples ────────────────────────────────────────────────────
import type { Example } from "./commands/_examples.js";
const ROOT_EXAMPLES: Example[] = [
["Create a new project", "hyperframes init my-video"],
["Start the live preview studio", "hyperframes preview"],
["Publish to hyperframes.dev", "hyperframes publish"],
["Render to MP4", "hyperframes render -o out.mp4"],
["Transparent WebM overlay", "hyperframes render --format webm -o out.webm"],
["Validate your composition", "hyperframes lint"],
["Inspect visual layout", "hyperframes inspect"],
["Check system dependencies", "hyperframes doctor"],
];
// ── Per-command examples loaded from command files ────────────────────────
// Each command file exports `examples: Example[]`. This function dynamically
// imports them so examples live next to the command they document.
async function loadExamples(name: string): Promise<Example[] | undefined> {
try {
const mod = await import(`./commands/${name}.js`);
return mod.examples;
} catch {
return undefined;
}
}
// Commands without their own file (e.g. listed in help but not yet a real command)
const STATIC_EXAMPLES: Record<string, Example[]> = {
skills: [["Install all skills to all supported AI tools", "hyperframes skills"]],
};
// ── Render root help ───────────────────────────────────────────────────────
function renderRootHelp(): string {
const NAME_COL = 19;
const CMD_COL = 46;
const lines: string[] = [];
lines.push(
`${c.bold("hyperframes")} ${c.dim(`v${VERSION}`)} — Create and render HTML video compositions`,
);
lines.push("");
lines.push(`${c.bold("Usage:")} hyperframes ${c.cyan("<command>")} [options]`);
lines.push("");
for (const group of GROUPS) {
lines.push(c.bold(`${group.title}:`));
for (const [name, desc] of group.commands) {
lines.push(` ${c.cyan(name.padEnd(NAME_COL))}${desc}`);
}
lines.push("");
}
lines.push(c.bold("Examples:"));
for (const [comment, command] of ROOT_EXAMPLES) {
lines.push(` ${c.dim("$")} ${command.padEnd(CMD_COL)} ${c.dim(comment)}`);
}
lines.push("");
lines.push(`Run ${c.cyan("hyperframes <command> --help")} for more information about a command.`);
return lines.join("\n");
}
// ── Format examples section (comment + command style) ────────────────────────────────
function formatExamples(examples: Example[]): string {
const lines: string[] = [];
lines.push(c.bold("Examples:"));
for (const [comment, command] of examples) {
lines.push(` ${c.gray(`# ${comment}`)}`);
lines.push(` ${command}`);
lines.push("");
}
return lines.join("\n");
}
// ── Main showUsage override ────────────────────────────────────────────────
export async function showUsage(cmd: CommandDef, parent?: CommandDef): Promise<void> {
if (!parent) {
console.log(renderRootHelp() + "\n");
return;
}
const meta = await (typeof cmd.meta === "function" ? cmd.meta() : cmd.meta);
const usage = await renderUsage(cmd, parent);
console.log(usage + "\n");
const name = meta?.name;
if (name) {
const examples = STATIC_EXAMPLES[name] ?? (await loadExamples(name));
if (examples) {
console.log(formatExamples(examples) + "\n");
}
}
}