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).
This commit is contained in:
James Russo
2026-05-17 13:06:00 -04:00
committed by GitHub
parent 78fce8bd8a
commit e90ad2da61
19 changed files with 1299 additions and 8 deletions
+1
View File
@@ -86,6 +86,7 @@ const subCommands = {
validate: () => import("./commands/validate.js").then((m) => m.default),
snapshot: () => import("./commands/snapshot.js").then((m) => m.default),
capture: () => import("./commands/capture.js").then((m) => m.default),
lambda: () => import("./commands/lambda.js").then((m) => m.default),
};
const main = defineCommand({
+317
View File
@@ -0,0 +1,317 @@
/**
* `hyperframes lambda` — top-level dispatcher for AWS Lambda subcommands.
*
* Each subverb lives in `./lambda/<name>.ts` and exports a single
* `runXxx(args)` async function. The subcommand surface is intentionally
* thin glue: argument parsing + help text here; the actual work
* (`renderToLambda` / `getRenderProgress` / `deploySite` / SAM driver)
* lives in `@hyperframes/aws-lambda/sdk`.
*/
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";
export const examples: Example[] = [
["Deploy the Lambda render stack to AWS", "hyperframes lambda deploy"],
[
"Render a composition on the deployed stack",
"hyperframes lambda render ./my-project --width 1920 --height 1080",
],
[
"Render and stream progress until done",
"hyperframes lambda render ./my-project --width 1920 --height 1080 --wait",
],
["Check progress for a started render", "hyperframes lambda progress hf-render-abcd1234"],
[
"Pre-upload a project so multiple renders share the upload",
"hyperframes lambda sites create ./my-project",
],
["Tear the stack down", "hyperframes lambda destroy"],
];
const HELP = `
${c.bold("hyperframes lambda")} ${c.dim("<subcommand> [args]")}
Deploy + drive distributed video renders on AWS Lambda.
${c.bold("SUBCOMMANDS:")}
${c.accent("deploy")} ${c.dim("Provision the Lambda + Step Functions + S3 stack via SAM")}
${c.accent("sites create")} ${c.dim("Tar + upload a project to S3 (reusable across renders)")}
${c.accent("render")} ${c.dim("Start a distributed render (returns a renderId)")}
${c.accent("progress")} ${c.dim("Print progress + cost for an in-flight or finished render")}
${c.accent("destroy")} ${c.dim("Tear the stack down (S3 bucket is retained)")}
${c.bold("FIRST RUN:")}
${c.accent("hyperframes lambda deploy")}
${c.accent("hyperframes lambda render ./my-project --width 1920 --height 1080 --wait")}
${c.bold("REQUIREMENTS:")}
• AWS CLI configured (env vars, ~/.aws/credentials, or SSO)
• AWS SAM CLI installed (https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html)
• bun on PATH (used to build the handler ZIP)
`;
export default defineCommand({
meta: { name: "lambda", description: "Deploy and drive renders on AWS Lambda" },
args: {
subcommand: {
type: "positional",
required: false,
description: "deploy | sites | render | progress | destroy",
},
target: {
type: "positional",
required: false,
description: "Subcommand-specific positional (project dir, render id, etc.)",
},
extra: {
type: "positional",
required: false,
description: "Extra positional (e.g. `sites create <projectDir>`)",
},
// Stack identity
"stack-name": {
type: "string",
description: "CloudFormation stack name (default: hyperframes-default)",
},
region: { type: "string", description: "AWS region (default: AWS_REGION env or us-east-1)" },
profile: { type: "string", description: "AWS profile name (default: AWS_PROFILE env)" },
// deploy
concurrency: { type: "string", description: "Lambda reserved concurrency (default: 8)" },
"chrome-source": {
type: "string",
description: "sparticuz | chrome-headless-shell (default: sparticuz)",
},
memory: { type: "string", description: "Lambda memory MB (default: 10240)" },
"skip-build": { type: "boolean", description: "Reuse existing handler.zip (deploy)" },
// sites / render
"site-id": { type: "string", description: "Explicit site id (overrides content hash)" },
width: { type: "string", description: "Render width in pixels" },
height: { type: "string", description: "Render height in pixels" },
fps: { type: "string", description: "Render fps (24 | 30 | 60)" },
format: { type: "string", description: "mp4 | mov | png-sequence (default: mp4)" },
codec: { type: "string", description: "h264 | h265 (mp4 only)" },
quality: { type: "string", description: "draft | standard | high" },
"chunk-size": { type: "string", description: "Frames per chunk (default: 240)" },
"max-parallel-chunks": { type: "string", description: "Max concurrent chunks (default: 16)" },
"execution-name": {
type: "string",
description: "Step Functions execution name (default: hf-render-<uuid>)",
},
"output-key": {
type: "string",
description: "Final output S3 key (default: renders/<exec>/output.<ext>)",
},
wait: { type: "boolean", description: "Block until the render finishes" },
"wait-interval-ms": {
type: "string",
description: "Poll cadence in ms when --wait is set (default: 5000)",
},
// shared
json: { type: "boolean", description: "Emit machine-readable JSON" },
},
async run({ args }) {
const subcommand = args.subcommand;
if (!subcommand) {
console.log(HELP);
return;
}
const stackName =
(args["stack-name"] as string | undefined) ??
// Lazy-imported so the dispatcher doesn't pull state.ts (and its
// node:fs deps) on every CLI invocation — only on lambda runs.
(await import("./lambda/state.js")).DEFAULT_STACK_NAME;
// Apply --profile globally before any AWS-SDK / `aws` / `sam` call runs.
// The AWS SDK + the SAM CLI both read AWS_PROFILE from the environment,
// so setting it here threads the value through render / progress / sites
// (which don't take an explicit awsProfile arg) without each subverb
// having to know about it. Region gets the same treatment so the SDK
// clients constructed inside the SDK pick it up too.
const profileFlag = args.profile as string | undefined;
if (profileFlag) process.env.AWS_PROFILE = profileFlag;
const regionFlag = args.region as string | undefined;
if (regionFlag) process.env.AWS_REGION = regionFlag;
// The lambda subverbs dynamic-import `@hyperframes/aws-lambda` at call
// time. We keep aws-lambda as a workspace devDependency (not a runtime
// dep) so the published CLI install stays small for users who don't
// deploy to Lambda. Subverbs other than `policies` need aws-lambda;
// catch the missing-module error here and turn it into a friendly hint.
const verbsNeedingSDK = new Set(["deploy", "sites", "render", "progress", "destroy"]);
if (verbsNeedingSDK.has(subcommand)) {
try {
await import("@hyperframes/aws-lambda/sdk");
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ERR_MODULE_NOT_FOUND") {
console.error(
`${c.error("@hyperframes/aws-lambda is not installed.")} The ${c.accent(`hyperframes lambda ${subcommand}`)} command needs it at runtime.\n` +
`Install it alongside the CLI:\n` +
` ${c.accent("npm install -g @hyperframes/aws-lambda")}\n` +
`Or, for an opt-in dev setup:\n` +
` ${c.accent("npm install @hyperframes/aws-lambda")}`,
);
process.exit(1);
}
throw err;
}
}
switch (subcommand) {
case "deploy": {
const { runDeploy } = await import("./lambda/deploy.js");
await runDeploy({
stackName,
region: args.region as string | undefined,
awsProfile: args.profile as string | undefined,
reservedConcurrency: parsePositiveInt(args.concurrency, "--concurrency"),
chromeSource: parseChromeSource(args["chrome-source"]),
lambdaMemoryMb: parsePositiveInt(args.memory, "--memory"),
skipBuild: Boolean(args["skip-build"]),
});
return;
}
case "sites": {
if (args.target !== "create") {
console.error(
`[lambda sites] unknown verb "${String(args.target)}". Only "create" is supported.`,
);
process.exit(1);
}
const projectDir = args.extra as string | undefined;
if (!projectDir) {
console.error(
"[lambda sites create] usage: hyperframes lambda sites create <projectDir>",
);
process.exit(1);
}
const { runSitesCreate } = await import("./lambda/sites.js");
await runSitesCreate({
projectDir,
stackName,
siteId: args["site-id"] as string | undefined,
json: Boolean(args.json),
});
return;
}
case "render": {
const projectDir = args.target as string | undefined;
if (!projectDir) {
console.error(
"[lambda render] usage: hyperframes lambda render <projectDir> --width <px> --height <px>",
);
process.exit(1);
}
const width = parsePositiveInt(args.width, "--width");
const height = parsePositiveInt(args.height, "--height");
if (width === undefined || height === undefined) {
console.error("[lambda render] --width and --height are required.");
process.exit(1);
}
const fpsRaw = parseIntFlag(args.fps) ?? 30;
if (fpsRaw !== 24 && fpsRaw !== 30 && fpsRaw !== 60) {
console.error(`[lambda render] --fps must be 24, 30, or 60; got ${fpsRaw}.`);
process.exit(1);
}
const { runRender } = await import("./lambda/render.js");
await runRender({
projectDir,
stackName,
siteId: args["site-id"] as string | undefined,
fps: fpsRaw,
width,
height,
format: parseFormat(args.format),
codec: parseCodec(args.codec),
quality: parseQuality(args.quality),
chunkSize: parsePositiveInt(args["chunk-size"], "--chunk-size"),
maxParallelChunks: parsePositiveInt(args["max-parallel-chunks"], "--max-parallel-chunks"),
executionName: args["execution-name"] as string | undefined,
outputKey: args["output-key"] as string | undefined,
json: Boolean(args.json),
wait: Boolean(args.wait),
waitIntervalMs: parsePositiveInt(args["wait-interval-ms"], "--wait-interval-ms") ?? 5000,
});
return;
}
case "progress": {
const target = args.target as string | undefined;
if (!target) {
console.error(
"[lambda progress] usage: hyperframes lambda progress <renderId | executionArn>",
);
process.exit(1);
}
const { runProgress } = await import("./lambda/progress.js");
await runProgress({ target, stackName, json: Boolean(args.json) });
return;
}
case "destroy": {
const { runDestroy } = await import("./lambda/destroy.js");
await runDestroy({ stackName, awsProfile: args.profile as string | undefined });
return;
}
default:
console.error(`${c.error("Unknown subcommand:")} ${subcommand}\n${HELP}`);
process.exit(1);
}
},
});
function parseIntFlag(raw: unknown): number | undefined {
if (raw === undefined || raw === null || raw === "") return undefined;
const n = Number.parseInt(String(raw), 10);
return Number.isFinite(n) ? n : undefined;
}
/**
* Parse a flag that must be a positive integer (>= 1) when supplied.
* Negative values or non-integers fail loudly instead of flowing into
* the SDK and producing opaque AWS validation errors mid-render.
*/
function parsePositiveInt(raw: unknown, flagName: string): number | undefined {
const n = parseIntFlag(raw);
if (n === undefined) return undefined;
if (!Number.isInteger(n) || n < 1) {
throw new Error(`[lambda] ${flagName} must be a positive integer; got ${n}`);
}
return n;
}
/**
* Parse a string-union flag against a closed set of allowed values.
* Returns `defaultValue` (which may be `undefined`) when the input is
* empty; throws with a flag-specific message when the value is set
* but unrecognised.
*/
function parseEnum<T extends string>(
raw: unknown,
allowed: readonly T[],
errorPrefix: string,
defaultValue: T | undefined,
): T | undefined {
if (raw === undefined || raw === null || raw === "") return defaultValue;
const s = String(raw);
if ((allowed as readonly string[]).includes(s)) return s as T;
throw new Error(`${errorPrefix} must be ${allowed.join("|")}; got ${s}`);
}
const FORMATS = ["mp4", "mov", "png-sequence"] as const;
const CODECS = ["h264", "h265"] as const;
const QUALITIES = ["draft", "standard", "high"] as const;
const CHROME_SOURCES = ["sparticuz", "chrome-headless-shell"] as const;
const parseFormat = (raw: unknown): (typeof FORMATS)[number] =>
parseEnum(raw, FORMATS, "[lambda render] --format", "mp4")!;
const parseCodec = (raw: unknown): (typeof CODECS)[number] | undefined =>
parseEnum(raw, CODECS, "[lambda render] --codec", undefined);
const parseQuality = (raw: unknown): (typeof QUALITIES)[number] | undefined =>
parseEnum(raw, QUALITIES, "[lambda render] --quality", undefined);
const parseChromeSource = (raw: unknown): (typeof CHROME_SOURCES)[number] =>
parseEnum(raw, CHROME_SOURCES, "[lambda deploy] --chrome-source", "sparticuz")!;
+114
View File
@@ -0,0 +1,114 @@
/**
* `hyperframes lambda deploy` — build the handler ZIP, sam-deploy the
* Phase 6a SAM template, and persist the stack outputs locally so the
* other lambda subcommands don't need re-derive them.
*
* Idempotent: re-running points at the same stack name and SAM resolves
* the changeset to a no-op when nothing changed.
*/
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { join, resolve } from "node:path";
import { c } from "../../ui/colors.js";
import { fetchStackOutputs, locateSamTemplate, samDeploy } from "./sam.js";
import { repoRoot } from "./repoRoot.js";
import { DEFAULT_STACK_NAME, writeStackOutputs } from "./state.js";
export interface DeployArgs {
stackName: string;
region: string;
awsProfile?: string;
reservedConcurrency: number;
chromeSource: "sparticuz" | "chrome-headless-shell";
lambdaMemoryMb: number;
/** Override the handler-ZIP rebuild step. Useful for CI runs that already built it. */
skipBuild: boolean;
}
const DEFAULT_REGION = "us-east-1";
const DEFAULT_MEMORY_MB = 10240;
// Low default reserved-concurrency so a first-time user doesn't get
// surprise-billed by a runaway Map state. Adopters with their own quota
// raise this explicitly via `--concurrency`.
const DEFAULT_CONCURRENCY = 8;
export async function runDeploy(args: Partial<DeployArgs> = {}): Promise<void> {
const resolved: DeployArgs = {
stackName: args.stackName ?? DEFAULT_STACK_NAME,
region: args.region ?? process.env.AWS_REGION ?? DEFAULT_REGION,
awsProfile: args.awsProfile ?? process.env.AWS_PROFILE,
reservedConcurrency: args.reservedConcurrency ?? DEFAULT_CONCURRENCY,
chromeSource: args.chromeSource ?? "sparticuz",
lambdaMemoryMb: args.lambdaMemoryMb ?? DEFAULT_MEMORY_MB,
skipBuild: args.skipBuild ?? false,
};
const root = repoRoot();
// Locate the SAM template up-front so users get a fast, clear error
// (not an opaque `sam deploy` failure) when this isn't a checkout.
locateSamTemplate(root);
if (!resolved.skipBuild) {
console.log(c.dim("→ Building handler ZIP"));
buildHandlerZip(root);
} else {
const zip = join(root, "packages", "aws-lambda", "dist", "handler.zip");
if (!existsSync(zip)) {
throw new Error(
`--skip-build set but ${zip} does not exist. Run \`bun run --cwd packages/aws-lambda build:zip\` first or drop --skip-build.`,
);
}
}
console.log(c.dim(`→ sam deploy (stack=${resolved.stackName} region=${resolved.region})`));
samDeploy({
repoRoot: root,
stackName: resolved.stackName,
region: resolved.region,
awsProfile: resolved.awsProfile,
reservedConcurrency: resolved.reservedConcurrency,
lambdaMemoryMb: resolved.lambdaMemoryMb,
chromeSource: resolved.chromeSource,
});
console.log(c.dim("→ Reading stack outputs"));
const outputs = fetchStackOutputs({
stackName: resolved.stackName,
region: resolved.region,
awsProfile: resolved.awsProfile,
});
const statePath = writeStackOutputs({
stackName: resolved.stackName,
region: resolved.region,
bucketName: outputs.bucketName,
stateMachineArn: outputs.stateMachineArn,
functionName: outputs.functionName,
lambdaMemoryMb: resolved.lambdaMemoryMb,
deployedAt: new Date().toISOString(),
});
console.log();
console.log(c.success("Stack deployed."));
console.log(` ${c.dim("Bucket:")} ${outputs.bucketName}`);
console.log(` ${c.dim("State machine:")} ${outputs.stateMachineArn}`);
console.log(` ${c.dim("Function:")} ${outputs.functionName}`);
console.log(` ${c.dim("State file:")} ${resolve(statePath)}`);
console.log();
console.log(c.dim(`Render with: hyperframes lambda render <project-dir>`));
}
function buildHandlerZip(root: string): void {
// bun run --cwd packages/aws-lambda build:zip
const result = spawnSync(
"bun",
["run", "--cwd", join(root, "packages", "aws-lambda"), "build:zip"],
{ stdio: "inherit" },
);
if (result.status !== 0) {
throw new Error(
`[lambda deploy] handler ZIP build exited with code ${result.status ?? "unknown"}`,
);
}
}
@@ -0,0 +1,42 @@
/**
* `hyperframes lambda destroy` — tear the CloudFormation stack down and
* drop the locally-cached stack outputs. Wraps `sam delete`.
*
* The render bucket is created with `Retain` deletion policy in the SAM
* template, so the underlying S3 bucket survives destruction. Adopters
* who want to fully delete it must do so via the AWS console / CLI
* after this command completes; we document that in the deploy guide
* rather than re-implementing the empty-and-delete dance here.
*/
import { c } from "../../ui/colors.js";
import { deleteStackOutputs, requireStack } from "./state.js";
import { samDelete } from "./sam.js";
import { repoRoot } from "./repoRoot.js";
export interface DestroyArgs {
stackName: string;
awsProfile?: string;
}
export async function runDestroy(args: DestroyArgs): Promise<void> {
const stack = requireStack(args.stackName);
console.log(c.dim(`→ sam delete (stack=${stack.stackName} region=${stack.region})`));
// Mirror deploy.ts's AWS_PROFILE env fallback — `AWS_PROFILE=prod
// hyperframes lambda destroy` should hit the same account `deploy`
// did, not the default credentials chain.
samDelete({
repoRoot: repoRoot(),
stackName: stack.stackName,
region: stack.region,
awsProfile: args.awsProfile ?? process.env.AWS_PROFILE,
});
deleteStackOutputs(args.stackName);
console.log();
console.log(c.success("Stack torn down."));
console.log(
c.dim(
`Note: the render bucket "${stack.bucketName}" was deployed with Retain — empty + delete it via the AWS console or CLI if you want to fully reclaim storage.`,
),
);
}
@@ -0,0 +1,84 @@
/**
* `hyperframes lambda progress <executionArn>` — print a single progress
* snapshot for a render. Wraps {@link getRenderProgress}. Accepts a
* full executionArn or a renderId (in which case we resolve the arn
* from the stack's region + state-machine name).
*/
import { c } from "../../ui/colors.js";
import { requireStack } from "./state.js";
export interface ProgressArgs {
/** Full SFN execution ARN OR the renderId (== execution name). */
target: string;
stackName: string;
json: boolean;
}
export async function runProgress(args: ProgressArgs): Promise<void> {
const stack = requireStack(args.stackName);
// Allow callers to pass either the full ARN or the renderId/exec name.
// The state machine ARN is on the stack, so deriving the execution ARN
// from a bare name is a deterministic suffix swap.
const executionArn = args.target.startsWith("arn:")
? args.target
: executionArnFromName(stack.stateMachineArn, args.target);
// Dynamic-import the SDK so tsup keeps it out of the static-import head
// of the CLI bundle. See sites.ts loadSDK() for the full rationale.
const { getRenderProgress } = await import("@hyperframes/aws-lambda/sdk");
const progress = await getRenderProgress({
executionArn,
region: stack.region,
defaultMemorySizeMb: stack.lambdaMemoryMb,
});
if (args.json) {
console.log(JSON.stringify(progress, null, 2));
return;
}
const pct = Math.round(progress.overallProgress * 100);
console.log(`${c.dim("Status:")} ${statusColor(progress.status)}`);
console.log(`${c.dim("Progress:")} ${pct}%`);
console.log(
`${c.dim("Frames:")} ${progress.framesRendered}${progress.totalFrames === null ? "" : ` / ${progress.totalFrames}`}`,
);
console.log(`${c.dim("Lambdas:")} ${progress.lambdasInvoked}`);
console.log(
`${c.dim("Cost:")} ${progress.costs.displayCost} (Lambda $${progress.costs.breakdown.lambdaUsd.toFixed(4)} + SFN $${progress.costs.breakdown.stepFunctionsUsd.toFixed(4)})`,
);
if (progress.outputFile) {
console.log(`${c.dim("Output:")} ${progress.outputFile.s3Uri}`);
}
if (progress.errors.length > 0) {
console.log();
console.log(c.error("Errors:"));
for (const err of progress.errors) {
console.log(` ${c.dim(err.state)}: ${err.error}${err.cause}`);
}
}
if (progress.fatalErrorEncountered) {
process.exitCode = 1;
}
}
function executionArnFromName(stateMachineArn: string, name: string): string {
// `arn:aws:states:<region>:<account>:stateMachine:<sm-name>` →
// `arn:aws:states:<region>:<account>:execution:<sm-name>:<exec-name>`
return stateMachineArn.replace(":stateMachine:", ":execution:") + `:${name}`;
}
function statusColor(status: string): string {
switch (status) {
case "SUCCEEDED":
return c.success(status);
case "FAILED":
case "TIMED_OUT":
case "ABORTED":
return c.error(status);
default:
return status;
}
}
+160
View File
@@ -0,0 +1,160 @@
/**
* `hyperframes lambda render <projectDir>` — start a distributed render
* against the deployed stack. Wraps {@link renderToLambda}. Does NOT
* poll — use `hyperframes lambda progress` for that.
*/
import { resolve as resolvePath } from "node:path";
import type { SerializableDistributedRenderConfig } from "@hyperframes/aws-lambda/sdk";
import { c } from "../../ui/colors.js";
import { requireStack, stateFilePath } from "./state.js";
// Dynamic-import the SDK so tsup keeps it out of the static-import head of
// the CLI bundle. See sites.ts loadSDK() for the full rationale.
async function loadSDK(): Promise<typeof import("@hyperframes/aws-lambda/sdk")> {
return import("@hyperframes/aws-lambda/sdk");
}
export interface RenderArgs {
projectDir: string;
stackName: string;
siteId?: string;
/** Composition config — fps/width/height/format required, rest optional. */
fps: 24 | 30 | 60;
width: number;
height: number;
format: "mp4" | "mov" | "png-sequence";
codec?: "h264" | "h265";
quality?: "draft" | "standard" | "high";
chunkSize?: number;
maxParallelChunks?: number;
executionName?: string;
outputKey?: string;
/** Print machine-readable JSON instead of the human-friendly summary. */
json: boolean;
/** Block until the render finishes. Polls `progress` until SUCCEEDED/FAILED. */
wait: boolean;
/** Poll cadence in ms when `--wait` is set. */
waitIntervalMs: number;
}
export async function runRender(args: RenderArgs): Promise<void> {
const stack = requireStack(args.stackName);
const projectDir = resolvePath(args.projectDir);
const config: SerializableDistributedRenderConfig = {
fps: args.fps,
width: args.width,
height: args.height,
format: args.format,
codec: args.codec,
quality: args.quality,
chunkSize: args.chunkSize,
maxParallelChunks: args.maxParallelChunks,
runtimeCap: "lambda",
};
// When the caller passes only --site-id, synthesise the minimum-shape
// SiteHandle pointing at the deterministic content-addressed key. The
// `bytes` / `uploadedAt` fields are intentionally placeholders — the
// SDK reads only `siteId` + `projectS3Uri` when `uploaded: false`.
const siteHandle = args.siteId
? {
siteId: args.siteId,
bucketName: stack.bucketName,
projectS3Uri: `s3://${stack.bucketName}/sites/${args.siteId}/project.tar.gz`,
bytes: 0,
uploadedAt: "",
uploaded: false,
}
: undefined;
const { renderToLambda } = await loadSDK();
const handle = await renderToLambda({
projectDir: siteHandle ? undefined : projectDir,
siteHandle,
bucketName: stack.bucketName,
stateMachineArn: stack.stateMachineArn,
region: stack.region,
config,
executionName: args.executionName,
outputKey: args.outputKey,
});
if (args.json) {
// --wait + --json should emit a single parseable JSON document: the
// final progress snapshot. Without --wait, emit the handle (the
// caller will poll progress separately). Previously this printed
// both, producing two concatenated JSON blobs that `jq -r` would
// misparse.
if (args.wait) {
await waitForCompletion(handle.executionArn, stack, args.waitIntervalMs, args.json);
} else {
console.log(JSON.stringify(handle, null, 2));
}
return;
}
console.log(c.success("Render started."));
console.log(` ${c.dim("Render ID:")} ${handle.renderId}`);
console.log(` ${c.dim("Execution ARN:")} ${handle.executionArn}`);
console.log(` ${c.dim("Output S3 URI:")} ${handle.outputS3Uri}`);
console.log(` ${c.dim("Project S3:")} ${handle.projectS3Uri}`);
console.log(` ${c.dim("Stack state:")} ${stateFilePath(args.stackName)}`);
console.log();
if (args.wait) {
await waitForCompletion(handle.executionArn, stack, args.waitIntervalMs, args.json);
return;
}
console.log(c.dim(`Poll with: hyperframes lambda progress ${handle.renderId}`));
}
async function waitForCompletion(
executionArn: string,
stack: { region: string; functionName: string; lambdaMemoryMb: number },
intervalMs: number,
json: boolean,
): Promise<void> {
// Lazy import to avoid pulling SFN client when only `render --no-wait` is used.
const { getRenderProgress } = await loadSDK();
let lastRendered = -1;
while (true) {
const progress = await getRenderProgress({
executionArn,
region: stack.region,
defaultMemorySizeMb: stack.lambdaMemoryMb,
});
if (!json && progress.framesRendered !== lastRendered) {
lastRendered = progress.framesRendered;
const total = progress.totalFrames ?? "?";
const pct = Math.round(progress.overallProgress * 100);
console.log(
` ${c.dim(`[${progress.status}]`)} ${pct}% • ${progress.framesRendered}/${total} frames • ${progress.costs.displayCost}`,
);
}
if (progress.status !== "RUNNING") {
if (json) {
console.log(JSON.stringify(progress, null, 2));
} else if (progress.status === "SUCCEEDED" && progress.outputFile) {
console.log();
console.log(c.success("Render complete."));
console.log(` ${c.dim("Output:")} ${progress.outputFile.s3Uri}`);
console.log(` ${c.dim("Size:")} ${progress.outputFile.bytes ?? "?"} bytes`);
console.log(` ${c.dim("Total cost:")} ${progress.costs.displayCost}`);
} else {
console.log();
console.log(c.error(`Render ended with status ${progress.status}.`));
for (const err of progress.errors) {
console.log(` ${c.dim(err.state)}: ${err.error}${err.cause}`);
}
process.exitCode = 1;
}
return;
}
await sleep(intervalMs);
}
}
function sleep(ms: number): Promise<void> {
return new Promise((res) => setTimeout(res, ms));
}
@@ -0,0 +1,33 @@
/**
* Resolve the HyperFrames repo root. Used by `lambda deploy`/`destroy`
* to find `examples/aws-lambda/template.yaml` and the handler ZIP build
* script.
*
* Walks up from this file's location until it finds a directory that
* contains `packages/aws-lambda/`. Caching is unnecessary — this runs
* once per CLI invocation.
*/
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
export function repoRoot(): string {
const override = process.env.HYPERFRAMES_REPO_ROOT;
if (override && existsSync(resolve(override, "packages", "aws-lambda", "package.json"))) {
return override;
}
let dir = dirname(fileURLToPath(import.meta.url));
for (let depth = 0; depth < 12; depth++) {
if (existsSync(resolve(dir, "packages", "aws-lambda", "package.json"))) {
return dir;
}
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
throw new Error(
"[hyperframes lambda] could not find the repo root (no packages/aws-lambda/ above this CLI's source). " +
"Run `hyperframes lambda` from within a hyperframes checkout, or set HYPERFRAMES_REPO_ROOT explicitly.",
);
}
+183
View File
@@ -0,0 +1,183 @@
/**
* Thin wrappers around the AWS SAM CLI used by `hyperframes lambda deploy`
* and `hyperframes lambda destroy`.
*
* We shell out instead of programmatically driving the CloudFormation API
* because:
* 1. SAM handles the rollback-on-failure semantics correctly, including
* stuck-rollback recovery. Re-implementing that in TypeScript would
* duplicate a non-trivial chunk of the SAM CLI.
* 2. The SAM template at `examples/aws-lambda/template.yaml` already
* describes the topology; adopters who customize the template
* shouldn't have to maintain it twice.
* 3. SAM's `--resolve-s3` auto-creates an artifact bucket for the
* handler ZIP upload, which we'd otherwise have to re-implement.
*
* CDK adopters use `HyperframesRenderStack` directly from their own
* CDK app — this CLI path is for users who don't want to write a CDK
* project.
*/
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { join } from "node:path";
/** Throws with a clear hint when the SAM CLI is not on PATH. */
export function assertSamAvailable(): void {
try {
execFileSync("sam", ["--version"], { stdio: "ignore" });
} catch {
throw new Error(
"`sam` CLI not found on PATH. Install AWS SAM CLI from https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html and retry.",
);
}
}
/** Throws with a clear hint when the `aws` CLI is not on PATH. */
export function assertAwsCliAvailable(): void {
try {
execFileSync("aws", ["--version"], { stdio: "ignore" });
} catch {
throw new Error(
"`aws` CLI not found on PATH. Install from https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html and configure credentials before retrying.",
);
}
}
export interface DeployOptions {
/** Repository root — the SAM template lives at `examples/aws-lambda/template.yaml` underneath. */
repoRoot: string;
/** CloudFormation stack name. */
stackName: string;
region: string;
awsProfile?: string;
reservedConcurrency?: number;
/** Lambda memory in MB. Forwarded as the `LambdaMemoryMb` parameter override. */
lambdaMemoryMb?: number;
chromeSource?: "sparticuz" | "chrome-headless-shell";
/** Pass-through stdio. Defaults to "inherit" so SAM's progress lines stream live. */
stdio?: "inherit" | "pipe";
}
/**
* Resolve the SAM template path relative to `repoRoot`. We look for the
* `examples/aws-lambda/template.yaml` first (development checkout) and
* fall back to the installed-package layout when running from a globally
* installed `hyperframes` CLI.
*/
export function locateSamTemplate(repoRoot: string): string {
const candidate = join(repoRoot, "examples", "aws-lambda", "template.yaml");
if (!existsSync(candidate)) {
throw new Error(
`[lambda] SAM template not found at ${candidate}. ` +
`If you're running from an installed package, point --sam-template at your local copy of examples/aws-lambda/template.yaml.`,
);
}
return candidate;
}
/** Run `sam deploy` non-interactively. Returns when SAM exits 0; throws on non-zero. */
export function samDeploy(opts: DeployOptions): void {
assertSamAvailable();
const paramOverrides = [
`ChromeSource=${opts.chromeSource ?? "sparticuz"}`,
`ReservedConcurrency=${opts.reservedConcurrency ?? -1}`,
];
if (opts.lambdaMemoryMb !== undefined) {
paramOverrides.push(`LambdaMemoryMb=${opts.lambdaMemoryMb}`);
}
const args = [
"deploy",
"--stack-name",
opts.stackName,
"--region",
opts.region,
"--resolve-s3",
"--capabilities",
"CAPABILITY_IAM",
"--no-confirm-changeset",
"--no-fail-on-empty-changeset",
"--parameter-overrides",
...paramOverrides,
];
if (opts.awsProfile) {
args.push("--profile", opts.awsProfile);
}
const samDir = join(opts.repoRoot, "examples", "aws-lambda");
const result = spawnSync("sam", args, { cwd: samDir, stdio: opts.stdio ?? "inherit" });
if (result.status !== 0) {
throw new Error(`[lambda] sam deploy exited with code ${result.status ?? "unknown"}`);
}
}
/** Run `sam delete` non-interactively. */
export function samDelete(opts: {
repoRoot: string;
stackName: string;
region: string;
awsProfile?: string;
stdio?: "inherit" | "pipe";
}): void {
assertSamAvailable();
const args = ["delete", "--stack-name", opts.stackName, "--region", opts.region, "--no-prompts"];
if (opts.awsProfile) {
args.push("--profile", opts.awsProfile);
}
const samDir = join(opts.repoRoot, "examples", "aws-lambda");
const result = spawnSync("sam", args, { cwd: samDir, stdio: opts.stdio ?? "inherit" });
if (result.status !== 0) {
throw new Error(`[lambda] sam delete exited with code ${result.status ?? "unknown"}`);
}
}
export interface StackOutputBag {
bucketName: string;
functionName: string;
stateMachineArn: string;
}
/**
* Query CloudFormation for the stack outputs the SAM template exports.
* Used after `samDeploy` to populate the local state file.
*/
export function fetchStackOutputs(opts: {
stackName: string;
region: string;
awsProfile?: string;
}): StackOutputBag {
assertAwsCliAvailable();
const args = [
"cloudformation",
"describe-stacks",
"--stack-name",
opts.stackName,
"--region",
opts.region,
"--query",
"Stacks[0].Outputs",
"--output",
"json",
];
if (opts.awsProfile) {
args.unshift("--profile", opts.awsProfile);
}
const out = execFileSync("aws", args, { encoding: "utf-8" });
const parsed = JSON.parse(out) as { OutputKey: string; OutputValue: string }[];
const byKey = new Map(parsed.map((o) => [o.OutputKey, o.OutputValue]));
const bucketName = byKey.get("RenderBucketName");
const functionName = byKey.get("RenderFunctionArn");
const stateMachineArn = byKey.get("RenderStateMachineArn");
if (!bucketName || !functionName || !stateMachineArn) {
throw new Error(
`[lambda] stack ${opts.stackName} is missing one of RenderBucketName/RenderFunctionArn/RenderStateMachineArn. Got keys: ${[...byKey.keys()].join(", ")}`,
);
}
return {
bucketName,
// RenderFunctionArn is the full ARN; the Lambda function name is the
// last colon-segment, which downstream `getRenderProgress` calls use
// for cost math + CloudWatch lookups.
functionName: functionName.split(":").pop() ?? functionName,
stateMachineArn,
};
}
+64
View File
@@ -0,0 +1,64 @@
/**
* `hyperframes lambda sites create <projectDir>` — tar + upload a project
* to the deployed render bucket at a content-addressed S3 prefix. Used
* by adopters who want to pre-stage a project so multiple subsequent
* renders share the upload.
*
* Without this verb, every `render` call re-tars the same tree on every
* invocation. With it, the same `siteId` resolves to a `HeadObject`-204
* short-circuit inside `deploySite`.
*/
import { resolve as resolvePath } from "node:path";
import { c } from "../../ui/colors.js";
import { DEFAULT_STACK_NAME, requireStack } from "./state.js";
// `@hyperframes/aws-lambda` is a workspace devDependency in `packages/cli`
// so the published CLI install stays small for users who don't deploy to
// Lambda. The lambda subverbs dynamic-import it on call. The dispatcher in
// `commands/lambda.ts` checks the import resolves before any subverb runs
// and prints a friendly install hint on `ERR_MODULE_NOT_FOUND`.
async function loadSDK(): Promise<typeof import("@hyperframes/aws-lambda/sdk")> {
return import("@hyperframes/aws-lambda/sdk");
}
export interface SitesCreateArgs {
projectDir: string;
stackName: string;
siteId?: string;
/** Print machine-readable JSON instead of the human-friendly summary. */
json: boolean;
}
export async function runSitesCreate(args: SitesCreateArgs): Promise<void> {
const stack = requireStack(args.stackName);
const projectDir = resolvePath(args.projectDir);
const { deploySite } = await loadSDK();
const handle = await deploySite({
projectDir,
bucketName: stack.bucketName,
region: stack.region,
siteId: args.siteId,
});
if (args.json) {
console.log(JSON.stringify(handle, null, 2));
return;
}
console.log(
c.success(handle.uploaded ? "Site uploaded." : "Site already up to date (skipped upload)."),
);
console.log(` ${c.dim("Site ID:")} ${handle.siteId}`);
console.log(` ${c.dim("S3 URI:")} ${handle.projectS3Uri}`);
console.log(` ${c.dim("Bytes:")} ${handle.bytes}`);
console.log(` ${c.dim("Uploaded at:")} ${handle.uploadedAt}`);
console.log();
console.log(
c.dim(
`Render with: hyperframes lambda render ${args.projectDir} --site-id=${handle.siteId}` +
(args.stackName === DEFAULT_STACK_NAME ? "" : ` --stack-name=${args.stackName}`),
),
);
}
@@ -0,0 +1,67 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import {
deleteStackOutputs,
listStackNames,
readStackOutputs,
stateFilePath,
type StackOutputs,
writeStackOutputs,
} from "./state.js";
let workdir: string;
beforeEach(() => {
workdir = mkdtempSync(join(tmpdir(), "hf-lambda-cli-state-"));
});
afterEach(() => {
rmSync(workdir, { recursive: true, force: true });
});
const sample: StackOutputs = {
stackName: "test",
region: "us-east-1",
bucketName: "bucket-x",
stateMachineArn: "arn:aws:states:us-east-1:1234:stateMachine:hf",
functionName: "hf-render",
lambdaMemoryMb: 10240,
deployedAt: "2026-05-16T00:00:00Z",
};
describe("lambda state file", () => {
it("round-trips outputs through disk", () => {
const path = writeStackOutputs(sample, workdir);
expect(path).toBe(stateFilePath("test", workdir));
const read = readStackOutputs("test", workdir);
expect(read).toEqual(sample);
});
it("returns null when the stack file is missing", () => {
expect(readStackOutputs("absent", workdir)).toBeNull();
});
it("lists stack names by parsing the filename prefix", () => {
writeStackOutputs({ ...sample, stackName: "alpha" }, workdir);
writeStackOutputs({ ...sample, stackName: "beta" }, workdir);
expect(listStackNames(workdir).sort()).toEqual(["alpha", "beta"]);
});
it("deleteStackOutputs removes the file (no error if absent)", () => {
writeStackOutputs(sample, workdir);
expect(readStackOutputs("test", workdir)).not.toBeNull();
deleteStackOutputs("test", workdir);
expect(readStackOutputs("test", workdir)).toBeNull();
// Re-delete is a no-op.
deleteStackOutputs("test", workdir);
});
it("handles malformed JSON by returning null instead of throwing", () => {
const path = stateFilePath("bad", workdir);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, "{ not json");
expect(readStackOutputs("bad", workdir)).toBeNull();
});
});
+99
View File
@@ -0,0 +1,99 @@
/**
* Persists `hyperframes lambda` stack outputs (bucket, state-machine ARN,
* region) so `render` / `progress` / `destroy` don't need to re-derive
* them from CloudFormation on every call.
*
* Stored at `<cwd>/.hyperframes/lambda-stack-<stackName>.json`. Project-
* local on purpose: a developer who runs `hyperframes lambda deploy` in
* two different worktrees will get two distinct stack files, which is
* the right default. If the user wants a shared default location, they
* can symlink the directory.
*/
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
export interface StackOutputs {
/** Project-name prefix passed to `deploy`. Used as CloudFormation stack-name suffix. */
stackName: string;
region: string;
bucketName: string;
stateMachineArn: string;
functionName: string;
/** Lambda memory MB used during deploy; carried so cost math doesn't have to re-derive it. */
lambdaMemoryMb: number;
deployedAt: string;
}
const STATE_DIR_NAME = ".hyperframes";
const STATE_FILE_PREFIX = "lambda-stack-";
/**
* Default CloudFormation stack name used when the caller doesn't pass
* `--stack-name`. Centralised so deploy/destroy/sites/dispatcher all
* agree on the literal `"hyperframes-default"`.
*/
export const DEFAULT_STACK_NAME = "hyperframes-default";
export function stateFilePath(
stackName: string = DEFAULT_STACK_NAME,
cwd: string = process.cwd(),
): string {
return join(cwd, STATE_DIR_NAME, `${STATE_FILE_PREFIX}${stackName}.json`);
}
export function writeStackOutputs(outputs: StackOutputs, cwd: string = process.cwd()): string {
const path = stateFilePath(outputs.stackName, cwd);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(outputs, null, 2) + "\n");
return path;
}
export function readStackOutputs(
stackName: string = DEFAULT_STACK_NAME,
cwd: string = process.cwd(),
): StackOutputs | null {
const path = stateFilePath(stackName, cwd);
if (!existsSync(path)) return null;
try {
return JSON.parse(readFileSync(path, "utf-8")) as StackOutputs;
} catch {
return null;
}
}
export function deleteStackOutputs(
stackName: string = DEFAULT_STACK_NAME,
cwd: string = process.cwd(),
): void {
const path = stateFilePath(stackName, cwd);
if (existsSync(path)) rmSync(path);
}
export function listStackNames(cwd: string = process.cwd()): string[] {
const dir = join(cwd, STATE_DIR_NAME);
if (!existsSync(dir)) return [];
return readdirSync(dir)
.filter((f) => f.startsWith(STATE_FILE_PREFIX) && f.endsWith(".json"))
.map((f) => f.slice(STATE_FILE_PREFIX.length, -".json".length));
}
/**
* Read stack outputs or print a helpful error and exit. Shared between
* `render`, `progress`, and `destroy` so the "did you run `deploy` first?"
* hint is consistent.
*/
export function requireStack(stackName: string, cwd: string = process.cwd()): StackOutputs {
const stack = readStackOutputs(stackName, cwd);
if (!stack) {
const known = listStackNames(cwd);
let hint = `Run \`hyperframes lambda deploy${stackName === DEFAULT_STACK_NAME ? "" : ` --stack-name=${stackName}`}\` first.`;
if (known.length) {
hint += ` Known stacks here: ${known.join(", ")}.`;
}
console.error(
`[hyperframes lambda] no stack state for "${stackName}" at ${stateFilePath(stackName, cwd)}. ${hint}`,
);
process.exit(1);
}
return stack;
}
+4
View File
@@ -51,6 +51,10 @@ const GROUPS: Group[] = [
["upgrade", "Check for updates and show upgrade instructions"],
],
},
{
title: "Deploy",
commands: [["lambda", "Deploy and drive distributed renders on AWS Lambda"]],
},
{
title: "AI & Integrations",
commands: [