diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index d95087384..fa30a0da9 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -917,6 +917,122 @@ hyperframes auth logout --yes # skip the confirmation prompt | `HEYGEN_API_URL` | API base URL (default `https://api.heygen.com`). | | `HEYGEN_CONFIG_DIR` | Credentials directory (default `~/.heygen`). | +## hyperframes cloud + +Render a HyperFrames composition on HeyGen's hosted cloud — no local Chrome, no local ffmpeg, no AWS to manage. Sign in once with `hyperframes auth login` and the same credential drives every `cloud` subcommand. + +```bash +hyperframes auth login # one-time +hyperframes cloud render ./my-video # zip + upload + poll + download +hyperframes cloud render ./my-video --no-wait # submit and exit with the render_id +hyperframes cloud list # browse recent renders +``` + +### Subcommands + +#### `cloud render []` + +End-to-end render: zips the project (excluding `.git`, `node_modules`, `dist`, `.next`, `coverage`, dotfiles), uploads it via `POST /v3/assets`, submits `POST /v3/hyperframes/renders`, polls `GET /v3/hyperframes/renders/{id}` until the render completes or fails, and streams the resulting video to disk. + +Render parameters mirror the local `hyperframes render` UX where they overlap: + +| Flag | Default | Meaning | +| --- | --- | --- | +| `--fps` | `30` | Integer 1-240. | +| `--quality` | `standard` | `draft`, `standard`, or `high`. | +| `--format` | `mp4` | `mp4`, `webm`, or `mov`. | +| `--resolution` | composition default | `landscape`, `portrait`, `landscape-4k`, `portrait-4k`, `square`, `square-4k`. | +| `--composition` / `-c` | `index.html` | Entry HTML file inside the zip. | +| `--variables` | — | Inline JSON object overriding `data-composition-variables`. | +| `--variables-file` | — | Path to a JSON file (alternative to `--variables`). | +| `--strict-variables` | off | Fail when variables are undeclared or have the wrong type. | +| `--title` | — | Free-text label echoed back in detail responses. | +| `--output` / `-o` | `renders/.` | Local destination for the downloaded video. | + +Lifecycle / control flags: + +| Flag | Meaning | +| --- | --- | +| `--no-wait` | Submit and exit immediately; print the `render_id` to stdout. | +| `--callback-url` | HTTPS webhook fired when the render terminates (compose with `--no-wait`). | +| `--callback-id` | Opaque tracking ID echoed in webhook payloads. | +| `--asset-id` | Skip zip+upload; submit an already-uploaded composition. Mutually exclusive with the project dir and `--url`. | +| `--url` | Submit a public HTTPS zip URL. Same mutual-exclusion as `--asset-id`. | +| `--poll-interval` | Poll cadence in seconds (default `10`). | +| `--max-wait` | Max poll duration in minutes (default `60`). | +| `--idempotency-key` | Optional `Idempotency-Key` for safe retries (1-255 chars from `[A-Za-z0-9_:.-]`). | +| `--json` | Emit machine-readable JSON instead of human-friendly progress. | + +```bash +# Default flow — render the current directory. +hyperframes cloud render + +# Pick a composition + output path. +hyperframes cloud render . \ + --composition compositions/intro.html \ + --output ./renders/intro.mp4 + +# Higher quality at 60fps. +hyperframes cloud render --quality high --fps 60 + +# Fire-and-forget with a webhook (no local polling). +hyperframes cloud render --callback-url https://example.com/hf-hook --no-wait + +# Re-render an already-uploaded composition (skips zip + upload). +hyperframes cloud render --asset-id asst_abc123 + +# Render from a public URL (no upload). +hyperframes cloud render --url https://cdn.example.com/site.zip +``` + +##### Safe retries via `--idempotency-key` + +The CLI transparently retries on a `401 Unauthorized` by force-refreshing the OAuth token and replaying the failed request. For most reads that's harmless, but `POST /v3/assets` (the zip upload) is *not* idempotent on its own — a retry without an `Idempotency-Key` would create a duplicate asset and bill the workspace twice. + +Pass `--idempotency-key ` whenever you want safe retries on `cloud render`. The key is forwarded to both the upload and submit calls; the server scopes idempotency per-endpoint, so reusing the same value across the two steps is safe and prevents duplicates on either step. Use a UUID per logical render, or any opaque string in `[A-Za-z0-9_:.-]` (1-255 chars). + +```bash +hyperframes cloud render . --idempotency-key "$(uuidgen)" +``` + +#### `cloud list` + +Pages through recent renders. Cursor-based: `--limit` caps a single page (1-100), `--token` resumes from a previous `next_token`, `--all` walks the full list until exhausted. + +```bash +hyperframes cloud list +hyperframes cloud list --limit 50 --json +hyperframes cloud list --all +``` + +#### `cloud get ` + +Fetches the full detail record for one render, including the short-lived signed `video_url` and `thumbnail_url` (presigned S3 URLs — re-fetch on demand rather than cache). + +```bash +hyperframes cloud get hfr_abc123 +hyperframes cloud get hfr_abc123 --json +``` + +#### `cloud delete ` + +Soft-deletes a render. Subsequent `GET` calls return 404 and the signed video URL stops working shortly after. Prompts for confirmation interactively; pass `--no-confirm` to bypass for scripts. + +```bash +hyperframes cloud delete hfr_abc123 +hyperframes cloud delete hfr_abc123 --no-confirm --json +``` + +### When to pick `cloud` vs `lambda` vs local render + +- `hyperframes render` (local): fastest iteration loop. Use during composition authoring. +- `hyperframes lambda render`: bring-your-own-AWS distributed rendering. Use when you've already invested in AWS and want chunked parallelism on your own account. +- `hyperframes cloud render`: zero-infra option. HeyGen runs the render; you pay per credit. Use when you don't want to manage Chrome/ffmpeg/AWS locally. + +### Auth + base URL + +`cloud` reuses the credential resolved by `hyperframes auth status`. Override the API base for staging tests with `HEYGEN_API_URL` (default `https://api.heygen.com`). + ## hyperframes lambda Deploy HyperFrames distributed rendering to AWS Lambda and drive renders from your laptop or CI. diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 379fd9b46..603d7a9df 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -125,6 +125,7 @@ const subCommands = { 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), + cloud: () => import("./commands/cloud.js").then((m) => m.default), auth: () => import("./commands/auth.js").then((m) => m.default), }; diff --git a/packages/cli/src/cloud/ansi.test.ts b/packages/cli/src/cloud/ansi.test.ts new file mode 100644 index 000000000..e48e020b1 --- /dev/null +++ b/packages/cli/src/cloud/ansi.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { padEndVisible, stripAnsi, visibleLength } from "./ansi.js"; + +describe("cloud/ansi", () => { + describe("stripAnsi", () => { + it("strips basic SGR codes (16-color)", () => { + expect(stripAnsi("\x1b[32mok\x1b[39m")).toBe("ok"); + }); + + it("strips bright SGR codes", () => { + expect(stripAnsi("\x1b[91merr\x1b[39m")).toBe("err"); + }); + + it("strips 24-bit truecolor sequences (used by c.accent)", () => { + expect(stripAnsi("\x1b[38;2;60;230;172maccent\x1b[39m")).toBe("accent"); + }); + + it("strips reset codes", () => { + expect(stripAnsi("\x1b[0mreset")).toBe("reset"); + }); + + it("leaves non-ANSI text alone", () => { + expect(stripAnsi("plain")).toBe("plain"); + }); + + it("handles nested codes", () => { + expect(stripAnsi("\x1b[1m\x1b[32mbold-green\x1b[39m\x1b[22m")).toBe("bold-green"); + }); + }); + + describe("visibleLength", () => { + it("returns the visible-only character count", () => { + expect(visibleLength("\x1b[32mok\x1b[39m")).toBe(2); + expect(visibleLength("\x1b[38;2;60;230;172maccent\x1b[39m")).toBe(6); + }); + }); + + describe("padEndVisible", () => { + it("pads to target visible width regardless of ANSI overhead", () => { + const padded = padEndVisible("\x1b[32mok\x1b[39m", 6); + // visible "ok" is 2 chars; padded to 6 visible chars = "ok " plus the ANSI overhead + expect(visibleLength(padded)).toBe(6); + }); + + it("does not trim when input is already longer than target", () => { + expect(padEndVisible("longer", 3)).toBe("longer"); + }); + }); +}); diff --git a/packages/cli/src/cloud/ansi.ts b/packages/cli/src/cloud/ansi.ts new file mode 100644 index 000000000..e54eed052 --- /dev/null +++ b/packages/cli/src/cloud/ansi.ts @@ -0,0 +1,32 @@ +/** + * Strip ANSI SGR escape sequences for visible-length column alignment. + * + * Earlier impl used `/\[\d+m/g` which (a) missed the ESC prefix + * (under-counting overhead by 1 per code) and (b) didn't match + * `ESC[38;2;…m` 24-bit truecolor sequences used by `c.accent`. This + * regex covers the full CSI SGR family. + * + * Constructed via `new RegExp("\\u001b...")` rather than a `/.../` + * literal because oxlint's `no-control-regex` rule flags ESC (0x1B) + * even in literal form, and the string-construction path keeps the + * intent obvious without needing a per-file disable. + */ + +// CSI SGR: ESC `[` { params with digits + semicolons } `m`. +// oxlint-disable-next-line no-control-regex +const ANSI_SGR_RE = new RegExp("\\u001b\\[[\\d;]*m", "g"); + +export function stripAnsi(s: string): string { + return s.replace(ANSI_SGR_RE, ""); +} + +/** Length of `s` after ANSI SGR codes are stripped. */ +export function visibleLength(s: string): number { + return stripAnsi(s).length; +} + +/** Like `String.prototype.padEnd` but counts visible chars only. */ +export function padEndVisible(s: string, target: number): string { + const overhead = s.length - visibleLength(s); + return s.padEnd(target + overhead); +} diff --git a/packages/cli/src/cloud/auth.ts b/packages/cli/src/cloud/auth.ts new file mode 100644 index 000000000..18c8f8e23 --- /dev/null +++ b/packages/cli/src/cloud/auth.ts @@ -0,0 +1,77 @@ +/** + * Bridge between the existing credential resolution chain (auth/) and the + * generated cloud client (`_gen/client.ts`). Hands the client a + * `getAuthHeaders()` callback that resolves credentials fresh on every + * request — so OAuth refreshes that happen between calls (e.g. during a + * long poll loop) are picked up automatically the next time the callback + * fires. + * + * Why this lives in `cloud/` instead of extending `auth/client.ts`: the + * auth client is scoped to `/v3/users/me` (the credential-verification + * endpoint) and we want the cloud client to be a standalone surface. The + * shared primitives (`buildAuthHeaders`, `resolveCredential`) are pulled + * in here without coupling the two clients. + */ + +import { apiBaseUrl, buildAuthHeaders } from "../auth/client.js"; +import { refreshTokens } from "../auth/oauth.js"; +import { resolveCredential, type ResolvedCredential } from "../auth/resolver.js"; + +/** + * Build the cloud client's `getAuthHeaders` callback. Each invocation + * re-resolves credentials so refreshes that happened since the last call + * are picked up. When the OAuth access token is past expiry AND a + * refresh_token is present, the token endpoint is hit before headers + * are returned. + */ +export async function resolveCloudAuthHeaders(): Promise> { + let credential = await resolveCredential(); + credential = await refreshIfNeeded(credential); + return buildAuthHeaders(credential); +} + +/** + * Return the base URL the cloud client should hit. Honors + * `HEYGEN_API_URL` (matches `auth/client.ts:apiBaseUrl`). + */ +export function resolveCloudBaseUrl(): string { + return apiBaseUrl(); +} + +// fallow-ignore-next-line complexity +async function refreshIfNeeded(credential: ResolvedCredential): Promise { + if (credential.type !== "oauth") return credential; + if (!credential.refreshable || !credential.refresh_token) return credential; + const fresh = await refreshTokens(credential.refresh_token); + return { + ...credential, + access_token: fresh.access_token, + expires_at: parseDateOrUndef(fresh.expires_at), + refreshable: false, + ...(fresh.refresh_token ? { refresh_token: fresh.refresh_token } : {}), + }; +} + +function parseDateOrUndef(value: string | undefined): Date | undefined { + if (!value) return undefined; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? undefined : d; +} + +/** + * Force-refresh the OAuth access token regardless of locally-known + * expiry. Used by `createCloudClient`'s 401-retry path when the server + * rejects a token that the local resolver thought was still valid (e.g. + * server-side revocation, clock skew, IdP rotation). No-op for API-key + * credentials. + * + * Throws if the credential can't be refreshed (no refresh_token, or the + * IdP refresh call itself fails). Callers should let the throw surface + * — the original 401 is what the user needs to see, not a confusing + * "refresh failed" message. + */ +export async function forceRefreshCredentials(): Promise { + const credential = await resolveCredential(); + if (credential.type !== "oauth" || !credential.refresh_token) return; + await refreshTokens(credential.refresh_token); +} diff --git a/packages/cli/src/cloud/download.test.ts b/packages/cli/src/cloud/download.test.ts new file mode 100644 index 000000000..3fd731ee3 --- /dev/null +++ b/packages/cli/src/cloud/download.test.ts @@ -0,0 +1,112 @@ +import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { downloadToFile } from "./download.js"; + +function makeBytesFetch(bytes: Uint8Array, headers: Record = {}): typeof fetch { + return (async () => + new Response(new Blob([bytes as unknown as BlobPart]), { + status: 200, + headers: { + "content-type": "application/octet-stream", + ...headers, + }, + })) as unknown as typeof fetch; +} + +function makeErrorFetch(status: number, statusText = "Not Found"): typeof fetch { + return (async () => new Response("nope", { status, statusText })) as unknown as typeof fetch; +} + +describe("cloud/download", () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "hf-cloud-download-")); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("streams the response body to disk and returns the byte count", async () => { + const payload = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + const dest = join(dir, "out.bin"); + const result = await downloadToFile("https://example/x", dest, { + fetchImpl: makeBytesFetch(payload, { "content-length": String(payload.length) }), + }); + expect(result.path).toBe(dest); + expect(result.bytes).toBe(payload.length); + const written = readFileSync(dest); + expect(written.equals(Buffer.from(payload))).toBe(true); + expect(statSync(dest).size).toBe(payload.length); + }); + + it("creates the destination's parent directory if missing", async () => { + const payload = new Uint8Array([42]); + const dest = join(dir, "nested", "subdir", "file.mp4"); + const result = await downloadToFile("https://example/x", dest, { + fetchImpl: makeBytesFetch(payload), + }); + expect(result.bytes).toBe(1); + expect(readFileSync(dest).at(0)).toBe(42); + }); + + it("reports progress with bytes downloaded and total when content-length is set", async () => { + const payload = new Uint8Array(64); + const dest = join(dir, "progress.bin"); + const calls: { bytes: number; total: number | undefined }[] = []; + await downloadToFile("https://example/x", dest, { + fetchImpl: makeBytesFetch(payload, { "content-length": "64" }), + onProgress: (bytes, total) => calls.push({ bytes, total }), + }); + expect(calls.length).toBeGreaterThanOrEqual(1); + const last = calls.at(-1)!; + expect(last.bytes).toBe(64); + expect(last.total).toBe(64); + }); + + it("throws on non-2xx responses", async () => { + const dest = join(dir, "missing.bin"); + await expect( + downloadToFile("https://example/x", dest, { + fetchImpl: makeErrorFetch(404), + }), + ).rejects.toThrow(/HTTP 404/); + }); + + it("rejects truncated downloads when bytes received < content-length", async () => { + // Returns 10 bytes but declares content-length: 20. + const dest = join(dir, "truncated.bin"); + const lyingFetch: typeof fetch = (async () => + new Response(new Blob([new Uint8Array(10) as unknown as BlobPart]), { + status: 200, + headers: { "content-length": "20" }, + })) as unknown as typeof fetch; + await expect( + downloadToFile("https://example/x", dest, { fetchImpl: lyingFetch }), + ).rejects.toThrow(/Truncated download/); + // Partial file must be cleaned up. + expect(() => statSync(dest)).toThrow(); + }); + + it("deletes the partial file when the abort signal fires mid-stream", async () => { + const dest = join(dir, "aborted.bin"); + // 1 MB payload should give the abort time to land mid-stream. + const payload = new Uint8Array(1024 * 1024); + const controller = new AbortController(); + const fetchImpl: typeof fetch = (async () => + new Response(new Blob([payload as unknown as BlobPart]), { + status: 200, + headers: { "content-length": String(payload.length) }, + })) as unknown as typeof fetch; + // Abort almost immediately. + queueMicrotask(() => controller.abort(new Error("user cancelled"))); + await expect( + downloadToFile("https://example/x", dest, { + fetchImpl, + signal: controller.signal, + }), + ).rejects.toThrow(); + expect(() => statSync(dest)).toThrow(); + }); +}); diff --git a/packages/cli/src/cloud/download.ts b/packages/cli/src/cloud/download.ts new file mode 100644 index 000000000..d450f8729 --- /dev/null +++ b/packages/cli/src/cloud/download.ts @@ -0,0 +1,151 @@ +/** + * Stream a presigned `video_url` (or any HTTPS URL) into a local file. + * + * The presigned URLs returned by `GET /v3/hyperframes/renders/{id}` are + * S3 URLs scoped per-request — they don't take any HeyGen auth header. + * That's why this lives separate from the cloud client: the client + * threads auth headers, the download path explicitly does NOT. + * + * Failure behavior is "all or nothing": on any error we (1) listen for + * stream errors / aborts so awaits resolve promptly instead of hanging, + * (2) verify the final byte count matches `content-length` when the + * server supplied one, and (3) `unlinkSync` the partial output so a + * subsequent retry doesn't pick up a corrupted file. + */ + +import { createWriteStream, mkdirSync, unlinkSync } from "node:fs"; +import { dirname } from "node:path"; + +export interface DownloadOptions { + signal?: AbortSignal; + /** Inject fetch (used by tests). */ + fetchImpl?: typeof fetch; + /** Called with (bytes downloaded, total or undefined). */ + onProgress?: (bytes: number, total: number | undefined) => void; +} + +export interface DownloadResult { + path: string; + bytes: number; +} + +/** + * Stream `url` into `destPath`. Creates the parent directory if needed, + * truncates any existing file at the destination, and deletes the + * partial output on any error so the caller never observes a corrupt + * file at the returned path. + */ +// fallow-ignore-next-line complexity +export async function downloadToFile( + url: string, + destPath: string, + options: DownloadOptions = {}, +): Promise { + const fetchImpl = options.fetchImpl ?? fetch; + const res = await fetchImpl(url, { signal: options.signal }); + if (!res.ok) { + throw new Error(`Failed to download ${url}: HTTP ${res.status} ${res.statusText}`); + } + if (!res.body) { + throw new Error(`Failed to download ${url}: empty response body`); + } + + mkdirSync(dirname(destPath), { recursive: true }); + + const totalHeader = res.headers.get("content-length"); + const total = totalHeader ? Number.parseInt(totalHeader, 10) : undefined; + const totalOpt = total !== undefined && Number.isFinite(total) ? total : undefined; + + const file = createWriteStream(destPath); + let bytes = 0; + let errored = false; + try { + for await (const chunk of res.body as unknown as AsyncIterable) { + if (options.signal?.aborted) { + throw options.signal.reason instanceof Error + ? options.signal.reason + : new Error("Download aborted"); + } + bytes += chunk.byteLength; + options.onProgress?.(bytes, totalOpt); + if (!file.write(chunk)) { + await waitForDrain(file, options.signal); + } + } + if (totalOpt !== undefined && bytes !== totalOpt) { + throw new Error( + `Truncated download: got ${bytes} bytes, expected ${totalOpt} (content-length). ` + + `The presigned URL may have expired mid-transfer — refetch via \`hyperframes cloud get\`.`, + ); + } + } catch (err) { + errored = true; + throw err; + } finally { + await closeFile(file); + if (errored) { + // Don't let a partial file pose as the final artifact. Best- + // effort unlink — if it fails (already gone, permission), we + // re-throw the original error. + try { + unlinkSync(destPath); + } catch { + /* swallow */ + } + } + } + return { path: destPath, bytes }; +} + +/** + * Resolve when the write stream emits `drain`, or reject on `error` / + * `close` / signal abort — avoids the hang from awaiting a one-shot + * `drain` event that never fires because the stream tore down first. + */ +function waitForDrain(file: NodeJS.WritableStream, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const cleanup = () => { + file.off("drain", onDrain); + file.off("error", onError); + file.off("close", onClose); + signal?.removeEventListener("abort", onAbort); + }; + const onDrain = (): void => { + cleanup(); + resolve(); + }; + const onError = (err: Error): void => { + cleanup(); + reject(err); + }; + const onClose = (): void => { + cleanup(); + reject(new Error("write stream closed before drain")); + }; + const onAbort = (): void => { + cleanup(); + const reason = signal?.reason; + reject(reason instanceof Error ? reason : new Error("Download aborted")); + }; + file.once("drain", onDrain); + file.once("error", onError); + file.once("close", onClose); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +function closeFile(file: NodeJS.WritableStream): Promise { + return new Promise((resolve) => { + // Best-effort cleanup: any underlying failure has already been + // surfaced as the original throw from the for-await loop. We + // listen for `error` so a failing close (bad fd, late ENOSPC on + // flush) doesn't leak an unhandled 'error' onto the stream, and + // resolve either way so the finally block proceeds to unlinkSync. + const done = (): void => { + file.off("error", done); + resolve(); + }; + file.once("error", done); + file.end(() => done()); + }); +} diff --git a/packages/cli/src/cloud/errors.ts b/packages/cli/src/cloud/errors.ts new file mode 100644 index 000000000..8e5f8af2b --- /dev/null +++ b/packages/cli/src/cloud/errors.ts @@ -0,0 +1,91 @@ +/** + * Shared API-error reporter for the cloud subverbs. + * + * Centralizes the three-branch `instanceof HyperframesApiError` → `Error` + * → `String` cascade so the curated `ERROR_CODE_HINTS` table is applied + * uniformly across `render`/`list`/`get`/`delete`. Without this, each + * subverb had to remember to consult the hint table (and most didn't, + * which is why review finding 10 was that `hyperframes_render_not_found` + * was unreachable from get/delete). + */ + +import { errorBox } from "../ui/format.js"; +import { HyperframesApiError } from "./_gen/client.js"; + +/** + * Hints surfaced when a HyperframesApiError carries a known machine- + * readable code. Keep entries actionable; if there's nothing useful to + * say, leave the code out and let the message stand on its own. + */ +const ERROR_CODE_HINTS: Record = { + hyperframes_project_invalid: + "The uploaded zip didn't validate. Confirm it contains index.html at the root (or matches --composition), and that all referenced assets are present.", + hyperframes_project_too_large: + "The zip exceeded the 32 MB limit. Trim large media (or pre-host them and reference by URL), then try again.", + hyperframes_render_not_found: + "The render_id no longer exists — either soft-deleted or never created.", + invalid_parameter: + "Check the listed parameter against `hyperframes cloud render --help` for the accepted values.", + authentication_failed: + "Run `hyperframes auth status` to confirm your credential; `hyperframes auth login` to re-auth.", + rate_limit_exceeded: "Retry after the duration in the Retry-After header.", +}; + +/** + * Print an errorBox and `process.exit(1)` for any unknown error from + * the cloud subverbs. The `stage` is the human-readable name of the + * step that failed (e.g. "Upload failed", "Submit failed", "Could not + * list cloud renders"). Returns `never` so call sites can `throw` from + * the catch block without a separate exit. + * + * Options: + * - `notFound`: short-circuit on a 404 with this friendly message + * (the render-id, asset-id, etc. that wasn't found). + * - `extraHints`: per-code overrides merged on top of + * `ERROR_CODE_HINTS`. + * - `suggestion`: a fallback line shown when no code-specific hint + * matches. Use this for caller-context that's always actionable + * (e.g. "Resume with: hyperframes cloud get hfr_X" on poll + * errors) so the user can recover without having to remember the + * render_id. + */ +// fallow-ignore-next-line complexity +export function reportApiError( + stage: string, + err: unknown, + options: { + notFound?: string; + extraHints?: Record; + suggestion?: string; + } = {}, +): never { + const hints = { ...ERROR_CODE_HINTS, ...options.extraHints }; + if (err instanceof HyperframesApiError) { + if (err.status === 404 && options.notFound) { + errorBox("Not found", options.notFound); + process.exit(1); + } + const hint = err.code ? hints[err.code] : undefined; + const title = `${stage} (HTTP ${err.status})`; + // Priority: code-specific hint > caller suggestion > bare code + // label > no third line. Code-specific hints win because they + // address the specific failure mode; the caller suggestion is a + // generic-context fallback. + if (hint) { + errorBox(title, err.message, hint); + } else if (options.suggestion) { + errorBox(title, err.message, options.suggestion); + } else if (err.code) { + errorBox(title, err.message, `code: ${err.code}`); + } else { + errorBox(title, err.message); + } + process.exit(1); + } + if (err instanceof Error) { + errorBox(stage, err.message, options.suggestion); + process.exit(1); + } + errorBox(stage, String(err), options.suggestion); + process.exit(1); +} diff --git a/packages/cli/src/cloud/index.ts b/packages/cli/src/cloud/index.ts new file mode 100644 index 000000000..4bd50e13c --- /dev/null +++ b/packages/cli/src/cloud/index.ts @@ -0,0 +1,68 @@ +/** + * Internal surface of the `cloud` module — only the symbols the `cloud` + * commands consume today. Don't add re-exports speculatively; SDK + * consumers can import directly from `_gen/client.js` or `_gen/types.js` + * if they need the broader generated surface. + */ + +export { PollTimeoutError, pollUntilTerminal } from "./poll.js"; +export { DEFAULT_MAX_WAIT_MS, DEFAULT_POLL_INTERVAL_MS } from "./poll.js"; +export { downloadToFile } from "./download.js"; + +export type { HyperframesCloudClient } from "./_gen/client.js"; +export type { CreateHyperframesRenderRequest, HyperframesRenderDetail } from "./_gen/types.js"; + +import { HyperframesApiError, HyperframesCloudClient } from "./_gen/client.js"; +import { forceRefreshCredentials, resolveCloudAuthHeaders, resolveCloudBaseUrl } from "./auth.js"; + +/** + * Convenience factory that wires the generated client to the standard + * credential resolver and adds a 401-retry-with-refresh decorator. + * + * The decorator catches `HyperframesApiError(status=401)` thrown from + * any method on the client, force-refreshes the OAuth token, and + * retries the call exactly once. This mirrors `auth/client.ts`'s + * `onUnauthenticatedRefresh` behavior so server-side revocations or + * clock-skew rejections don't fail the cloud command outright when a + * refresh would have fixed them. + */ +export async function createCloudClient(): Promise { + const client = new HyperframesCloudClient({ + baseUrl: resolveCloudBaseUrl(), + getAuthHeaders: resolveCloudAuthHeaders, + }); + return wrapWith401Retry(client); +} + +function wrapWith401Retry(client: HyperframesCloudClient): HyperframesCloudClient { + return new Proxy(client, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver); + if (typeof value !== "function") return value; + // Bind so internal `this`-references in the generated client + // resolve back to the original instance, not the Proxy. + const original = value.bind(target) as (...args: unknown[]) => Promise; + // Only wrap the public endpoint methods (return Promises). Don't + // gate on method name — the generated client has stable shape, + // and a future endpoint would otherwise be missed. + // fallow-ignore-next-line complexity + return async (...args: unknown[]): Promise => { + try { + return await original(...args); + } catch (err) { + if (err instanceof HyperframesApiError && err.status === 401) { + // Best-effort refresh; if it fails, surface the original + // 401 not the refresh error. + try { + await forceRefreshCredentials(); + } catch { + throw err; + } + return await original(...args); + } + throw err; + } + }; + }, + }); +} diff --git a/packages/cli/src/cloud/parsing.test.ts b/packages/cli/src/cloud/parsing.test.ts new file mode 100644 index 000000000..b75d0ed61 --- /dev/null +++ b/packages/cli/src/cloud/parsing.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { parseEnumFlag, parseIntFlag, parseNumericFlag } from "./parsing.js"; + +describe("cloud/parsing", () => { + // process.exit has signature `(code?) => never` which doesn't unify + // with vi.spyOn's mock-function inference; cast through `unknown` so + // the test compiles. The spy itself still records calls correctly. + let exitSpy: { mockRestore: () => void } & { mock: { calls: unknown[][] } }; + let errorSpy: { mockRestore: () => void }; + + beforeEach(() => { + exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit called"); + }) as unknown as (code?: string | number | null) => never) as unknown as typeof exitSpy; + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + exitSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + describe("parseIntFlag", () => { + it("returns undefined when raw is undefined", () => { + expect(parseIntFlag(undefined, { flag: "--x" })).toBeUndefined(); + }); + + it("parses a clean integer", () => { + expect(parseIntFlag("42", { flag: "--x" })).toBe(42); + }); + + it("rejects trailing garbage that Number.parseInt would silently accept", () => { + expect(() => parseIntFlag("10abc", { flag: "--x" })).toThrow("process.exit called"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("rejects decimals", () => { + expect(() => parseIntFlag("10.5", { flag: "--x" })).toThrow("process.exit called"); + }); + + it("enforces min", () => { + expect(() => parseIntFlag("0", { flag: "--x", min: 1 })).toThrow("process.exit called"); + }); + + it("enforces max", () => { + expect(() => parseIntFlag("101", { flag: "--x", max: 100 })).toThrow("process.exit called"); + }); + + it("accepts negative integers when no min is set", () => { + expect(parseIntFlag("-5", { flag: "--x" })).toBe(-5); + }); + }); + + describe("parseNumericFlag", () => { + it("parses decimals", () => { + expect(parseNumericFlag("1.5", { flag: "--x" })).toBe(1.5); + }); + + it("parses integers", () => { + expect(parseNumericFlag("10", { flag: "--x" })).toBe(10); + }); + + it("rejects trailing garbage that Number.parseFloat would silently accept", () => { + expect(() => parseNumericFlag("10seconds", { flag: "--x" })).toThrow("process.exit called"); + }); + + it("rejects NaN", () => { + expect(() => parseNumericFlag("not-a-number", { flag: "--x" })).toThrow( + "process.exit called", + ); + }); + }); + + describe("parseEnumFlag", () => { + it("accepts a known value", () => { + expect(parseEnumFlag("draft", ["draft", "standard", "high"], { flag: "--quality" })).toBe( + "draft", + ); + }); + + it("rejects an unknown value", () => { + expect(() => + parseEnumFlag("ultra", ["draft", "standard", "high"], { flag: "--quality" }), + ).toThrow("process.exit called"); + }); + + it("returns undefined when raw is undefined", () => { + expect( + parseEnumFlag(undefined, ["draft", "standard", "high"], { flag: "--quality" }), + ).toBeUndefined(); + }); + }); +}); diff --git a/packages/cli/src/cloud/parsing.ts b/packages/cli/src/cloud/parsing.ts new file mode 100644 index 000000000..f8d8364f9 --- /dev/null +++ b/packages/cli/src/cloud/parsing.ts @@ -0,0 +1,99 @@ +/** + * Strict numeric parsers for CLI flags. + * + * `Number.parseInt`/`Number.parseFloat` silently truncate trailing + * garbage ("10abc" → 10), which we don't want for user-facing flags + * like `--limit`, `--fps`, `--poll-interval`. These wrappers reject + * anything that isn't a complete numeric literal. + */ + +import { errorBox } from "../ui/format.js"; + +const INTEGER_RE = /^-?\d+$/; +const NUMERIC_RE = /^-?\d+(\.\d+)?$/; + +export interface IntFlagOptions { + flag: string; + min?: number; + max?: number; +} + +/** + * Parse an integer flag, exit(1) with an errorBox on any non-integer + * input (including trailing garbage like "10abc" and decimals like + * "10.5"). Returns `undefined` when `raw === undefined` so callers can + * supply their own defaults. + */ +export function parseIntFlag(raw: string | undefined, opts: IntFlagOptions): number | undefined { + if (raw === undefined) return undefined; + if (!INTEGER_RE.test(raw)) { + errorBox(`Invalid ${opts.flag}`, `Got "${raw}". Must be an integer${rangeSuffix(opts)}.`); + process.exit(1); + } + const n = Number.parseInt(raw, 10); + enforceRange(opts.flag, raw, n, opts); + return n; +} + +export interface FloatFlagOptions { + flag: string; + min?: number; + max?: number; +} + +/** + * Parse a non-NaN finite numeric flag, exit(1) on any non-numeric + * input. Accepts integers and decimals. + */ +export function parseNumericFlag( + raw: string | undefined, + opts: FloatFlagOptions, +): number | undefined { + if (raw === undefined) return undefined; + if (!NUMERIC_RE.test(raw)) { + errorBox(`Invalid ${opts.flag}`, `Got "${raw}". Must be a number${rangeSuffix(opts)}.`); + process.exit(1); + } + const n = Number.parseFloat(raw); + if (!Number.isFinite(n)) { + errorBox(`Invalid ${opts.flag}`, `Got "${raw}". Must be a finite number.`); + process.exit(1); + } + enforceRange(opts.flag, raw, n, opts); + return n; +} + +function enforceRange( + flag: string, + raw: string, + value: number, + bounds: { min?: number; max?: number }, +): void { + if (bounds.min !== undefined && value < bounds.min) { + errorBox(`Invalid ${flag}`, `Got "${raw}". Minimum is ${bounds.min}.`); + process.exit(1); + } + if (bounds.max !== undefined && value > bounds.max) { + errorBox(`Invalid ${flag}`, `Got "${raw}". Maximum is ${bounds.max}.`); + process.exit(1); + } +} + +/** Parse an enum-typed flag against a closed set of allowed values. */ +export function parseEnumFlag( + raw: string | undefined, + allowed: readonly T[], + opts: { flag: string }, +): T | undefined { + if (raw === undefined) return undefined; + if ((allowed as readonly string[]).includes(raw)) return raw as T; + errorBox(`Invalid ${opts.flag}`, `Got "${raw}". Must be one of: ${allowed.join(", ")}.`); + process.exit(1); +} + +function rangeSuffix(opts: { min?: number; max?: number }): string { + if (opts.min !== undefined && opts.max !== undefined) return ` (${opts.min}-${opts.max})`; + if (opts.min !== undefined) return ` (>= ${opts.min})`; + if (opts.max !== undefined) return ` (<= ${opts.max})`; + return ""; +} diff --git a/packages/cli/src/cloud/poll.test.ts b/packages/cli/src/cloud/poll.test.ts new file mode 100644 index 000000000..6b290fc53 --- /dev/null +++ b/packages/cli/src/cloud/poll.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from "vitest"; +import { + DEFAULT_MAX_WAIT_MS, + DEFAULT_POLL_INTERVAL_MS, + PollTimeoutError, + isTerminal, + pollUntilTerminal, +} from "./poll.js"; +import type { HyperframesCloudClient } from "./_gen/client.js"; +import type { HyperframesRenderDetail } from "./_gen/types.js"; + +function makeDetail(overrides: Partial): HyperframesRenderDetail { + return { + render_id: "hfr_test", + status: "queued", + format: "mp4", + ...overrides, + }; +} + +/** Build a stub client that returns the supplied details in order. */ +function stubClient(details: HyperframesRenderDetail[]): HyperframesCloudClient { + const stack = [...details]; + return { + async getRender() { + const next = stack.shift(); + if (!next) throw new Error("ran out of stubbed responses"); + return next; + }, + } as unknown as HyperframesCloudClient; +} + +describe("cloud/poll", () => { + describe("isTerminal", () => { + it("treats completed/failed as terminal", () => { + expect(isTerminal("completed")).toBe(true); + expect(isTerminal("failed")).toBe(true); + }); + it("treats queued/rendering as non-terminal", () => { + expect(isTerminal("queued")).toBe(false); + expect(isTerminal("rendering")).toBe(false); + }); + }); + + describe("defaults", () => { + it("matches the documented 10s / 60min defaults", () => { + expect(DEFAULT_POLL_INTERVAL_MS).toBe(10_000); + expect(DEFAULT_MAX_WAIT_MS).toBe(60 * 60 * 1000); + }); + }); + + describe("pollUntilTerminal", () => { + it("returns immediately when the first poll is terminal", async () => { + const client = stubClient([makeDetail({ status: "completed" })]); + const sleep = vi.fn(async () => {}); + const result = await pollUntilTerminal(client, "hfr_test", { sleep }); + expect(result.status).toBe("completed"); + expect(sleep).not.toHaveBeenCalled(); + }); + + it("sleeps between non-terminal polls and returns on the terminal one", async () => { + const client = stubClient([ + makeDetail({ status: "queued" }), + makeDetail({ status: "rendering" }), + makeDetail({ status: "completed" }), + ]); + const sleep = vi.fn(async () => {}); + const now = (() => { + let t = 0; + return () => { + t += 1000; + return t; + }; + })(); + const ticks: string[] = []; + const result = await pollUntilTerminal(client, "hfr_test", { + sleep, + now, + intervalMs: 5_000, + onTick: (d) => ticks.push(d.status), + }); + expect(result.status).toBe("completed"); + expect(ticks).toEqual(["queued", "rendering", "completed"]); + // Two sleeps: one after queued, one after rendering. None after the + // terminal completed response. + expect(sleep).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledWith(5_000); + }); + + it("throws PollTimeoutError when elapsed exceeds maxWaitMs", async () => { + const client = stubClient([ + makeDetail({ status: "queued" }), + makeDetail({ status: "rendering" }), + makeDetail({ status: "rendering" }), + ]); + const sleep = vi.fn(async () => {}); + // Each `now()` returns +500ms; total elapses past 1s on the second + // call, so maxWaitMs=1 triggers immediately. + const now = (() => { + let t = 0; + return () => { + t += 1000; + return t; + }; + })(); + await expect( + pollUntilTerminal(client, "hfr_test", { + sleep, + now, + intervalMs: 5_000, + maxWaitMs: 1, + }), + ).rejects.toBeInstanceOf(PollTimeoutError); + }); + + it("aborts when the AbortSignal is fired", async () => { + const client = stubClient([makeDetail({ status: "queued" })]); + const controller = new AbortController(); + controller.abort(new Error("user cancelled")); + await expect( + pollUntilTerminal(client, "hfr_test", { signal: controller.signal }), + ).rejects.toThrow("user cancelled"); + }); + }); +}); diff --git a/packages/cli/src/cloud/poll.ts b/packages/cli/src/cloud/poll.ts new file mode 100644 index 000000000..9a32beabc --- /dev/null +++ b/packages/cli/src/cloud/poll.ts @@ -0,0 +1,108 @@ +/** + * Poll a HyperFrames render until it reaches a terminal state. + * + * Defaults: 10s interval, 60min cap — confirmed with the API owner. The + * `start_to_close` timeout on the underlying Temporal workflow is the + * same order of magnitude, so polling longer would only hide a stuck + * workflow rather than recover from one. + * + * Spinner output is silenced when stdout isn't a TTY (CI, piped output) + * so the JSON-emitting modes upstream don't get garbled. + */ + +import type { HyperframesCloudClient } from "./_gen/client.js"; +import type { HyperframesRenderDetail, HyperframesRenderStatus } from "./_gen/types.js"; + +export interface PollOptions { + intervalMs?: number; + maxWaitMs?: number; + /** Called once per tick with the latest render state. */ + onTick?: (detail: HyperframesRenderDetail, elapsedMs: number) => void; + /** Inject a clock for tests. */ + now?: () => number; + /** Inject a sleep for tests. */ + sleep?: (ms: number) => Promise; + signal?: AbortSignal; +} + +export const DEFAULT_POLL_INTERVAL_MS = 10_000; +export const DEFAULT_MAX_WAIT_MS = 60 * 60 * 1000; + +const TERMINAL_STATUSES: ReadonlySet = new Set(["completed", "failed"]); + +export class PollTimeoutError extends Error { + readonly lastDetail: HyperframesRenderDetail; + constructor(lastDetail: HyperframesRenderDetail, elapsedMs: number) { + super( + `Render ${lastDetail.render_id} did not reach a terminal state within ${Math.round(elapsedMs / 1000)}s`, + ); + this.name = "PollTimeoutError"; + this.lastDetail = lastDetail; + } +} + +export function isTerminal(status: HyperframesRenderStatus): boolean { + return TERMINAL_STATUSES.has(status); +} + +/** + * Poll `GET /v3/hyperframes/renders/{id}` until status is `completed` or + * `failed`, or `maxWaitMs` elapses (in which case throws + * {@link PollTimeoutError}). Errors from the underlying request bubble + * up immediately — they are not retried, because every error class the + * API can return at this stage (404, 401, 5xx) is unlikely to recover + * on a retry inside the poll window. + */ +export async function pollUntilTerminal( + client: HyperframesCloudClient, + renderId: string, + options: PollOptions = {}, +): Promise { + const intervalMs = options.intervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const maxWaitMs = options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS; + const now = options.now ?? (() => Date.now()); + // Default sleep honors the abort signal so Ctrl+C feels immediate + // instead of waiting out the full interval. Tests inject a no-op + // sleep that ignores the signal — that's fine, they don't abort. + const sleep = options.sleep ?? defaultAbortableSleep(options.signal); + + const started = now(); + + while (true) { + if (options.signal?.aborted) { + throw signalAbortError(options.signal); + } + const detail = await client.getRender({ render_id: renderId, signal: options.signal }); + const elapsed = now() - started; + options.onTick?.(detail, elapsed); + + if (isTerminal(detail.status)) { + return detail; + } + if (elapsed >= maxWaitMs) { + throw new PollTimeoutError(detail, elapsed); + } + await sleep(intervalMs); + } +} + +function signalAbortError(signal: AbortSignal): Error { + const reason = signal.reason; + return reason instanceof Error ? reason : new Error("Poll aborted"); +} + +function defaultAbortableSleep(signal?: AbortSignal): (ms: number) => Promise { + // fallow-ignore-next-line complexity + return (ms: number) => + new Promise((resolve, reject) => { + const onAbort = (): void => { + clearTimeout(timer); + reject(signalAbortError(signal!)); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} diff --git a/packages/cli/src/cloud/statusColor.ts b/packages/cli/src/cloud/statusColor.ts new file mode 100644 index 000000000..551e47e3d --- /dev/null +++ b/packages/cli/src/cloud/statusColor.ts @@ -0,0 +1,21 @@ +/** + * Shared status-string colorizer used by `cloud list/get/render`. + * Extracted so changes to the color palette propagate atomically; without + * this, fallow flagged the repeated switch as duplication. + */ + +import { c } from "../ui/colors.js"; +import type { HyperframesRenderStatus } from "./_gen/types.js"; + +export function colorStatus(status: HyperframesRenderStatus | string): string { + switch (status) { + case "completed": + return c.success(status); + case "failed": + return c.error(status); + case "rendering": + return c.progress(status); + default: + return c.dim(status); + } +} diff --git a/packages/cli/src/commands/cloud.ts b/packages/cli/src/commands/cloud.ts new file mode 100644 index 000000000..bf1604cd6 --- /dev/null +++ b/packages/cli/src/commands/cloud.ts @@ -0,0 +1,69 @@ +/** + * `hyperframes cloud` — top-level dispatcher for cloud-render subverbs. + * + * Each subverb lives in `./cloud/.ts`. The dispatcher loads them + * dynamically so the cloud surface doesn't impact CLI cold-start when + * the user is running `render` / `preview` / etc. + * + * Auth is the existing `cli/src/auth/` chain — `cloud` subverbs call + * into `cloud/auth.ts` which bridges `resolveCredential` + + * `buildAuthHeaders` into the generated client. There is no new + * credentials store and no new env var. + */ + +import { defineCommand } from "citty"; +import type { Example } from "./_examples.js"; +import { c } from "../ui/colors.js"; + +export const examples: Example[] = [ + ["Render the current directory in the cloud", "hyperframes cloud render"], + ["Render a specific project", "hyperframes cloud render ./my-video"], + [ + "Render at 60fps + high quality, save to a path", + "hyperframes cloud render ./my-video --fps 60 --quality high -o ./out.mp4", + ], + [ + "Fire-and-forget with a webhook", + "hyperframes cloud render ./my-video --callback-url https://example.com/hf-hook --no-wait", + ], + ["Resubmit an already-uploaded zip", "hyperframes cloud render --asset-id asst_abc123"], + [ + "Render from a public HTTPS zip", + "hyperframes cloud render --url https://cdn.example.com/site.zip", + ], + ["List recent cloud renders", "hyperframes cloud list"], + ["Fetch one render's status + signed URLs", "hyperframes cloud get hfr_abc123"], + ["Soft-delete a render", "hyperframes cloud delete hfr_abc123"], +]; + +const HELP = ` +${c.bold("hyperframes cloud")} ${c.dim(" [args]")} + +Render HyperFrames compositions on HeyGen's cloud infrastructure. The +project zip is uploaded, the render is dispatched, and the resulting +video is downloaded locally — without spinning up Chrome or ffmpeg +on your machine. + +${c.bold("SUBCOMMANDS:")} + ${c.accent("render")} ${c.dim("Submit a project (or asset_id / url) and download the result")} + ${c.accent("list")} ${c.dim("List recent renders in your account")} + ${c.accent("get")} ${c.dim("Fetch one render's status + signed URLs")} + ${c.accent("delete")} ${c.dim("Soft-delete a render (GET 404s afterward)")} + +${c.bold("AUTH:")} + Uses the credential you signed in with via ${c.accent("hyperframes auth login")}. + Override the API base with ${c.accent("HEYGEN_API_URL")} (default https://api.heygen.com). +`; + +export default defineCommand({ + meta: { name: "cloud", description: "Render HyperFrames compositions on the HeyGen cloud" }, + subCommands: { + render: () => import("./cloud/render.js").then((m) => m.default), + list: () => import("./cloud/list.js").then((m) => m.default), + get: () => import("./cloud/get.js").then((m) => m.default), + delete: () => import("./cloud/delete.js").then((m) => m.default), + }, + async run({ args }) { + if (!args._?.[0]) console.log(HELP); + }, +}); diff --git a/packages/cli/src/commands/cloud/delete.ts b/packages/cli/src/commands/cloud/delete.ts new file mode 100644 index 000000000..50289f41e --- /dev/null +++ b/packages/cli/src/commands/cloud/delete.ts @@ -0,0 +1,87 @@ +/** + * `hyperframes cloud delete ` — soft-delete a cloud render. + * + * Subsequent GET calls return 404. The signed video URL stops working + * shortly after. There's no undo from the CLI side. + */ + +import { defineCommand } from "citty"; +import { createCloudClient } from "../../cloud/index.js"; +import { reportApiError } from "../../cloud/errors.js"; +import { withMeta } from "../../utils/updateCheck.js"; +import { c } from "../../ui/colors.js"; +import { errorBox } from "../../ui/format.js"; + +export default defineCommand({ + meta: { name: "delete", description: "Soft-delete a cloud render" }, + args: { + id: { + type: "positional", + required: true, + description: "Render ID to delete", + }, + json: { + type: "boolean", + description: "Emit machine-readable JSON", + default: false, + }, + "no-confirm": { + type: "boolean", + description: "Skip the interactive confirmation prompt (required for scripts and --json)", + default: false, + }, + }, + // fallow-ignore-next-line complexity + async run({ args }) { + if (!args["no-confirm"]) { + // Don't auto-bypass the prompt just because stdin isn't a TTY + // or `--json` was passed — both used to silently skip the + // safety check. Force the caller to opt in via `--no-confirm` + // so cron jobs, CI shells, and JSON consumers can't soft-delete + // by accident. + if (args.json || !process.stdin.isTTY) { + errorBox( + "Confirmation required", + "delete cannot prompt for confirmation here — stdin isn't a TTY or --json was passed.", + "Re-run with --no-confirm to acknowledge the irreversible delete.", + ); + process.exit(1); + } + const ok = await confirmDelete(args.id); + if (!ok) { + // Distinct exit code so wrapper scripts can tell an explicit + // decline apart from an API/system error. + console.log(c.dim("Aborted.")); + process.exit(2); + } + } + const client = await createCloudClient(); + try { + const response = await client.deleteRender({ render_id: args.id }); + if (args.json) { + console.log( + JSON.stringify( + withMeta({ render: { render_id: response.render_id }, deleted: true }), + null, + 2, + ), + ); + return; + } + console.log(`${c.success("✓")} Deleted ${c.accent(response.render_id)}`); + } catch (err) { + reportApiError("Could not delete render", err, { + notFound: `No render found with id "${args.id}".`, + }); + } + }, +}); + +async function confirmDelete(id: string): Promise { + const clack = await import("@clack/prompts"); + const answer = await clack.confirm({ + message: `Delete render ${id}? This is irreversible.`, + initialValue: false, + }); + return answer === true; +} diff --git a/packages/cli/src/commands/cloud/get.ts b/packages/cli/src/commands/cloud/get.ts new file mode 100644 index 000000000..edcd17766 --- /dev/null +++ b/packages/cli/src/commands/cloud/get.ts @@ -0,0 +1,86 @@ +/** + * `hyperframes cloud get ` — fetch detail for a single render. + * + * Includes the signed `video_url` and `thumbnail_url` when status is + * `completed`. The signed URLs are short-lived; don't paste them into + * docs / chat — fetch them on demand. + */ + +import { defineCommand } from "citty"; +import { createCloudClient } from "../../cloud/index.js"; +import { reportApiError } from "../../cloud/errors.js"; +import { colorStatus } from "../../cloud/statusColor.js"; +import { withMeta } from "../../utils/updateCheck.js"; +import type { HyperframesRenderDetail } from "../../cloud/index.js"; +import { c } from "../../ui/colors.js"; + +export default defineCommand({ + meta: { name: "get", description: "Fetch detail for one cloud render" }, + args: { + id: { + type: "positional", + required: true, + description: "Render ID (returned by `cloud render` / `cloud list`)", + }, + json: { + type: "boolean", + description: "Emit machine-readable JSON", + default: false, + }, + }, + async run({ args }) { + const client = await createCloudClient(); + try { + const detail = await client.getRender({ render_id: args.id }); + if (args.json) { + console.log(JSON.stringify(withMeta({ render: detail }), null, 2)); + return; + } + printHuman(detail); + } catch (err) { + reportApiError("Could not fetch render", err, { + notFound: `No render found with id "${args.id}".`, + }); + } + }, +}); + +// fallow-ignore-next-line complexity +function printHuman(detail: HyperframesRenderDetail): void { + const rows: [string, string | undefined][] = [ + ["Render ID:", c.accent(detail.render_id)], + ["Status: ", colorStatus(detail.status)], + ["Format: ", detail.format], + ["Quality: ", detail.quality ?? undefined], + ["Fps: ", detail.fps?.toString()], + ["Resolution:", detail.resolution ?? undefined], + ["Composition:", detail.composition ?? undefined], + ["Title: ", detail.title ?? undefined], + ["Callback ID:", detail.callback_id ?? undefined], + [ + "Duration: ", + detail.duration !== undefined && detail.duration !== null + ? `${detail.duration.toFixed(2)}s` + : undefined, + ], + [ + "Created: ", + detail.created_at !== undefined && detail.created_at !== null + ? new Date(detail.created_at * 1000).toISOString() + : undefined, + ], + [ + "Completed:", + detail.completed_at !== undefined && detail.completed_at !== null + ? new Date(detail.completed_at * 1000).toISOString() + : undefined, + ], + ["Video URL:", detail.video_url ?? undefined], + ["Thumbnail:", detail.thumbnail_url ?? undefined], + ["Failure: ", detail.failure_message ?? undefined], + ]; + for (const [label, value] of rows) { + if (value === undefined) continue; + console.log(`${c.bold(label)} ${value}`); + } +} diff --git a/packages/cli/src/commands/cloud/list.ts b/packages/cli/src/commands/cloud/list.ts new file mode 100644 index 000000000..ab575e2bf --- /dev/null +++ b/packages/cli/src/commands/cloud/list.ts @@ -0,0 +1,144 @@ +/** + * `hyperframes cloud list` — page through GET /v3/hyperframes/renders. + * + * Cursor pagination: `--limit` caps a single page (max 100 per the + * spec), `--all` walks `next_token` until exhausted. Default page size + * mirrors the API default (10). + */ + +import { defineCommand } from "citty"; +import { createCloudClient } from "../../cloud/index.js"; +import { padEndVisible } from "../../cloud/ansi.js"; +import { reportApiError } from "../../cloud/errors.js"; +import { parseIntFlag } from "../../cloud/parsing.js"; +import { colorStatus } from "../../cloud/statusColor.js"; +import { withMeta } from "../../utils/updateCheck.js"; +import type { HyperframesRenderDetail } from "../../cloud/index.js"; +import { c } from "../../ui/colors.js"; +import { errorBox } from "../../ui/format.js"; + +// Safety cap on --all to defend against a buggy backend serving the +// same next_token in a loop. 50 pages at the maximum page size of 100 +// covers 5,000 renders — well past anyone's expected list size. +const MAX_ALL_PAGES = 50; + +export default defineCommand({ + meta: { name: "list", description: "List recent cloud renders" }, + args: { + limit: { + type: "string", + description: "Items per page (1-100; default 10)", + }, + token: { + type: "string", + description: "Resume from a previous next_token cursor", + }, + all: { + type: "boolean", + description: "Fetch every page (follows next_token until exhausted)", + default: false, + }, + json: { + type: "boolean", + description: "Emit machine-readable JSON", + default: false, + }, + }, + // fallow-ignore-next-line complexity + async run({ args }) { + const limit = parseIntFlag(args.limit, { flag: "--limit", min: 1, max: 100 }); + + const client = await createCloudClient(); + + try { + if (args.all) { + const renders = await fetchAll(client, limit); + emit(renders, args.json, null, false); + } else { + const page = await client.listRenders({ limit, token: args.token }); + emit(page.data ?? [], args.json, page.next_token ?? null, Boolean(page.has_more)); + } + } catch (err) { + reportApiError("Could not list cloud renders", err); + } + }, +}); + +// fallow-ignore-next-line complexity +async function fetchAll( + client: Awaited>, + pageSize: number | undefined, +): Promise { + const out: HyperframesRenderDetail[] = []; + const seenCursors = new Set(); + let token: string | undefined; + for (let page = 0; page < MAX_ALL_PAGES; page++) { + const result = await client.listRenders({ limit: pageSize, token }); + out.push(...(result.data ?? [])); + if (!result.has_more) return out; + // Defensive: server said `has_more: true` but didn't hand us a + // cursor to use. Better to surface the malformed shape than + // return a silently-truncated list. + if (!result.next_token) { + errorBox( + "Pagination cursor missing", + "Server returned has_more: true with no next_token — incomplete response.", + "Retry the command, or report this if it persists.", + ); + process.exit(1); + } + if (seenCursors.has(result.next_token)) { + errorBox( + "Pagination loop detected", + `Server returned the same next_token (${result.next_token}) twice.`, + "Retry the command, or report this if it persists.", + ); + process.exit(1); + } + seenCursors.add(result.next_token); + token = result.next_token; + } + errorBox( + "Pagination cap reached", + `Stopped after ${MAX_ALL_PAGES} pages to avoid an unbounded loop.`, + `Re-run with a higher --limit, or paginate manually with --token.`, + ); + process.exit(1); +} + +// fallow-ignore-next-line complexity +function emit( + renders: HyperframesRenderDetail[], + asJson: boolean, + nextToken: string | null, + hasMore: boolean, +): void { + if (asJson) { + const payload: Record = { renders, has_more: hasMore }; + if (nextToken !== null) payload["next_token"] = nextToken; + console.log(JSON.stringify(withMeta(payload), null, 2)); + return; + } + if (renders.length === 0) { + console.log(c.dim("No renders found.")); + return; + } + const idWidth = Math.max(8, ...renders.map((r) => r.render_id.length)); + const statusWidth = Math.max(6, ...renders.map((r) => r.status.length)); + for (const r of renders) { + const id = c.accent(r.render_id); + const status = colorStatus(r.status); + const created = + r.created_at !== undefined && r.created_at !== null + ? new Date(r.created_at * 1000).toISOString() + : "—"; + const title = r.title ? ` ${c.dim(r.title)}` : ""; + console.log( + `${padEndVisible(id, idWidth)} ${padEndVisible(status, statusWidth)} ${c.dim(created)}${title}`, + ); + } + if (nextToken) { + console.log(""); + console.log(c.dim(`More results — pass --token ${nextToken} to continue.`)); + } +} diff --git a/packages/cli/src/commands/cloud/render.ts b/packages/cli/src/commands/cloud/render.ts new file mode 100644 index 000000000..d5f502908 --- /dev/null +++ b/packages/cli/src/commands/cloud/render.ts @@ -0,0 +1,587 @@ +/** + * `hyperframes cloud render` — orchestrate a cloud-rendered HyperFrames + * composition end-to-end: + * + * 1. Resolve the project (or reuse a pre-uploaded `--asset-id` / + * `--url`). + * 2. Zip the project (reuses `createPublishArchive` so the + * file-ignore set matches the existing `publish` command exactly). + * 3. Upload the zip via `POST /v3/assets` (multipart) — the server + * branches on the detected `application/zip` MIME. + * 4. Submit the render via `POST /v3/hyperframes/renders` with a + * `project: {type:"asset_id", asset_id}` shape. + * 5. If `--no-wait`: print the `render_id` and exit immediately. + * Otherwise poll `GET /v3/hyperframes/renders/{id}` every + * `--poll-interval` (default 10s, max 60min). `--callback-url` + * can be combined with either mode: the webhook always fires when + * the server-side render terminates, independent of whether the + * CLI is still polling. + * 6. On `completed`: stream the signed `video_url` to disk. + * 7. On `failed`: print `failure_message` and exit 1. + * + * Auth comes from the existing `cli/src/auth/` chain via `cloud/auth.ts`. + * The cloud HTTP client (`cloud/_gen/client.ts`) is generated from + * `experiment-framework/openapi/external-api.json`; never hand-edit it. + */ + +import { defineCommand } from "citty"; + +import { c } from "../../ui/colors.js"; +import { errorBox, formatBytes, formatDuration } from "../../ui/format.js"; +import { resolveProject } from "../../utils/project.js"; +import { createPublishArchive } from "../../utils/publishProject.js"; +import { + reportVariableIssues, + resolveVariablesArg, + validateVariablesAgainstProject, +} from "../../utils/variables.js"; +import { withMeta } from "../../utils/updateCheck.js"; +import type { Example } from "../_examples.js"; + +import { + DEFAULT_MAX_WAIT_MS, + DEFAULT_POLL_INTERVAL_MS, + PollTimeoutError, + createCloudClient, + downloadToFile, + pollUntilTerminal, +} from "../../cloud/index.js"; +import { reportApiError } from "../../cloud/errors.js"; +import { parseEnumFlag, parseIntFlag, parseNumericFlag } from "../../cloud/parsing.js"; +import { colorStatus } from "../../cloud/statusColor.js"; +import type { + CreateHyperframesRenderRequest, + HyperframesCloudClient, + HyperframesRenderDetail, +} from "../../cloud/index.js"; +import { isAbsolute, resolve as resolvePath } from "node:path"; + +const VALID_QUALITY = ["draft", "standard", "high"] as const; +const VALID_FORMAT = ["mp4", "webm", "mov"] as const; +const VALID_RESOLUTION = [ + "landscape", + "portrait", + "landscape-4k", + "portrait-4k", + "square", + "square-4k", +] as const; + +const FORMAT_EXT: Record = { mp4: ".mp4", webm: ".webm", mov: ".mov" }; + +export const examples: Example[] = [ + ["Render the current directory in the cloud", "hyperframes cloud render"], + [ + "Pick a specific composition + output path", + "hyperframes cloud render . --composition compositions/intro.html -o ./renders/intro.mp4", + ], + ["Higher quality, 60fps", "hyperframes cloud render --quality high --fps 60"], + [ + "Submit and exit; webhook fires when the render terminates", + "hyperframes cloud render --callback-url https://example.com/hook --no-wait", + ], + [ + "Override variables (parametrized render)", + 'hyperframes cloud render --variables \'{"title":"Q4 Recap","theme":"dark"}\'', + ], + ["Re-render an already-uploaded zip", "hyperframes cloud render --asset-id asst_abc123"], +]; + +export default defineCommand({ + meta: { name: "render", description: "Render a HyperFrames composition in the cloud" }, + args: { + dir: { type: "positional", required: false, description: "Project directory (default: .)" }, + fps: { type: "string", description: "Frames per second (1-240). Default: 30." }, + quality: { type: "string", description: "draft | standard | high (default: standard)" }, + format: { type: "string", description: "mp4 | webm | mov (default: mp4)" }, + resolution: { + type: "string", + description: + "Resolution preset: landscape | portrait | landscape-4k | portrait-4k | square | square-4k", + }, + composition: { + type: "string", + alias: "c", + description: "Entry HTML file inside the zip (default: index.html)", + }, + variables: { + type: "string", + description: + 'Inline JSON object overriding data-composition-variables. Example: --variables \'{"title":"X"}\'', + }, + "variables-file": { + type: "string", + description: "Path to a JSON file with variable values (alternative to --variables)", + }, + "strict-variables": { + type: "boolean", + description: "Fail when --variables keys are undeclared or have the wrong type", + default: false, + }, + title: { + type: "string", + description: "Free-text label echoed back in detail responses", + }, + "callback-url": { + type: "string", + description: + "HTTPS webhook fired when the render terminates. Fires regardless of whether the CLI is still polling — combine with --no-wait for true fire-and-forget.", + }, + "callback-id": { + type: "string", + description: "Opaque tracking ID echoed in webhook payloads", + }, + "asset-id": { + type: "string", + description: + "Skip zip+upload and submit an already-uploaded composition. Mutually exclusive with --url and the project dir.", + }, + url: { + type: "string", + description: + "Public HTTPS URL of a composition zip. Mutually exclusive with --asset-id and the project dir.", + }, + "no-wait": { + type: "boolean", + description: "Submit and exit; print the render_id to stdout", + default: false, + }, + output: { + type: "string", + alias: "o", + description: "Destination path for the downloaded video (default: renders/.)", + }, + "poll-interval": { + type: "string", + description: `Poll cadence in seconds (default: ${DEFAULT_POLL_INTERVAL_MS / 1000})`, + }, + "max-wait": { + type: "string", + description: `Max poll duration in minutes (default: ${DEFAULT_MAX_WAIT_MS / 60_000})`, + }, + json: { + type: "boolean", + description: "Emit machine-readable JSON instead of human-friendly progress", + default: false, + }, + "idempotency-key": { + type: "string", + description: "Optional Idempotency-Key for safe retries (1-255 chars from [A-Za-z0-9_:.-])", + }, + }, + // fallow-ignore-next-line complexity + async run({ args }) { + const asJson = Boolean(args.json); + const fps = parseIntFlag(args.fps, { flag: "--fps", min: 1, max: 240 }); + const quality = parseEnumFlag(args.quality, VALID_QUALITY, { flag: "--quality" }); + const format = parseEnumFlag(args.format, VALID_FORMAT, { flag: "--format" }); + const resolution = parseEnumFlag(args.resolution, VALID_RESOLUTION, { + flag: "--resolution", + }); + const pollIntervalMs = parsePollIntervalMs(args["poll-interval"]); + const maxWaitMs = parseMaxWaitMs(args["max-wait"]); + validateIdempotencyKey(args["idempotency-key"]); + + // Project resolution runs BEFORE variables resolution so a user + // passing conflicting inputs (`dir + --asset-id`) sees the + // structural error before any variable parsing errors. + const project = resolveProjectInput({ + dir: args.dir, + assetId: args["asset-id"], + url: args.url, + }); + + const variables = resolveVariablesAndValidateIfLocal( + args.variables, + args["variables-file"], + args["strict-variables"] ?? false, + project, + ); + + const client = await createCloudClient(); + + const upload = await maybeUploadProject(client, project, asJson, args["idempotency-key"]); + const submitted = await submitRender(client, { + projectInput: upload.projectInput, + fps, + quality, + format, + resolution, + composition: args.composition, + variables, + title: args.title, + callbackUrl: args["callback-url"], + callbackId: args["callback-id"], + idempotencyKey: args["idempotency-key"], + }); + + const renderId = submitted.render_id; + if (args["no-wait"]) { + if (asJson) { + console.log( + JSON.stringify( + withMeta({ render: { render_id: renderId, status: "queued" as const } }), + null, + 2, + ), + ); + } else { + console.log(""); + console.log(`${c.success("✓")} Submitted ${c.accent(renderId)}`); + console.log(c.dim(` Poll with: hyperframes cloud get ${renderId}`)); + } + return; + } + + if (!asJson) { + console.log(""); + console.log(c.dim(` Polling ${renderId} every ${pollIntervalMs / 1000}s …`)); + } + + const detail = await pollWithProgress(client, renderId, asJson, { + intervalMs: pollIntervalMs, + maxWaitMs, + }); + + if (detail.status === "failed") { + handleFailedRender(detail, asJson); + } + + if (!detail.video_url) { + errorBox( + "Render completed but returned no video_url", + `render_id: ${renderId}. Try \`hyperframes cloud get ${renderId}\` to inspect raw fields.`, + ); + process.exit(1); + } + + const outputPath = resolveOutputPath(args.output, renderId, detail.format); + const downloadResult = await streamVideo(detail.video_url, outputPath, asJson); + + if (asJson) { + console.log( + JSON.stringify( + withMeta({ + render: detail, + output_path: outputPath, + bytes_written: downloadResult.bytes, + }), + null, + 2, + ), + ); + } + }, +}); + +// --------------------------------------------------------------------------- +// Argument parsing — defers to cloud/parsing.ts for strict validators +// --------------------------------------------------------------------------- + +function parsePollIntervalMs(raw: string | undefined): number { + const n = parseNumericFlag(raw, { flag: "--poll-interval", min: 1 }); + return n === undefined ? DEFAULT_POLL_INTERVAL_MS : Math.round(n * 1000); +} + +function parseMaxWaitMs(raw: string | undefined): number { + const n = parseNumericFlag(raw, { flag: "--max-wait", min: 0.0001 }); + return n === undefined ? DEFAULT_MAX_WAIT_MS : Math.round(n * 60_000); +} + +const IDEMPOTENCY_KEY_RE = /^[A-Za-z0-9_:.-]{1,255}$/; + +function validateIdempotencyKey(key: string | undefined): void { + if (key === undefined) return; + if (!IDEMPOTENCY_KEY_RE.test(key)) { + errorBox("Invalid --idempotency-key", `Got "${key}". Must be 1-255 chars from [A-Za-z0-9_:.-]`); + process.exit(1); + } +} + +// --------------------------------------------------------------------------- +// Project resolution (dir | asset-id | url) — exactly one source +// --------------------------------------------------------------------------- + +interface ProjectInputSource { + kind: "dir" | "asset_id" | "url"; + dir?: string; + assetId?: string; + url?: string; +} + +// fallow-ignore-next-line complexity +function resolveProjectInput(opts: { + dir: string | undefined; + assetId: string | undefined; + url: string | undefined; +}): ProjectInputSource { + // Count every source the user explicitly supplied. The positional + // `dir` defaults to `undefined` when omitted (not to "."), so we + // can detect "user actually typed something" vs. "default to cwd". + const explicit = { + dir: opts.dir !== undefined && opts.dir !== "", + assetId: opts.assetId !== undefined && opts.assetId !== "", + url: opts.url !== undefined && opts.url !== "", + }; + const count = Number(explicit.dir) + Number(explicit.assetId) + Number(explicit.url); + if (count > 1) { + errorBox("Conflicting inputs", "Pass only one of: project dir, --asset-id, --url."); + process.exit(1); + } + if (explicit.assetId) return { kind: "asset_id", assetId: opts.assetId }; + if (explicit.url) return { kind: "url", url: opts.url }; + return { kind: "dir", dir: opts.dir ?? "." }; +} + +function resolveVariablesAndValidateIfLocal( + inline: string | undefined, + filePath: string | undefined, + strict: boolean, + source: ProjectInputSource, +): Record | undefined { + const variables = resolveVariablesArg(inline, filePath); + if (!variables || Object.keys(variables).length === 0) return variables; + // Only validate against the local composition when we actually have + // a local project on disk. For --asset-id / --url paths the schema + // lives on the server side, so we send the variables as-is and let + // the API surface any mismatch via `hyperframes_project_invalid`. + if (source.kind !== "dir") return variables; + // `resolveProject` calls process.exit on a missing/invalid dir, so + // there's no need to wrap this in try/catch — if it returns, the + // index.html is present. The earlier impl had a dead try/catch. + const { indexPath } = resolveProject(source.dir); + const issues = validateVariablesAgainstProject(indexPath, variables); + reportVariableIssues(issues, { strict, quiet: false }); + return variables; +} + +// --------------------------------------------------------------------------- +// Upload step (only when project is a local dir) +// --------------------------------------------------------------------------- + +interface UploadResult { + projectInput: CreateHyperframesRenderRequest["project"]; +} + +// fallow-ignore-next-line complexity +async function maybeUploadProject( + client: HyperframesCloudClient, + source: ProjectInputSource, + asJson: boolean, + idempotencyKey: string | undefined, +): Promise { + if (source.kind === "asset_id") { + return { projectInput: { type: "asset_id", asset_id: source.assetId! } }; + } + if (source.kind === "url") { + return { projectInput: { type: "url", url: source.url! } }; + } + + const project = resolveProject(source.dir); + if (!asJson) { + console.log(""); + console.log(`${c.accent("◆")} Zipping ${c.accent(project.name)}`); + } + let archive; + try { + archive = createPublishArchive(project.dir); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + errorBox("Zip failed", msg, "Check the project for missing files or unreadable permissions."); + process.exit(1); + } + if (!asJson) { + console.log(c.dim(` ${archive.fileCount} files · ${formatBytes(archive.buffer.byteLength)}`)); + } + + if (!asJson) { + console.log(""); + console.log(`${c.accent("◆")} Uploading to /v3/assets`); + } + const uploadStart = Date.now(); + let uploaded; + try { + uploaded = await client.uploadAsset({ + file: archive.buffer, + filename: `${project.name}.zip`, + // Tag the multipart part with application/zip so downstream + // proxies / WAFs / any server-side path that keys off the + // part Content-Type see the intended type. The asset + // controller currently sniffs magic bytes from the file + // bytes, so this is belt-and-suspenders today; without it, + // FormData defaults to application/octet-stream. + mimeType: "application/zip", + idempotencyKey, + }); + } catch (err) { + reportApiError("Upload failed", err); + } + if (!asJson) { + console.log( + c.dim( + ` asset_id: ${c.accent(uploaded.asset_id)} · ${formatDuration(Date.now() - uploadStart)}`, + ), + ); + } + return { projectInput: { type: "asset_id", asset_id: uploaded.asset_id } }; +} + +// --------------------------------------------------------------------------- +// Submit step +// --------------------------------------------------------------------------- + +interface SubmitOptions { + projectInput: CreateHyperframesRenderRequest["project"]; + fps: number | undefined; + quality: "draft" | "standard" | "high" | undefined; + format: "mp4" | "webm" | "mov" | undefined; + resolution: CreateHyperframesRenderRequest["resolution"] | undefined; + composition: string | undefined; + variables: Record | undefined; + title: string | undefined; + callbackUrl: string | undefined; + callbackId: string | undefined; + idempotencyKey: string | undefined; +} + +async function submitRender( + client: HyperframesCloudClient, + opts: SubmitOptions, +): Promise<{ render_id: string }> { + const body = buildRenderBody(opts); + try { + return await client.createRender({ body, idempotencyKey: opts.idempotencyKey }); + } catch (err) { + reportApiError("Submit failed", err); + } +} + +// fallow-ignore-next-line complexity +function buildRenderBody(opts: SubmitOptions): CreateHyperframesRenderRequest { + const body: CreateHyperframesRenderRequest = { project: opts.projectInput }; + if (opts.fps !== undefined) body.fps = opts.fps; + if (opts.quality !== undefined) body.quality = opts.quality; + if (opts.format !== undefined) body.format = opts.format; + if (opts.resolution !== undefined) body.resolution = opts.resolution; + if (opts.composition !== undefined) body.composition = opts.composition; + if (opts.variables !== undefined) body.variables = opts.variables; + if (opts.title !== undefined) body.title = opts.title; + if (opts.callbackUrl !== undefined) body.callback_url = opts.callbackUrl; + if (opts.callbackId !== undefined) body.callback_id = opts.callbackId; + return body; +} + +// --------------------------------------------------------------------------- +// Poll + progress +// --------------------------------------------------------------------------- + +// fallow-ignore-next-line complexity +async function pollWithProgress( + client: HyperframesCloudClient, + renderId: string, + asJson: boolean, + poll: { intervalMs: number; maxWaitMs: number }, +): Promise { + // ANSI carriage-return redraws only make sense on a TTY. CI logs and + // file redirects get one append per status change instead, and JSON + // mode stays silent altogether. + const interactive = !asJson && process.stdout.isTTY === true; + let lastStatus = ""; + try { + return await pollUntilTerminal(client, renderId, { + intervalMs: poll.intervalMs, + maxWaitMs: poll.maxWaitMs, + // fallow-ignore-next-line complexity + onTick: (detail, elapsedMs) => { + if (asJson) return; + if (interactive) { + if (detail.status === lastStatus) { + process.stdout.write(`\r\x1b[2K ${formatTickLine(detail, elapsedMs)}`); + } else { + if (lastStatus) process.stdout.write("\n"); + process.stdout.write(` ${formatTickLine(detail, elapsedMs)}`); + lastStatus = detail.status; + } + } else if (detail.status !== lastStatus) { + // Non-TTY: one line per status transition, no carriage returns. + console.log(` ${formatTickLine(detail, elapsedMs)}`); + lastStatus = detail.status; + } + }, + }); + } catch (err) { + if (!asJson && lastStatus && interactive) process.stdout.write("\n"); + if (err instanceof PollTimeoutError) { + errorBox( + "Poll timed out", + err.message, + `The render may still complete. Resume with: hyperframes cloud get ${renderId}`, + ); + process.exit(1); + } + return reportApiError("API error during poll", err, { + suggestion: `The render may still be running. Resume with: hyperframes cloud get ${renderId}`, + }); + } finally { + if (!asJson && lastStatus && interactive) process.stdout.write("\n"); + } +} + +function formatTickLine(detail: HyperframesRenderDetail, elapsedMs: number): string { + const status = colorStatus(detail.status); + return `${status} ${c.dim(formatDuration(elapsedMs))}`; +} + +// --------------------------------------------------------------------------- +// Terminal handlers +// --------------------------------------------------------------------------- + +function handleFailedRender(detail: HyperframesRenderDetail, asJson: boolean): never { + if (asJson) { + console.log(JSON.stringify(withMeta({ render: detail }), null, 2)); + process.exit(1); + } + errorBox( + "Render failed", + detail.failure_message ?? "(no failure_message returned)", + `Inspect: hyperframes cloud get ${detail.render_id}`, + ); + process.exit(1); +} + +function resolveOutputPath(output: string | undefined, renderId: string, format: string): string { + if (output) { + return isAbsolute(output) ? output : resolvePath(process.cwd(), output); + } + const ext = FORMAT_EXT[format] ?? `.${format}`; + return resolvePath(process.cwd(), "renders", `${renderId}${ext}`); +} + +// fallow-ignore-next-line complexity +async function streamVideo( + url: string, + destPath: string, + asJson: boolean, +): Promise<{ bytes: number }> { + // `downloadToFile` already creates the parent directory and cleans + // up the partial file on error — no pre-mkdir needed here. + if (!asJson) { + console.log(""); + console.log(`${c.accent("◆")} Downloading to ${c.accent(destPath)}`); + } + try { + const result = await downloadToFile(url, destPath); + if (!asJson) { + console.log(c.dim(` ${formatBytes(result.bytes)} written`)); + } + return { bytes: result.bytes }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + errorBox( + "Download failed", + message, + "The presigned URL is short-lived; re-fetch with `hyperframes cloud get`.", + ); + process.exit(1); + } +} diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index c9e22450d..4a0330031 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -53,7 +53,10 @@ const GROUPS: Group[] = [ }, { title: "Deploy", - commands: [["lambda", "Deploy and drive distributed renders on AWS Lambda"]], + commands: [ + ["cloud", "Render compositions on HeyGen's cloud (no local Chrome/ffmpeg)"], + ["lambda", "Deploy and drive distributed renders on AWS Lambda"], + ], }, { title: "AI & Integrations", @@ -97,12 +100,32 @@ const ROOT_EXAMPLES: Example[] = [ // ── 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 { +// +// For nested subverbs (e.g. `cloud render`), try the parent-scoped path +// first (`commands/cloud/render.js`) so we don't collide with the +// top-level command of the same name (`commands/render.js`). +// fallow-ignore-next-line complexity +async function loadExamples(name: string, parentName?: string): Promise { + // Skip the parent-scoped lookup for the root command — `parentName` + // is `'hyperframes'` for every top-level subcommand and no + // `./commands/hyperframes/.js` directory will ever exist. + if (parentName && parentName !== "hyperframes") { + const examples = await tryLoadExamples(`./commands/${parentName}/${name}.js`); + if (examples) return examples; + } + return await tryLoadExamples(`./commands/${name}.js`); +} + +async function tryLoadExamples(modulePath: string): Promise { try { - const mod = await import(`./commands/${name}.js`); + const mod = await import(modulePath); return mod.examples; - } catch { - return undefined; + } catch (err) { + // Only swallow "file doesn't exist" — re-throw real load errors + // (syntax error, broken import, init-time throw) so a developer + // sees the diagnostic instead of getting silently wrong help. + if ((err as NodeJS.ErrnoException).code === "ERR_MODULE_NOT_FOUND") return undefined; + throw err; } } @@ -156,6 +179,7 @@ function formatExamples(examples: Example[]): string { } // ── Main showUsage override ──────────────────────────────────────────────── +// fallow-ignore-next-line complexity export async function showUsage(cmd: CommandDef, parent?: CommandDef): Promise { if (!parent) { console.log(renderRootHelp() + "\n"); @@ -168,7 +192,9 @@ export async function showUsage(cmd: CommandDef, parent?: CommandDef): Promise