packages: publish as @hanzo/frame and talk to api.hanzo.ai

The npm scope becomes @hanzo: the CLI is @hanzo/frame, every library is
@hanzo/frame-<name>. Versions go to 0.7.82.

Credentials come from Hanzo IAM. `hanzo login` runs the IAM device flow and
writes ~/.hanzo/credentials.json; unattended runs read HANZO_API_KEY delivered
from Hanzo KMS. src/api.ts is the one place that knows the base URL and the
credential, replacing the OAuth client, PKCE flow, loopback server, token
store, and ~/.heygen credential file. `frames auth` keeps only `status`, since
this CLI no longer issues credentials.

Publish and feedback post to api.hanzo.ai/v1 without the HeyGen routing header.
The HeyGen-hosted `frames cloud` render target is gone; local, AWS Lambda, and
Google Cloud Run remain. Voice resolves to Hanzo speech (POST /v1/audio/speech)
before local Kokoro; music resolves to local MusicGen.

No analytics project is configured, so the CLI and studio send no events —
they previously reported to HeyGen's. Drops the HeyGen wordmark from the studio
header and the HeyGen promo render fixture with its brand assets and fonts.
This commit is contained in:
zooqueen
2026-08-13 17:21:08 -07:00
parent ae80da5998
commit 375d1e2816
709 changed files with 1669 additions and 11821 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frames",
"description": "Frames by Hanzo. Write HTML, render video. Compositions, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for Frames.",
"version": "0.7.81",
"version": "0.7.82",
"author": {
"name": "Hanzo AI",
"email": "[email protected]",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frames",
"description": "Write HTML, render video. Compositions, Tailwind v4 styles, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for Frames.",
"version": "0.7.81",
"version": "0.7.82",
"author": {
"name": "Hanzo AI",
"email": "[email protected]",
+1 -1
View File
@@ -3,7 +3,7 @@
"name": "frames",
"displayName": "Frames by Hanzo",
"description": "Write HTML, render video. Compositions, Tailwind v4 styles, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for Frames.",
"version": "0.7.81",
"version": "0.7.82",
"author": {
"name": "Hanzo AI",
"email": "[email protected]"
+11 -11
View File
@@ -23,9 +23,9 @@ bun run format:check # Check formatting
### Running Tests
```bash
bun run --filter @frames/core test # Core unit tests (vitest)
bun run --filter @frames/engine test # Engine unit tests (vitest)
bun run --filter @frames/core test:hyperframe-runtime-ci # Runtime contract tests
bun run --filter @hanzo/frame-core test # Core unit tests (vitest)
bun run --filter @hanzo/frame-engine test # Engine unit tests (vitest)
bun run --filter @hanzo/frame-core test:hyperframe-runtime-ci # Runtime contract tests
```
### Linting & Formatting
@@ -124,13 +124,13 @@ The script wipes `docs/catalog/` before regenerating, so deleted items are autom
## Packages
| Package | Description |
| ------------------ | ------------------------------------------- |
| `@frames/core` | Types, HTML generation, runtime, linter |
| `@frames/engine` | Seekable page-to-video capture engine |
| `@frames/producer` | Full rendering pipeline (capture + encode) |
| `@frames/studio` | Composition editor UI |
| `frames` | CLI for creating, previewing, and rendering |
| Package | Description |
| ----------------------- | ------------------------------------------- |
| `@hanzo/frame-core` | Types, HTML generation, runtime, linter |
| `@hanzo/frame-engine` | Seekable page-to-video capture engine |
| `@hanzo/frame-producer` | Full rendering pipeline (capture + encode) |
| `@hanzo/frame-studio` | Composition editor UI |
| `@hanzo/frame` | CLI for creating, previewing, and rendering |
## Releasing (Maintainers)
@@ -164,7 +164,7 @@ bun run set-version 0.2.0-rc.1 # release candidate (--tag rc)
bun run set-version 0.2.0 # final stable release (--tag latest)
```
Consumers install pre-releases with `npm install @frames/core@alpha` (or `@beta`, `@rc`). The `latest` tag is never touched by pre-releases, so `npm install @frames/core` always gets the last stable version.
Consumers install pre-releases with `npm install @hanzo/frame-core@alpha` (or `@beta`, `@rc`). The `latest` tag is never touched by pre-releases, so `npm install @hanzo/frame-core` always gets the last stable version.
Pre-releases also create GitHub Releases marked as **pre-release**.
+10 -10
View File
@@ -237,16 +237,16 @@ Full documentation: [frames.hanzo.ai/introduction](https://frames.hanzo.ai/intro
## Packages
| Package | Description |
| ----------------------------------------------------------- | ----------------------------------------------------------------- |
| [`@hanzo/frame`](packages/cli) | CLI for creating, previewing, linting, and rendering compositions |
| [`@frames/core`](packages/core) | Types, parsers, generators, linter, runtime, and frame adapters |
| [`@frames/engine`](packages/engine) | Seekable page-to-video capture engine using Puppeteer and FFmpeg |
| [`@frames/producer`](packages/producer) | Full rendering pipeline for capture, encode, and audio mix |
| [`@frames/studio`](packages/studio) | Browser-based composition editor UI |
| [`@frames/player`](packages/player) | Embeddable `<frames-player>` web component |
| [`@frames/shader-transitions`](packages/shader-transitions) | WebGL shader transitions for compositions |
| [`@frames/aws-lambda`](packages/aws-lambda) | AWS Lambda SDK and deployment surface for distributed renders |
| Package | Description |
| ---------------------------------------------------------------- | ----------------------------------------------------------------- |
| [`@hanzo/frame`](packages/cli) | CLI for creating, previewing, linting, and rendering compositions |
| [`@hanzo/frame-core`](packages/core) | Types, parsers, generators, linter, runtime, and frame adapters |
| [`@hanzo/frame-engine`](packages/engine) | Seekable page-to-video capture engine using Puppeteer and FFmpeg |
| [`@hanzo/frame-producer`](packages/producer) | Full rendering pipeline for capture, encode, and audio mix |
| [`@hanzo/frame-studio`](packages/studio) | Browser-based composition editor UI |
| [`@hanzo/frame-player`](packages/player) | Embeddable `<frames-player>` web component |
| [`@hanzo/frame-shader-transitions`](packages/shader-transitions) | WebGL shader transitions for compositions |
| [`@hanzo/frame-aws-lambda`](packages/aws-lambda) | AWS Lambda SDK and deployment surface for distributed renders |
## Community
+1 -1
View File
@@ -23,4 +23,4 @@ We will acknowledge receipt within 48 hours and aim to provide a fix or mitigati
## Scope
This policy applies to all packages in the `@frames/*` npm scope and the code in this repository.
This policy applies to the `@hanzo/frame` packages and the code in this repository.
+66 -66
View File
@@ -7,7 +7,7 @@
"devDependencies": {
"@commitlint/cli": "^20.5.0",
"@commitlint/config-conventional": "^20.5.0",
"@frames/player": "workspace:*",
"@hanzo/frame-player": "workspace:*",
"@types/node": "^25.0.10",
"concurrently": "^8.2.0",
"fallow": "^2.75.0",
@@ -22,12 +22,12 @@
},
},
"packages/aws-lambda": {
"name": "@frames/aws-lambda",
"name": "@hanzo/frame-aws-lambda",
"version": "0.7.81",
"dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/client-sfn": "^3.700.0",
"@frames/producer": "workspace:^",
"@hanzo/frame-producer": "workspace:^",
"@sparticuz/chromium": "148.0.0",
"ffmpeg-static": "^5.2.0",
"ffprobe-static": "^3.1.0",
@@ -55,7 +55,7 @@
],
},
"packages/cli": {
"name": "@frames/cli",
"name": "@hanzo/frame",
"version": "0.7.81",
"bin": {
"frames": "./bin/frames.mjs",
@@ -81,15 +81,15 @@
},
"devDependencies": {
"@clack/prompts": "^1.1.0",
"@frames/aws-lambda": "workspace:*",
"@frames/core": "workspace:*",
"@frames/engine": "workspace:*",
"@frames/gcp-cloud-run": "workspace:*",
"@frames/lint": "workspace:*",
"@frames/parsers": "workspace:*",
"@frames/producer": "workspace:*",
"@frames/studio": "workspace:*",
"@frames/studio-server": "workspace:*",
"@hanzo/frame-aws-lambda": "workspace:*",
"@hanzo/frame-core": "workspace:*",
"@hanzo/frame-engine": "workspace:*",
"@hanzo/frame-gcp-cloud-run": "workspace:*",
"@hanzo/frame-lint": "workspace:*",
"@hanzo/frame-parsers": "workspace:*",
"@hanzo/frame-producer": "workspace:*",
"@hanzo/frame-studio": "workspace:*",
"@hanzo/frame-studio-server": "workspace:*",
"@types/fontkit": "^2.0.9",
"@types/mime-types": "^3.0.1",
"@types/node": "^25.0.10",
@@ -105,13 +105,13 @@
},
},
"packages/core": {
"name": "@frames/core",
"name": "@hanzo/frame-core",
"version": "0.7.81",
"dependencies": {
"@chenglou/pretext": "^0.0.5",
"@frames/lint": "workspace:*",
"@frames/parsers": "workspace:*",
"@frames/studio-server": "workspace:*",
"@hanzo/frame-lint": "workspace:*",
"@hanzo/frame-parsers": "workspace:*",
"@hanzo/frame-studio-server": "workspace:*",
"bpm-detective": "^2.0.5",
"linkedom": "^0.18.12",
"postcss": "^8.5.8",
@@ -130,11 +130,11 @@
},
},
"packages/engine": {
"name": "@frames/engine",
"name": "@hanzo/frame-engine",
"version": "0.7.81",
"dependencies": {
"@frames/core": "workspace:^",
"@frames/parsers": "workspace:^",
"@hanzo/frame-core": "workspace:^",
"@hanzo/frame-parsers": "workspace:^",
"@hono/node-server": "^2.0.5",
"hono": "^4.6.0",
"linkedom": "^0.18.12",
@@ -149,10 +149,10 @@
},
},
"packages/gcp-cloud-run": {
"name": "@frames/gcp-cloud-run",
"name": "@hanzo/frame-gcp-cloud-run",
"version": "0.7.81",
"dependencies": {
"@frames/producer": "workspace:^",
"@hanzo/frame-producer": "workspace:^",
"@google-cloud/storage": "^7.14.0",
"@google-cloud/workflows": "^4.2.0",
"@hono/node-server": "^2.0.5",
@@ -170,10 +170,10 @@
},
},
"packages/lint": {
"name": "@frames/lint",
"name": "@hanzo/frame-lint",
"version": "0.7.81",
"dependencies": {
"@frames/parsers": "workspace:*",
"@hanzo/frame-parsers": "workspace:*",
"htmlparser2": "^10.1.0",
"linkedom": "^0.18.12",
"postcss": "^8.5.8",
@@ -188,7 +188,7 @@
},
},
"packages/parsers": {
"name": "@frames/parsers",
"name": "@hanzo/frame-parsers",
"version": "0.7.81",
"dependencies": {
"@babel/parser": "^7.27.0",
@@ -199,7 +199,7 @@
"recast": "^0.23.11",
},
"devDependencies": {
"@frames/core": "workspace:*",
"@hanzo/frame-core": "workspace:*",
"@types/node": "^25.0.10",
"tsup": "^8.0.0",
"tsx": "^4.21.0",
@@ -208,10 +208,10 @@
},
},
"packages/player": {
"name": "@frames/player",
"name": "@hanzo/frame-player",
"version": "0.7.81",
"dependencies": {
"@frames/core": "workspace:*",
"@hanzo/frame-core": "workspace:*",
},
"devDependencies": {
"@types/bun": "^1.1.0",
@@ -223,7 +223,7 @@
},
},
"packages/producer": {
"name": "@frames/producer",
"name": "@hanzo/frame-producer",
"version": "0.7.81",
"dependencies": {
"@fontsource/archivo-black": "^5.2.8",
@@ -237,11 +237,11 @@
"@fontsource/oswald": "^5.2.8",
"@fontsource/outfit": "^5.2.8",
"@fontsource/space-mono": "^5.2.9",
"@frames/core": "workspace:^",
"@frames/engine": "workspace:^",
"@frames/lint": "workspace:^",
"@frames/parsers": "workspace:^",
"@frames/studio-server": "workspace:^",
"@hanzo/frame-core": "workspace:^",
"@hanzo/frame-engine": "workspace:^",
"@hanzo/frame-lint": "workspace:^",
"@hanzo/frame-parsers": "workspace:^",
"@hanzo/frame-studio-server": "workspace:^",
"@hono/node-server": "^2.0.5",
"hono": "^4.6.0",
"linkedom": "^0.18.12",
@@ -258,7 +258,7 @@
"@fontsource/poppins": "^5.2.7",
"@fontsource/roboto": "^5.2.10",
"@fontsource/source-code-pro": "^5.2.7",
"@frames/aws-lambda": "workspace:*",
"@hanzo/frame-aws-lambda": "workspace:*",
"@types/node": "^25.0.10",
"@webgpu/types": "^0.1.69",
"esbuild": "^0.25.12",
@@ -268,11 +268,11 @@
},
},
"packages/sdk": {
"name": "@frames/sdk",
"name": "@hanzo/frame-sdk",
"version": "0.7.81",
"dependencies": {
"@frames/core": "workspace:*",
"@frames/parsers": "workspace:*",
"@hanzo/frame-core": "workspace:*",
"@hanzo/frame-parsers": "workspace:*",
"linkedom": "^0.18.12",
},
"devDependencies": {
@@ -283,11 +283,11 @@
},
},
"packages/sdk-playground": {
"name": "@frames/sdk-playground",
"name": "@hanzo/frame-sdk-playground",
"version": "0.6.106",
"dependencies": {
"@frames/core": "workspace:*",
"@frames/sdk": "workspace:*",
"@hanzo/frame-core": "workspace:*",
"@hanzo/frame-sdk": "workspace:*",
"gsap": "^3.15.0",
},
"devDependencies": {
@@ -298,7 +298,7 @@
},
},
"packages/shader-transitions": {
"name": "@frames/shader-transitions",
"name": "@hanzo/frame-shader-transitions",
"version": "0.7.81",
"dependencies": {
"html2canvas": "^1.4.1",
@@ -310,7 +310,7 @@
},
},
"packages/studio": {
"name": "@frames/studio",
"name": "@hanzo/frame-studio",
"version": "0.7.81",
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
@@ -324,11 +324,11 @@
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "6.40.0",
"@frames/core": "workspace:*",
"@frames/parsers": "workspace:*",
"@frames/player": "workspace:*",
"@frames/sdk": "workspace:*",
"@frames/studio-server": "workspace:*",
"@hanzo/frame-core": "workspace:*",
"@hanzo/frame-parsers": "workspace:*",
"@hanzo/frame-player": "workspace:*",
"@hanzo/frame-sdk": "workspace:*",
"@hanzo/frame-studio-server": "workspace:*",
"@phosphor-icons/react": "^2.1.10",
"bpm-detective": "^2.0.5",
"dompurify": "^3.2.4",
@@ -337,7 +337,7 @@
"mediabunny": "^1.45.3",
},
"devDependencies": {
"@frames/producer": "workspace:*",
"@hanzo/frame-producer": "workspace:*",
"@types/react": "19",
"@types/react-dom": "19",
"@vitejs/plugin-react": "^4.0.0",
@@ -358,11 +358,11 @@
},
},
"packages/studio-server": {
"name": "@frames/studio-server",
"name": "@hanzo/frame-studio-server",
"version": "0.7.81",
"dependencies": {
"@frames/core": "workspace:*",
"@frames/parsers": "workspace:*",
"@hanzo/frame-core": "workspace:*",
"@hanzo/frame-parsers": "workspace:*",
"hono": "^4.0.0",
"linkedom": "^0.18.12",
"postcss": "^8.5.8",
@@ -706,33 +706,33 @@
"@fontsource/space-mono": ["@fontsource/[email protected]", "", {}, "sha512-b61faFOHEISQ/pD25G+cfGY9o/WW6lRv6hBQQfpWvEJ4y1V+S4gmth95EVyBE2VL3qDYHeVQ8nBzrplzdXTDDg=="],
"@frames/aws-lambda": ["@frames/aws-lambda@workspace:packages/aws-lambda"],
"@hanzo/frame-aws-lambda": ["@hanzo/frame-aws-lambda@workspace:packages/aws-lambda"],
"@frames/cli": ["@frames/cli@workspace:packages/cli"],
"@hanzo/frame": ["@hanzo/frame@workspace:packages/cli"],
"@frames/core": ["@frames/core@workspace:packages/core"],
"@hanzo/frame-core": ["@hanzo/frame-core@workspace:packages/core"],
"@frames/engine": ["@frames/engine@workspace:packages/engine"],
"@hanzo/frame-engine": ["@hanzo/frame-engine@workspace:packages/engine"],
"@frames/gcp-cloud-run": ["@frames/gcp-cloud-run@workspace:packages/gcp-cloud-run"],
"@hanzo/frame-gcp-cloud-run": ["@hanzo/frame-gcp-cloud-run@workspace:packages/gcp-cloud-run"],
"@frames/lint": ["@frames/lint@workspace:packages/lint"],
"@hanzo/frame-lint": ["@hanzo/frame-lint@workspace:packages/lint"],
"@frames/parsers": ["@frames/parsers@workspace:packages/parsers"],
"@hanzo/frame-parsers": ["@hanzo/frame-parsers@workspace:packages/parsers"],
"@frames/player": ["@frames/player@workspace:packages/player"],
"@hanzo/frame-player": ["@hanzo/frame-player@workspace:packages/player"],
"@frames/producer": ["@frames/producer@workspace:packages/producer"],
"@hanzo/frame-producer": ["@hanzo/frame-producer@workspace:packages/producer"],
"@frames/sdk": ["@frames/sdk@workspace:packages/sdk"],
"@hanzo/frame-sdk": ["@hanzo/frame-sdk@workspace:packages/sdk"],
"@frames/sdk-playground": ["@frames/sdk-playground@workspace:packages/sdk-playground"],
"@hanzo/frame-sdk-playground": ["@hanzo/frame-sdk-playground@workspace:packages/sdk-playground"],
"@frames/shader-transitions": ["@frames/shader-transitions@workspace:packages/shader-transitions"],
"@hanzo/frame-shader-transitions": ["@hanzo/frame-shader-transitions@workspace:packages/shader-transitions"],
"@frames/studio": ["@frames/studio@workspace:packages/studio"],
"@hanzo/frame-studio": ["@hanzo/frame-studio@workspace:packages/studio"],
"@frames/studio-server": ["@frames/studio-server@workspace:packages/studio-server"],
"@hanzo/frame-studio-server": ["@hanzo/frame-studio-server@workspace:packages/studio-server"],
"@google-cloud/paginator": ["@google-cloud/[email protected]", "", { "dependencies": { "arrify": "^2.0.0", "extend": "^3.0.2" } }, "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg=="],
+7 -7
View File
@@ -11,11 +11,11 @@
"type": "module",
"scripts": {
"dev": "bun run studio",
"build": "bun run --filter '@frames/{parsers,lint,studio-server}' build && bun run --filter @frames/core build && bun run --filter '@frames/{core,engine,producer,player,studio,shader-transitions,aws-lambda,gcp-cloud-run,sdk}' build && bun run --filter @frames/cli build && bun run --filter @frames/sdk-playground build",
"build:producer": "bun run --filter @frames/producer build",
"studio": "bun run --filter @frames/studio dev",
"build:frames-runtime": "bun run --filter @frames/core build:frames-runtime",
"build:frames-runtime:modular": "bun run --filter @frames/core build:frames-runtime:modular",
"build": "bun run --filter '@hanzo/{frame-parsers,frame-lint,frame-studio-server}' build && bun run --filter @hanzo/frame-core build && bun run --filter '@hanzo/{frame-core,frame-engine,frame-producer,frame-player,frame-studio,frame-shader-transitions,frame-aws-lambda,frame-gcp-cloud-run,frame-sdk}' build && bun run --filter @hanzo/frame build && bun run --filter @hanzo/frame-sdk-playground build",
"build:producer": "bun run --filter @hanzo/frame-producer build",
"studio": "bun run --filter @hanzo/frame-studio dev",
"build:frames-runtime": "bun run --filter @hanzo/frame-core build:frames-runtime",
"build:frames-runtime:modular": "bun run --filter @hanzo/frame-core build:frames-runtime:modular",
"verify:packed-manifests": "node scripts/verify-packed-manifests.mjs",
"validate:release-channel": "node scripts/validate-release-channel.mjs",
"set-version": "tsx scripts/set-version.ts",
@@ -44,7 +44,7 @@
"producer:test:unit:vitest": "bun run --cwd packages/producer test:unit:vitest",
"producer:test:integration": "bun run --cwd packages/producer test:integration",
"test:regression": "bun run --cwd packages/producer test:regression",
"player:perf": "bun run --filter @frames/player perf",
"player:perf": "bun run --filter @hanzo/frame-player perf",
"format:check": "oxfmt --check .",
"knip": "knip",
"test:scripts": "node --import tsx --test scripts/check-tracked-artifacts.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs",
@@ -58,7 +58,7 @@
"devDependencies": {
"@commitlint/cli": "^20.5.0",
"@commitlint/config-conventional": "^20.5.0",
"@frames/player": "workspace:*",
"@hanzo/frame-player": "workspace:*",
"@types/node": "^25.0.10",
"concurrently": "^8.2.0",
"fallow": "^2.75.0",
+7 -7
View File
@@ -1,4 +1,4 @@
# @frames/aws-lambda
# @hanzo/frame-aws-lambda
AWS Lambda adapter for Frames distributed rendering. Ships three
things together:
@@ -28,9 +28,9 @@ smoke flow; the SDK + CDK are the supported public surface for adopters.
┌──────────────────────────────────────────────────────────────────┐
│ One Lambda function (this package's `dist/handler.zip`) │
│ handler.mjs │
│ ├─ Action="plan" → @frames/producer/distributed │
│ ├─ Action="renderChunk" → @frames/producer/distributed │
│ └─ Action="assemble" → @frames/producer/distributed │
│ ├─ Action="plan" → @hanzo/frame-producer/distributed │
│ ├─ Action="renderChunk" → @hanzo/frame-producer/distributed │
│ └─ Action="assemble" → @hanzo/frame-producer/distributed │
│ bin/ffmpeg — ffmpeg-static │
│ node_modules/@sparticuz/chromium/ — Lambda-optimised Chromium │
└──────────────────────────────────────────────────────────────────┘
@@ -136,7 +136,7 @@ After deploying the stack (via the SAM template, CDK construct below, or
your own CFN of choice), drive renders from Node:
```ts
import { deploySite, getRenderProgress, renderToLambda } from "@frames/aws-lambda";
import { deploySite, getRenderProgress, renderToLambda } from "@hanzo/frame-aws-lambda";
// One-time upload per project version.
const site = await deploySite({
@@ -184,7 +184,7 @@ transfer.
```ts
import { App, Stack } from "aws-cdk-lib";
import { HyperframesRenderStack } from "@frames/aws-lambda/cdk";
import { HyperframesRenderStack } from "@hanzo/frame-aws-lambda/cdk";
const app = new App();
const stack = new Stack(app, "MyApp");
@@ -201,7 +201,7 @@ new CfnOutput(stack, "StateMachineArn", { value: render.stateMachine.stateMachin
`aws-cdk-lib` and `constructs` are **optional peer dependencies**: SDK-only
consumers don't pull them at runtime. The construct itself imports from
`@frames/aws-lambda/cdk`.
`@hanzo/frame-aws-lambda/cdk`.
## What's still ahead
+4 -4
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* Build script for @frames/aws-lambda (public OSS package).
* Build script for @hanzo/frame-aws-lambda (public OSS package).
*
* Bundles each subpath barrel via esbuild → dist/, then emits .d.ts via tsc.
*
@@ -33,8 +33,8 @@ const sharedOpts = {
external: [
"@aws-sdk/client-s3",
"@aws-sdk/client-sfn",
"@frames/producer",
"@frames/producer/distributed",
"@hanzo/frame-producer",
"@hanzo/frame-producer/distributed",
"@sparticuz/chromium",
"aws-cdk-lib",
"constructs",
@@ -53,7 +53,7 @@ await Promise.all([
]);
// esbuild doesn't emit .d.ts. tsc does, with a build-only tsconfig that
// drops the workspace `paths` overrides so `@frames/producer` resolves
// drops the workspace `paths` overrides so `@hanzo/frame-producer` resolves
// through node_modules to the sibling package's already-built `dist/`
// types instead of pulling its full source tree into emit (which would
// violate rootDir).
+1 -1
View File
@@ -1,5 +1,5 @@
{
"package": "@frames/aws-lambda",
"package": "@hanzo/frame-aws-lambda",
"subpaths": {
".": {
"source": "./src/index.ts",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@frames/aws-lambda",
"version": "0.7.81",
"name": "@hanzo/frame-aws-lambda",
"version": "0.7.82",
"description": "AWS Lambda adapter for Frames distributed rendering — handler, client-side SDK, and CDK construct.",
"repository": {
"type": "git",
@@ -71,7 +71,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/client-sfn": "^3.700.0",
"@frames/producer": "workspace:^",
"@hanzo/frame-producer": "workspace:^",
"@sparticuz/chromium": "148.0.0",
"ffmpeg-static": "^5.2.0",
"ffprobe-static": "^3.1.0",
+8 -8
View File
@@ -190,25 +190,25 @@ async function bundleHandler(stagingDir: string): Promise<void> {
const workspaceAliasPlugin: esbuild.Plugin = {
name: "workspace-alias",
setup(build) {
build.onResolve({ filter: /^@frames\/producer\/distributed$/ }, () => ({
build.onResolve({ filter: /^@hanzo\/frame-producer\/distributed$/ }, () => ({
path: resolve(monorepoRoot, "packages/producer/src/distributed.ts"),
}));
build.onResolve({ filter: /^@frames\/producer$/ }, () => ({
build.onResolve({ filter: /^@hanzo\/frame-producer$/ }, () => ({
path: resolve(monorepoRoot, "packages/producer/src/index.ts"),
}));
build.onResolve({ filter: /^@frames\/engine$/ }, () => ({
build.onResolve({ filter: /^@hanzo\/frame-engine$/ }, () => ({
path: resolve(monorepoRoot, "packages/engine/src/index.ts"),
}));
build.onResolve({ filter: /^@frames\/engine\/alpha-blit$/ }, () => ({
build.onResolve({ filter: /^@hanzo\/frame-engine\/alpha-blit$/ }, () => ({
path: resolve(monorepoRoot, "packages/engine/src/utils/alphaBlit.ts"),
}));
build.onResolve({ filter: /^@frames\/engine\/shader-transitions$/ }, () => ({
build.onResolve({ filter: /^@hanzo\/frame-engine\/shader-transitions$/ }, () => ({
path: resolve(monorepoRoot, "packages/engine/src/utils/shaderTransitions.ts"),
}));
build.onResolve({ filter: /^@frames\/core$/ }, () => ({
build.onResolve({ filter: /^@hanzo\/frame-core$/ }, () => ({
path: resolve(monorepoRoot, "packages/core/src/index.ts"),
}));
build.onResolve({ filter: /^@frames\/core\/lint$/ }, () => ({
build.onResolve({ filter: /^@hanzo\/frame-core\/lint$/ }, () => ({
path: resolve(monorepoRoot, "packages/core/src/lint/index.ts"),
}));
},
@@ -335,7 +335,7 @@ function stageHyperframeRuntime(stagingDir: string): void {
if (!existsSync(manifestSrc) || !existsSync(iifeSrc)) {
throw new Error(
`[build-zip] hyperframe runtime artifacts missing under ${coreDist}. ` +
`Run 'bun run --filter @frames/core build:frames-runtime:modular' first.`,
`Run 'bun run --filter @hanzo/frame-core build:frames-runtime:modular' first.`,
);
}
cpSync(manifestSrc, join(stagingDir, "hyperframe.manifest.json"));
@@ -11,7 +11,7 @@
* `aws-cdk-lib` and `constructs` are **peerDependencies**. The package
* still type-checks (and the snapshot test still runs) because they're
* also `devDependencies`, but adopters who only consume the SDK side of
* `@frames/aws-lambda` don't pull the CDK tree at runtime.
* `@hanzo/frame-aws-lambda` don't pull the CDK tree at runtime.
*
* Drift from the SAM template is guarded by the snapshot test
* (`HyperframesRenderStack.snapshot.test.ts`), which diffs the synthed
@@ -487,7 +487,7 @@ export class HyperframesRenderStack extends Construct {
* to `packages/aws-lambda/dist/handler.zip`. The package is published with
* `main: "./src/index.ts"`, so this path resolves correctly both in the
* source tree (during `bun test` / local CDK synth) and in a consumer's
* `node_modules/@frames/aws-lambda/` install.
* `node_modules/@hanzo/frame-aws-lambda/` install.
*/
function defaultHandlerZipPath(): string {
const here = dirname(fileURLToPath(import.meta.url));
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* CDK subpath export — `@frames/aws-lambda/cdk`.
* CDK subpath export — `@hanzo/frame-aws-lambda/cdk`.
*
* Pulled into its own subpath so SDK-only consumers don't import
* `aws-cdk-lib`. The construct itself depends on `aws-cdk-lib` and
+2 -2
View File
@@ -19,9 +19,9 @@
import type {
DistributedFormat,
SerializableDistributedRenderConfig,
} from "@frames/producer/distributed";
} from "@hanzo/frame-producer/distributed";
export type { SerializableDistributedRenderConfig } from "@frames/producer/distributed";
export type { SerializableDistributedRenderConfig } from "@hanzo/frame-producer/distributed";
/** Discriminator for the three roles the one Lambda image fulfills. */
export type LambdaAction = "plan" | "renderChunk" | "assemble";
+2 -2
View File
@@ -6,9 +6,9 @@
* looks like vs a png-sequence.
*/
import type { DistributedFormat } from "@frames/producer/distributed";
import type { DistributedFormat } from "@hanzo/frame-producer/distributed";
export type { DistributedFormat } from "@frames/producer/distributed";
export type { DistributedFormat } from "@hanzo/frame-producer/distributed";
// Closed-enum lookup table. TS enforces exhaustiveness via the
// `Record<DistributedFormat, string>` annotation — adding a format to
+24 -24
View File
@@ -10,7 +10,7 @@
* - It plumbs S3 download/upload calls in the correct order.
*
* The real OSS primitives are NOT exercised here — they live in
* `@frames/producer/distributed` and have their own coverage in
* `@hanzo/frame-producer/distributed` and have their own coverage in
* `packages/producer`. The Lambda handler is thin glue; this file pins
* the glue's contract.
*/
@@ -29,7 +29,7 @@ import {
type PlanV2ArtifactPublisher,
type PlanV2Manifest,
publishPlanV2FromV1,
} from "@frames/producer/distributed";
} from "@hanzo/frame-producer/distributed";
import { recomputePlanHashFromPlanDir } from "../../producer/src/services/render/stages/freezePlan.js";
import type { AssembleEvent, LambdaEvent, PlanEvent, RenderChunkEvent } from "./events.js";
import { handler, unwrapEvent } from "./handler.js";
@@ -221,11 +221,11 @@ describe("handler dispatch", () => {
const result = await handler(event, {
s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client,
primitives: {
plan: planMock as unknown as typeof import("@frames/producer/distributed").plan,
plan: planMock as unknown as typeof import("@hanzo/frame-producer/distributed").plan,
renderChunk:
renderChunkMock as unknown as typeof import("@frames/producer/distributed").renderChunk,
renderChunkMock as unknown as typeof import("@hanzo/frame-producer/distributed").renderChunk,
assemble:
assembleMock as unknown as typeof import("@frames/producer/distributed").assemble,
assembleMock as unknown as typeof import("@hanzo/frame-producer/distributed").assemble,
},
tmpRoot,
skipChromeResolution: true,
@@ -279,13 +279,13 @@ describe("handler dispatch", () => {
primitives: {
plan: mock(async () => {
throw terminal;
}) as unknown as typeof import("@frames/producer/distributed").plan,
}) as unknown as typeof import("@hanzo/frame-producer/distributed").plan,
renderChunk: mock(async () => {
throw new Error("unused");
}) as unknown as typeof import("@frames/producer/distributed").renderChunk,
}) as unknown as typeof import("@hanzo/frame-producer/distributed").renderChunk,
assemble: mock(async () => {
throw new Error("unused");
}) as unknown as typeof import("@frames/producer/distributed").assemble,
}) as unknown as typeof import("@hanzo/frame-producer/distributed").assemble,
},
tmpRoot,
skipChromeResolution: true,
@@ -346,11 +346,11 @@ describe("handler dispatch", () => {
await handler(event, {
s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client,
primitives: {
plan: planMock as unknown as typeof import("@frames/producer/distributed").plan,
plan: planMock as unknown as typeof import("@hanzo/frame-producer/distributed").plan,
renderChunk:
renderChunkMock as unknown as typeof import("@frames/producer/distributed").renderChunk,
renderChunkMock as unknown as typeof import("@hanzo/frame-producer/distributed").renderChunk,
assemble:
assembleMock as unknown as typeof import("@frames/producer/distributed").assemble,
assembleMock as unknown as typeof import("@hanzo/frame-producer/distributed").assemble,
},
tmpRoot,
});
@@ -416,11 +416,11 @@ describe("handler dispatch", () => {
const result = await handler(event, {
s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client,
primitives: {
plan: planMock as unknown as typeof import("@frames/producer/distributed").plan,
plan: planMock as unknown as typeof import("@hanzo/frame-producer/distributed").plan,
renderChunk:
renderChunkMock as unknown as typeof import("@frames/producer/distributed").renderChunk,
renderChunkMock as unknown as typeof import("@hanzo/frame-producer/distributed").renderChunk,
assemble:
assembleMock as unknown as typeof import("@frames/producer/distributed").assemble,
assembleMock as unknown as typeof import("@hanzo/frame-producer/distributed").assemble,
},
tmpRoot,
skipChromeResolution: true,
@@ -467,11 +467,11 @@ describe("handler dispatch", () => {
await handler(event, {
s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client,
primitives: {
plan: planMock as unknown as typeof import("@frames/producer/distributed").plan,
plan: planMock as unknown as typeof import("@hanzo/frame-producer/distributed").plan,
renderChunk:
renderChunkMock as unknown as typeof import("@frames/producer/distributed").renderChunk,
renderChunkMock as unknown as typeof import("@hanzo/frame-producer/distributed").renderChunk,
assemble:
assembleMock as unknown as typeof import("@frames/producer/distributed").assemble,
assembleMock as unknown as typeof import("@hanzo/frame-producer/distributed").assemble,
},
tmpRoot,
skipChromeResolution: true,
@@ -523,12 +523,12 @@ describe("handler dispatch", () => {
primitives: {
plan: mock(async () => {
throw new Error("should not be called");
}) as unknown as typeof import("@frames/producer/distributed").plan,
}) as unknown as typeof import("@hanzo/frame-producer/distributed").plan,
renderChunk: mock(async () => {
throw new Error("should not be called");
}) as unknown as typeof import("@frames/producer/distributed").renderChunk,
}) as unknown as typeof import("@hanzo/frame-producer/distributed").renderChunk,
assemble:
assembleMock as unknown as typeof import("@frames/producer/distributed").assemble,
assembleMock as unknown as typeof import("@hanzo/frame-producer/distributed").assemble,
},
tmpRoot,
skipChromeResolution: true,
@@ -599,13 +599,13 @@ describe("handler dispatch", () => {
primitives: {
plan: mock(async () => {
throw new Error("v1 plan should not be called");
}) as unknown as typeof import("@frames/producer/distributed").plan,
}) as unknown as typeof import("@hanzo/frame-producer/distributed").plan,
planV2WithPublisher:
planV2WithPublisherMock as unknown as typeof import("@frames/producer/distributed").planV2WithPublisher,
planV2WithPublisherMock as unknown as typeof import("@hanzo/frame-producer/distributed").planV2WithPublisher,
renderChunk:
renderChunkMock as unknown as typeof import("@frames/producer/distributed").renderChunk,
renderChunkMock as unknown as typeof import("@hanzo/frame-producer/distributed").renderChunk,
assemble:
assembleMock as unknown as typeof import("@frames/producer/distributed").assemble,
assembleMock as unknown as typeof import("@hanzo/frame-producer/distributed").assemble,
},
tmpRoot,
skipChromeResolution: true,
+2 -2
View File
@@ -4,7 +4,7 @@
* One Lambda function, three roles. Step Functions dispatches by setting
* `event.Action`; the handler unwraps Map-state envelopes, primes the
* Lambda environment (Chrome path, ffmpeg path, tmpdir), and forwards to
* the matching OSS primitive from `@frames/producer/distributed`.
* the matching OSS primitive from `@hanzo/frame-producer/distributed`.
*
* Everything heavy — capture, encode, audio mix — happens inside the OSS
* primitives. The handler is thin glue: parse event → S3 download → call
@@ -31,7 +31,7 @@ import {
type PlanV2MaterializationTarget,
readPlanV2Manifest,
renderChunk,
} from "@frames/producer/distributed";
} from "@hanzo/frame-producer/distributed";
import { resolveChromeExecutablePath } from "./chromium.js";
import { type DistributedFormat, formatExtension } from "./formatExtension.js";
import type {
+3 -3
View File
@@ -1,5 +1,5 @@
/**
* `@frames/aws-lambda` — Lambda adapter for the Frames
* `@hanzo/frame-aws-lambda` — Lambda adapter for the Frames
* distributed render pipeline.
*
* Two surfaces, one package:
@@ -16,9 +16,9 @@
* The CDK L2 construct lives at the `./cdk` subpath export so SDK-only
* consumers don't pull `aws-cdk-lib` into their runtime graph:
*
* import { HyperframesRenderStack } from "@frames/aws-lambda/cdk";
* import { HyperframesRenderStack } from "@hanzo/frame-aws-lambda/cdk";
*
* The package is NOT a dependency of `@frames/producer`; consumers
* The package is NOT a dependency of `@hanzo/frame-producer`; consumers
* install it separately.
*/
+1 -1
View File
@@ -7,7 +7,7 @@ import {
PlanV2IntegrityError,
type PlanV2ArtifactPublisher,
type PlanV2PublishBlob,
} from "@frames/producer/distributed";
} from "@hanzo/frame-producer/distributed";
import { parseS3Uri, uploadContentAddressedFileToS3 } from "./s3Transport.js";
export interface S3PlanV2ArtifactPublisherOptions {
+1 -1
View File
@@ -16,7 +16,7 @@ import { mkdtempSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { HeadObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { hashProjectDir } from "@frames/producer/distributed";
import { hashProjectDir } from "@hanzo/frame-producer/distributed";
import { formatS3Uri, tarDirectory, uploadFileToS3 } from "../s3Transport.js";
/** Options for {@link deploySite}. */
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* SDK subpath export — `@frames/aws-lambda/sdk`.
* SDK subpath export — `@hanzo/frame-aws-lambda/sdk`.
*
* Pulled into its own subpath so consumers that only drive Lambda renders
* (CLI, CI scripts, adopter tooling) don't pay the cost of importing
@@ -3,18 +3,18 @@
*
* The cloud-agnostic config-shape validation (`validateDistributedRenderConfig`,
* `validateVariablesPayload`, `InvalidConfigError`) lives in
* `@frames/producer/distributed` and is shared with the other adapters.
* `@hanzo/frame-producer/distributed` and is shared with the other adapters.
* This module re-exports those and adds the one piece specific to Step
* Functions: the 256 KiB Standard-workflow execution-input size cap.
*/
import { InvalidConfigError } from "@frames/producer/distributed";
import { InvalidConfigError } from "@hanzo/frame-producer/distributed";
export {
InvalidConfigError,
validateDistributedRenderConfig,
validateVariablesPayload,
} from "@frames/producer/distributed";
} from "@hanzo/frame-producer/distributed";
/**
* Hard cap on Step Functions Standard workflow execution input — 256 KiB per
+2 -2
View File
@@ -10,8 +10,8 @@
"noEmit": true,
"baseUrl": ".",
"paths": {
"@frames/producer": ["../producer/src/index.ts"],
"@frames/producer/distributed": ["../producer/src/distributed.ts"]
"@hanzo/frame-producer": ["../producer/src/index.ts"],
"@hanzo/frame-producer/distributed": ["../producer/src/distributed.ts"]
}
},
"include": ["src/**/*"],
+9 -7
View File
@@ -1,19 +1,21 @@
# frames
# @hanzo/frame
CLI for creating, previewing, and rendering HTML video compositions.
## Install
```bash
npm install -g frames
npm install -g @hanzo/frame
```
Or use directly with npx:
```bash
npx frames <command>
npx @hanzo/frame <command>
```
Both install the `frames` command.
**Requirements:** Node.js >= 22, FFmpeg
## Commands
@@ -124,7 +126,7 @@ Full documentation: [frames.hanzo.ai/packages/cli](https://frames.hanzo.ai/packa
## Related packages
- [`@frames/core`](../core) — types, parsers, frame adapters
- [`@frames/engine`](../engine) — rendering engine
- [`@frames/producer`](../producer) — render pipeline
- [`@frames/studio`](../studio) — composition editor UI
- [`@hanzo/frame-core`](../core) — types, parsers, frame adapters
- [`@hanzo/frame-engine`](../engine) — rendering engine
- [`@hanzo/frame-producer`](../producer) — render pipeline
- [`@hanzo/frame-studio`](../studio) — composition editor UI
+14 -11
View File
@@ -1,6 +1,6 @@
{
"name": "@frames/cli",
"version": "0.7.81",
"name": "@hanzo/frame",
"version": "0.7.82",
"description": "Frames CLI — create, preview, and render HTML video compositions",
"license": "Apache-2.0",
"repository": {
@@ -16,6 +16,9 @@
"dist"
],
"type": "module",
"publishConfig": {
"access": "public"
},
"scripts": {
"test": "vitest run",
"dev": "tsx src/cli.ts",
@@ -49,15 +52,15 @@
},
"devDependencies": {
"@clack/prompts": "^1.1.0",
"@frames/aws-lambda": "workspace:*",
"@frames/core": "workspace:*",
"@frames/engine": "workspace:*",
"@frames/gcp-cloud-run": "workspace:*",
"@frames/lint": "workspace:*",
"@frames/parsers": "workspace:*",
"@frames/producer": "workspace:*",
"@frames/studio": "workspace:*",
"@frames/studio-server": "workspace:*",
"@hanzo/frame-aws-lambda": "workspace:*",
"@hanzo/frame-core": "workspace:*",
"@hanzo/frame-engine": "workspace:*",
"@hanzo/frame-gcp-cloud-run": "workspace:*",
"@hanzo/frame-lint": "workspace:*",
"@hanzo/frame-parsers": "workspace:*",
"@hanzo/frame-producer": "workspace:*",
"@hanzo/frame-studio": "workspace:*",
"@hanzo/frame-studio-server": "workspace:*",
"@types/fontkit": "^2.0.9",
"@types/mime-types": "^3.0.1",
"@types/node": "^25.0.10",
+1 -1
View File
@@ -7,7 +7,7 @@ import { createRequire } from "node:module";
import { dirname, join } from "node:path";
const require = createRequire(import.meta.url);
const coreRoot = dirname(require.resolve("@frames/core/package.json"));
const coreRoot = dirname(require.resolve("@hanzo/frame-core/package.json"));
const entry = join(coreRoot, "src/beats/beatDetection.ts");
await build({
+1 -1
View File
@@ -113,7 +113,7 @@ async function main() {
// `play`. resolvePlayerPath/resolveSlideshowPath look for these alongside the
// built CLI (dist/<name>.global.js), so they must ship in the package — the
// monorepo-dev fallback paths don't exist once installed from npm. Without
// this, `npx frames present` fails with "@frames/player not found".
// this, `npx frames present` fails with "@hanzo/frame-player not found".
const playerDist = join(REPO_ROOT, "packages", "player", "dist");
const playerGlobals = [
[join(playerDist, "frames-player.global.js"), join(DIST, "frames-player.global.js")],
+102
View File
@@ -0,0 +1,102 @@
/**
* How this CLI reaches `api.hanzo.ai`, and the credential it presents.
*
* Hanzo IAM issues credentials; this CLI only reads them. `hanzo login` runs
* the IAM device flow and writes `~/.hanzo/credentials.json`; CI and services
* receive a platform key from Hanzo KMS in the environment. Nothing here
* mints, refreshes, prompts for, or writes a credential — that is IAM's job,
* and the `hanzo` CLI is the one place a human performs it.
*
* Precedence matches the `hanzo` CLI: env before store, platform key before
* user token.
*/
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
const DEFAULT_BASE_URL = "https://api.hanzo.ai";
/** Base URL for every API call. `HANZO_API_URL` points it at a dev gateway. */
export function apiBaseUrl(): string {
const override = process.env["HANZO_API_URL"];
const base = override && override.length > 0 ? override : DEFAULT_BASE_URL;
return base.replace(/\/+$/, "");
}
export function apiUrl(path: string): string {
return `${apiBaseUrl()}${path}`;
}
export type CredentialSource = "HANZO_API_KEY" | "HANZO_TOKEN" | "store";
export interface Credential {
token: string;
source: CredentialSource;
/** Who the token belongs to, when the store recorded it. */
subject?: string;
/** Org slug the token is scoped to, when the store recorded it. */
owner?: string;
}
/** `~/.hanzo/credentials.json`, written by `hanzo login`. `HANZO_HOME` moves it. */
export function credentialPath(): string {
const home = process.env["HANZO_HOME"];
return join(home && home.length > 0 ? home : join(homedir(), ".hanzo"), "credentials.json");
}
/**
* The credential to present, or `null` when the machine has none. An expired
* stored token counts as none: refreshing it is `hanzo login`'s job, so the
* remedy is the same either way.
*/
export async function credential(): Promise<Credential | null> {
const key = process.env["HANZO_API_KEY"];
if (key && key.length > 0) return { token: key, source: "HANZO_API_KEY" };
const token = process.env["HANZO_TOKEN"];
if (token && token.length > 0) return { token, source: "HANZO_TOKEN" };
return await readStoredCredential();
}
async function readStoredCredential(): Promise<Credential | null> {
let raw: string;
try {
raw = await readFile(credentialPath(), "utf8");
} catch {
return null;
}
let store: unknown;
try {
store = JSON.parse(raw);
} catch {
return null;
}
if (!store || typeof store !== "object") return null;
const fields = store as Record<string, unknown>;
const token = fields["access_token"];
if (typeof token !== "string" || token.length === 0) return null;
const expiry = fields["expiry"];
if (typeof expiry === "number" && expiry > 0 && Date.now() / 1000 >= expiry) return null;
return {
token,
source: "store",
...pickString(fields, "subject"),
...pickString(fields, "owner"),
};
}
function pickString(fields: Record<string, unknown>, key: string): Record<string, string> {
const value = fields[key];
return typeof value === "string" && value.length > 0 ? { [key]: value } : {};
}
export function headers(cred: Credential): Record<string, string> {
return { authorization: `Bearer ${cred.token}` };
}
/** Where a credential came from, for `auth status`. */
export function describeSource(source: CredentialSource): string {
return source === "store" ? `file (${credentialPath()})` : `env (${source})`;
}
+15 -25
View File
@@ -1,49 +1,39 @@
import { describe, expect, it } from "vitest";
import { decideMusic, decideVoice, KOKORO_PIP, MUSICGEN_PIP } from "./providers.js";
describe("decideVoice — mirrors the skill's heygen → elevenlabs → kokoro order", () => {
it("prefers HeyGen when configured", () => {
const r = decideVoice({ hasHeygen: true, elevenlabs: true, kokoro: true });
expect(r.engine).toBe("heygen");
describe("decideVoice — mirrors the skill's hanzo → kokoro order", () => {
it("prefers Hanzo speech when a credential is present", () => {
const r = decideVoice({ hosted: true, kokoro: true });
expect(r.engine).toBe("hanzo");
expect(r.ready).toBe(true);
});
it("falls to ElevenLabs only when key + module are both present", () => {
expect(decideVoice({ hasHeygen: false, elevenlabs: true, kokoro: true }).engine).toBe(
"elevenlabs",
);
});
it("falls to Kokoro when no cloud provider is usable", () => {
expect(decideVoice({ hasHeygen: false, elevenlabs: false, kokoro: true }).engine).toBe(
"kokoro",
);
it("falls to Kokoro when nothing is hosted", () => {
expect(decideVoice({ hosted: false, kokoro: true }).engine).toBe("kokoro");
});
it("flags Kokoro as not-ready with a pip hint when deps are missing", () => {
const r = decideVoice({ hasHeygen: false, elevenlabs: false, kokoro: false });
const r = decideVoice({ hosted: false, kokoro: false });
expect(r.engine).toBe("kokoro");
expect(r.ready).toBe(false);
expect(r.setupHint).toBe(KOKORO_PIP);
});
it("omits the hint when Kokoro is ready", () => {
expect(
decideVoice({ hasHeygen: false, elevenlabs: false, kokoro: true }).setupHint,
).toBeUndefined();
expect(decideVoice({ hosted: false, kokoro: true }).setupHint).toBeUndefined();
});
});
describe("decideMusic — mirrors the skill's heygen → lyria → musicgen order", () => {
it("prefers HeyGen, then Lyria, then MusicGen", () => {
expect(decideMusic({ hasHeygen: true, lyria: true, musicgen: true }).engine).toBe("heygen");
expect(decideMusic({ hasHeygen: false, lyria: true, musicgen: true }).engine).toBe("lyria");
expect(decideMusic({ hasHeygen: false, lyria: false, musicgen: true }).engine).toBe("musicgen");
describe("decideMusic — MusicGen runs locally", () => {
it("reports MusicGen as ready when its deps are installed", () => {
const r = decideMusic({ musicgen: true });
expect(r.engine).toBe("musicgen");
expect(r.ready).toBe(true);
expect(r.setupHint).toBeUndefined();
});
it("flags MusicGen as not-ready with a pip hint when deps are missing", () => {
const r = decideMusic({ hasHeygen: false, lyria: false, musicgen: false });
expect(r.engine).toBe("musicgen");
const r = decideMusic({ musicgen: false });
expect(r.ready).toBe(false);
expect(r.setupHint).toBe(MUSICGEN_PIP);
});
+17 -35
View File
@@ -4,8 +4,8 @@
* media-use skill scripts use, so `auth status` and `doctor`
* report the same engine the render pipeline would pick:
*
* voice: HeyGen Starfish → ElevenLabs (key + `elevenlabs`) → Kokoro (local)
* music: HeyGen library → Lyria (key + `google.genai`) → MusicGen (local)
* voice: Hanzo speech (credential) → Kokoro (local)
* music: MusicGen (local)
*
* The decision is split from the probing: `decide*` is pure (unit-tested
* without spawning Python); `gather*` collects the live facts.
@@ -21,40 +21,35 @@ export const MUSICGEN_MODULES = ["transformers", "torch", "soundfile", "numpy"];
export const KOKORO_PIP = "pip install kokoro-onnx soundfile";
export const MUSICGEN_PIP = "pip install transformers torch soundfile numpy";
export type VoiceEngine = "heygen" | "elevenlabs" | "kokoro";
export type MusicEngine = "heygen" | "lyria" | "musicgen";
export type VoiceEngine = "hanzo" | "kokoro";
export type MusicEngine = "musicgen";
export interface EngineReadiness<E> {
engine: E;
/** Human label, e.g. "Kokoro". */
label: string;
/** A local engine (no account needed) vs a cloud provider keyed by env. */
/** A local engine (no account needed) vs a hosted one keyed by credential. */
local: boolean;
/** Usable right now: cloud key present, or local deps installed. */
/** Usable right now: credential present, or local deps installed. */
ready: boolean;
/** Shown when `ready` is false — how to make it ready. */
setupHint?: string;
}
export interface VoiceFacts {
hasHeygen: boolean;
/** ELEVENLABS_API_KEY set AND the `elevenlabs` module importable. */
elevenlabs: boolean;
/** A Hanzo credential resolved, so `POST /v1/audio/speech` is reachable. */
hosted: boolean;
/** Kokoro's local deps importable. */
kokoro: boolean;
}
export interface MusicFacts {
hasHeygen: boolean;
/** A Gemini/Google key set AND `google.genai` importable. */
lyria: boolean;
/** MusicGen's local deps importable. */
musicgen: boolean;
}
export function decideVoice(f: VoiceFacts): EngineReadiness<VoiceEngine> {
if (f.hasHeygen) return { engine: "heygen", label: "HeyGen Starfish", local: false, ready: true };
if (f.elevenlabs) return { engine: "elevenlabs", label: "ElevenLabs", local: false, ready: true };
if (f.hosted) return { engine: "hanzo", label: "Hanzo speech", local: false, ready: true };
return {
engine: "kokoro",
label: "Kokoro",
@@ -65,8 +60,6 @@ export function decideVoice(f: VoiceFacts): EngineReadiness<VoiceEngine> {
}
export function decideMusic(f: MusicFacts): EngineReadiness<MusicEngine> {
if (f.hasHeygen) return { engine: "heygen", label: "HeyGen library", local: false, ready: true };
if (f.lyria) return { engine: "lyria", label: "Lyria (Gemini)", local: false, ready: true };
return {
engine: "musicgen",
label: "MusicGen",
@@ -76,27 +69,16 @@ export function decideMusic(f: MusicFacts): EngineReadiness<MusicEngine> {
};
}
/** Collect live voice facts. Skips Python probes when HeyGen is configured. */
function gatherVoiceFacts(hasHeygen: boolean): VoiceFacts {
if (hasHeygen) return { hasHeygen, elevenlabs: false, kokoro: false };
const elevenlabs = Boolean(process.env["ELEVENLABS_API_KEY"]) && hasPythonModules(["elevenlabs"]);
const kokoro = hasPythonModules(KOKORO_MODULES);
return { hasHeygen, elevenlabs, kokoro };
/** Collect live voice facts. Skips the Python probe when speech is hosted. */
function gatherVoiceFacts(hosted: boolean): VoiceFacts {
if (hosted) return { hosted, kokoro: false };
return { hosted, kokoro: hasPythonModules(KOKORO_MODULES) };
}
/** Collect live music facts. Skips Python probes when HeyGen is configured. */
function gatherMusicFacts(hasHeygen: boolean): MusicFacts {
if (hasHeygen) return { hasHeygen, lyria: false, musicgen: false };
const hasLyriaKey = Boolean(process.env["GEMINI_API_KEY"] || process.env["GOOGLE_API_KEY"]);
const lyria = hasLyriaKey && hasPythonModules(["google.genai"]);
const musicgen = hasPythonModules(MUSICGEN_MODULES);
return { hasHeygen, lyria, musicgen };
export function resolveVoice(hosted: boolean): EngineReadiness<VoiceEngine> {
return decideVoice(gatherVoiceFacts(hosted));
}
export function resolveVoice(hasHeygen: boolean): EngineReadiness<VoiceEngine> {
return decideVoice(gatherVoiceFacts(hasHeygen));
}
export function resolveMusic(hasHeygen: boolean): EngineReadiness<MusicEngine> {
return decideMusic(gatherMusicFacts(hasHeygen));
export function resolveMusic(): EngineReadiness<MusicEngine> {
return decideMusic({ musicgen: hasPythonModules(MUSICGEN_MODULES) });
}
-54
View File
@@ -1,54 +0,0 @@
/**
* Shared fixtures for auth-module tests. Centralises the env-snapshot
* + tmp-config-dir pattern so resolver.test.ts and oauth.test.ts don't
* each maintain a copy of the same beforeEach/afterEach plumbing.
*
* Only loaded by `*.test.ts` — runtime code doesn't depend on it.
*/
import { promises as fs } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ENV_KEYS = [
"HEYGEN_API_KEY",
"FRAMES_API_KEY",
"HEYGEN_CONFIG_DIR",
"HEYGEN_API_URL",
"FRAMES_OAUTH_CLIENT_ID",
] as const;
type EnvKey = (typeof ENV_KEYS)[number];
export interface EnvFixture {
/** Tmp config dir; deleted on `restore()`. */
dir: string;
/** Restore env + delete tmp dir. Idempotent. */
restore: () => Promise<void>;
}
/**
* Take a snapshot of the auth-related env, clear them, make a tmp
* `HEYGEN_CONFIG_DIR`, and return a `restore()` that undoes all of
* the above.
*/
export async function setupTempAuthEnv(prefix = "hf-auth-test-"): Promise<EnvFixture> {
const dir = await fs.mkdtemp(join(tmpdir(), prefix));
const saved: Partial<Record<EnvKey, string | undefined>> = {};
for (const k of ENV_KEYS) {
saved[k] = process.env[k];
delete process.env[k];
}
process.env["HEYGEN_CONFIG_DIR"] = dir;
const restore = async (): Promise<void> => {
for (const k of ENV_KEYS) {
const v = saved[k];
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
await fs.rm(dir, { recursive: true, force: true });
};
return { dir, restore };
}
-37
View File
@@ -1,37 +0,0 @@
/**
* Open a URL in the user's default browser. Falls back to printing the
* URL when no browser is openable (SSH session, CI, `BROWSER=none`,
* or `open` rejects).
*/
import { c } from "../ui/colors.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
export interface OpenBrowserResult {
/** True when we successfully invoked the platform "open" command. */
opened: boolean;
}
export async function openBrowser(url: string): Promise<OpenBrowserResult> {
if (process.env["BROWSER"] === "none" || process.env["HF_NO_BROWSER"] === "1") {
printManualInstructions(url);
return { opened: false };
}
try {
const open = (await import("open")).default;
await open(url);
return { opened: true };
} catch (err) {
printManualInstructions(url, normalizeErrorMessage(err));
return { opened: false };
}
}
function printManualInstructions(url: string, detail?: string): void {
if (detail) {
console.error(c.warn(`Could not open browser automatically (${detail}).`));
} else {
console.error(c.warn("Browser auto-open is disabled."));
}
console.error(`Open this URL manually to continue:\n ${c.accent(url)}`);
}
-310
View File
@@ -1,310 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
AuthClient,
HEYGEN_CLI_SOURCE,
HEYGEN_CLI_SOURCE_HEADER,
HEYGEN_CLIENT_SOURCE,
HEYGEN_CLIENT_SOURCE_HEADER,
apiBaseUrl,
buildAuthHeaders,
} from "./client.js";
import { isAuthError } from "./errors.js";
import type { ResolvedCredential } from "./resolver.js";
function jsonFetch(body: unknown, status = 200): typeof fetch {
return (async () =>
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch;
}
function textFetch(body: string, status: number): typeof fetch {
return (async () => new Response(body, { status })) as unknown as typeof fetch;
}
function apiKeyCred(): ResolvedCredential {
return { type: "api_key", key: "hg_x", source: "env" };
}
function makeClient(fetchImpl: typeof fetch): AuthClient {
return new AuthClient({ baseUrl: "https://api.test.example", fetchImpl });
}
// getCurrentUser is expected to reject with a specific auth-error code.
async function expectAuthCode(promise: Promise<unknown>, code: string): Promise<void> {
await expect(promise).rejects.toSatisfy(
(err) => isAuthError(err) && (err as { code: string }).code === code,
);
}
// getCurrentUser is expected to reject; assertMessage inspects the scrubbed message.
async function expectRejectionMessage(
client: AuthClient,
assertMessage: (msg: string) => void,
): Promise<void> {
try {
await client.getCurrentUser(apiKeyCred());
} catch (err) {
assertMessage((err as Error).message);
return;
}
throw new Error("expected rejection");
}
describe("auth/client", () => {
const original = process.env["HEYGEN_API_URL"];
beforeEach(() => {
delete process.env["HEYGEN_API_URL"];
});
afterEach(() => {
if (original !== undefined) process.env["HEYGEN_API_URL"] = original;
else delete process.env["HEYGEN_API_URL"];
});
it("apiBaseUrl defaults to https://api.heygen.com", () => {
expect(apiBaseUrl()).toBe("https://api.heygen.com");
});
it("apiBaseUrl honors HEYGEN_API_URL and strips trailing slash", () => {
process.env["HEYGEN_API_URL"] = "https://api.dev.heygen.com/";
expect(apiBaseUrl()).toBe("https://api.dev.heygen.com");
});
it("buildAuthHeaders uses Bearer for oauth", () => {
const cred: ResolvedCredential = {
type: "oauth",
access_token: "at_123",
source: "file_json",
refreshable: false,
};
expect(buildAuthHeaders(cred)).toEqual({
authorization: "Bearer at_123",
[HEYGEN_CLI_SOURCE_HEADER]: HEYGEN_CLI_SOURCE,
[HEYGEN_CLIENT_SOURCE_HEADER]: HEYGEN_CLIENT_SOURCE,
});
});
it("buildAuthHeaders uses x-api-key for api_key, without the cli-source header but with the tool tag", () => {
expect(buildAuthHeaders(apiKeyCred())).toEqual({
"x-api-key": "hg_x",
[HEYGEN_CLIENT_SOURCE_HEADER]: HEYGEN_CLIENT_SOURCE,
});
});
it("getCurrentUser parses a wrapped {data: {...}} payload", async () => {
const client = makeClient(
jsonFetch({
code: 100,
message: "ok",
data: {
username: "alice",
email: "[email protected]",
billing_type: "subscription",
subscription: {
plan: "team",
credits: {
premium_credits: { remaining: 4200, resets_at: "2026-12-01T00:00:00Z" },
add_on_credits: { remaining: 9 },
},
},
},
}),
);
const user = await client.getCurrentUser(apiKeyCred());
expect(user.username).toBe("alice");
expect(user.email).toBe("[email protected]");
expect(user.subscription?.plan).toBe("team");
expect(user.subscription?.credits?.premium_credits?.remaining).toBe(4200);
expect(user.subscription?.credits?.premium_credits?.resets_at).toBe("2026-12-01T00:00:00Z");
expect(user.subscription?.credits?.add_on_credits?.remaining).toBe(9);
});
it("getCurrentUser parses an unwrapped payload", async () => {
const client = makeClient(jsonFetch({ email: "[email protected]" }));
const user = await client.getCurrentUser(apiKeyCred());
expect(user.email).toBe("[email protected]");
});
it("getCurrentUser throws ErrUnauthenticated on 401", async () => {
const client = makeClient(textFetch("invalid token", 401));
await expectAuthCode(client.getCurrentUser(apiKeyCred()), "UNAUTHENTICATED");
});
it("getCurrentUser throws ErrApi on 5xx", async () => {
const client = makeClient(textFetch("upstream", 503));
await expectAuthCode(client.getCurrentUser(apiKeyCred()), "API_ERROR");
});
it("getCurrentUser throws ErrApi when 2xx body is not valid JSON", async () => {
const fetchImpl = (async () =>
new Response("<html>not json</html>", {
status: 200,
headers: { "content-type": "text/html" },
})) as unknown as typeof fetch;
const client = makeClient(fetchImpl);
await expectAuthCode(client.getCurrentUser(apiKeyCred()), "API_ERROR");
});
it("getCurrentUser returns empty UserInfo when payload.data is an array", async () => {
const client = makeClient(jsonFetch({ code: 0, data: [{ email: "x@y" }] }));
const user = await client.getCurrentUser(apiKeyCred());
expect(user).toEqual({});
});
it("getCurrentUser scrubs hg_ keys and JWTs from 401 detail", async () => {
const fetchImpl = textFetch(
'invalid request — got header "x-api-key: hg_supersecret_abc123"',
401,
);
const client = makeClient(fetchImpl);
await expectRejectionMessage(client, (msg) => {
expect(msg).not.toContain("hg_supersecret_abc123");
expect(msg).toContain("<redacted>");
});
});
it("getCurrentUser redacts the full Authorization: Bearer value (not just the scheme)", async () => {
const fetchImpl = textFetch(
"rejected — echoed Authorization: Bearer at_opaque_secret_999",
401,
);
const client = makeClient(fetchImpl);
await expectRejectionMessage(client, (msg) => {
expect(msg).not.toContain("at_opaque_secret_999");
expect(msg).not.toContain("Bearer at_opaque_secret_999");
expect(msg).toContain("<redacted>");
});
});
it("getCurrentUser retries once on 401 when refresh hook is configured for OAuth", async () => {
let callCount = 0;
const observed: string[] = [];
const fetchImpl = (async (_url: string, init?: RequestInit) => {
callCount++;
const headers = (init?.headers as Record<string, string>) ?? {};
observed.push(headers["authorization"] ?? "");
if (callCount === 1) return new Response("expired", { status: 401 });
return new Response(JSON.stringify({ email: "a@b" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as unknown as typeof fetch;
const client = new AuthClient({
baseUrl: "https://api.test.example",
fetchImpl,
onUnauthenticatedRefresh: async () => ({
access_token: "fresh_at",
refresh_token: "fresh_rt",
}),
});
const user = await client.getCurrentUser({
type: "oauth",
access_token: "stale_at",
refresh_token: "rt",
source: "file_json",
refreshable: true,
});
expect(user.email).toBe("a@b");
expect(observed[0]).toBe("Bearer stale_at");
expect(observed[1]).toBe("Bearer fresh_at");
expect(callCount).toBe(2);
});
it("getCurrentUser hook returns full token set so the retry credential carries any rotated refresh_token", async () => {
// The hook contract now returns `OAuthTokens` (access_token plus an
// optional rotated refresh_token), not just an access_token string.
// For IdPs that rotate refresh_tokens on every exchange, any future
// retry on the in-memory credential needs the FRESH rt — the in-
// memory credential must be rebuilt from the hook's return, not
// re-use the stale rt from the original credential.
let receivedRt = "";
const fetchImpl = (async () =>
new Response("expired", { status: 401 })) as unknown as typeof fetch;
const client = new AuthClient({
baseUrl: "https://api.test.example",
fetchImpl,
onUnauthenticatedRefresh: async (rt) => {
receivedRt = rt;
// Hook MUST be able to return a rotated refresh_token — this
// would have been impossible with the old `Promise<string>`
// contract. The type checker fails the build if the shape drifts.
return { access_token: "fresh_at", refresh_token: "rotated_rt" };
},
});
await expect(
client.getCurrentUser({
type: "oauth",
access_token: "stale_at",
refresh_token: "ORIGINAL_rt",
source: "file_json",
refreshable: true,
}),
).rejects.toSatisfy((err) => isAuthError(err));
expect(receivedRt).toBe("ORIGINAL_rt");
});
it("getCurrentUser does NOT retry on 401 for api_key credentials", async () => {
let callCount = 0;
const fetchImpl = (async () => {
callCount++;
return new Response("invalid", { status: 401 });
}) as unknown as typeof fetch;
const client = new AuthClient({
baseUrl: "https://api.test.example",
fetchImpl,
onUnauthenticatedRefresh: async () => ({ access_token: "fresh" }),
});
await expect(client.getCurrentUser(apiKeyCred())).rejects.toSatisfy((err) => {
return isAuthError(err) && (err as { code: string }).code === "UNAUTHENTICATED";
});
expect(callCount).toBe(1);
});
it("getCurrentUser surfaces 401 when refresh hook returns null (refresh failed)", async () => {
const fetchImpl = (async () =>
new Response("nope", { status: 401 })) as unknown as typeof fetch;
const { ErrRefreshFailed } = await import("./errors.js");
const client = new AuthClient({
baseUrl: "https://api.test.example",
fetchImpl,
onUnauthenticatedRefresh: async () => {
throw ErrRefreshFailed("invalid_grant");
},
});
await expect(
client.getCurrentUser({
type: "oauth",
access_token: "stale",
refresh_token: "rt",
source: "file_json",
refreshable: true,
}),
).rejects.toSatisfy((err) => {
return isAuthError(err) && (err as { code: string }).code === "UNAUTHENTICATED";
});
});
it("getCurrentUser sends the right header for oauth credentials", async () => {
let captured: Record<string, string> = {};
const fetchImpl = (async (_url: string, init?: RequestInit) => {
captured = (init?.headers as Record<string, string>) ?? {};
return new Response(JSON.stringify({ email: "[email protected]" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as unknown as typeof fetch;
const client = makeClient(fetchImpl);
await client.getCurrentUser({
type: "oauth",
access_token: "at_xyz",
source: "file_json",
refreshable: false,
});
expect(captured["authorization"]).toBe("Bearer at_xyz");
});
});
-247
View File
@@ -1,247 +0,0 @@
/**
* Minimal typed HTTP client for HeyGen endpoints needed by the auth
* commands. Hand-written rather than codegen'd because the surface is
* one endpoint (`/v3/users/me`) and pulling in an OpenAPI pipeline is
* disproportionate.
*
* Reads `HEYGEN_API_URL` (default `https://api.heygen.com`) so dev
* testing is one env var away.
*
* Auth header selection:
* - OAuth → `Authorization: Bearer <token>`
* - API key → `x-api-key: <key>`
*
* The backend `/v3/users/me` accepts both. See
* `movio/api_service/app/controller/user_v3.py`.
*/
import { ErrApi, ErrUnauthenticated, isAuthError } from "./errors.js";
import type { ResolvedCredential } from "./resolver.js";
import { scrubCredentials } from "./scrub.js";
import type { OAuthTokens } from "./store.js";
const DEFAULT_BASE_URL = "https://api.heygen.com";
export const HEYGEN_CLI_SOURCE_HEADER = "X-HeyGen-Source";
export const HEYGEN_CLI_SOURCE = "cli";
// Tool-attribution header identifying this CLI as the request origin. Sent on
// every HeyGen call (both auth types) so the backend can isolate frames CLI
// usage in billing meta. Distinct from HEYGEN_CLI_SOURCE_HEADER above, which is
// the OAuth-only cli-source header that gates the free allowance.
export const HEYGEN_CLIENT_SOURCE_HEADER = "X-HeyGen-Client-Source";
export const HEYGEN_CLIENT_SOURCE = "frames";
export function apiBaseUrl(): string {
const override = process.env["HEYGEN_API_URL"];
return override && override.length > 0 ? override.replace(/\/+$/, "") : DEFAULT_BASE_URL;
}
export type BillingType = "wallet" | "subscription" | "usage_based" | string;
export interface WalletInfo {
currency?: string;
remaining_balance?: number;
auto_reload?: boolean;
}
/**
* The API returns `credits.{premium,add_on}_credits` as nested objects
* (`{ remaining, resets_at? }`), not bare numbers — discovered live
* against api.heygen.com. Modelling them as nested objects so the row
* formatter can render them properly instead of `[object Object]`.
*/
export interface CreditBalance {
remaining?: number;
resets_at?: string;
}
export interface SubscriptionInfo {
plan?: string;
credits?: {
premium_credits?: CreditBalance;
add_on_credits?: CreditBalance;
};
}
export interface UsageBasedInfo {
spending_current_usd?: number;
spending_cap_usd?: number;
}
/** Subset of the backend response we surface to users today. */
export interface UserInfo {
username?: string;
email?: string;
first_name?: string;
last_name?: string;
billing_type?: BillingType;
wallet?: WalletInfo;
subscription?: SubscriptionInfo;
usage_based?: UsageBasedInfo;
}
export interface AuthClientOptions {
/** Override base URL (otherwise `HEYGEN_API_URL` / default). */
baseUrl?: string;
/** Inject a custom fetch (used by tests). */
fetchImpl?: typeof fetch;
/**
* Hook for refreshing an OAuth credential on 401. The hook should
* exchange the supplied refresh_token for new tokens, persist them,
* and return the FULL new token set — at minimum a fresh
* `access_token`, plus a fresh `refresh_token` if the IdP rotated it.
* Returning the full set lets the retry's credential carry the new
* refresh_token, so any subsequent refresh on the same in-memory
* credential doesn't re-use a now-invalidated rotated RT.
* Wired in by the auth commands; injectable for tests.
*/
onUnauthenticatedRefresh?: (refresh_token: string) => Promise<OAuthTokens>;
}
export class AuthClient {
private readonly base: string;
private readonly fetchImpl: typeof fetch;
private readonly onRefresh?: (refresh_token: string) => Promise<OAuthTokens>;
constructor(opts: AuthClientOptions = {}) {
this.base = (opts.baseUrl ?? apiBaseUrl()).replace(/\/+$/, "");
this.fetchImpl = opts.fetchImpl ?? fetch;
this.onRefresh = opts.onUnauthenticatedRefresh;
}
/**
* `GET /v3/users/me`. Throws `ErrUnauthenticated` on 401, `ErrApi`
* on any other non-2xx or non-JSON body.
*
* On OAuth 401 with a refresh hook configured, the request is
* retried once after refreshing the access token. The retry's
* outcome is what the caller sees — if the refresh itself fails
* (REFRESH_FAILED) or the retry still 401s, the user lands on a
* "please log in again" path upstream.
*/
async getCurrentUser(credential: ResolvedCredential): Promise<UserInfo> {
const url = `${this.base}/v3/users/me`;
return await this.fetchUser(url, credential, true);
}
// fallow-ignore-next-line complexity
private async fetchUser(
url: string,
credential: ResolvedCredential,
allowRefresh: boolean,
): Promise<UserInfo> {
const headers = buildAuthHeaders(credential);
const res = await this.fetchImpl(url, { method: "GET", headers });
if (res.status === 401) {
if (
allowRefresh &&
credential.type === "oauth" &&
credential.refresh_token &&
this.onRefresh
) {
const refreshed = await this.tryRefresh(credential.refresh_token);
if (refreshed) {
// Carry the new refresh_token forward too — for IdPs that
// rotate RTs on every refresh, a future retry on this
// in-memory credential would otherwise re-send the old
// (now-invalidated) one.
const next: ResolvedCredential = {
...credential,
access_token: refreshed.access_token,
...(refreshed.refresh_token ? { refresh_token: refreshed.refresh_token } : {}),
};
return await this.fetchUser(url, next, false);
}
}
const detail = await safeText(res);
throw ErrUnauthenticated(detail || `${res.status} ${res.statusText}`);
}
if (!res.ok) {
throw ErrApi(res.status, (await safeText(res)) || res.statusText);
}
let payload: unknown;
try {
payload = await res.json();
} catch (err) {
throw ErrApi(res.status, `non-JSON body: ${(err as Error).message}`);
}
return extractUserInfo(payload);
}
private async tryRefresh(refresh_token: string): Promise<OAuthTokens | null> {
if (!this.onRefresh) return null;
try {
return await this.onRefresh(refresh_token);
} catch (err) {
// Refresh failure should be surfaced upstream by the caller via
// the retry's 401, not by throwing here — so callers consistently
// see "please log in again" rather than mixed error types.
if (isAuthError(err) && err.code === "REFRESH_FAILED") return null;
throw err;
}
}
}
export function buildAuthHeaders(credential: ResolvedCredential): Record<string, string> {
if (credential.type === "oauth") {
return {
authorization: `Bearer ${credential.access_token}`,
[HEYGEN_CLI_SOURCE_HEADER]: HEYGEN_CLI_SOURCE,
[HEYGEN_CLIENT_SOURCE_HEADER]: HEYGEN_CLIENT_SOURCE,
};
}
// API-key traffic keeps the normal billing path; the backend ignores the
// cli-source header for it, so we don't send it (avoids a contradictory
// "cli-source claim on an API-key request"). The tool-attribution header IS
// sent here — an API-key frames call is still frames usage.
return { "x-api-key": credential.key, [HEYGEN_CLIENT_SOURCE_HEADER]: HEYGEN_CLIENT_SOURCE };
}
async function safeText(res: Response): Promise<string> {
try {
const body = (await res.text()).slice(0, 500);
return scrubCredentials(body);
} catch {
return "";
}
}
/**
* The backend wraps responses in `{code, message, data: {...}}` for some
* endpoints and returns raw fields directly for others. Handle both.
*/
function extractUserInfo(payload: unknown): UserInfo {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return {};
const obj = payload as Record<string, unknown>;
const wrapped = obj["data"];
const data =
wrapped && typeof wrapped === "object" && !Array.isArray(wrapped)
? (wrapped as Record<string, unknown>)
: obj;
return {
username: pickString(data, "username"),
email: pickString(data, "email"),
first_name: pickString(data, "first_name"),
last_name: pickString(data, "last_name"),
billing_type: pickString(data, "billing_type"),
wallet: pickObject(data, "wallet") as WalletInfo | undefined,
subscription: pickObject(data, "subscription") as SubscriptionInfo | undefined,
usage_based: pickObject(data, "usage_based") as UsageBasedInfo | undefined,
};
}
function pickString(obj: Record<string, unknown>, key: string): string | undefined {
const v = obj[key];
return typeof v === "string" ? v : undefined;
}
function pickObject(
obj: Record<string, unknown>,
key: string,
): Record<string, unknown> | undefined {
const v = obj[key];
return v && typeof v === "object" && !Array.isArray(v)
? (v as Record<string, unknown>)
: undefined;
}
-43
View File
@@ -1,43 +0,0 @@
import { describe, expect, it } from "vitest";
import {
AuthError,
ErrApi,
ErrInvalidStore,
ErrNotConfigured,
ErrUnauthenticated,
isAuthError,
} from "./errors.js";
describe("auth/errors", () => {
it("ErrNotConfigured carries the right code + hint", () => {
const err = ErrNotConfigured();
expect(err).toBeInstanceOf(AuthError);
expect(err.code).toBe("NOT_CONFIGURED");
expect(err.hint).toContain("frames auth login");
});
it("ErrInvalidStore wraps the detail", () => {
const err = ErrInvalidStore("malformed at line 3");
expect(err.code).toBe("INVALID_STORE");
expect(err.message).toContain("malformed at line 3");
});
it("ErrUnauthenticated includes detail when provided", () => {
expect(ErrUnauthenticated().code).toBe("UNAUTHENTICATED");
expect(ErrUnauthenticated("invalid token").message).toContain("invalid token");
});
it("ErrApi captures status + detail", () => {
const err = ErrApi(503, "upstream timeout");
expect(err.code).toBe("API_ERROR");
expect(err.message).toContain("503");
expect(err.message).toContain("upstream timeout");
});
it("isAuthError narrows properly", () => {
expect(isAuthError(ErrNotConfigured())).toBe(true);
expect(isAuthError(new Error("plain"))).toBe(false);
expect(isAuthError(null)).toBe(false);
expect(isAuthError("string")).toBe(false);
});
});
-66
View File
@@ -1,66 +0,0 @@
/**
* Typed errors for the auth layer. Callers branch on `code` so commands
* can map specific failures to friendly UX without parsing messages.
*/
export type AuthErrorCode =
| "NOT_CONFIGURED"
| "INVALID_STORE"
| "API_ERROR"
| "UNAUTHENTICATED"
| "OAUTH_NOT_CONFIGURED"
| "REFRESH_FAILED";
export class AuthError extends Error {
readonly code: AuthErrorCode;
readonly hint?: string;
constructor(code: AuthErrorCode, message: string, hint?: string) {
super(message);
this.name = "AuthError";
this.code = code;
this.hint = hint;
}
}
export const ErrNotConfigured = () =>
new AuthError(
"NOT_CONFIGURED",
"No HeyGen credentials found",
"Run `frames auth login` to sign in.",
);
export const ErrInvalidStore = (detail: string) =>
new AuthError(
"INVALID_STORE",
`Credential file is unreadable: ${detail}`,
"Delete ~/.heygen/credentials and run `frames auth login` to re-create it.",
);
export const ErrUnauthenticated = (detail?: string) =>
new AuthError(
"UNAUTHENTICATED",
detail ? `HeyGen rejected the credential: ${detail}` : "HeyGen rejected the credential",
"Run `frames auth login` to re-authenticate.",
);
export const ErrApi = (status: number, detail: string) =>
new AuthError("API_ERROR", `HeyGen API error (${status}): ${detail}`);
export const ErrOAuthNotConfigured = () =>
new AuthError(
"OAUTH_NOT_CONFIGURED",
"OAuth client is not configured",
"Set FRAMES_OAUTH_CLIENT_ID, or run `frames auth login --api-key`.",
);
export const ErrRefreshFailed = (detail?: string) =>
new AuthError(
"REFRESH_FAILED",
detail ? `Failed to refresh OAuth tokens: ${detail}` : "Failed to refresh OAuth tokens",
"Run `frames auth login` to re-authenticate.",
);
export function isAuthError(err: unknown): err is AuthError {
return err instanceof AuthError;
}
-39
View File
@@ -1,39 +0,0 @@
/**
* Public surface of the auth library — only the symbols the auth
* commands consume today. Internal types stay in their source files.
*/
export { isAuthError } from "./errors.js";
export {
clearOAuth,
deleteStore,
hasPreservedUnknownData,
isHeaderSafe,
readStore,
writeStore,
} from "./store.js";
export type { Credentials, StoredUserInfo } from "./store.js";
export {
clearUserInfo,
isUserInfoEmpty,
loadUserInfo,
saveUserInfo,
userDisplayName,
} from "./user.js";
export { configDir, credentialPath } from "./paths.js";
export { tryResolveCredential } from "./resolver.js";
export type { ResolvedCredential } from "./resolver.js";
export { AuthClient } from "./client.js";
export type { UserInfo } from "./client.js";
export {
assertOAuthConfiguredOrExit,
refreshTokens,
revokeTokens,
startAuthorizationCodeFlow,
} from "./oauth.js";
-76
View File
@@ -1,76 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { startLoopback, type LoopbackHandle } from "./loopback.js";
describe("auth/loopback", () => {
let active: LoopbackHandle | null = null;
afterEach(async () => {
if (active) {
await active.close().catch(() => {});
active = null;
}
});
it("captures `code` when state matches", async () => {
const handle = await startLoopback({ state: "expected_state", timeoutMs: 5_000 });
active = handle;
const redirect = new URL(handle.redirectUri);
const callback = new URL(`${handle.redirectUri}?code=abc123&state=expected_state`);
const fetchPromise = fetch(callback.toString());
const result = await handle.result;
const res = await fetchPromise;
expect(result.code).toBe("abc123");
expect(result.redirectUri).toContain(redirect.host);
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toContain("Signed in");
});
async function expectRejection(args: {
expectedState: string;
query: string;
pattern: RegExp;
}): Promise<void> {
const handle = await startLoopback({ state: args.expectedState, timeoutMs: 5_000 });
active = handle;
await fetch(`${handle.redirectUri}?${args.query}`).catch(() => {});
await expect(handle.result).rejects.toThrow(args.pattern);
}
it("rejects when state does not match", async () => {
await expectRejection({
expectedState: "expected",
query: "code=abc&state=wrong",
pattern: /state mismatch/i,
});
});
it("rejects when the IdP returns an error", async () => {
await expectRejection({
expectedState: "s",
query: "error=access_denied&error_description=user+denied&state=s",
pattern: /access_denied/,
});
});
it("rejects when code is missing from the callback", async () => {
await expectRejection({ expectedState: "s", query: "state=s", pattern: /code/ });
});
it("times out when no callback arrives", async () => {
const handle = await startLoopback({ state: "s", timeoutMs: 200 });
active = handle;
await expect(handle.result).rejects.toThrow(/timed out/i);
});
it("404s non-callback paths and does not resolve the flow", async () => {
const handle = await startLoopback({ state: "s", timeoutMs: 1_000 });
active = handle;
const res = await fetch(`${handle.redirectUri.replace("/oauth/callback", "/other")}`);
expect(res.status).toBe(404);
// Flow is still waiting — kill it via timeout.
await expect(handle.result).rejects.toThrow(/timed out/i);
});
});
-214
View File
@@ -1,214 +0,0 @@
/**
* Loopback HTTP server for the OAuth authorization-code callback.
*
* The CLI binds a server to `127.0.0.1:0` (ephemeral port), sends the
* user's browser to `/v1/oauth/authorize?redirect_uri=…` pointing at
* this server, and waits for the redirect carrying `?code=…&state=…`.
*
* The backend wildcards localhost ports for public clients
* (`movio/model/oauth2.py:check_redirect_uri`), so the registered
* redirect URI's port is just a placeholder — the actual port the
* server lands on is what matters at runtime.
*
* Times out after 120s. Validates `state` matches the value we
* generated. Renders a small "you can close this window" page back
* to the browser before shutting down.
*/
import { timingSafeEqual } from "node:crypto";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { AddressInfo } from "node:net";
export interface LoopbackOptions {
/** Expected `state` value — flow fails if it doesn't match. */
state: string;
/** Timeout in ms (default 120s). */
timeoutMs?: number;
/** Override port for tests (default 0 = ephemeral). */
port?: number;
}
export interface LoopbackResult {
/** Authorization code from the IdP. */
code: string;
/** The full redirect_uri (with port) we listened on. */
redirectUri: string;
}
export interface LoopbackHandle {
/** Promise that resolves with the captured code, or rejects on timeout/error. */
result: Promise<LoopbackResult>;
/** Redirect URI to pass to /v1/oauth/authorize. */
redirectUri: string;
/** Stop the server early (e.g. user cancels). */
close: () => Promise<void>;
}
const CALLBACK_PATH = "/oauth/callback";
const DEFAULT_TIMEOUT_MS = 120_000;
export async function startLoopback(opts: LoopbackOptions): Promise<LoopbackHandle> {
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
let resolveResult!: (value: LoopbackResult) => void;
let rejectResult!: (err: Error) => void;
const result = new Promise<LoopbackResult>((resolve, reject) => {
resolveResult = resolve;
rejectResult = reject;
});
// redirectUri is the value the IdP sees on /authorize. RFC 6749 §4.1.3
// requires the token exchange's redirect_uri to be byte-identical to
// it, so we capture this string once and reuse it on both hops — never
// reconstructing from req.socket.localAddress later (which can drift
// on dual-stack hosts).
let redirectUri = "";
const server = createServer((req, res) =>
handleRequest(req, res, opts.state, redirectUri, resolveResult, rejectResult),
);
await listen(server, opts.port ?? 0);
const address = server.address() as AddressInfo;
redirectUri = `http://127.0.0.1:${address.port}${CALLBACK_PATH}`;
let closed = false;
const close = async (): Promise<void> => {
if (closed) return;
closed = true;
clearTimeout(timer);
// `server.close()` only refuses NEW connections — it does NOT
// terminate existing keep-alive sockets, which browsers default to
// and idle for minutes (Chrome ~5min). Without `closeAllConnections`
// the CLI process hangs after "Signed in" until the browser closes
// its idle socket. `respond()` also emits `Connection: close` so the
// browser doesn't try to keep-alive in the first place.
server.closeAllConnections?.();
await new Promise<void>((resolve) => server.close(() => resolve()));
};
const timer = setTimeout(() => {
rejectResult(new Error(`OAuth callback timed out after ${timeoutMs}ms`));
void close();
}, timeoutMs);
// When result settles, drain the timer + shutdown.
result.finally(close).catch(() => {});
return { result, redirectUri, close };
}
async function listen(server: Server, port: number): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
}
// fallow-ignore-next-line complexity
function handleRequest(
req: IncomingMessage,
res: ServerResponse,
expectedState: string,
redirectUri: string,
resolveResult: (value: LoopbackResult) => void,
rejectResult: (err: Error) => void,
): void {
// Only GET is part of the OAuth redirect contract. Anything else is
// probe-traffic on the ephemeral port; reject without leaking that a
// CLI is listening there.
if (req.method !== "GET") {
res.writeHead(405, { "content-type": "text/plain" }).end("Method Not Allowed");
return;
}
const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (url.pathname !== CALLBACK_PATH) {
res.writeHead(404, { "content-type": "text/plain" }).end("Not Found");
return;
}
const params = url.searchParams;
const error = params.get("error");
if (error) {
const desc = params.get("error_description") ?? "";
respond(res, 400, errorPage(error, desc));
rejectResult(new Error(`OAuth authorize returned error: ${error}${desc ? ` — ${desc}` : ""}`));
return;
}
const state = params.get("state");
if (!state || !stateMatches(state, expectedState)) {
respond(res, 400, errorPage("invalid_state", "State parameter did not match."));
rejectResult(new Error("OAuth state mismatch — possible CSRF, aborting."));
return;
}
const code = params.get("code");
if (!code) {
respond(
res,
400,
errorPage("missing_code", "Authorization code is missing from the redirect."),
);
rejectResult(new Error("OAuth redirect did not include `code`."));
return;
}
respond(res, 200, successPage());
resolveResult({ code, redirectUri });
}
/**
* Constant-time comparison for the OAuth `state` parameter. Real
* exploitability is very low (loopback, 256-bit entropy, narrow flow
* window), but the rest of the auth path uses crypto-grade primitives
* and a `!==` here would be a gratuitous deviation in security review.
*/
function stateMatches(actual: string, expected: string): boolean {
const a = Buffer.from(actual, "utf8");
const b = Buffer.from(expected, "utf8");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
function respond(res: ServerResponse, status: number, body: string): void {
res
.writeHead(status, {
"content-type": "text/html; charset=utf-8",
"cache-control": "no-store",
// Tell the browser not to keep the TCP socket alive — otherwise
// `server.close()` blocks on the idle keep-alive timeout
// (Chrome ~5min). Combined with `server.closeAllConnections()`
// in `close()` this guarantees the CLI exits promptly after the
// user sees the success page.
connection: "close",
})
.end(body);
}
function successPage(): string {
return `<!doctype html><html><head><meta charset="utf-8"><title>Signed in to HeyGen</title>
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0b0f14;color:#e6e8eb}main{max-width:480px;text-align:center;padding:32px;border-radius:12px;background:#11161d;border:1px solid #1f2630}h1{font-weight:600;margin:0 0 8px;color:#3CE6AC}p{margin:0;color:#9aa3ad}</style>
</head><body><main><h1>You're signed in.</h1><p>You can close this tab and return to your terminal.</p></main></body></html>`;
}
function errorPage(code: string, description: string): string {
const safeCode = escapeHtml(code);
const safeDesc = escapeHtml(description);
return `<!doctype html><html><head><meta charset="utf-8"><title>Sign-in failed</title>
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#1a0b0b;color:#e6e8eb}main{max-width:480px;text-align:center;padding:32px;border-radius:12px;background:#1d1111;border:1px solid #301f1f}h1{font-weight:600;margin:0 0 8px;color:#ff7a7a}code{background:#2a1414;padding:2px 6px;border-radius:4px}p{margin:8px 0 0;color:#9aa3ad}</style>
</head><body><main><h1>Sign-in failed</h1><p><code>${safeCode}</code></p><p>${safeDesc}</p></main></body></html>`;
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
-359
View File
@@ -1,359 +0,0 @@
import { promises as fs } from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { setupTempAuthEnv } from "./_test-utils.js";
import { isAuthError } from "./errors.js";
import {
parseTokenResponse,
refreshTokens,
resolveClientId,
revokeTokens,
startAuthorizationCodeFlow,
} from "./oauth.js";
import { readStore, writeStore } from "./store.js";
// Mock the interactive bits so startAuthorizationCodeFlow runs headless.
vi.mock("./loopback.js", () => ({
startLoopback: vi.fn(async () => ({
result: Promise.resolve({
code: "auth_code_123",
redirectUri: "http://127.0.0.1:12345/oauth/callback",
}),
redirectUri: "http://127.0.0.1:12345/oauth/callback",
close: vi.fn(async () => {}),
})),
}));
vi.mock("./browser.js", () => ({
openBrowser: vi.fn(async () => ({ opened: true })),
}));
describe("auth/oauth", () => {
let fixture: Awaited<ReturnType<typeof setupTempAuthEnv>>;
beforeEach(async () => {
fixture = await setupTempAuthEnv("hf-oauth-");
});
afterEach(async () => {
await fixture.restore();
});
describe("resolveClientId", () => {
it("returns the env override when set", () => {
process.env["FRAMES_OAUTH_CLIENT_ID"] = "test_client_id";
expect(resolveClientId()).toBe("test_client_id");
});
it("returns the build-time default when env is unset", () => {
expect(resolveClientId()).toMatch(/.+/);
});
});
describe("parseTokenResponse", () => {
it("parses a full token response", () => {
const tokens = parseTokenResponse({
access_token: "at_123",
refresh_token: "rt_456",
token_type: "Bearer",
expires_in: 3600,
scope: "openid profile",
});
expect(tokens.access_token).toBe("at_123");
expect(tokens.refresh_token).toBe("rt_456");
expect(tokens.token_type).toBe("Bearer");
expect(tokens.scope).toBe("openid profile");
expect(tokens.expires_at).toBeDefined();
const expiresAt = new Date(tokens.expires_at!);
// Should be approximately 1 hour in the future
const diff = expiresAt.getTime() - Date.now();
expect(diff).toBeGreaterThan(3500 * 1000);
expect(diff).toBeLessThan(3700 * 1000);
});
it("accepts expires_in as a string (some servers serialize as string)", () => {
const tokens = parseTokenResponse({ access_token: "at", expires_in: "1800" });
expect(tokens.expires_at).toBeDefined();
});
it("rejects responses missing access_token", () => {
expect(() => parseTokenResponse({ token_type: "Bearer" })).toThrow();
});
it("rejects array payloads", () => {
expect(() => parseTokenResponse([])).toThrow();
});
it("rejects null payloads", () => {
expect(() => parseTokenResponse(null)).toThrow();
});
it("rejects access_token containing CR/LF", () => {
expect(() => parseTokenResponse({ access_token: "at\r\nX-Evil: 1" })).toSatisfy(
() => true, // assertion done below via toThrow
);
expect(() => parseTokenResponse({ access_token: "at\r\nX-Evil: 1" })).toThrow(
/control characters/,
);
});
it("rejects refresh_token containing CR/LF", () => {
expect(() => parseTokenResponse({ access_token: "at", refresh_token: "rt\nbad" })).toThrow(
/control characters/,
);
});
it("clamps non-positive expires_in to avoid an immediate-refresh loop", () => {
const zero = parseTokenResponse({ access_token: "at", expires_in: 0 });
const negative = parseTokenResponse({ access_token: "at", expires_in: -100 });
// both should resolve to a future time
expect(new Date(zero.expires_at!).getTime()).toBeGreaterThan(Date.now() + 25 * 1000);
expect(new Date(negative.expires_at!).getTime()).toBeGreaterThan(Date.now() + 25 * 1000);
});
it("uses REFRESH_FAILED error code on shape failures (not API_ERROR)", () => {
try {
parseTokenResponse(null);
} catch (err) {
expect(isAuthError(err)).toBe(true);
if (isAuthError(err)) expect(err.code).toBe("REFRESH_FAILED");
return;
}
throw new Error("expected throw");
});
});
describe("refreshTokens", () => {
it("posts grant_type=refresh_token and persists the response", async () => {
process.env["HEYGEN_API_URL"] = "https://api.test.example";
let capturedBody: string | undefined;
const fetchImpl = (async (_url: string, init?: RequestInit) => {
capturedBody = init?.body as string;
return new Response(
JSON.stringify({
access_token: "new_at",
refresh_token: "new_rt",
expires_in: 3600,
token_type: "Bearer",
scope: "openid profile",
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}) as unknown as typeof fetch;
const tokens = await refreshTokens("old_rt", { fetchImpl });
expect(tokens.access_token).toBe("new_at");
expect(tokens.refresh_token).toBe("new_rt");
expect(capturedBody).toContain("grant_type=refresh_token");
expect(capturedBody).toContain("refresh_token=old_rt");
// Should have persisted.
const { credentials } = await readStore();
expect(credentials.oauth?.access_token).toBe("new_at");
});
it("preserves the prior refresh_token when the server omits it (no rotation)", async () => {
await writeStore({
oauth: {
access_token: "old_at",
refresh_token: "keep_me_rt",
expires_at: "2026-01-01T00:00:00Z",
},
});
const fetchImpl = (async () =>
new Response(JSON.stringify({ access_token: "new_at", expires_in: 3600 }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch;
await refreshTokens("keep_me_rt", { fetchImpl });
const { credentials } = await readStore();
expect(credentials.oauth?.access_token).toBe("new_at");
// Critical: refresh_token MUST survive a no-rotation refresh.
expect(credentials.oauth?.refresh_token).toBe("keep_me_rt");
});
it("preserves an existing api_key when persisting refreshed oauth", async () => {
await writeStore({ api_key: "hg_keep" });
const fetchImpl = (async () =>
new Response(JSON.stringify({ access_token: "new_at", expires_in: 60 }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch;
await refreshTokens("old_rt", { fetchImpl });
const { credentials } = await readStore();
expect(credentials.api_key).toBe("hg_keep");
expect(credentials.oauth?.access_token).toBe("new_at");
});
it("preserves an unknown key INSIDE the oauth sub-object across a refresh", async () => {
// The refresh path is `persistOAuth(preserveMissing: true)`, i.e.
// `{ ...existing.oauth, ...tokens }` — object spread carries the
// hidden Symbol-keyed unknown bag from `existing.oauth`, so a key
// another CLI wrote inside `oauth` (e.g. an `id_token`) must survive
// the no-rotation refresh. Refresh is the most-frequent write path,
// so a silent regression here would be the worst case.
const path = (await import("./paths.js")).credentialPath();
await fs.writeFile(
path,
JSON.stringify({
oauth: {
access_token: "old_at",
refresh_token: "keep_me_rt",
id_token: "future_id_token_value",
},
}),
{ mode: 0o600 },
);
const fetchImpl = (async () =>
new Response(JSON.stringify({ access_token: "new_at", expires_in: 3600 }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch;
await refreshTokens("keep_me_rt", { fetchImpl });
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.oauth.access_token).toBe("new_at");
// The unknown oauth sub-key rode through on the hidden slot.
expect(onDisk.oauth.id_token).toBe("future_id_token_value");
});
it("throws REFRESH_FAILED on 400/401", async () => {
const fetchImpl = (async () =>
new Response("invalid_grant", { status: 400 })) as unknown as typeof fetch;
await expect(refreshTokens("bad_rt", { fetchImpl })).rejects.toSatisfy((err) => {
return isAuthError(err) && (err as { code: string }).code === "REFRESH_FAILED";
});
});
it("throws API_ERROR on 5xx", async () => {
const fetchImpl = (async () =>
new Response("upstream", { status: 503 })) as unknown as typeof fetch;
await expect(refreshTokens("rt", { fetchImpl })).rejects.toSatisfy((err) => {
return isAuthError(err) && (err as { code: string }).code === "API_ERROR";
});
});
});
describe("revokeTokens", () => {
it("never throws on network failure (best-effort)", async () => {
const fetchImpl = (async () => {
throw new Error("connection refused");
}) as unknown as typeof fetch;
await expect(revokeTokens("any_token", { fetchImpl })).resolves.toBeUndefined();
});
it("respects the timeout", async () => {
let aborted = false;
const fetchImpl = (async (_url: string, init?: RequestInit) => {
const signal = init?.signal;
return new Promise<Response>((_resolve, reject) => {
signal?.addEventListener("abort", () => {
aborted = true;
reject(new Error("aborted"));
});
});
}) as unknown as typeof fetch;
await revokeTokens("token", { fetchImpl, timeoutMs: 50 });
expect(aborted).toBe(true);
});
it("sends token_type_hint when provided", async () => {
let capturedBody = "";
const fetchImpl = (async (_url: string, init?: RequestInit) => {
capturedBody = init?.body as string;
return new Response("", { status: 200 });
}) as unknown as typeof fetch;
await revokeTokens("tok", { fetchImpl, token_type_hint: "refresh_token" });
expect(capturedBody).toContain("token_type_hint=refresh_token");
});
it("returns silently when client_id is unconfigured (no throw)", async () => {
process.env["FRAMES_OAUTH_CLIENT_ID"] = "";
// With the baked-in default cleared from env, revokeTokens still has
// the build-time default. Force it to fail by setting the override to
// a value AND nulling the default isn't possible from a test — instead
// verify that the function never throws via the standard path.
const fetchImpl = (async () => new Response("", { status: 200 })) as unknown as typeof fetch;
await expect(revokeTokens("tok", { fetchImpl })).resolves.toBeUndefined();
});
});
describe("error scrubbing", () => {
it("refreshTokens does not leak token-shaped secrets from the error body", async () => {
const fetchImpl = (async () =>
new Response(
'{"error":"invalid_grant","echoed":"refresh_token=rt_leak_secret&code_verifier=cv_leak"}',
{ status: 400 },
)) as unknown as typeof fetch;
try {
await refreshTokens("rt_leak_secret", { fetchImpl });
throw new Error("expected rejection");
} catch (err) {
const msg = (err as Error).message;
expect(msg).not.toContain("rt_leak_secret");
expect(msg).not.toContain("cv_leak");
expect(msg).toContain("<redacted>");
}
});
});
describe("startAuthorizationCodeFlow persistence", () => {
function tokenFetch(body: Record<string, unknown>): typeof fetch {
return (async () =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch;
}
it("overwrites the OAuth block on fresh login (no inherited refresh_token)", async () => {
// Pre-seed a prior session whose refresh_token must NOT leak into
// the new login when the new response omits one.
await writeStore({
oauth: { access_token: "old_at", refresh_token: "OLD_rt_should_not_survive" },
});
const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 3600 });
await startAuthorizationCodeFlow({ fetchImpl });
const { credentials } = await readStore();
expect(credentials.oauth?.access_token).toBe("new_at");
// Fresh login is a clean session — the old refresh_token is gone.
expect(credentials.oauth?.refresh_token).toBeUndefined();
});
it("preserves a co-located api_key across fresh login", async () => {
await writeStore({ api_key: "hg_keep_me" });
const fetchImpl = tokenFetch({ access_token: "new_at", refresh_token: "new_rt" });
await startAuthorizationCodeFlow({ fetchImpl });
const { credentials } = await readStore();
expect(credentials.api_key).toBe("hg_keep_me");
expect(credentials.oauth?.access_token).toBe("new_at");
expect(credentials.oauth?.refresh_token).toBe("new_rt");
});
it("preserves the user block AND unknown/foreign keys across fresh login", async () => {
// The OAuth write path must not drop co-located data the other CLI
// (or a prior login) wrote. Seed a user block plus a future key,
// then log in fresh — both must survive the OAuth-block overwrite.
const path = (await import("./paths.js")).credentialPath();
await fs.writeFile(
path,
JSON.stringify({
oauth: { access_token: "old_at" },
user: { email: "[email protected]", username: "jdoe" },
future_field: { keep: true },
}),
{ mode: 0o600 },
);
const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 3600 });
await startAuthorizationCodeFlow({ fetchImpl });
const { credentials } = await readStore();
expect(credentials.oauth?.access_token).toBe("new_at");
expect(credentials.user).toEqual({ email: "[email protected]", username: "jdoe" });
// The unknown key is on a hidden slot — assert via the raw file.
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.future_field).toEqual({ keep: true });
});
});
});
-442
View File
@@ -1,442 +0,0 @@
import { failCommand } from "../utils/commandResult.js";
/**
* OAuth 2.0 + PKCE driver for the HeyGen public OAuth flow.
*
* Entry points:
* - `startAuthorizationCodeFlow()` — full interactive login (browser
* opens, loopback waits, code → tokens, persist).
* - `refreshTokens()` — POST <token-endpoint> with grant_type=refresh_token.
* - `revokeTokens()` — best-effort POST <revoke-endpoint>.
*
* Endpoints split across two hosts (verified live):
* - **Authorize** — GET https://app.heygen.com/oauth/authorize
* Renders the consent screen, has to live on the same origin as the
* user's web session (cookies). The Next.js SPA shell serves this.
* - **Token** — POST https://api2.heygen.com/v1/oauth/token
* Server-to-server JSON API. `app.heygen.com/oauth/token` returns
* the SPA HTML for direct POSTs — confirmed by curl; only the api2
* route returns JSON OAuth responses.
* - **Revoke** — POST https://api2.heygen.com/v1/oauth/revoke
* Same host/prefix as token.
*
* The `heygen-oauth-urls.ts` default in `frames-internal/demo-next`
* lists `app.heygen.com/oauth/token` as the default — that's either
* proxied via a Next.js rewrite or set via `HEYGEN_OAUTH_TOKEN_URL` in
* their prod env. A direct fetch to it returns the SPA shell, so we
* use the api2 endpoint here.
*
* Overrides:
* - `FRAMES_OAUTH_AUTHORIZE_URL`
* - `FRAMES_OAUTH_TOKEN_URL`
* - `FRAMES_OAUTH_REVOKE_URL`
* - `FRAMES_OAUTH_CLIENT_ID`
*
* Public client — no `client_secret`.
*/
import { ErrApi, ErrOAuthNotConfigured, ErrRefreshFailed, isAuthError } from "./errors.js";
import { generatePkcePair, generateState } from "./pkce.js";
import { startLoopback } from "./loopback.js";
import { openBrowser } from "./browser.js";
import { scrubCredentials } from "./scrub.js";
import {
isHeaderSafe,
readStore,
writeStore,
type Credentials,
type OAuthTokens,
} from "./store.js";
import { c } from "../ui/colors.js";
const REVOKE_TIMEOUT_MS = 5_000;
const MIN_EXPIRES_IN_SECONDS = 30;
/**
* Default OAuth client_id baked at build time. Override with the
* `FRAMES_OAUTH_CLIENT_ID` env var. Empty string means "not
* configured" — `resolveClientId()` errors cleanly with a pointer
* at `--api-key`.
*/
const DEFAULT_CLIENT_ID = "q2A2QRSke2LrFTPJhoDbHtXh";
const DEFAULT_SCOPES = "openid profile email";
// Endpoint defaults — see file-header comment for why these straddle two
// hosts. Each is independently overridable.
const DEFAULT_AUTHORIZE_URL = "https://app.heygen.com/oauth/authorize";
const DEFAULT_TOKEN_URL = "https://api2.heygen.com/v1/oauth/token";
const DEFAULT_REVOKE_URL = "https://api2.heygen.com/v1/oauth/revoke";
function authorizeEndpoint(): string {
return process.env["FRAMES_OAUTH_AUTHORIZE_URL"] || DEFAULT_AUTHORIZE_URL;
}
function tokenEndpoint(): string {
return process.env["FRAMES_OAUTH_TOKEN_URL"] || DEFAULT_TOKEN_URL;
}
function revokeEndpoint(): string {
return process.env["FRAMES_OAUTH_REVOKE_URL"] || DEFAULT_REVOKE_URL;
}
export interface AuthorizeFlowOptions {
/** Override scopes (default `openid profile email`). */
scope?: string;
/** Inject a custom fetch (used by tests). */
fetchImpl?: typeof fetch;
/** Override timeout in ms (default 120s). */
timeoutMs?: number;
}
export interface AuthorizeFlowResult {
tokens: OAuthTokens;
/** Returned identity info for friendly post-login UX. */
userInfo?: Record<string, unknown>;
}
export interface RefreshOptions {
fetchImpl?: typeof fetch;
}
/** Read the client_id, throwing `ErrOAuthNotConfigured` when unset. */
export function resolveClientId(): string {
const override = process.env["FRAMES_OAUTH_CLIENT_ID"];
const id = override && override.length > 0 ? override : DEFAULT_CLIENT_ID;
if (!id || id.length === 0) throw ErrOAuthNotConfigured();
return id;
}
/**
* For command-entry points: throw the standard `ErrOAuthNotConfigured`
* via `resolveClientId()`; on a real misconfig, print a friendly hint
* and exit with status 1 rather than dumping a stack. Throws non-auth
* errors so callers can surface programmer bugs.
*/
export function assertOAuthConfiguredOrExit(): void {
try {
resolveClientId();
} catch (err) {
if (isAuthError(err) && err.code === "OAUTH_NOT_CONFIGURED") {
console.error(`Error: ${err.message}`);
if (err.hint) console.error(err.hint);
failCommand();
}
throw err;
}
}
export async function startAuthorizationCodeFlow(
opts: AuthorizeFlowOptions = {},
): Promise<AuthorizeFlowResult> {
const clientId = resolveClientId();
const scope = opts.scope ?? DEFAULT_SCOPES;
const pkce = generatePkcePair();
const state = generateState();
const loopback = await startLoopback({ state, timeoutMs: opts.timeoutMs });
const authorizeUrl = buildAuthorizeUrl({
clientId,
redirectUri: loopback.redirectUri,
scope,
state,
challenge: pkce.challenge,
});
// Print the host+path only (no state / code_challenge) so live
// values can't leak into scrollback / CI logs during the 120s window.
console.log(`Opening browser to ${c.dim(authorizeHost(authorizeUrl))} ...`);
const { opened } = await openBrowser(authorizeUrl);
if (!opened) {
// openBrowser already printed the manual URL; surface it as the
// last on-screen instruction so it isn't buried above "Waiting…".
console.log(c.dim("(open the URL above to continue)"));
}
console.log(`Waiting for callback on ${c.accent(loopback.redirectUri)} ...`);
let codeResult;
try {
codeResult = await loopback.result;
} catch (err) {
await loopback.close().catch(() => {});
throw err;
}
const tokens = await exchangeCodeForTokens({
clientId,
code: codeResult.code,
redirectUri: codeResult.redirectUri,
verifier: pkce.verifier,
fetchImpl: opts.fetchImpl,
});
// Fresh login → clean OAuth block (no inherited refresh_token).
await persistOAuth(tokens, { preserveMissing: false });
return { tokens };
}
export async function refreshTokens(
refresh_token: string,
opts: RefreshOptions = {},
): Promise<OAuthTokens> {
const clientId = resolveClientId();
const fetchImpl = opts.fetchImpl ?? fetch;
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token,
client_id: clientId,
});
const res = await fetchImpl(tokenEndpoint(), {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
accept: "application/json",
},
body: body.toString(),
});
if (res.status === 400 || res.status === 401) {
throw ErrRefreshFailed(await safeText(res));
}
if (!res.ok) {
throw ErrApi(res.status, (await safeText(res)) || res.statusText);
}
const payload = await readJsonOrThrow(res);
const tokens = parseTokenResponse(payload);
// Refresh grant → preserve a refresh_token the server didn't rotate.
await persistOAuth(tokens, { preserveMissing: true });
return tokens;
}
export interface RevokeOptions extends RefreshOptions {
/** Hint to the server about which token we're sending (RFC 7009). */
token_type_hint?: "access_token" | "refresh_token";
/** Abort the revoke after this many ms (default 5s). */
timeoutMs?: number;
}
/**
* RFC 7009 revoke. Best-effort: never throws. A hung IdP or unset
* client_id MUST NOT block local logout — both are caught here so the
* caller can wipe local state immediately afterward regardless.
*/
export async function revokeTokens(token: string, opts: RevokeOptions = {}): Promise<void> {
let clientId: string;
try {
clientId = resolveClientId();
} catch {
// OAuth not configured — nothing to revoke server-side.
return;
}
const fetchImpl = opts.fetchImpl ?? fetch;
const params: Record<string, string> = { token, client_id: clientId };
if (opts.token_type_hint) params["token_type_hint"] = opts.token_type_hint;
const body = new URLSearchParams(params);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? REVOKE_TIMEOUT_MS);
try {
const res = await fetchImpl(revokeEndpoint(), {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: body.toString(),
signal: controller.signal,
});
if (!res.ok) {
console.error(
c.dim(`Note: token revoke returned HTTP ${res.status}; local credentials cleared anyway.`),
);
}
} catch {
/* timeout or network error — silent, this is best-effort */
} finally {
clearTimeout(timer);
}
}
function authorizeHost(fullUrl: string): string {
try {
const u = new URL(fullUrl);
return `${u.origin}${u.pathname}`;
} catch {
return fullUrl.split("?")[0] ?? fullUrl;
}
}
function buildAuthorizeUrl(args: {
clientId: string;
redirectUri: string;
scope: string;
state: string;
challenge: string;
}): string {
const params = new URLSearchParams({
response_type: "code",
client_id: args.clientId,
redirect_uri: args.redirectUri,
scope: args.scope,
state: args.state,
code_challenge: args.challenge,
code_challenge_method: "S256",
});
return `${authorizeEndpoint()}?${params.toString()}`;
}
async function exchangeCodeForTokens(args: {
clientId: string;
code: string;
redirectUri: string;
verifier: string;
fetchImpl?: typeof fetch;
}): Promise<OAuthTokens> {
const fetchImpl = args.fetchImpl ?? fetch;
const body = new URLSearchParams({
grant_type: "authorization_code",
code: args.code,
redirect_uri: args.redirectUri,
client_id: args.clientId,
code_verifier: args.verifier,
});
const res = await fetchImpl(tokenEndpoint(), {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
accept: "application/json",
},
body: body.toString(),
});
if (res.status === 400 || res.status === 401) {
// The authorization code is single-use and short-lived. A 400/401
// here almost always means it expired during the loopback wait or
// was already redeemed — surface an actionable message instead of
// a bare "HeyGen API error (400)".
const detail = (await safeText(res)) || res.statusText;
throw ErrRefreshFailed(
`authorization code rejected (${detail}); please run \`auth login\` again`,
);
}
if (!res.ok) {
throw ErrApi(res.status, (await safeText(res)) || res.statusText);
}
return parseTokenResponse(await readJsonOrThrow(res));
}
/**
* Parse the RFC 6749 token response. Backend may also include
* `id_token` (OIDC) but we ignore that today.
*
* Throws `ErrInvalidTokenResponse` (an AuthError carrying
* REFRESH_FAILED code, so callers using `tryRefresh` consistently
* route the user to "log in again" instead of a generic API error)
* on shape failures.
*/
// fallow-ignore-next-line complexity
export function parseTokenResponse(payload: unknown): OAuthTokens {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
throw ErrRefreshFailed("token endpoint returned a non-object payload");
}
const obj = payload as Record<string, unknown>;
const accessToken = stringField(obj, "access_token");
if (!accessToken) {
throw ErrRefreshFailed("token endpoint did not return an access_token");
}
if (!isHeaderSafe(accessToken)) {
throw ErrRefreshFailed("access_token contains control characters");
}
const out: OAuthTokens = { access_token: accessToken };
const refreshToken = stringField(obj, "refresh_token");
if (refreshToken) {
if (!isHeaderSafe(refreshToken)) {
throw ErrRefreshFailed("refresh_token contains control characters");
}
out.refresh_token = refreshToken;
}
const tokenType = stringField(obj, "token_type");
if (tokenType) out.token_type = tokenType;
const scope = stringField(obj, "scope");
if (scope) out.scope = scope;
const expiresIn = numericField(obj, "expires_in");
if (expiresIn !== undefined) {
// Clamp to a sensible minimum so a misbehaving / clock-skewed
// server returning 0 or a negative value doesn't put expires_at in
// the past and cause the 401-refresh path to loop.
const clamped = Math.max(expiresIn, MIN_EXPIRES_IN_SECONDS);
out.expires_at = new Date(Date.now() + clamped * 1000).toISOString();
}
return out;
}
function stringField(obj: Record<string, unknown>, key: string): string | undefined {
const v = obj[key];
return typeof v === "string" && v.length > 0 ? v : undefined;
}
function numericField(obj: Record<string, unknown>, key: string): number | undefined {
const v = obj[key];
if (typeof v === "number" && Number.isFinite(v)) return v;
if (typeof v === "string") {
const n = Number.parseFloat(v);
return Number.isFinite(n) ? n : undefined;
}
return undefined;
}
/**
* Persist a new OAuth token set, always preserving a co-located
* `api_key`.
*
* `preserveMissing` controls how the new tokens combine with whatever
* OAuth block is already on disk:
* - `false` (fresh authorization-code login): overwrite the OAuth
* block entirely. A new interactive login is a clean session — it
* must NOT inherit the previous session's refresh_token, or a
* response that omits one would pair a new access token with a
* stale refresh token and break/misroute the next refresh.
* - `true` (refresh grant): keep the prior refresh_token / scope /
* token_type when the response omits them. RFC 6749 §6 lets the
* token endpoint skip refresh_token on a no-rotation refresh, and
* dropping it would brick future refreshes.
*/
async function persistOAuth(
tokens: OAuthTokens,
opts: { preserveMissing: boolean },
): Promise<void> {
let existing: Credentials = {};
try {
const { credentials } = await readStore();
existing = credentials;
} catch {
// Treat unreadable existing file as empty — we're about to
// overwrite the OAuth block anyway.
existing = {};
}
const oauth: OAuthTokens = opts.preserveMissing
? { ...existing.oauth, ...tokens }
: { ...tokens };
// Start from the existing record so co-located data survives: the
// api_key, the friendly-display `user` block, AND any unknown/foreign
// keys another CLI wrote (carried on a hidden symbol slot by spread).
// Only the `oauth` block is overwritten here.
await writeStore({ ...existing, oauth });
}
async function readJsonOrThrow(res: Response): Promise<unknown> {
try {
return await res.json();
} catch (err) {
throw ErrApi(res.status, `non-JSON body: ${(err as Error).message}`);
}
}
async function safeText(res: Response): Promise<string> {
try {
// Token/revoke requests carry refresh_token, authorization code, and
// code_verifier in the form body; a server/proxy error page can echo
// request data back. Scrub before the text reaches any error message.
return scrubCredentials((await res.text()).slice(0, 500));
} catch {
return "";
}
}
-32
View File
@@ -1,32 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { homedir } from "node:os";
import { join } from "node:path";
import { CREDENTIAL_FILENAME, configDir, credentialPath } from "./paths.js";
describe("auth/paths", () => {
const original = process.env["HEYGEN_CONFIG_DIR"];
beforeEach(() => {
delete process.env["HEYGEN_CONFIG_DIR"];
});
afterEach(() => {
if (original !== undefined) process.env["HEYGEN_CONFIG_DIR"] = original;
else delete process.env["HEYGEN_CONFIG_DIR"];
});
it("defaults to ~/.heygen", () => {
expect(configDir()).toBe(join(homedir(), ".heygen"));
});
it("honors HEYGEN_CONFIG_DIR override", () => {
process.env["HEYGEN_CONFIG_DIR"] = "/tmp/some-test-dir";
expect(configDir()).toBe("/tmp/some-test-dir");
expect(credentialPath()).toBe(join("/tmp/some-test-dir", CREDENTIAL_FILENAME));
});
it("treats empty HEYGEN_CONFIG_DIR as unset", () => {
process.env["HEYGEN_CONFIG_DIR"] = "";
expect(configDir()).toBe(join(homedir(), ".heygen"));
});
});
-25
View File
@@ -1,25 +0,0 @@
/**
* Filesystem layout for the shared HeyGen credential store. Mirrors
* `heygen-cli/internal/paths/paths.go` so both CLIs read the same file.
* `HEYGEN_CONFIG_DIR` overrides the directory.
*/
import { homedir } from "node:os";
import { join } from "node:path";
/**
* Filename for the credential store. Matches heygen-cli (no `.json`
* suffix) so a `~/.heygen/credentials` written by either CLI is
* readable by the other — see `heygen-cli/internal/auth/file_resolver.go`.
*/
export const CREDENTIAL_FILENAME = "credentials";
export function configDir(): string {
const override = process.env["HEYGEN_CONFIG_DIR"];
if (override && override.length > 0) return override;
return join(homedir(), ".heygen");
}
export function credentialPath(): string {
return join(configDir(), CREDENTIAL_FILENAME);
}
-45
View File
@@ -1,45 +0,0 @@
import { describe, expect, it } from "vitest";
import { createHash } from "node:crypto";
import { generatePkcePair, generateState } from "./pkce.js";
describe("auth/pkce", () => {
it("generates a verifier within RFC 7636 length bounds (43-128 chars)", () => {
const { verifier } = generatePkcePair();
expect(verifier.length).toBeGreaterThanOrEqual(43);
expect(verifier.length).toBeLessThanOrEqual(128);
});
it("uses only URL-safe base64 characters", () => {
const { verifier, challenge } = generatePkcePair();
expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/);
expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
});
it("challenge is the SHA-256 hash of the verifier, base64url-encoded", () => {
const { verifier, challenge } = generatePkcePair();
const expected = createHash("sha256")
.update(verifier)
.digest("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
expect(challenge).toBe(expected);
});
it("always uses S256 method", () => {
expect(generatePkcePair().method).toBe("S256");
});
it("generates distinct verifiers on each call", () => {
const a = generatePkcePair().verifier;
const b = generatePkcePair().verifier;
expect(a).not.toBe(b);
});
it("generates state values with sufficient entropy", () => {
const state = generateState();
expect(state.length).toBeGreaterThanOrEqual(40);
expect(state).toMatch(/^[A-Za-z0-9_-]+$/);
expect(state).not.toBe(generateState());
});
});
-43
View File
@@ -1,43 +0,0 @@
/**
* PKCE (Proof Key for Code Exchange, RFC 7636) helpers.
*
* The flow:
* 1. Generate a high-entropy `code_verifier` (43–128 URL-safe chars).
* 2. Send `code_challenge = base64url(SHA-256(code_verifier))` to
* `/v1/oauth/authorize`.
* 3. After the user consents, exchange the returned `code` + the
* original `code_verifier` at `/v1/oauth/token`. The server hashes
* the verifier and rejects the exchange if it doesn't match the
* challenge that opened the flow.
*
* PKCE removes the need for a client secret — perfect for a CLI that
* can't keep one.
*/
import { createHash, randomBytes } from "node:crypto";
const VERIFIER_BYTES = 64; // 64 random bytes → 86 base64url chars (well within 43-128)
export interface PkcePair {
/** Sent on the exchange; kept secret by the CLI between the two HTTP hops. */
verifier: string;
/** Sent on the authorize URL. */
challenge: string;
/** Always "S256" for HeyGen's backend (`code_challenge_method`). */
method: "S256";
}
export function generatePkcePair(): PkcePair {
const verifier = base64UrlEncode(randomBytes(VERIFIER_BYTES));
const challenge = base64UrlEncode(createHash("sha256").update(verifier).digest());
return { verifier, challenge, method: "S256" };
}
/** OAuth `state` parameter — a CSRF token bound to this flow. */
export function generateState(): string {
return base64UrlEncode(randomBytes(32));
}
function base64UrlEncode(buf: Buffer): string {
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
-143
View File
@@ -1,143 +0,0 @@
import { promises as fs } from "node:fs";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { setupTempAuthEnv } from "./_test-utils.js";
import { isAuthError } from "./errors.js";
import { resolveCredential, tryResolveCredential } from "./resolver.js";
import { writeStore } from "./store.js";
describe("auth/resolver", () => {
let fixture: Awaited<ReturnType<typeof setupTempAuthEnv>>;
let dir: string;
beforeEach(async () => {
fixture = await setupTempAuthEnv("hf-auth-resolve-");
dir = fixture.dir;
});
afterEach(async () => {
await fixture.restore();
});
it("prefers HEYGEN_API_KEY over everything else", async () => {
process.env["HEYGEN_API_KEY"] = "env-key";
process.env["FRAMES_API_KEY"] = "alias-key";
await writeStore({ api_key: "file-key" });
const r = await resolveCredential();
expect(r).toEqual({ type: "api_key", key: "env-key", source: "env" });
});
it("falls through to FRAMES_API_KEY", async () => {
process.env["FRAMES_API_KEY"] = "alias-key";
await writeStore({ api_key: "file-key" });
const r = await resolveCredential();
expect(r).toEqual({ type: "api_key", key: "alias-key", source: "env_alias" });
});
it("returns file api_key when no env is set", async () => {
await writeStore({ api_key: "file-key" });
const r = await resolveCredential();
expect(r.type).toBe("api_key");
if (r.type === "api_key") {
expect(r.key).toBe("file-key");
expect(r.source).toBe("file_json");
}
});
it("prefers fresh oauth over api_key", async () => {
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
await writeStore({
api_key: "file-key",
oauth: { access_token: "fresh-at", refresh_token: "rt", expires_at: future },
});
const r = await resolveCredential();
expect(r.type).toBe("oauth");
if (r.type === "oauth") {
expect(r.access_token).toBe("fresh-at");
// Fresh access_token does NOT need refresh, even with refresh_token present.
expect(r.refreshable).toBe(false);
}
});
it("oauth without expires_at is treated as fresh (refreshable=false)", async () => {
await writeStore({
oauth: { access_token: "at", refresh_token: "rt" },
});
const r = await resolveCredential();
expect(r.type).toBe("oauth");
if (r.type === "oauth") expect(r.refreshable).toBe(false);
});
it("marks expired-but-refreshable oauth as refreshable", async () => {
const past = new Date(Date.now() - 60 * 60 * 1000).toISOString();
await writeStore({
oauth: { access_token: "stale-at", refresh_token: "rt", expires_at: past },
});
const r = await resolveCredential();
expect(r.type).toBe("oauth");
if (r.type === "oauth") expect(r.refreshable).toBe(true);
});
it("skips expired non-refreshable oauth and falls through to api_key", async () => {
const past = new Date(Date.now() - 60 * 60 * 1000).toISOString();
await writeStore({
api_key: "fallback",
oauth: { access_token: "stale-at", expires_at: past },
});
const r = await resolveCredential();
expect(r.type).toBe("api_key");
if (r.type === "api_key") expect(r.key).toBe("fallback");
});
it("rejects HEYGEN_API_KEY containing CRLF (header-injection guard)", async () => {
process.env["HEYGEN_API_KEY"] = "hg_x\r\nX-Evil: 1";
await expect(resolveCredential()).rejects.toSatisfy((err) => {
return isAuthError(err) && (err as { code: string }).code === "INVALID_STORE";
});
});
it("throws ErrNotConfigured when nothing is configured", async () => {
await expect(resolveCredential()).rejects.toSatisfy((err) => {
return isAuthError(err) && (err as { code: string }).code === "NOT_CONFIGURED";
});
});
it("identifies legacy plaintext file source", async () => {
const path = join(dir, "credentials");
await fs.writeFile(path, "hg_legacy_key", { mode: 0o600 });
const r = await resolveCredential();
expect(r.type).toBe("api_key");
if (r.type === "api_key") {
expect(r.key).toBe("hg_legacy_key");
expect(r.source).toBe("file_legacy");
}
});
it("tryResolveCredential returns null when not configured", async () => {
expect(await tryResolveCredential()).toBeNull();
});
it("tryResolveCredential surfaces broken-file errors", async () => {
const path = join(dir, "credentials");
await fs.writeFile(path, "{not valid", { mode: 0o600 });
await expect(tryResolveCredential()).rejects.toSatisfy((err) => isAuthError(err));
});
it("uses injected now() for expiry decisions", async () => {
// expires_at is one hour ago in real time. Injecting `now` two
// hours in the past makes the token appear fresh (still valid for
// another hour), so the resolver should NOT mark it refreshable.
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
await writeStore({
oauth: { access_token: "at", refresh_token: "rt", expires_at: oneHourAgo },
});
const r = await resolveCredential({
now: () => new Date(Date.now() - 2 * 60 * 60 * 1000),
});
expect(r.type).toBe("oauth");
if (r.type === "oauth") {
expect(r.access_token).toBe("at");
expect(r.refreshable).toBe(false);
}
});
});
-123
View File
@@ -1,123 +0,0 @@
/**
* Chain resolver for HeyGen credentials.
*
* Priority — first non-empty wins:
* 1. `HEYGEN_API_KEY` env (matches heygen-cli)
* 2. `FRAMES_API_KEY` env (alias for parity with other tools)
* 3. `~/.heygen/credentials` (JSON) — unexpired OAuth, else api_key
*
* Absent sources fall through. A broken file (parse error, bad shape)
* surfaces immediately as `ErrInvalidStore` — silently falling back
* would mask user config bugs.
*
* Expiry policy: an OAuth access_token whose `expires_at` is in the
* past (60s skew) is considered expired. If a `refresh_token` is also
* present, callers can still use it via `refreshable: true`. Otherwise
* the api_key (if any) wins.
*/
import { isHeaderSafe, readStore } from "./store.js";
import { ErrInvalidStore, ErrNotConfigured, isAuthError } from "./errors.js";
type CredentialSource = "env" | "env_alias" | "file_json" | "file_legacy";
interface ApiKeyCredential {
type: "api_key";
key: string;
source: CredentialSource;
}
interface OAuthCredential {
type: "oauth";
access_token: string;
refresh_token?: string;
expires_at?: Date;
scope?: string;
source: CredentialSource;
/** True when the access_token is expired but a refresh_token exists. */
refreshable: boolean;
}
export type ResolvedCredential = ApiKeyCredential | OAuthCredential;
const EXPIRY_SKEW_MS = 60 * 1000;
export interface ResolveOptions {
now?: () => Date;
}
export async function resolveCredential(opts: ResolveOptions = {}): Promise<ResolvedCredential> {
const now = (opts.now ?? (() => new Date()))();
const heygenEnv = process.env["HEYGEN_API_KEY"];
if (heygenEnv && heygenEnv.length > 0) {
if (!isHeaderSafe(heygenEnv)) {
throw ErrInvalidStore("HEYGEN_API_KEY contains control characters");
}
return { type: "api_key", key: heygenEnv, source: "env" };
}
const hfEnv = process.env["FRAMES_API_KEY"];
if (hfEnv && hfEnv.length > 0) {
if (!isHeaderSafe(hfEnv)) {
throw ErrInvalidStore("FRAMES_API_KEY contains control characters");
}
return { type: "api_key", key: hfEnv, source: "env_alias" };
}
const { credentials, source } = await readStore();
if (source === "absent") throw ErrNotConfigured();
const fileSource: CredentialSource = source === "file_legacy" ? "file_legacy" : "file_json";
if (credentials.oauth) {
const oauth = pickOAuth(credentials.oauth, now, fileSource);
if (oauth) return oauth;
}
if (credentials.api_key) {
return { type: "api_key", key: credentials.api_key, source: fileSource };
}
throw ErrNotConfigured();
}
/** Like `resolveCredential` but returns `null` instead of throwing `NOT_CONFIGURED`. */
export async function tryResolveCredential(
opts: ResolveOptions = {},
): Promise<ResolvedCredential | null> {
try {
return await resolveCredential(opts);
} catch (err) {
if (isAuthError(err) && err.code === "NOT_CONFIGURED") {
return null;
}
throw err;
}
}
function pickOAuth(
tokens: NonNullable<Awaited<ReturnType<typeof readStore>>["credentials"]["oauth"]>,
now: Date,
source: CredentialSource,
): OAuthCredential | null {
const expiresAt = parseDate(tokens.expires_at);
const expired = expiresAt !== undefined && expiresAt.getTime() - EXPIRY_SKEW_MS < now.getTime();
if (expired && !tokens.refresh_token) return null;
const out: OAuthCredential = {
type: "oauth",
access_token: tokens.access_token,
source,
refreshable: expired && tokens.refresh_token !== undefined,
};
if (tokens.refresh_token) out.refresh_token = tokens.refresh_token;
if (expiresAt) out.expires_at = expiresAt;
if (tokens.scope) out.scope = tokens.scope;
return out;
}
function parseDate(s: string | undefined): Date | undefined {
if (!s) return undefined;
const d = new Date(s);
return Number.isNaN(d.getTime()) ? undefined : d;
}
-74
View File
@@ -1,74 +0,0 @@
import { describe, expect, it } from "vitest";
import { scrubCredentials } from "./scrub.js";
describe("auth/scrub", () => {
it("redacts HeyGen API keys", () => {
const out = scrubCredentials("rejected key hg_supersecret_abc123 from header");
expect(out).not.toContain("hg_supersecret_abc123");
expect(out).toContain("hg_<redacted>");
});
it("redacts sk_V2_ keys echoed inline (not just after Authorization:)", () => {
// Real HeyGen keys are `sk_V2_…`. A token echoed in a stack trace or
// JSON payload — without a header-name anchor — must still be
// redacted, per the threat model the scrubber is written for.
const out = scrubCredentials(
"error: token sk_V2_hgu_supersecret_real999 was rejected upstream",
);
expect(out).not.toContain("sk_V2_hgu_supersecret_real999");
expect(out).toContain("sk_<redacted>");
});
it("redacts authorization / x-api-key header lines", () => {
const out = scrubCredentials("x-api-key: hg_zzz999\nauthorization: Bearer abc");
expect(out).not.toContain("hg_zzz999");
expect(out).not.toContain("Bearer abc");
expect(out).toContain("x-api-key: <redacted>");
expect(out).toContain("authorization: <redacted>");
});
it("redacts the full Authorization: Bearer value, not just the scheme", () => {
const out = scrubCredentials("echoed Authorization: Bearer at_opaque_secret_999\nnext line");
// The opaque token after `Bearer` must be gone.
expect(out).not.toContain("at_opaque_secret_999");
expect(out).not.toContain("Bearer at_opaque_secret_999");
expect(out).toContain("Authorization: <redacted>");
// Redaction stops at the line break — unrelated following lines survive.
expect(out).toContain("next line");
});
it("redacts JWT-shaped tokens", () => {
const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abcDEF_123-xyz";
const out = scrubCredentials(`token was ${jwt}`);
expect(out).not.toContain(jwt);
expect(out).toContain("<jwt-redacted>");
});
it("redacts form-encoded OAuth secrets (refresh_token, code, code_verifier)", () => {
const body =
"error: grant_type=refresh_token&refresh_token=rt_secret_abc&code=auth_code_xyz&code_verifier=verif_123";
const out = scrubCredentials(body);
expect(out).not.toContain("rt_secret_abc");
expect(out).not.toContain("auth_code_xyz");
expect(out).not.toContain("verif_123");
expect(out).toContain("refresh_token=<redacted>");
expect(out).toContain("code=<redacted>");
expect(out).toContain("code_verifier=<redacted>");
// grant_type is not a secret — keep it for debuggability.
expect(out).toContain("grant_type=refresh_token");
});
it("redacts JSON-encoded OAuth secrets", () => {
const body = '{"error":"invalid","access_token":"at_leak","refresh_token":"rt_leak"}';
const out = scrubCredentials(body);
expect(out).not.toContain("at_leak");
expect(out).not.toContain("rt_leak");
expect(out).toContain('"access_token":"<redacted>"');
expect(out).toContain('"refresh_token":"<redacted>"');
});
it("does not over-redact unrelated text", () => {
const out = scrubCredentials("error_code=42 the request failed validation");
expect(out).toBe("error_code=42 the request failed validation");
});
});
-45
View File
@@ -1,45 +0,0 @@
/**
* Redact credential-shaped substrings from error bodies before they
* reach user-facing messages, `--json` output, or logs.
*
* Both the `/v3/users/me` client and the OAuth token/revoke endpoints
* surface upstream error bodies. A misbehaving server or proxy can echo
* request data (API keys, OAuth `refresh_token` / `code` / `code_verifier`,
* bearer tokens, JWTs) into those bodies — this scrubber makes sure none
* of it lands in scrollback or CI logs.
*/
// Both HeyGen key prefixes: legacy `hg_…` and current `sk_V2_…` (plus
// any `sk_<segment>_…` partner format). A bare key echoed inline in an
// error body — without an Authorization:/x-api-key: header anchor —
// must still be redacted, so we match the prefix directly.
const HEYGEN_KEY = /\b(hg|sk)_[A-Za-z0-9_-]{4,}/g;
// Redact the ENTIRE header value to end-of-line. `Authorization: Bearer
// <token>` is two whitespace-separated words, so a `\S+` would stop after
// the scheme and leave the opaque token exposed.
const HEADER_LINE = /(authorization|x-api-key)[ \t]*[:=][ \t]*[^\r\n]+/gi;
const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
// Sensitive OAuth/credential field names. Matched both as
// form-encoded (`name=value`) and JSON (`"name":"value"`).
const SECRET_FIELDS = [
"access_token",
"refresh_token",
"code_verifier",
"client_secret",
"id_token",
"token",
"code",
];
const FORM_FIELD = new RegExp(`\\b(${SECRET_FIELDS.join("|")})=[^&\\s"']+`, "gi");
const JSON_FIELD = new RegExp(`("(?:${SECRET_FIELDS.join("|")})"\\s*:\\s*)"[^"]*"`, "gi");
export function scrubCredentials(s: string): string {
return s
.replace(HEYGEN_KEY, "$1_<redacted>")
.replace(HEADER_LINE, "$1: <redacted>")
.replace(JWT, "<jwt-redacted>")
.replace(FORM_FIELD, "$1=<redacted>")
.replace(JSON_FIELD, '$1"<redacted>"');
}
-458
View File
@@ -1,458 +0,0 @@
import { promises as fs } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { isAuthError } from "./errors.js";
import {
clearOAuth,
deleteStore,
hasPreservedUnknownData,
readStore,
writeStore,
type Credentials,
} from "./store.js";
async function makeTmpDir(): Promise<string> {
return fs.mkdtemp(join(tmpdir(), "hf-auth-store-"));
}
// POSIX file modes don't apply on Windows — `fs.chmod` only toggles the
// read-only bit there, so `stat.mode & 0o777` reports 0o666/0o444
// regardless of what we requested. Skip the mode assertions on win32;
// the 0600/0700 hardening is a Unix concern.
const IS_POSIX = process.platform !== "win32";
describe("auth/store", () => {
let dir: string;
let path: string;
beforeEach(async () => {
dir = await makeTmpDir();
path = join(dir, "credentials");
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
it("returns absent when the file does not exist", async () => {
const result = await readStore(path);
expect(result).toEqual({ credentials: {}, source: "absent" });
});
it("round-trips api_key only", async () => {
const creds: Credentials = { api_key: "hg_test_abc" };
await writeStore(creds, path);
const result = await readStore(path);
expect(result.source).toBe("file_json");
expect(result.credentials).toEqual(creds);
});
it("round-trips oauth tokens", async () => {
const creds: Credentials = {
oauth: {
access_token: "at_123",
refresh_token: "rt_456",
expires_at: "2026-06-25T12:00:00.000Z",
scope: "openid profile",
token_type: "Bearer",
},
};
await writeStore(creds, path);
const result = await readStore(path);
expect(result.credentials).toEqual(creds);
});
it("round-trips both api_key and oauth", async () => {
const creds: Credentials = {
api_key: "hg_test_abc",
oauth: { access_token: "at_123" },
};
await writeStore(creds, path);
const result = await readStore(path);
expect(result.credentials.api_key).toBe("hg_test_abc");
expect(result.credentials.oauth?.access_token).toBe("at_123");
});
it("reads legacy one-line plaintext format", async () => {
await fs.writeFile(path, "hg_legacy_key\n", { mode: 0o600 });
const result = await readStore(path);
expect(result.source).toBe("file_legacy");
expect(result.credentials.api_key).toBe("hg_legacy_key");
});
it("treats empty file as absent", async () => {
await fs.writeFile(path, "", { mode: 0o600 });
const result = await readStore(path);
expect(result.source).toBe("absent");
});
it("throws ErrInvalidStore on garbage JSON", async () => {
await fs.writeFile(path, "{not valid json", { mode: 0o600 });
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
});
it("throws ErrInvalidStore on multi-line non-JSON content", async () => {
await fs.writeFile(path, "not\na\nkey", { mode: 0o600 });
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
});
it.skipIf(!IS_POSIX)("writes file 0600 and dir 0700", async () => {
const nested = join(dir, "sub", "deeper");
const p = join(nested, "credentials");
await writeStore({ api_key: "hg_x" }, p);
expect((await fs.stat(p)).mode & 0o777).toBe(0o600);
expect((await fs.stat(nested)).mode & 0o777).toBe(0o700);
});
it("preserves content across overwrites", async () => {
await writeStore({ api_key: "first" }, path);
await writeStore({ api_key: "second" }, path);
if (IS_POSIX) {
expect((await fs.stat(path)).mode & 0o777).toBe(0o600);
}
const result = await readStore(path);
expect(result.credentials.api_key).toBe("second");
});
it("rejects empty-string api_key", async () => {
await fs.writeFile(path, JSON.stringify({ api_key: "" }), { mode: 0o600 });
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
});
it("rejects api_key with CR/LF (header-injection guard)", async () => {
await fs.writeFile(path, JSON.stringify({ api_key: "hg_x\r\nX-Evil: foo" }), { mode: 0o600 });
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
});
it("strips oauth fields containing CR/LF rather than crashing later", async () => {
await fs.writeFile(
path,
JSON.stringify({
oauth: {
access_token: "good_at",
refresh_token: "bad_rt\r\nX-Smuggle: 1",
},
}),
{ mode: 0o600 },
);
const result = await readStore(path);
expect(result.credentials.oauth?.access_token).toBe("good_at");
expect(result.credentials.oauth?.refresh_token).toBeUndefined();
});
it("rejects access_token containing CR/LF (header-injection guard)", async () => {
await fs.writeFile(path, JSON.stringify({ oauth: { access_token: "at\r\nX-Evil: 1" } }), {
mode: 0o600,
});
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
});
it("accepts a legacy plaintext key of any HeyGen key format", async () => {
// Real HeyGen keys come in multiple formats (`sk_V2_…`, `hg_…`,
// partner keys, etc.). The CLI doesn't shape-check — the backend
// does. Any single-line printable non-empty value is accepted as
// a legacy key here; the next /v3/users/me call decides validity.
await fs.writeFile(path, "sk_V2_hgu_kVzzCxfI3cT_Yi96MxT2Ki6UamtWxyP7oOIPqsxaFHqN", {
mode: 0o600,
});
const result = await readStore(path);
expect(result.source).toBe("file_legacy");
expect(result.credentials.api_key).toBe(
"sk_V2_hgu_kVzzCxfI3cT_Yi96MxT2Ki6UamtWxyP7oOIPqsxaFHqN",
);
});
it("still rejects plaintext that contains a space (not a credential shape)", async () => {
await fs.writeFile(path, "hello world this is not a key", { mode: 0o600 });
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
});
it("still rejects too-short plaintext", async () => {
await fs.writeFile(path, "tiny", { mode: 0o600 });
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
});
it("rejects oauth without access_token", async () => {
await fs.writeFile(path, JSON.stringify({ oauth: { refresh_token: "rt" } }), {
mode: 0o600,
});
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
});
it("exposes only the typed surface for known keys (unknown keys hidden from callers)", async () => {
// The typed `credentials` view shows only the modelled keys —
// unknown/foreign keys are captured in a hidden (symbol-keyed)
// passthrough slot, not the enumerable surface, so callers can't
// accidentally read them. Round-trip preservation is covered below.
await fs.writeFile(path, JSON.stringify({ api_key: "hg_x", future_field: { stuff: 1 } }), {
mode: 0o600,
});
const result = await readStore(path);
expect(Object.keys(result.credentials)).toEqual(["api_key"]);
expect(result.credentials.api_key).toBe("hg_x");
});
// --- Cross-CLI forward compatibility: unknown-field preservation. ---
// The credentials file is SHARED with the Go `heygen` CLI. If this CLI
// strips keys it doesn't model when it writes the file back, it
// silently destroys the other CLI's data (and vice versa). The writer
// MUST round-trip unknown fields untouched.
it("preserves an unknown TOP-LEVEL key across a read → write round-trip", async () => {
// Simulate a file another CLI version wrote with a future key.
await fs.writeFile(
path,
JSON.stringify({ api_key: "hg_x", future_field: { nested: [1, 2], flag: true } }),
{ mode: 0o600 },
);
// Read it, then write back a typed update (here: just the api_key it
// surfaced). The unknown key must survive.
const { credentials } = await readStore(path);
await writeStore(credentials, path);
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.api_key).toBe("hg_x");
expect(onDisk.future_field).toEqual({ nested: [1, 2], flag: true });
});
it("preserves the heygen-cli `user` block when this CLI rewrites only the credential", async () => {
// The exact cross-CLI data-loss scenario: heygen-cli wrote a `user`
// block; frames-cli updates the api_key and must not drop it.
await fs.writeFile(
path,
JSON.stringify({
api_key: "hg_old",
user: { email: "[email protected]", first_name: "Jane", last_name: "Doe", username: "jdoe" },
}),
{ mode: 0o600 },
);
const { credentials } = await readStore(path);
credentials.api_key = "hg_new";
await writeStore(credentials, path);
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.api_key).toBe("hg_new");
expect(onDisk.user).toEqual({
email: "[email protected]",
first_name: "Jane",
last_name: "Doe",
username: "jdoe",
});
});
it("preserves an unknown key INSIDE the oauth sub-object", async () => {
await fs.writeFile(
path,
JSON.stringify({
oauth: { access_token: "at_1", id_token: "future_id_token_value" },
}),
{ mode: 0o600 },
);
const { credentials } = await readStore(path);
await writeStore(credentials, path);
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.oauth.access_token).toBe("at_1");
expect(onDisk.oauth.id_token).toBe("future_id_token_value");
});
it("preserves an unknown key INSIDE the user sub-object", async () => {
await fs.writeFile(
path,
JSON.stringify({
api_key: "hg_x",
user: { email: "[email protected]", avatar_url: "https://cdn/x.png" },
}),
{ mode: 0o600 },
);
const { credentials } = await readStore(path);
await writeStore(credentials, path);
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.user.email).toBe("[email protected]");
expect(onDisk.user.avatar_url).toBe("https://cdn/x.png");
});
it("round-trips the user block (schema + omitempty: empty fields are not written)", async () => {
const creds: Credentials = {
api_key: "hg_x",
user: { email: "[email protected]", username: "u" },
};
await writeStore(creds, path);
const result = await readStore(path);
expect(result.credentials.user).toEqual({ email: "[email protected]", username: "u" });
// omitempty: only the populated fields appear on disk — no empty
// first_name / last_name strings littering the file.
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.user).toEqual({ email: "[email protected]", username: "u" });
expect(Object.keys(onDisk.user)).toEqual(["email", "username"]);
});
it('omits an all-empty user block entirely (no `"user": {}` litter)', async () => {
await writeStore({ api_key: "hg_x", user: {} }, path);
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.user).toBeUndefined();
expect(onDisk.api_key).toBe("hg_x");
});
it("backwards-compat: a legacy file WITHOUT a user block parses with user undefined", async () => {
await fs.writeFile(path, JSON.stringify({ api_key: "hg_legacy" }), { mode: 0o600 });
const result = await readStore(path);
expect(result.source).toBe("file_json");
expect(result.credentials.api_key).toBe("hg_legacy");
expect(result.credentials.user).toBeUndefined();
});
it("ignores a malformed user sub-field rather than rejecting the whole file", async () => {
// The user block is additive metadata — a junk sub-field must never
// block resolving a perfectly good api_key. Non-string fields are
// dropped; the credential survives.
await fs.writeFile(
path,
JSON.stringify({ api_key: "hg_x", user: { email: "[email protected]", first_name: 12345 } }),
{ mode: 0o600 },
);
const result = await readStore(path);
expect(result.credentials.api_key).toBe("hg_x");
expect(result.credentials.user).toEqual({ email: "[email protected]" });
});
it("deleteStore is idempotent", async () => {
await writeStore({ api_key: "hg_x" }, path);
await deleteStore(path);
await deleteStore(path);
await expect(fs.access(path)).rejects.toThrow();
});
it("clearOAuth removes only the oauth field", async () => {
await writeStore({ api_key: "hg_keep", oauth: { access_token: "drop_me" } }, path);
await clearOAuth(path);
const result = await readStore(path);
expect(result.credentials.oauth).toBeUndefined();
expect(result.credentials.api_key).toBe("hg_keep");
});
it("clearOAuth removes the whole file when no api_key remains", async () => {
await writeStore({ oauth: { access_token: "only" } }, path);
await clearOAuth(path);
await expect(fs.access(path)).rejects.toThrow();
});
it("clearOAuth keeps the user block (and unknown keys) when an api_key survives", async () => {
await fs.writeFile(
path,
JSON.stringify({
api_key: "hg_keep",
oauth: { access_token: "drop_me" },
user: { email: "[email protected]" },
future_field: 1,
}),
{ mode: 0o600 },
);
await clearOAuth(path);
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.oauth).toBeUndefined();
expect(onDisk.api_key).toBe("hg_keep");
expect(onDisk.user).toEqual({ email: "[email protected]" });
expect(onDisk.future_field).toBe(1);
});
it("clearOAuth is a no-op when file is absent", async () => {
await clearOAuth(path);
await expect(fs.access(path)).rejects.toThrow();
});
// --- Destructive paths must not clobber preserved unknown data. ---
// When clearing the only known credential would otherwise delete the
// file, a surviving unknown/foreign top-level key (a future credential
// another CLI owns) must keep the file alive — deleting would clobber
// exactly the cross-CLI data this machinery exists to preserve.
it("clearOAuth keeps the file (writing the unknown bag) when no api_key but a foreign top-level key survives", async () => {
await fs.writeFile(
path,
JSON.stringify({
oauth: { access_token: "drop_me" },
future_credential: { token: "owned_by_other_cli" },
}),
{ mode: 0o600 },
);
await clearOAuth(path);
// File must still exist and carry the foreign key.
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.oauth).toBeUndefined();
expect(onDisk.future_credential).toEqual({ token: "owned_by_other_cli" });
});
it("clearOAuth keeps the file when no api_key but a foreign key survives inside the user block", async () => {
// The user block has no known friendly fields, only a foreign sub-key
// — the block itself survives the oauth clear, so its unknown data
// must too.
await fs.writeFile(
path,
JSON.stringify({
oauth: { access_token: "drop_me" },
user: { external_org_id: "org_123" },
}),
{ mode: 0o600 },
);
await clearOAuth(path);
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.oauth).toBeUndefined();
expect(onDisk.user).toEqual({ external_org_id: "org_123" });
});
it("clearOAuth still deletes the file when only a known (empty-after-clear) surface remains", async () => {
// No api_key, no foreign data — just the oauth block being cleared.
// Nothing worth preserving, so the file goes.
await writeStore({ oauth: { access_token: "only" } }, path);
await clearOAuth(path);
await expect(fs.access(path)).rejects.toThrow();
});
describe("hasPreservedUnknownData", () => {
it("false for an empty record", () => {
expect(hasPreservedUnknownData({})).toBe(false);
});
it("false for a record with only known fields", () => {
expect(
hasPreservedUnknownData({
api_key: "hg_x",
oauth: { access_token: "at" },
user: { email: "[email protected]" },
}),
).toBe(false);
});
it("true when a top-level unknown key was captured at read time", async () => {
await fs.writeFile(path, JSON.stringify({ api_key: "hg_x", future_field: 1 }), {
mode: 0o600,
});
const { credentials } = await readStore(path);
expect(hasPreservedUnknownData(credentials)).toBe(true);
});
it("true when an unknown key was captured inside the oauth sub-object", async () => {
await fs.writeFile(
path,
JSON.stringify({ oauth: { access_token: "at", id_token: "future" } }),
{ mode: 0o600 },
);
const { credentials } = await readStore(path);
expect(hasPreservedUnknownData(credentials)).toBe(true);
});
it("true when an unknown key was captured inside the user sub-object", async () => {
await fs.writeFile(
path,
JSON.stringify({ api_key: "hg_x", user: { email: "[email protected]", avatar_url: "x" } }),
{ mode: 0o600 },
);
const { credentials } = await readStore(path);
expect(hasPreservedUnknownData(credentials)).toBe(true);
});
});
});
-464
View File
@@ -1,464 +0,0 @@
/**
* Read/write the shared `~/.heygen/credentials` file (JSON contents,
* no `.json` extension — the path matches heygen-cli).
*
* Current format:
* {
* "api_key": "hg_...",
* "oauth": {
* "access_token": "...",
* "refresh_token": "...",
* "expires_at": "<ISO-8601 UTC>",
* "scope": "openid profile",
* "token_type": "Bearer"
* },
* "user": {
* "email": "...",
* "first_name": "...",
* "last_name": "...",
* "username": "..."
* }
* }
*
* Legacy: a single-line plaintext API key (the format heygen-cli has
* written historically). If `JSON.parse` rejects the file, we treat the
* trimmed contents as an API key; the next write upgrades to JSON.
*
* Writes go to a temp file + rename, 0600 mode, parent dir 0700.
*
* Cross-CLI forward compatibility: this file is SHARED with the Go
* `heygen` CLI (and any future tool). Either CLI may write keys this
* version doesn't model yet. To avoid one CLI silently clobbering the
* other's data on round-trip, the reader stashes every unrecognized
* top-level key (and every unrecognized key inside the `oauth` / `user`
* sub-objects) into a hidden passthrough bag, and the writer re-emits
* them verbatim. Known fields are still strictly validated; the
* passthrough is purely additive and never feeds an HTTP header.
*
* The same contract binds the destructive paths (`clearOAuth`,
* `clearUserInfo`, and the failed `auth login --api-key` rollback): when
* removing a credential would leave the file with no known credential but
* a surviving unknown/foreign top-level (or user-block) key, they write
* the credential-less remnant rather than deleting the file — see
* `hasPreservedUnknownData`. Deleting there would clobber exactly the
* cross-CLI data this machinery exists to preserve.
*/
import { promises as fs } from "node:fs";
import { dirname } from "node:path";
import { credentialPath } from "./paths.js";
import { ErrInvalidStore } from "./errors.js";
const FILE_MODE = 0o600;
const DIR_MODE = 0o700;
/**
* Symbol-keyed slot holding the raw JSON of any keys this CLI version
* doesn't model, captured at read time and re-emitted verbatim at write
* time. A symbol (rather than a string key) keeps it off the typed
* surface so callers can't accidentally read/write it, and `Object.keys`
* / `JSON.stringify` skip it. See the module header for the rationale.
*/
const UNKNOWN = Symbol("hf.credentials.unknownFields");
/** Keys this CLI version models at the top level of the credentials file. */
const KNOWN_ROOT_KEYS = new Set(["api_key", "oauth", "user"]);
/** Keys this CLI version models inside the `oauth` sub-object. */
const KNOWN_OAUTH_KEYS = new Set([
"access_token",
"refresh_token",
"expires_at",
"scope",
"token_type",
]);
/** Keys this CLI version models inside the `user` sub-object. */
const KNOWN_USER_KEYS = new Set(["email", "first_name", "last_name", "username"]);
export interface OAuthTokens {
access_token: string;
refresh_token?: string;
/** ISO-8601 UTC. */
expires_at?: string;
scope?: string;
token_type?: string;
/** Unknown/future keys captured for cross-CLI round-trip. */
[UNKNOWN]?: Record<string, unknown>;
}
/**
* Friendly-display metadata captured at login time from `/v3/users/me`.
* NOT a credential — additive identity info persisted alongside the
* credential so `auth status` can show "Logged in as ..." without
* re-hitting the API. All fields optional; a file with no `user` block
* (a pre-this-change login) is fully backwards-compatible. Mirrors the
* `user` block heygen-cli writes — see `internal/auth/user_store.go`.
*/
export interface StoredUserInfo {
email?: string;
first_name?: string;
last_name?: string;
username?: string;
/** Unknown/future keys captured for cross-CLI round-trip. */
[UNKNOWN]?: Record<string, unknown>;
}
export interface Credentials {
api_key?: string;
oauth?: OAuthTokens;
user?: StoredUserInfo;
/** Unknown/future top-level keys captured for cross-CLI round-trip. */
[UNKNOWN]?: Record<string, unknown>;
}
export type StoreSource = "file_json" | "file_legacy" | "absent";
export interface ReadResult {
credentials: Credentials;
source: StoreSource;
}
export async function readStore(path = credentialPath()): Promise<ReadResult> {
let raw: string;
try {
raw = await fs.readFile(path, "utf8");
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return { credentials: {}, source: "absent" };
}
throw ErrInvalidStore(`unable to read ${path}: ${(err as Error).message}`);
}
const trimmed = raw.trim();
if (trimmed.length === 0) return { credentials: {}, source: "absent" };
if (trimmed.startsWith("{")) {
return { credentials: parseJsonStore(trimmed), source: "file_json" };
}
if (looksLikeApiKey(trimmed)) {
return { credentials: { api_key: trimmed }, source: "file_legacy" };
}
throw ErrInvalidStore("file is not JSON and does not look like a plain API key");
}
export async function writeStore(credentials: Credentials, path = credentialPath()): Promise<void> {
await ensureDir(dirname(path));
const body = JSON.stringify(serializeCredentials(credentials), null, 2);
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
await fs.writeFile(tmp, `${body}\n`, { mode: FILE_MODE, encoding: "utf8" });
// `mode` on `writeFile` is masked by umask and only applies on file
// creation — explicit chmod is the only reliable way to land on 0600.
// `rename` moves the (already-0600) tmp inode over the destination,
// so the final file carries the tmp's mode; no post-rename chmod
// needed even when overwriting a looser-permissioned file.
await fs.chmod(tmp, FILE_MODE);
await fs.rename(tmp, path);
}
export async function deleteStore(path = credentialPath()): Promise<void> {
try {
await fs.unlink(path);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
throw err;
}
}
/**
* True when `credentials` carries any unrecognized/foreign data captured
* on a hidden passthrough slot — either a top-level unknown key, or an
* unknown key inside the `oauth` / `user` sub-objects.
*
* The cleanup / rollback paths (`clearOAuth`, `clearUserInfo`, the failed
* `auth login --api-key` rollback) use this to decide between deleting the
* file and writing a credential-less remnant. When no known credential
* survives BUT foreign data does, that data may be a future credential or
* metadata key another CLI owns — deleting the file would clobber exactly
* what the cross-CLI forward-compatibility contract promises to preserve.
* So those paths write the remaining record (carrying the unknown bag)
* instead of deleting. Only when nothing worth preserving remains do they
* delete.
*/
export function hasPreservedUnknownData(credentials: Credentials): boolean {
if (hasUnknownBag(credentials[UNKNOWN])) return true;
if (hasUnknownBag(credentials.oauth?.[UNKNOWN])) return true;
if (hasUnknownBag(credentials.user?.[UNKNOWN])) return true;
return false;
}
function hasUnknownBag(bag: Record<string, unknown> | undefined): boolean {
return bag !== undefined && Object.keys(bag).length > 0;
}
/** Remove only the `oauth` block. Used by `auth logout --keep-api-key`. */
export async function clearOAuth(path = credentialPath()): Promise<void> {
const { credentials, source } = await readStore(path);
if (source === "absent" || !credentials.oauth) return;
// Drop oauth, keep everything else (api_key, the friendly-display
// user block, and any unknown/foreign keys) so a logout that only
// clears the OAuth session doesn't silently wipe co-located data.
const next: Credentials = { ...credentials };
delete next.oauth;
if (!next.api_key && !hasPreservedUnknownData(next)) {
// Nothing worth preserving survives. The leftover user block had no
// friendly fields and there's no foreign/unknown data to round-trip,
// so this is orphaned metadata with no credential to attach to — drop
// the file. (A surviving top-level / user-block unknown key, by
// contrast, may be a future credential another CLI owns, so we keep
// the file in that case.)
await deleteStore(path);
return;
}
await writeStore(next, path);
}
async function ensureDir(dir: string): Promise<void> {
try {
const stat = await fs.stat(dir);
if (!stat.isDirectory()) {
throw ErrInvalidStore(`${dir} exists and is not a directory`);
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
await fs.mkdir(dir, { recursive: true, mode: DIR_MODE });
}
try {
await fs.chmod(dir, DIR_MODE);
} catch {
/* perm-less filesystems are fine */
}
}
function parseJsonStore(text: string): Credentials {
const obj = parseJsonObject(text, "credential file root");
const out: Credentials = {};
const apiKey = pickRequiredStringOrAbsent(obj, "api_key", "api_key");
if (apiKey !== undefined) {
if (!isHeaderSafe(apiKey)) {
throw ErrInvalidStore("api_key must not contain control characters");
}
out.api_key = apiKey;
}
if (obj["oauth"] !== undefined && obj["oauth"] !== null) {
out.oauth = parseOAuth(obj["oauth"]);
}
if (obj["user"] !== undefined && obj["user"] !== null) {
out.user = parseUser(obj["user"]);
}
// Capture any top-level keys this CLI version doesn't model so the
// next write round-trips them instead of dropping another CLI's data.
const unknownRoot = collectUnknown(obj, KNOWN_ROOT_KEYS);
if (unknownRoot) out[UNKNOWN] = unknownRoot;
return out;
}
/** Optional-field picker variants used by the data-driven parsers. */
type FieldPicker = "header_safe" | "non_empty";
const PICKERS: Record<
FieldPicker,
(obj: Record<string, unknown>, key: string) => string | undefined
> = {
header_safe: pickHeaderSafeString,
non_empty: pickNonEmptyString,
};
/**
* Copy each `[field, picker]` from `obj` onto `out` when the picker
* yields a value. Data-driven so the optional-field handling stays a
* single loop instead of a long if-chain per parser. `out` is written
* through an index cast — the `spec` field names are the contract that
* keeps the assignments type-correct at the call site.
*/
function assignOptionalStrings(
out: object,
obj: Record<string, unknown>,
spec: readonly [string, FieldPicker][],
): void {
const target = out as Record<string, unknown>;
for (const [field, picker] of spec) {
const v = PICKERS[picker](obj, field);
if (v) target[field] = v;
}
}
const OAUTH_OPTIONAL: readonly [string, FieldPicker][] = [
["refresh_token", "header_safe"],
["expires_at", "non_empty"],
["scope", "non_empty"],
["token_type", "non_empty"],
];
function parseOAuth(raw: unknown): OAuthTokens {
const obj = asJsonObject(raw, "oauth");
const accessToken = pickHeaderSafeString(obj, "access_token");
if (!accessToken) {
throw ErrInvalidStore("oauth.access_token must be a non-empty string with no control chars");
}
const out: OAuthTokens = { access_token: accessToken };
assignOptionalStrings(out, obj, OAUTH_OPTIONAL);
const unknownOAuth = collectUnknown(obj, KNOWN_OAUTH_KEYS);
if (unknownOAuth) out[UNKNOWN] = unknownOAuth;
return out;
}
const USER_OPTIONAL: readonly [string, FieldPicker][] = [
["email", "non_empty"],
["first_name", "non_empty"],
["last_name", "non_empty"],
["username", "non_empty"],
];
/**
* Parse the friendly-display `user` block. Every field is optional and
* lenient — a wrong-typed or empty value is simply skipped rather than
* rejected, because this is additive METADATA, not a credential, and a
* malformed sub-field must never block resolving a perfectly good
* api_key / oauth token. (Contrast with `parseOAuth`, where a missing
* access_token is a hard error.) Unknown keys round-trip.
*/
function parseUser(raw: unknown): StoredUserInfo {
const obj = asJsonObject(raw, "user");
const out: StoredUserInfo = {};
assignOptionalStrings(out, obj, USER_OPTIONAL);
const unknownUser = collectUnknown(obj, KNOWN_USER_KEYS);
if (unknownUser) out[UNKNOWN] = unknownUser;
return out;
}
/** Narrow `raw` to a plain JSON object or throw a labelled error. */
function asJsonObject(raw: unknown, label: string): Record<string, unknown> {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
throw ErrInvalidStore(`${label} must be a JSON object`);
}
return raw as Record<string, unknown>;
}
/**
* Return a shallow copy of every entry in `obj` whose key is not in
* `known`, or `undefined` when there are none. Used to capture
* unrecognized JSON for verbatim re-emission on the next write.
*/
function collectUnknown(
obj: Record<string, unknown>,
known: Set<string>,
): Record<string, unknown> | undefined {
let bag: Record<string, unknown> | undefined;
for (const key of Object.keys(obj)) {
if (known.has(key)) continue;
(bag ??= {})[key] = obj[key];
}
return bag;
}
function parseJsonObject(text: string, label: string): Record<string, unknown> {
let raw: unknown;
try {
raw = JSON.parse(text);
} catch (err) {
throw ErrInvalidStore(`invalid JSON: ${(err as Error).message}`);
}
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
throw ErrInvalidStore(`${label} must be a JSON object`);
}
return raw as Record<string, unknown>;
}
function pickNonEmptyString(obj: Record<string, unknown>, key: string): string | undefined {
const v = obj[key];
return typeof v === "string" && v.length > 0 ? v : undefined;
}
/** Like `pickNonEmptyString` but rejects values containing control chars. */
function pickHeaderSafeString(obj: Record<string, unknown>, key: string): string | undefined {
const v = pickNonEmptyString(obj, key);
return v !== undefined && isHeaderSafe(v) ? v : undefined;
}
/**
* Header-safety check for credential strings: reject any string with
* CR, LF, NUL, or other C0 control characters. Without this, a
* malicious credentials.json could smuggle extra request headers via
* `Authorization` / `x-api-key` (RFC 7230 header injection).
*/
export function isHeaderSafe(s: string): boolean {
// Reject U+0000-U+001F (C0 controls) and U+007F (DEL) — bytes that
// aren't allowed in HTTP header values. Using charCodeAt avoids
// embedding control characters in regex source (lint requirement).
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
if (c < 0x20 || c === 0x7f) return false;
}
return true;
}
/**
* Strict variant: returns the string when present and non-empty,
* `undefined` when the key is absent or null, and throws when the
* field is present-but-invalid (wrong type or empty string).
*/
function pickRequiredStringOrAbsent(
obj: Record<string, unknown>,
key: string,
errorLabel: string,
): string | undefined {
const v = obj[key];
if (v === undefined || v === null) return undefined;
if (typeof v !== "string" || v.length === 0) {
throw ErrInvalidStore(`${errorLabel} must be a non-empty string`);
}
return v;
}
function serializeCredentials(c: Credentials): Record<string, unknown> {
// Re-emit unrecognized top-level keys first so the known fields below
// are authoritative (collectUnknown already excludes known keys, so
// there's no real collision — this is belt-and-suspenders).
const out: Record<string, unknown> = { ...(c[UNKNOWN] ?? {}) };
if (c.api_key) out["api_key"] = c.api_key;
if (c.oauth) out["oauth"] = serializeOAuth(c.oauth);
if (c.user) {
const user = serializeUser(c.user);
// Omit an all-empty user block entirely (no empty `"user": {}` litter).
if (Object.keys(user).length > 0) out["user"] = user;
}
return out;
}
function serializeOAuth(o: OAuthTokens): Record<string, unknown> {
const oauth: Record<string, unknown> = { ...(o[UNKNOWN] ?? {}) };
oauth["access_token"] = o.access_token;
if (o.refresh_token) oauth["refresh_token"] = o.refresh_token;
if (o.expires_at) oauth["expires_at"] = o.expires_at;
if (o.scope) oauth["scope"] = o.scope;
if (o.token_type) oauth["token_type"] = o.token_type;
return oauth;
}
function serializeUser(u: StoredUserInfo): Record<string, unknown> {
const user: Record<string, unknown> = { ...(u[UNKNOWN] ?? {}) };
if (u.email) user["email"] = u.email;
if (u.first_name) user["first_name"] = u.first_name;
if (u.last_name) user["last_name"] = u.last_name;
if (u.username) user["username"] = u.username;
return user;
}
/**
* Legacy-plaintext heuristic. HeyGen API keys come in multiple formats
* (`sk_V2_…`, historic `hg_…`, partner keys, etc.) and the CLI should
* NOT shape-check them — the backend's `/v3/users/me` is the source of
* truth and the existing `auth login` rollback handles bad keys cleanly.
* We only require: a single line, printable, of reasonable length, and
* header-safe (no CR/LF). JSON files are detected separately by the
* leading `{`, so this path can't swallow a JSON fragment.
*/
function looksLikeApiKey(s: string): boolean {
if (s.length < 8) return false;
if (!isHeaderSafe(s)) return false;
// Single line of printable ASCII (excluding space, since real keys
// don't contain spaces — a space-bearing blob is almost certainly
// not a credential).
return /^[!-~]+$/.test(s);
}
-167
View File
@@ -1,167 +0,0 @@
import { promises as fs } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { readStore, writeStore } from "./store.js";
import {
clearUserInfo,
isUserInfoEmpty,
loadUserInfo,
saveUserInfo,
userDisplayName,
type StoredUserInfo,
} from "./user.js";
async function makeTmpDir(): Promise<string> {
return fs.mkdtemp(join(tmpdir(), "hf-auth-user-"));
}
describe("auth/user — userDisplayName priority", () => {
const cases: { name: string; ui: StoredUserInfo; want: string | undefined }[] = [
{
name: "email wins over everything",
ui: { email: "[email protected]", first_name: "Jane", last_name: "Doe", username: "jdoe" },
want: "[email protected]",
},
{
name: "no email → first last",
ui: { first_name: "Jane", last_name: "Doe", username: "jdoe" },
want: "Jane Doe",
},
{ name: "only first name", ui: { first_name: "Jane", username: "jdoe" }, want: "Jane" },
{ name: "only last name", ui: { last_name: "Doe", username: "jdoe" }, want: "Doe" },
{ name: "only username", ui: { username: "jdoe" }, want: "jdoe" },
{ name: "all empty → undefined", ui: {}, want: undefined },
];
for (const tc of cases) {
it(tc.name, () => {
expect(userDisplayName(tc.ui)).toBe(tc.want);
});
}
});
describe("auth/user — isUserInfoEmpty", () => {
it("true for an all-empty block", () => {
expect(isUserInfoEmpty({})).toBe(true);
});
it("false when any field is set", () => {
expect(isUserInfoEmpty({ email: "[email protected]" })).toBe(false);
expect(isUserInfoEmpty({ username: "u" })).toBe(false);
expect(isUserInfoEmpty({ first_name: "J" })).toBe(false);
expect(isUserInfoEmpty({ last_name: "D" })).toBe(false);
});
});
describe("auth/user — save / load / clear", () => {
let dir: string;
let path: string;
beforeEach(async () => {
dir = await makeTmpDir();
path = join(dir, "credentials");
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
it("round-trips a user block through the credentials file", async () => {
await writeStore({ api_key: "hg_x" }, path);
const ui: StoredUserInfo = {
email: "[email protected]",
first_name: "Jane",
last_name: "Doe",
username: "jdoe",
};
await saveUserInfo(ui, path);
expect(await loadUserInfo(path)).toEqual(ui);
});
it("saveUserInfo preserves a co-located api_key", async () => {
await writeStore({ api_key: "hg_keep" }, path);
await saveUserInfo({ email: "[email protected]" }, path);
const { credentials } = await readStore(path);
expect(credentials.api_key).toBe("hg_keep");
expect(credentials.user).toEqual({ email: "[email protected]" });
});
it("saveUserInfo upgrades a legacy plaintext file to JSON with the user block", async () => {
await fs.writeFile(path, "hg_legacy_key\n", { mode: 0o600 });
await saveUserInfo({ email: "[email protected]" }, path);
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.api_key).toBe("hg_legacy_key");
expect(onDisk.user).toEqual({ email: "[email protected]" });
});
it("saveUserInfo with an empty block is a no-op (does not blank an existing block)", async () => {
await writeStore({ api_key: "hg_x", user: { email: "[email protected]" } }, path);
const before = await fs.readFile(path, "utf8");
await saveUserInfo({}, path);
const after = await fs.readFile(path, "utf8");
expect(after).toBe(before);
expect(await loadUserInfo(path)).toEqual({ email: "[email protected]" });
});
it("loadUserInfo returns null for an absent file", async () => {
expect(await loadUserInfo(path)).toBeNull();
});
it("loadUserInfo returns null for a legacy file without a user block (backwards-compat)", async () => {
await fs.writeFile(path, JSON.stringify({ api_key: "hg_legacy" }), { mode: 0o600 });
expect(await loadUserInfo(path)).toBeNull();
});
it("clearUserInfo removes only the user block, keeping the credential", async () => {
await writeStore({ api_key: "hg_keep", user: { email: "[email protected]" } }, path);
await clearUserInfo(path);
const { credentials } = await readStore(path);
expect(credentials.api_key).toBe("hg_keep");
expect(credentials.user).toBeUndefined();
});
it("clearUserInfo removes the whole file when no credential survives", async () => {
// A file holding ONLY a user block (no credential) — clearing leaves
// nothing, so the file should be removed entirely.
await fs.writeFile(path, JSON.stringify({ user: { email: "[email protected]" } }), { mode: 0o600 });
await clearUserInfo(path);
await expect(fs.access(path)).rejects.toThrow();
});
it("clearUserInfo is a no-op when there is no user block", async () => {
await writeStore({ api_key: "hg_only" }, path);
const before = await fs.readFile(path, "utf8");
await clearUserInfo(path);
const after = await fs.readFile(path, "utf8");
expect(after).toBe(before);
});
it("clearUserInfo keeps the file (preserving a foreign top-level key) when no credential survives", async () => {
// The file holds a user block plus a future/foreign top-level key but
// NO known credential. Clearing the user block must NOT delete the
// file — the foreign key may be a credential another CLI owns, and
// dropping it would clobber the cross-CLI data the store preserves.
await fs.writeFile(
path,
JSON.stringify({
user: { email: "[email protected]" },
future_credential: { token: "owned_by_other_cli" },
}),
{ mode: 0o600 },
);
await clearUserInfo(path);
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.user).toBeUndefined();
expect(onDisk.future_credential).toEqual({ token: "owned_by_other_cli" });
});
it("saveUserInfo does not clobber an unknown/foreign top-level key", async () => {
// The cross-CLI invariant exercised through the persistence helper.
await fs.writeFile(path, JSON.stringify({ api_key: "hg_x", future_field: 42 }), {
mode: 0o600,
});
await saveUserInfo({ email: "[email protected]" }, path);
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.future_field).toBe(42);
expect(onDisk.user).toEqual({ email: "[email protected]" });
});
});
-115
View File
@@ -1,115 +0,0 @@
/**
* Persistence + display helpers for the friendly-display `user` block in
* the shared `~/.heygen/credentials` file.
*
* The `user` block is additive METADATA captured at login time from
* `GET /v3/users/me` — NOT a credential. It lets `auth status` (and the
* post-login "Logged in as ..." line) show a recognizable identity
* without re-hitting the API on every invocation, and keeps working when
* the API is unreachable.
*
* This file is SHARED with the Go `heygen` CLI, which writes the same
* `user` block (see `heygen-cli/internal/auth/user_store.go`). Persisting
* here goes through `readStore` / `writeStore`, which round-trip every
* unrecognized key — so saving our user block never clobbers a key the
* other CLI wrote.
*/
import { credentialPath } from "./paths.js";
import {
readStore,
writeStore,
deleteStore,
hasPreservedUnknownData,
type StoredUserInfo,
} from "./store.js";
export type { StoredUserInfo } from "./store.js";
/** True when `u` carries no friendly fields worth surfacing. */
export function isUserInfoEmpty(u: StoredUserInfo): boolean {
return !u.email && !u.first_name && !u.last_name && !u.username;
}
/**
* Most friendly name available, in priority order:
* email > "first last" > first-only > last-only > username > undefined.
* The "first-only" / "last-only" intermediates come from `combineName`,
* which returns `first || last || ""` when only one of the two is present
* (so a user with just a first name still resolves to that first name,
* not straight to username). The caller falls back to its own marker
* (e.g. "(unknown user)") on `undefined`. Mirrors `UserInfo.DisplayName()`
* in heygen-cli.
*/
export function userDisplayName(u: StoredUserInfo): string | undefined {
if (u.email) return u.email;
const name = combineName(u.first_name, u.last_name);
if (name) return name;
return u.username || undefined;
}
/**
* `"first last"` when both are present; otherwise `first || last || ""`
* (one-sided fall-through), and `""` when neither is set. The empty-string
* return is treated as falsy by `userDisplayName`, so it falls through to
* `username`.
*/
function combineName(first?: string, last?: string): string {
if (first && last) return `${first} ${last}`;
return first || last || "";
}
/**
* Persist the friendly-display block, preserving any co-located api_key /
* oauth blocks and unknown/foreign keys. An all-empty `StoredUserInfo`
* is a no-op (the caller should typically gate on `isUserInfoEmpty`
* before calling, but we guard here too so a failed probe can't blank an
* existing block by accident).
*
* A broken pre-existing file surfaces as a thrown `ErrInvalidStore` from
* `readStore` — callers treat persistence as best-effort and warn rather
* than fail the login.
*/
export async function saveUserInfo(info: StoredUserInfo, path = credentialPath()): Promise<void> {
if (isUserInfoEmpty(info)) return;
// readStore preserves co-located api_key / oauth blocks and any
// unknown/foreign keys (captured on a hidden slot), so writing back
// only attaches the user block. A legacy single-line plaintext file
// parses into { api_key }, so this upgrades it to JSON in passing.
const { credentials } = await readStore(path);
credentials.user = { ...info };
await writeStore(credentials, path);
}
/**
* Read the friendly-display block. Returns `null` when the file is absent
* or carries no `user` block (a pre-this-change login). Genuine parse
* errors propagate so the caller can warn rather than silently pretend
* the file is clean.
*/
export async function loadUserInfo(path = credentialPath()): Promise<StoredUserInfo | null> {
const { credentials, source } = await readStore(path);
if (source === "absent") return null;
if (!credentials.user || isUserInfoEmpty(credentials.user)) return null;
return credentials.user;
}
/**
* Remove the friendly-display block, leaving any co-located credential
* (and unknown/foreign keys) intact. When neither a known credential NOR
* any preserved unknown/foreign data survives, the orphaned-metadata file
* is removed entirely. A surviving unknown top-level key (a future
* credential another CLI owns) keeps the file — deleting it would clobber
* exactly the cross-CLI data the store machinery exists to preserve. A
* no-op when there is nothing to clear.
*/
export async function clearUserInfo(path = credentialPath()): Promise<void> {
const { credentials, source } = await readStore(path);
if (source === "absent" || !credentials.user) return;
delete credentials.user;
if (!credentials.api_key && !credentials.oauth && !hasPreservedUnknownData(credentials)) {
await deleteStore(path);
return;
}
await writeStore(credentials, path);
}
@@ -9,7 +9,7 @@
* .png → single RGBA still (only when input is also a single image)
*
* The encode flags for VP9-with-alpha mirror the `chunkEncoder.ts` pattern in
* @frames/engine — `-pix_fmt yuva420p` plus the
* @hanzo/frame-engine — `-pix_fmt yuva420p` plus the
* `-metadata:s:v:0 alpha_mode=1` tag are what make Chrome's `<video>` element
* decode the alpha plane.
*/
@@ -18,7 +18,7 @@ import { extname } from "node:path";
import { findFFmpeg, findFFprobe, getFFmpegInstallHint } from "../browser/ffmpeg.js";
import { createSession, type Session } from "./inference.js";
import { type Device, type ModelId } from "./manager.js";
import { DEFAULT_VP9_CPU_USED } from "@frames/engine";
import { DEFAULT_VP9_CPU_USED } from "@hanzo/frame-engine";
export type OutputFormat = "webm" | "mov" | "png";
@@ -111,7 +111,7 @@ interface EngineMetadata {
async function probeMedia(inputPath: string): Promise<MediaInfo> {
const isImage = inferInputKind(inputPath) === "image";
const engine = (await import("@frames/engine")) as {
const engine = (await import("@hanzo/frame-engine")) as {
extractMediaMetadata: (path: string) => Promise<EngineMetadata>;
};
const meta = await engine.extractMediaMetadata(inputPath);
+2 -2
View File
@@ -1,4 +1,4 @@
// Run the shared beat detection (@frames/core/beats) in a headless Chrome
// Run the shared beat detection (@hanzo/frame-core/beats) in a headless Chrome
// so results match the Studio exactly — same Web Audio decode + same
// bpm-detective. Used by the `beats` CLI command to write the beat file before
// the Studio is ever opened.
@@ -32,7 +32,7 @@ function findPrebuiltBundle(): string | null {
async function buildFromCoreSource(): Promise<string> {
const esbuild = await import("esbuild");
const coreRoot = dirname(require.resolve("@frames/core/package.json"));
const coreRoot = dirname(require.resolve("@hanzo/frame-core/package.json"));
const entry = join(coreRoot, "src/beats/beatDetection.ts");
const result = await esbuild.build({
stdin: {
+1 -1
View File
@@ -17,7 +17,7 @@ afterEach(() => {
});
// Lookup mechanics (PATH scan, common-dir fallback, Windows shim preference)
// are covered by @frames/parsers ffBinaries.test.ts. These tests pin the
// are covered by @hanzo/frame-parsers ffBinaries.test.ts. These tests pin the
// CLI wrapper's contract: a configured-but-missing override means "not found"
// so callers surface the install hint instead of a spawn error.
describe("findFFmpeg / findFFprobe", () => {
+2 -2
View File
@@ -1,8 +1,8 @@
import { execFileSync } from "node:child_process";
import { findFfBinary } from "@frames/parsers/ff-binaries";
import { findFfBinary } from "@hanzo/frame-parsers/ff-binaries";
import { detectLinuxDistro, ffmpegInstallCommand } from "./linuxDeps.js";
export { FFMPEG_PATH_ENV, FFPROBE_PATH_ENV } from "@frames/parsers/ff-binaries";
export { FFMPEG_PATH_ENV, FFPROBE_PATH_ENV } from "@hanzo/frame-parsers/ff-binaries";
export type H264EncoderMode = "software" | "gpu";
+3 -3
View File
@@ -10,7 +10,7 @@
*/
import type { Page } from "puppeteer-core";
import { parseAnimatedGifMetadata } from "@frames/core";
import { parseAnimatedGifMetadata } from "@hanzo/frame-core";
export interface CatalogedAsset {
url: string;
@@ -68,7 +68,7 @@ export async function catalogAssets(page: Page): Promise<CatalogedAsset[]> {
var rect = el.getBoundingClientRect();
ctx.aboveFold = rect.top < window.innerHeight;
} catch(e) {}
// Structural logo-candidate signals: class-substring alone caught 0/32 SVGs on heygen.com.
// Structural logo-candidate signals: class-substring alone caught 0/32 SVGs on a real marketing site.
ctx.inBanner = el.closest('header, nav, [role="banner"]') !== null;
var homeAnchor = el.closest('a[href]');
if (homeAnchor) {
@@ -76,7 +76,7 @@ export async function catalogAssets(page: Page): Promise<CatalogedAsset[]> {
ctx.inHomeLink = aHref === '/' || aHref === '#' || aHref === './' ||
/^https?:\\/\\/[^/]+\\/?$/.test(aHref);
}
// Brand can be first ("HeyGen - Ideas"), last ("Ideas - HeyGen"), or colon-separated ("Vercel: Build").
// Brand can be first ("Acme - Ideas"), last ("Ideas - Acme"), or colon-separated ("Vercel: Build").
var titleParts = (document.title || '').split(/[-|—:]/);
if (desc) {
for (var ti = 0; ti < titleParts.length; ti++) {
@@ -163,7 +163,7 @@ export async function openSettledCompositionPage(
const { ensureBrowser } = await import("../browser/manager.js");
const browser = await ensureBrowser();
const puppeteer = await import("puppeteer-core");
const { buildChromeArgs } = await import("@frames/engine");
const { buildChromeArgs } = await import("@hanzo/frame-engine");
let chromeBrowser: Browser | undefined;
try {
@@ -439,7 +439,7 @@ const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => {
.map(function(e) { return { value: e.value, area: e.area }; });
// ── 9. Frosted-glass panels (backdrop-filter) ──
// A defining material on modern hero UIs (HeyGen's prompt box, Stripe's floating chrome): a
// A defining material on modern hero UIs (a prompt box, Stripe's floating chrome): a
// translucent surface with a backdrop blur. Capture the RAW fill (rgba/gradient — alpha preserved,
// unlike rgbToHex) + the blur, ranked by area. This is what lets a frame render a real frosted card.
var glassSamples = Array.from(document.querySelectorAll(
+3 -3
View File
@@ -60,8 +60,9 @@ if (rootVersionRequested) {
}
// ── Load .env from CWD ─────────────────────────────────────────────────────
// Agents run from the project directory where .env holds API keys (Gemini,
// HeyGen, ElevenLabs). Load it automatically so they don't need `source .env`.
// Agents run from the project directory where .env holds tool settings. Load
// it automatically so they don't need `source .env`. Credentials belong in
// Hanzo KMS, delivered through the environment — never committed to a project.
try {
const { readFileSync } = await import("node:fs");
const { resolve } = await import("node:path");
@@ -159,7 +160,6 @@ const commandLoaders = {
capture: () => import("./commands/capture.js").then((m) => m.default),
lambda: () => import("./commands/lambda.js").then((m) => m.default),
cloudrun: () => import("./commands/cloudrun.js").then((m) => m.default),
cloud: () => import("./commands/cloud.js").then((m) => m.default),
auth: () => import("./commands/auth.js").then((m) => m.default),
figma: () => import("./commands/figma.js").then((m) => m.default),
};
-346
View File
@@ -1,346 +0,0 @@
/**
* AUTO-GENERATED from experiment-framework/openapi/external-api.json.
* DO NOT EDIT MANUALLY. Re-run
* `python scripts/generate_frames_cli_client.py` in
* experiment-framework to regenerate.
*/
import type {
CompleteAssetUploadRequest,
CompleteAssetUploadResponse,
CreateAssetUploadRequest,
CreateAssetUploadResponse,
CreateHyperframesRenderRequest,
CreateHyperframesRenderResponse,
DeleteHyperframesRenderResponse,
HyperframesRenderDetail,
UploadAssetV3Response,
} from "./types.js";
export type AuthHeaders = Record<string, string>;
/**
* Caller-provided context. Keep the shape narrow so the cli/src/auth/
* module stays the single owner of credential resolution.
*/
export interface HyperframesCloudClientOptions {
/** Base URL like "https://api.heygen.com" (no trailing slash). */
baseUrl: string;
/**
* Return the auth headers to attach to every request. Called once per
* request so callers can refresh OAuth tokens transparently.
*/
getAuthHeaders: () => Promise<AuthHeaders> | AuthHeaders;
/** Override fetch (used by tests). */
fetchImpl?: typeof fetch;
}
/**
* Standard error envelope used by /v3 endpoints. See StandardAPIError in
* types.ts for the field shape.
*/
export class HyperframesApiError extends Error {
readonly status: number;
readonly code?: string;
readonly param?: string | null;
readonly docUrl?: string | null;
readonly raw: unknown;
constructor(opts: {
status: number;
message: string;
code?: string;
param?: string | null;
docUrl?: string | null;
raw?: unknown;
}) {
super(opts.message);
this.name = "HyperframesApiError";
this.status = opts.status;
this.code = opts.code;
this.param = opts.param;
this.docUrl = opts.docUrl;
this.raw = opts.raw;
}
}
interface RequestOptions {
method: string;
path: string;
query?: Record<string, string | number | undefined>;
body?: unknown;
multipart?: FormData;
idempotencyKey?: string;
signal?: AbortSignal;
/**
* When true (default), the ``data`` wrapper of the standard /v3
* response envelope is unwrapped before returning. List endpoints set
* this to false so callers can read ``has_more`` / ``next_token``.
*/
unwrapData?: boolean;
}
/**
* Typed client for the Frames cloud-render API. Auto-generated; do
* not hand-edit. Submit new endpoints by adding them to
* scripts/generate_frames_cli_client.py in experiment-framework.
*/
export class HyperframesCloudClient {
private readonly baseUrl: string;
private readonly getAuthHeaders: () => Promise<AuthHeaders> | AuthHeaders;
private readonly fetchImpl: typeof fetch;
constructor(opts: HyperframesCloudClientOptions) {
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
this.getAuthHeaders = opts.getAuthHeaders;
this.fetchImpl = opts.fetchImpl ?? fetch;
}
private async request<T>(opts: RequestOptions): Promise<T> {
const url = this.buildUrl(opts.path, opts.query);
const auth = await this.getAuthHeaders();
const headers: Record<string, string> = { ...auth };
let body: BodyInit | undefined;
if (opts.multipart) {
body = opts.multipart;
// fetch sets the multipart boundary automatically; do NOT set
// Content-Type here or the upload will be rejected.
} else if (opts.body !== undefined) {
headers["content-type"] = "application/json";
body = JSON.stringify(opts.body);
}
if (opts.idempotencyKey) {
headers["Idempotency-Key"] = opts.idempotencyKey;
}
const res = await this.fetchImpl(url, {
method: opts.method,
headers,
body,
signal: opts.signal,
});
if (!res.ok) {
throw await this.toApiError(res);
}
// 204 No Content
if (res.status === 204) {
return undefined as T;
}
const text = await res.text();
if (!text) {
return undefined as T;
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (err) {
throw new HyperframesApiError({
status: res.status,
message: `Invalid JSON response: ${(err as Error).message}`,
raw: text.slice(0, 500),
});
}
// The /v3 envelope is {data: T, ...}. Unwrap when present and the
// call site asked for it (the default) so consumers read the inner
// payload directly. List endpoints opt out so they can read
// ``has_more`` / ``next_token``.
const unwrap = opts.unwrapData !== false;
if (
unwrap &&
parsed &&
typeof parsed === "object" &&
"data" in (parsed as Record<string, unknown>)
) {
const envelope = parsed as { data: T };
return envelope.data;
}
return parsed as T;
}
private buildUrl(path: string, query?: Record<string, string | number | undefined>): string {
const url = new URL(this.baseUrl + path);
if (query) {
for (const [k, v] of Object.entries(query)) {
if (v === undefined) continue;
url.searchParams.set(k, String(v));
}
}
return url.toString();
}
private async toApiError(res: Response): Promise<HyperframesApiError> {
let parsed: unknown;
try {
parsed = await res.json();
} catch {
parsed = undefined;
}
const err =
parsed && typeof parsed === "object" && "error" in (parsed as Record<string, unknown>)
? ((parsed as Record<string, unknown>).error as Record<string, unknown> | undefined)
: undefined;
return new HyperframesApiError({
status: res.status,
message:
(err && typeof err.message === "string" && err.message) ||
`HTTP ${res.status} ${res.statusText}`,
code: err && typeof err.code === "string" ? err.code : undefined,
param:
err && (typeof err.param === "string" || err.param === null)
? (err.param as string | null)
: undefined,
docUrl:
err && (typeof err.doc_url === "string" || err.doc_url === null)
? (err.doc_url as string | null)
: undefined,
raw: parsed,
});
}
/**
* Upload Asset
*
* Uploads a file (image, video, audio, PDF, or SRT subtitle) and returns an asset_id for use in other endpoints. Max 32 MB. Supported types: png, jpeg, mp4, webm, mp3, wav, pdf, srt.
*/
async uploadAsset(args: {
file: Blob | Buffer | Uint8Array;
filename: string;
mimeType?: string;
idempotencyKey?: string;
signal?: AbortSignal;
}): Promise<UploadAssetV3Response> {
const fd = new FormData();
const blobOpts = args.mimeType ? { type: args.mimeType } : undefined;
const blob =
args.file instanceof Blob
? args.file
: new Blob([args.file as unknown as BlobPart], blobOpts);
fd.append("file", blob, args.filename);
return await this.request<UploadAssetV3Response>({
method: "POST",
path: "/v3/assets",
multipart: fd,
idempotencyKey: args.idempotencyKey,
signal: args.signal,
});
}
/**
* Create Asset Upload
*
* Begin a direct-to-S3 upload. Returns an asset_id and a presigned upload_url; PUT the file bytes to upload_url, then call POST /v3/assets/{asset_id}/complete. Unlike POST /v3/assets (which proxies the bytes), this never sends the file through the API.
*/
async createAssetUpload(args: {
body: CreateAssetUploadRequest;
idempotencyKey?: string;
signal?: AbortSignal;
}): Promise<CreateAssetUploadResponse> {
return await this.request<CreateAssetUploadResponse>({
method: "POST",
path: "/v3/assets/direct-uploads",
body: args.body,
idempotencyKey: args.idempotencyKey,
signal: args.signal,
});
}
/**
* Complete Asset Upload
*
* Finalize a direct-to-S3 upload into a reusable asset. Call after the upload PUT returns 200. Idempotent: repeated calls return the same finalized asset.
*/
async completeAssetUpload(args: {
asset_id: string;
body: CompleteAssetUploadRequest;
signal?: AbortSignal;
}): Promise<CompleteAssetUploadResponse> {
return await this.request<CompleteAssetUploadResponse>({
method: "POST",
path: `/v3/assets/${encodeURIComponent(args.asset_id)}/complete`,
body: args.body,
signal: args.signal,
});
}
/**
* Create Frames Render
*
* Renders a Frames composition (an HTML+JS+assets project bundled as a .zip) into a video. Submit the project via `url`, `asset_id` (pre-uploaded via POST /v3/assets), or inline `base64`. Returns a `render_id` to poll via GET /v3/frames/renders/{render_id}.
*/
async createRender(args: {
body: CreateHyperframesRenderRequest;
idempotencyKey?: string;
signal?: AbortSignal;
}): Promise<CreateHyperframesRenderResponse> {
return await this.request<CreateHyperframesRenderResponse>({
method: "POST",
path: "/v3/frames/renders",
body: args.body,
idempotencyKey: args.idempotencyKey,
signal: args.signal,
});
}
/**
* List Frames Renders
*
* Returns a cursor-paginated list of Frames renders in the account, newest first.
*/
async listRenders(args: { limit?: number; token?: string; signal?: AbortSignal }): Promise<{
data?: Array<HyperframesRenderDetail>;
has_more?: boolean;
next_token?: string | null;
}> {
const query: Record<string, string | number | undefined> = {
limit: args.limit,
token: args.token,
};
return await this.request<{
data?: Array<HyperframesRenderDetail>;
has_more?: boolean;
next_token?: string | null;
}>({
method: "GET",
path: "/v3/frames/renders",
query,
unwrapData: false,
signal: args.signal,
});
}
/**
* Get Frames Render
*
* Returns full details for a single Frames render, including status and signed video_url when complete.
*/
async getRender(args: {
render_id: string;
signal?: AbortSignal;
}): Promise<HyperframesRenderDetail> {
return await this.request<HyperframesRenderDetail>({
method: "GET",
path: `/v3/frames/renders/${encodeURIComponent(args.render_id)}`,
signal: args.signal,
});
}
/**
* Delete Frames Render
*
* Soft-deletes a Frames render. Subsequent GETs return 404.
*/
async deleteRender(args: {
render_id: string;
signal?: AbortSignal;
}): Promise<DeleteHyperframesRenderResponse> {
return await this.request<DeleteHyperframesRenderResponse>({
method: "DELETE",
path: `/v3/frames/renders/${encodeURIComponent(args.render_id)}`,
signal: args.signal,
});
}
}
-363
View File
@@ -1,363 +0,0 @@
/**
* AUTO-GENERATED from experiment-framework/openapi/external-api.json.
* DO NOT EDIT MANUALLY. Re-run
* `python scripts/generate_frames_cli_client.py` in
* experiment-framework to regenerate.
*/
// Component schemas reachable from the cloud-render endpoint set.
// Add a new path/method to TARGET_ENDPOINTS in
// scripts/generate_frames_cli_client.py to extend this list.
/**
* Asset input via base64-encoded content.
*/
export interface AssetBase64 {
/**
* Input type discriminator
*/
type: "base64";
/**
* MIME type of the encoded content (e.g. "image/png")
*/
media_type: string;
/**
* Base64-encoded file content
*/
data: string;
}
/**
* Asset input via HeyGen asset ID from the asset upload endpoint.
*/
export interface AssetId {
/**
* Input type discriminator
*/
type: "asset_id";
/**
* HeyGen asset ID from the asset upload endpoint
*/
asset_id: string;
}
/**
* Asset input via publicly accessible HTTPS URL.
*/
export interface AssetUrl {
/**
* Input type discriminator
*/
type: "url";
/**
* Publicly accessible HTTPS URL for the asset
*/
url: string;
}
/**
* Finalize a presigned upload (POST /v3/assets/{asset_id}/complete).
*/
export interface CompleteAssetUploadRequest {
/**
* Optional SHA256 (hex) cross-check.
*/
checksum_sha256?: string | null;
}
/**
* Result of finalizing an upload.
*/
export interface CompleteAssetUploadResponse {
/**
* The reusable asset identifier.
*/
asset_id: string;
/**
* Public URL of the finalized asset.
*/
url: string;
/**
* MIME type detected from the stored bytes.
*/
mime_type: string;
/**
* Size of the stored object in bytes.
*/
size_bytes: number;
/**
* Asset status, e.g. 'processing'.
*/
status: "processing";
}
/**
* Request to begin a presigned direct-to-S3 upload (POST
* /v3/assets/direct-uploads).
*/
export interface CreateAssetUploadRequest {
/**
* Original filename for reference/metadata. The stored object's extension is
* derived from content_type.
*/
filename: string;
/**
* Declared MIME type (e.g. 'video/mp4', 'image/png', 'audio/mpeg',
* 'application/pdf', 'application/zip'). Verified against the stored bytes at
* completion.
*/
content_type: string;
/**
* Exact byte size of the file. Signed into the upload URL so it cannot be
* exceeded.
*/
size_bytes: number;
/**
* Optional SHA256 of the file as hex. When provided, S3 enforces it on upload.
*/
checksum_sha256?: string | null;
}
/**
* Presigned upload instructions.
*/
export interface CreateAssetUploadResponse {
/**
* Reusable asset identifier. Becomes usable after POST
* /v3/assets/{asset_id}/complete.
*/
asset_id: string;
/**
* Presigned S3 URL. PUT the raw file bytes here.
*/
upload_url: string;
/**
* Headers that must be sent verbatim on the PUT request.
*/
upload_headers: Record<string, unknown>;
/**
* Seconds until the upload URL expires.
*/
expires_in_seconds: number;
/**
* Maximum allowed upload size in bytes.
*/
max_bytes: number;
/**
* Upload lifecycle status. Always 'pending_upload' here.
*/
status: "pending_upload";
}
/**
* Request body for POST /v3/frames/renders.
*/
export interface CreateHyperframesRenderRequest {
/**
* Frames composition .zip — provide as {type: 'url', url: '...'}, {type:
* 'asset_id', asset_id: '...'} (pre-uploaded via POST /v3/assets), or {type:
* 'base64', media_type: 'application/zip', data: '...'}. Zip must contain
* index.html at the root (or the path you set in `composition`).
*/
project: AssetUrl | AssetId | AssetBase64;
/**
* Output frames per second. Defaults to 30 if not provided.
*/
fps?: number | null;
/**
* Render quality preset; higher quality is slower.
*/
quality?: "draft" | "standard" | "high";
/**
* Output container/codec.
*/
format?: "mp4" | "webm" | "mov";
/**
* Output resolution tier. Defaults to '1080p'. Pass '4k' for 4K renders
* (billed at 1.5x).
*/
resolution?: HyperframesResolution;
/**
* Output aspect ratio. Defaults to '16:9' (landscape). Pass '9:16' for
* portrait or '1:1' for square.
*/
aspect_ratio?: HyperframesAspectRatio;
/**
* Entry HTML file relative to the project root (e.g. compositions/intro.html).
* Defaults to index.html when omitted.
*/
composition?: string | null;
/**
* Optional overrides for the composition's data-composition-variables. Use
* this to parameterise a single composition across multiple renders.
*/
variables?: Record<string, unknown> | null;
/**
* Free-text label for the render; echoed back in detail responses.
*/
title?: string | null;
/**
* Opaque client tracking ID, echoed back in webhook payloads.
*/
callback_id?: string | null;
/**
* Per-request HTTPS webhook URL the render fires when it terminates.
*/
callback_url?: string | null;
}
/**
* Response for POST /v3/frames/renders.
*/
export interface CreateHyperframesRenderResponse {
/**
* Frames render identifier — poll GET /v3/frames/renders/{render_id}
* for status.
*/
render_id: string;
}
/**
* Response for DELETE /v3/frames/renders/{render_id}.
*/
export interface DeleteHyperframesRenderResponse {
/**
* ID of the deleted render.
*/
render_id: string;
}
/**
* Output aspect ratio. Only the three ratios already supported end-to-end by
* the render pipeline are exposed today: ``16:9`` (landscape), ``9:16``
* (portrait), ``1:1`` (square). ``auto`` and other social-media ratios (4:5,
* 5:4) are reserved for a follow-up PR that wires composition-dim inference at
* the controller boundary.
*/
export type HyperframesAspectRatio = "16:9" | "9:16" | "1:1";
/**
* Detailed Frames render resource.
*/
export interface HyperframesRenderDetail {
/**
* Unique render identifier.
*/
render_id: string;
/**
* Current lifecycle state.
*/
status: HyperframesRenderStatus;
/**
* Caller-supplied free-text label.
*/
title?: string | null;
/**
* Caller-supplied client tracking ID.
*/
callback_id?: string | null;
/**
* Presigned download URL for the rendered video. Present only when status is
* 'completed'.
*/
video_url?: string | null;
/**
* Presigned download URL for the auto-generated thumbnail.
*/
thumbnail_url?: string | null;
/**
* Video duration in seconds; null until completed.
*/
duration?: number | null;
/**
* Frames per second the render was created at.
*/
fps?: number | null;
/**
* Render quality preset.
*/
quality?: "draft" | "standard" | "high" | null;
/**
* Output container/codec.
*/
format: "mp4" | "webm" | "mov";
/**
* Resolution tier, if one was set.
*/
resolution?: HyperframesResolution | null;
/**
* Aspect ratio, if one was set.
*/
aspect_ratio?: HyperframesAspectRatio | null;
/**
* Composition entry file path.
*/
composition?: string | null;
/**
* Unix timestamp when the render was created.
*/
created_at?: number | null;
/**
* Unix timestamp when the render terminated. Null until status is 'completed'
* or 'failed'.
*/
completed_at?: number | null;
/**
* Error description. Present only when status is 'failed'.
*/
failure_message?: string | null;
}
/**
* Lifecycle status of a Frames render.
*/
export type HyperframesRenderStatus = "queued" | "rendering" | "completed" | "failed";
/**
* Output resolution tier. Pricing diverges only at 4K (1.5x multiplier). The
* render-pipeline value set is intentionally narrow at launch; 720p and other
* tiers will follow once the producer/CLI surface catches up.
*/
export type HyperframesResolution = "1080p" | "4k";
export interface StandardAPIError {
/**
* Machine-readable error code
*/
code: string;
/**
* Human-readable error message
*/
message: string;
/**
* Which request field caused the error
*/
param?: string | null;
/**
* Link to error documentation
*/
doc_url?: string | null;
}
/**
* Response from uploading an asset via POST /v3/assets.
*/
export interface UploadAssetV3Response {
/**
* Unique asset identifier for use in other endpoints like POST
* /v3/video-agents
*/
asset_id: string;
/**
* Public URL of the uploaded asset
*/
url: string;
/**
* Detected MIME type of the file
*/
mime_type: string;
/**
* File size in bytes
*/
size_bytes: number;
}
-49
View File
@@ -1,49 +0,0 @@
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");
});
});
});
-32
View File
@@ -1,32 +0,0 @@
/**
* 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);
}
-77
View File
@@ -1,77 +0,0 @@
/**
* 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<Record<string, string>> {
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<ResolvedCredential> {
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<void> {
const credential = await resolveCredential();
if (credential.type !== "oauth" || !credential.refresh_token) return;
await refreshTokens(credential.refresh_token);
}
@@ -1,196 +0,0 @@
import { describe, expect, it } from "vitest";
import {
ASPECT_RATIO_MATCH_TOLERANCE,
detectAspectRatioFromHtmlString,
} from "./detectAspectRatio.js";
const ROOT_DIV = (width: string | number, height: string | number, extra = "") =>
`<div data-composition-id="root" data-width="${width}" data-height="${height}"${extra ? " " + extra : ""}></div>`;
describe("detectAspectRatioFromHtmlString — matches", () => {
it("matches 16:9 on canonical 1920x1080", () => {
const result = detectAspectRatioFromHtmlString(ROOT_DIV(1920, 1080));
expect(result.kind).toBe("matched");
if (result.kind === "matched") {
expect(result.aspectRatio).toBe("16:9");
expect(result.width).toBe(1920);
expect(result.height).toBe(1080);
}
});
it("matches 9:16 on canonical 1080x1920", () => {
const result = detectAspectRatioFromHtmlString(ROOT_DIV(1080, 1920));
expect(result.kind).toBe("matched");
if (result.kind === "matched") {
expect(result.aspectRatio).toBe("9:16");
}
});
it("matches 1:1 on canonical 1080x1080", () => {
const result = detectAspectRatioFromHtmlString(ROOT_DIV(1080, 1080));
expect(result.kind).toBe("matched");
if (result.kind === "matched") {
expect(result.aspectRatio).toBe("1:1");
}
});
it("matches 16:9 on 1280x720 (smaller resolution, same ratio)", () => {
const result = detectAspectRatioFromHtmlString(ROOT_DIV(1280, 720));
expect(result.kind).toBe("matched");
if (result.kind === "matched") expect(result.aspectRatio).toBe("16:9");
});
it("matches 16:9 on 3840x2160 (4K)", () => {
const result = detectAspectRatioFromHtmlString(ROOT_DIV(3840, 2160));
expect(result.kind).toBe("matched");
if (result.kind === "matched") expect(result.aspectRatio).toBe("16:9");
});
it("matches within tolerance for 1916x1080 (16:9 with slight pixel slop)", () => {
// 1916/1080 = 1.774 — within ±0.05 of 16/9 = 1.778
const result = detectAspectRatioFromHtmlString(ROOT_DIV(1916, 1080));
expect(result.kind).toBe("matched");
if (result.kind === "matched") expect(result.aspectRatio).toBe("16:9");
});
});
describe("detectAspectRatioFromHtmlString — non-matches surface as warnings", () => {
it("returns no-match for 4:5 portrait social (864x1080)", () => {
const result = detectAspectRatioFromHtmlString(ROOT_DIV(864, 1080));
expect(result.kind).toBe("no-match");
if (result.kind === "no-match") {
expect(result.width).toBe(864);
expect(result.height).toBe(1080);
expect(result.ratio).toBeCloseTo(0.8, 2);
}
});
it("returns no-match for 5:4 (1350x1080) — outside 1:1 tolerance band", () => {
const result = detectAspectRatioFromHtmlString(ROOT_DIV(1350, 1080));
expect(result.kind).toBe("no-match");
if (result.kind === "no-match") {
expect(result.ratio).toBeCloseTo(1.25, 2);
}
});
it("returns no-match for 3:2 ratio (1500x1000) — outside all three bands", () => {
// 1500/1000 = 1.5. 16:9 = 1.778 (Δ ≈ 0.28), 1:1 = 1.0 (Δ = 0.5),
// 9:16 = 0.5625 (Δ ≈ 0.94). All outside ±0.05. Common ratio for
// older photo-style compositions; needs the no-match warning so
// the user opts into a supported ratio explicitly.
const result = detectAspectRatioFromHtmlString(ROOT_DIV(1500, 1000));
expect(result.kind).toBe("no-match");
if (result.kind === "no-match") {
expect(result.ratio).toBeCloseTo(1.5, 2);
}
});
it("returns no-match for 21:9 ultrawide (2560x1080)", () => {
// 2560/1080 = 2.37. Outside ±0.05 of 16/9 = 1.778.
const result = detectAspectRatioFromHtmlString(ROOT_DIV(2560, 1080));
expect(result.kind).toBe("no-match");
});
it("4:5 is genuinely outside the 1:1 tolerance band — sanity-check the cutoff", () => {
// 4:5 = 0.8; 1:1 = 1.0; difference = 0.2 — well outside 0.05 tolerance.
// This pins the tolerance choice: if someone tightens or loosens it,
// 4:5 must still NOT match 1:1, otherwise we silently mis-classify
// portrait social as square.
const ratio = 0.8;
const diffFromOne = Math.abs(ratio - 1.0);
expect(diffFromOne).toBeGreaterThan(ASPECT_RATIO_MATCH_TOLERANCE);
});
});
describe("detectAspectRatioFromHtmlString — structural edge cases", () => {
it("returns no-root-div when HTML has no composition root", () => {
const html = "<html><body><h1>no composition here</h1></body></html>";
expect(detectAspectRatioFromHtmlString(html).kind).toBe("no-root-div");
});
it("returns no-dims when root div is missing data-width", () => {
const html = `<div data-composition-id="root" data-height="1080"></div>`;
expect(detectAspectRatioFromHtmlString(html).kind).toBe("no-dims");
});
it("returns no-dims when root div is missing data-height", () => {
const html = `<div data-composition-id="root" data-width="1920"></div>`;
expect(detectAspectRatioFromHtmlString(html).kind).toBe("no-dims");
});
it("returns invalid-dims when width/height are zero", () => {
const result = detectAspectRatioFromHtmlString(ROOT_DIV(0, 1080));
expect(result.kind).toBe("invalid-dims");
if (result.kind === "invalid-dims") expect(result.width).toBe(0);
});
it("returns no-dims when data-width includes a sign character", () => {
// The attribute extractor only captures unsigned numbers — a negative
// value (`-1920`) doesn't match the regex at all and the field reads
// as "missing". That's the desired outcome; we don't want to surface
// negative dims as a separate failure mode the user has to interpret.
const result = detectAspectRatioFromHtmlString(ROOT_DIV(-1920, 1080));
expect(result.kind).toBe("no-dims");
});
it("handles unquoted attribute values", () => {
const html = `<div data-composition-id=root data-width=1920 data-height=1080></div>`;
const result = detectAspectRatioFromHtmlString(html);
expect(result.kind).toBe("matched");
if (result.kind === "matched") expect(result.aspectRatio).toBe("16:9");
});
it("handles single-quoted attribute values", () => {
const html = `<div data-composition-id='root' data-width='1920' data-height='1080'></div>`;
expect(detectAspectRatioFromHtmlString(html).kind).toBe("matched");
});
it("ignores attributes on the wrong tag", () => {
// A non-composition div has the dims; the actual root has neither. The
// extractor must NOT swap attributes across tags.
const html = `
<div class="header" data-width="100" data-height="100"></div>
<div data-composition-id="root"></div>
`;
expect(detectAspectRatioFromHtmlString(html).kind).toBe("no-dims");
});
it("uses the FIRST composition root encountered (the page root)", () => {
// Sub-compositions also have data-composition-id; the regex finds the
// first match, which by HF convention is the page-level root.
const html = `
<div data-composition-id="root" data-width="1920" data-height="1080">
<div data-composition-id="sub" data-width="1080" data-height="1080"></div>
</div>
`;
const result = detectAspectRatioFromHtmlString(html);
expect(result.kind).toBe("matched");
if (result.kind === "matched") {
expect(result.aspectRatio).toBe("16:9");
expect(result.width).toBe(1920);
expect(result.height).toBe(1080);
}
});
it("handles attributes in arbitrary order", () => {
const html = `<div data-width="1080" id="root" data-height="1920" data-composition-id="root" class="bg"></div>`;
const result = detectAspectRatioFromHtmlString(html);
expect(result.kind).toBe("matched");
if (result.kind === "matched") expect(result.aspectRatio).toBe("9:16");
});
it("handles whitespace + newlines inside the opening tag", () => {
const html = `<div
data-composition-id="root"
data-width="1080"
data-height="1080"
></div>`;
expect(detectAspectRatioFromHtmlString(html).kind).toBe("matched");
});
it("handles a self-closing-style tag (rare but valid in MDX/JSX-flavored input)", () => {
const html = `<div data-composition-id="root" data-width="1920" data-height="1080"/>`;
expect(detectAspectRatioFromHtmlString(html).kind).toBe("matched");
});
});
-125
View File
@@ -1,125 +0,0 @@
/**
* Auto-detect a Frames composition's aspect ratio from the entry HTML's
* root `<div data-composition-id ...>` `data-width` / `data-height` attributes.
*
* The cloud-render CLI uses this when the user hasn't passed `--aspect-ratio`
* explicitly AND the project source is a local directory (asset-id and url
* paths can't be inspected client-side). The result is passed through to the
* `/v3/frames/renders` request body, so the rendered output preserves
* the composition's intended ratio without the user having to remember the
* flag.
*
* Detection only matches the three values the API currently supports:
* `16:9`, `9:16`, `1:1`. Anything else (e.g. 4:5, 5:4, or an unusual custom
* ratio) returns a `"no-match"` result with the computed ratio for the
* caller to surface to the user. `auto`-style fallbacks (server-side, etc.)
* are out of scope here.
*
* Parsing approach: a narrow regex over the HTML. The root composition div is
* a well-defined pattern (`data-composition-id` always appears on it) and we
* only need two attributes off the same tag. Pulling in `jsdom` or a full DOM
* parser is heavier than the problem warrants.
*/
import { readFileSync } from "node:fs";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
export type SupportedAspectRatio = "16:9" | "9:16" | "1:1";
export type AspectRatioDetection =
| {
kind: "matched";
aspectRatio: SupportedAspectRatio;
width: number;
height: number;
}
| { kind: "no-root-div" }
| { kind: "no-dims" }
| { kind: "invalid-dims"; width: number; height: number }
| { kind: "no-match"; width: number; height: number; ratio: number }
| { kind: "read-error"; error: string };
// Absolute tolerance on the computed ratio. Wide enough to absorb
// floating-point sloppiness on canonical ratios (e.g. 1920×1080 = 1.7778);
// tight enough to keep 4:5 (0.8) and 5:4 (1.25) outside the bands so they
// fall through to the "no-match" warning instead of getting silently
// mis-classified as 1:1 or 16:9.
const RATIO_TOLERANCE = 0.05;
const SUPPORTED_RATIOS: Array<{ value: SupportedAspectRatio; ratio: number }> = [
{ value: "16:9", ratio: 16 / 9 },
{ value: "9:16", ratio: 9 / 16 },
{ value: "1:1", ratio: 1 },
];
// First `<div ... data-composition-id="..." ...>` opening tag in the file.
// Quote style is intentionally permissive — single, double, or unquoted all
// match. Case-insensitive to handle `<DIV>` or `Data-Composition-Id` mid-edit.
const ROOT_COMPOSITION_DIV_RE =
/<div\b[^>]*?\bdata-composition-id\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)[^>]*>/i;
// `data-width` / `data-height` attribute extractors. Accept integer or float
// values, quoted or unquoted. The `\d` class restricts to ASCII digits — no
// locale comma surprises.
const DATA_WIDTH_RE =
/\bdata-width\s*=\s*(?:"(\d+(?:\.\d+)?)"|'(\d+(?:\.\d+)?)'|(\d+(?:\.\d+)?))(?=\s|>|\/)/i;
const DATA_HEIGHT_RE =
/\bdata-height\s*=\s*(?:"(\d+(?:\.\d+)?)"|'(\d+(?:\.\d+)?)'|(\d+(?:\.\d+)?))(?=\s|>|\/)/i;
function extractAttributeNumber(tag: string, re: RegExp): number | null {
const match = tag.match(re);
if (!match) return null;
// First capture group that matched (quoted-double | quoted-single | unquoted).
const raw = match[1] ?? match[2] ?? match[3];
if (raw === undefined) return null;
const value = Number(raw);
return Number.isFinite(value) ? value : null;
}
/**
* Parse the HTML at `entryHtmlPath` and detect which supported aspect ratio
* the composition's root div is authored at.
*
* Pure function except for `readFileSync` — no logging, no `process.exit`.
* The caller decides how to surface each result kind to the user.
*/
export function detectAspectRatioFromHtml(entryHtmlPath: string): AspectRatioDetection {
let html: string;
try {
html = readFileSync(entryHtmlPath, "utf-8");
} catch (err) {
return { kind: "read-error", error: normalizeErrorMessage(err) };
}
return detectAspectRatioFromHtmlString(html);
}
/**
* Same as `detectAspectRatioFromHtml`, but takes the HTML as a string instead
* of a file path. Exposed for tests + composition-string callers.
*/
export function detectAspectRatioFromHtmlString(html: string): AspectRatioDetection {
const tagMatch = html.match(ROOT_COMPOSITION_DIV_RE);
if (!tagMatch) return { kind: "no-root-div" };
const openTag = tagMatch[0];
const width = extractAttributeNumber(openTag, DATA_WIDTH_RE);
const height = extractAttributeNumber(openTag, DATA_HEIGHT_RE);
if (width === null || height === null) return { kind: "no-dims" };
if (width <= 0 || height <= 0) return { kind: "invalid-dims", width, height };
const ratio = width / height;
for (const candidate of SUPPORTED_RATIOS) {
if (Math.abs(ratio - candidate.ratio) <= RATIO_TOLERANCE) {
return { kind: "matched", aspectRatio: candidate.value, width, height };
}
}
return { kind: "no-match", width, height, ratio };
}
/**
* The tolerance used when matching the computed ratio to a supported value.
* Exposed for tests + caller introspection (e.g. warning messages that want
* to mention the bounds).
*/
export const ASPECT_RATIO_MATCH_TOLERANCE = RATIO_TOLERANCE;
-112
View File
@@ -1,112 +0,0 @@
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<string, string> = {}): 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();
});
});
-151
View File
@@ -1,151 +0,0 @@
/**
* Stream a presigned `video_url` (or any HTTPS URL) into a local file.
*
* The presigned URLs returned by `GET /v3/frames/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<DownloadResult> {
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<Uint8Array>) {
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 \`frames 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<void> {
return new Promise<void>((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<void> {
return new Promise<void>((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());
});
}
-141
View File
@@ -1,141 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// errorBox writes to the console; mock it so we can assert which title /
// message / third line the cascade picked without matching ANSI output.
vi.mock("../ui/format.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../ui/format.js")>()),
errorBox: vi.fn(),
}));
import { errorBox } from "../ui/format.js";
import { HyperframesApiError } from "./_gen/client.js";
import { reportApiError } from "./errors.js";
import { CliRuntimeError } from "../utils/commandResult.js";
describe("cloud/errors reportApiError", () => {
beforeEach(() => {
vi.mocked(errorBox).mockClear();
});
it("short-circuits a 404 to a Not found box when notFound is provided", () => {
const err = new HyperframesApiError({ status: 404, message: "missing" });
expect(() =>
reportApiError("Get failed", err, { notFound: "render hfr_x no longer exists" }),
).toThrow(CliRuntimeError);
expect(errorBox).toHaveBeenCalledWith("Not found", "render hfr_x no longer exists");
});
it("uses the code-specific hint when the error code is known", () => {
const err = new HyperframesApiError({
status: 429,
message: "slow down",
code: "rate_limit_exceeded",
});
expect(() => reportApiError("Submit failed", err)).toThrow(CliRuntimeError);
expect(errorBox).toHaveBeenCalledWith(
"Submit failed (HTTP 429)",
"slow down",
"Retry after the duration in the Retry-After header.",
);
});
it("points oversized projects to dry-run and .framesignore", () => {
const err = new HyperframesApiError({
status: 413,
message: "project too large",
code: "frames_project_too_large",
});
expect(() => reportApiError("Upload failed", err)).toThrow(CliRuntimeError);
expect(errorBox).toHaveBeenCalledWith(
"Upload failed (HTTP 413)",
"project too large",
"The zip exceeded the 200 MB limit. Run `frames cloud render --dry-run`, then add only verified-unneeded paths to `.framesignore` or pre-host required large media.",
);
});
it("prefers the code-specific hint over a caller suggestion", () => {
const err = new HyperframesApiError({
status: 400,
message: "bad param",
code: "invalid_parameter",
});
expect(() => reportApiError("Submit failed", err, { suggestion: "see --help" })).toThrow(
CliRuntimeError,
);
expect(errorBox).toHaveBeenCalledWith(
"Submit failed (HTTP 400)",
"bad param",
"Check the listed parameter against `frames cloud render --help` for the accepted values.",
);
});
it("falls back to the caller suggestion when the code has no hint", () => {
const err = new HyperframesApiError({
status: 500,
message: "boom",
code: "some_unmapped_code",
});
expect(() => reportApiError("List failed", err, { suggestion: "retry shortly" })).toThrow(
CliRuntimeError,
);
expect(errorBox).toHaveBeenCalledWith("List failed (HTTP 500)", "boom", "retry shortly");
});
it("falls back to a bare code label when there is no hint and no suggestion", () => {
const err = new HyperframesApiError({
status: 500,
message: "boom",
code: "some_unmapped_code",
});
expect(() => reportApiError("List failed", err)).toThrow(CliRuntimeError);
expect(errorBox).toHaveBeenCalledWith(
"List failed (HTTP 500)",
"boom",
"code: some_unmapped_code",
);
});
it("omits the third line when there is no code, hint, or suggestion", () => {
const err = new HyperframesApiError({ status: 503, message: "unavailable" });
expect(() => reportApiError("Get failed", err)).toThrow(CliRuntimeError);
expect(errorBox).toHaveBeenCalledWith("Get failed (HTTP 503)", "unavailable");
});
it("merges extraHints on top of the built-in table", () => {
const err = new HyperframesApiError({ status: 418, message: "teapot", code: "custom_code" });
expect(() =>
reportApiError("Brew failed", err, { extraHints: { custom_code: "use coffee instead" } }),
).toThrow(CliRuntimeError);
expect(errorBox).toHaveBeenCalledWith("Brew failed (HTTP 418)", "teapot", "use coffee instead");
});
it("lets an extraHints entry override a built-in hint", () => {
const err = new HyperframesApiError({
status: 429,
message: "slow down",
code: "rate_limit_exceeded",
});
expect(() =>
reportApiError("Submit failed", err, {
extraHints: { rate_limit_exceeded: "custom backoff guidance" },
}),
).toThrow(CliRuntimeError);
expect(errorBox).toHaveBeenCalledWith(
"Submit failed (HTTP 429)",
"slow down",
"custom backoff guidance",
);
});
it("reports a plain Error with the stage title and suggestion", () => {
expect(() =>
reportApiError("Upload failed", new Error("disk full"), { suggestion: "free up space" }),
).toThrow(CliRuntimeError);
expect(errorBox).toHaveBeenCalledWith("Upload failed", "disk full", "free up space");
});
it("stringifies a non-Error thrown value", () => {
expect(() => reportApiError("Weird failure", "just a string")).toThrow(CliRuntimeError);
expect(errorBox).toHaveBeenCalledWith("Weird failure", "just a string", undefined);
});
});
-90
View File
@@ -1,90 +0,0 @@
import { failCommand } from "../utils/commandResult.js";
/**
* 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 `frames_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<string, string> = {
frames_project_too_large:
"The zip exceeded the 200 MB limit. Run `frames cloud render --dry-run`, then add only verified-unneeded paths to `.framesignore` or pre-host required large media.",
frames_render_not_found:
"The render_id no longer exists — either soft-deleted or never created.",
invalid_parameter:
"Check the listed parameter against `frames cloud render --help` for the accepted values.",
authentication_failed:
"Run `frames auth status` to confirm your credential; `frames 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: frames 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<string, string>;
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);
failCommand();
}
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);
}
failCommand();
}
if (err instanceof Error) {
errorBox(stage, err.message, options.suggestion);
failCommand();
}
errorBox(stage, String(err), options.suggestion);
failCommand();
}
@@ -1,15 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const generatedClientSource = readFileSync(new URL("./_gen/client.ts", import.meta.url), "utf8");
describe("generated cloud client source contract", () => {
it("documents SRT subtitles on the uploadAsset endpoint", () => {
const uploadAssetDocs = generatedClientSource.match(
/\/\*\*\s*\*\s*Upload Asset[\s\S]*?\*\/\s+async uploadAsset/,
)?.[0];
expect(uploadAssetDocs).toContain("SRT subtitle");
expect(uploadAssetDocs).toMatch(/Supported types:[^\n]*\bsrt\b/);
});
});
-147
View File
@@ -1,147 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// The 401-retry decorator calls forceRefreshCredentials() and the factory
// resolves base URL / auth headers from auth.js. Mock the module so the
// tests control the token lifecycle without touching the real credential
// store on disk.
vi.mock("./auth.js", () => ({
forceRefreshCredentials: vi.fn(),
resolveCloudAuthHeaders: vi.fn(),
resolveCloudBaseUrl: vi.fn(() => "https://cloud.test"),
}));
import { forceRefreshCredentials, resolveCloudAuthHeaders } from "./auth.js";
import { createCloudClient } from "./index.js";
import { HyperframesApiError } from "./_gen/client.js";
const jsonResponse = (status: number, body: unknown): Response =>
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
const ok = (body: unknown): Response => jsonResponse(200, body);
const unauthorized = (): Response =>
jsonResponse(401, { error: { message: "token revoked", code: "unauthorized" } });
// Narrow a recorded fetch call to the headers of its RequestInit without
// casting; throws (failing the test) if the call shape is unexpected.
const headersOf = (call: readonly unknown[] | undefined): unknown => {
const init = call?.[1];
if (init === null || init === undefined || typeof init !== "object" || !("headers" in init)) {
throw new Error("expected fetch to be called with a RequestInit carrying headers");
}
return init.headers;
};
describe("createCloudClient 401-retry decorator", () => {
// The generated client falls back to global fetch when no fetchImpl is
// injected, and createCloudClient doesn't expose that knob — stub the
// global so the decorator under test wraps the same client the cloud
// commands get.
let fetchMock: ReturnType<typeof vi.fn>;
let token: string;
beforeEach(() => {
token = "tok-old";
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
vi.mocked(resolveCloudAuthHeaders).mockImplementation(async () => ({
authorization: `Bearer ${token}`,
}));
vi.mocked(forceRefreshCredentials).mockReset();
vi.mocked(forceRefreshCredentials).mockImplementation(async () => {
token = "tok-new";
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("passes a successful call through without refreshing", async () => {
fetchMock.mockResolvedValueOnce(ok({ data: { id: "hfr_1", status: "complete" } }));
const client = await createCloudClient();
const render = await client.getRender({ render_id: "hfr_1" });
expect(render).toEqual({ id: "hfr_1", status: "complete" });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(forceRefreshCredentials).not.toHaveBeenCalled();
});
it("refreshes once on 401 and retries with the new token", async () => {
fetchMock
.mockResolvedValueOnce(unauthorized())
.mockResolvedValueOnce(ok({ data: { id: "hfr_1", status: "complete" } }));
const client = await createCloudClient();
const render = await client.getRender({ render_id: "hfr_1" });
expect(render).toEqual({ id: "hfr_1", status: "complete" });
expect(forceRefreshCredentials).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(2);
// The retry must re-resolve credentials, not replay the stale header:
// a refresh that isn't picked up would 401 forever.
expect(headersOf(fetchMock.mock.calls[0])).toMatchObject({
authorization: "Bearer tok-old",
});
expect(headersOf(fetchMock.mock.calls[1])).toMatchObject({
authorization: "Bearer tok-new",
});
});
it("surfaces the original 401 when the refresh itself fails", async () => {
fetchMock.mockResolvedValueOnce(unauthorized());
vi.mocked(forceRefreshCredentials).mockRejectedValueOnce(new Error("refresh_token expired"));
const client = await createCloudClient();
const call = client.getRender({ render_id: "hfr_1" });
// The decorator promises to surface the 401, not the refresh error —
// the 401 carries the API's message/code, which reportApiError needs.
await expect(call).rejects.toMatchObject({
name: "HyperframesApiError",
status: 401,
message: "token revoked",
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("retries exactly once: a second 401 propagates", async () => {
fetchMock.mockResolvedValueOnce(unauthorized()).mockResolvedValueOnce(unauthorized());
const client = await createCloudClient();
const call = client.getRender({ render_id: "hfr_1" });
await expect(call).rejects.toMatchObject({ status: 401 });
expect(forceRefreshCredentials).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("does not refresh on non-401 API errors", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse(500, { error: { message: "internal", code: "internal_error" } }),
);
const client = await createCloudClient();
const call = client.listRenders({});
await expect(call).rejects.toBeInstanceOf(HyperframesApiError);
await expect(call).rejects.toMatchObject({ status: 500 });
expect(forceRefreshCredentials).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("does not refresh on transport errors that aren't HyperframesApiError", async () => {
fetchMock.mockRejectedValueOnce(new TypeError("fetch failed"));
const client = await createCloudClient();
const call = client.getRender({ render_id: "hfr_1" });
await expect(call).rejects.toThrow("fetch failed");
expect(forceRefreshCredentials).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
-68
View File
@@ -1,68 +0,0 @@
/**
* 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<HyperframesCloudClient> {
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<unknown>;
// 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<unknown> => {
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;
}
};
},
});
}
-83
View File
@@ -1,83 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { parseEnumFlag, parseIntFlag, parseNumericFlag } from "./parsing.js";
import { CliUsageError } from "../utils/commandResult.js";
describe("cloud/parsing", () => {
let errorSpy: { mockRestore: () => void };
beforeEach(() => {
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
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(CliUsageError);
});
it("rejects decimals", () => {
expect(() => parseIntFlag("10.5", { flag: "--x" })).toThrow(CliUsageError);
});
it("enforces min", () => {
expect(() => parseIntFlag("0", { flag: "--x", min: 1 })).toThrow(CliUsageError);
});
it("enforces max", () => {
expect(() => parseIntFlag("101", { flag: "--x", max: 100 })).toThrow(CliUsageError);
});
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(CliUsageError);
});
it("rejects NaN", () => {
expect(() => parseNumericFlag("not-a-number", { flag: "--x" })).toThrow(CliUsageError);
});
});
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(CliUsageError);
});
it("returns undefined when raw is undefined", () => {
expect(
parseEnumFlag(undefined, ["draft", "standard", "high"], { flag: "--quality" }),
).toBeUndefined();
});
});
});
-100
View File
@@ -1,100 +0,0 @@
import { failUsage } from "../utils/commandResult.js";
/**
* 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)}.`);
failUsage();
}
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)}.`);
failUsage();
}
const n = Number.parseFloat(raw);
if (!Number.isFinite(n)) {
errorBox(`Invalid ${opts.flag}`, `Got "${raw}". Must be a finite number.`);
failUsage();
}
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}.`);
failUsage();
}
if (bounds.max !== undefined && value > bounds.max) {
errorBox(`Invalid ${flag}`, `Got "${raw}". Maximum is ${bounds.max}.`);
failUsage();
}
}
/** Parse an enum-typed flag against a closed set of allowed values. */
export function parseEnumFlag<T extends string>(
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(", ")}.`);
failUsage();
}
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 "";
}
-125
View File
@@ -1,125 +0,0 @@
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>): 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");
});
});
});
-108
View File
@@ -1,108 +0,0 @@
/**
* Poll a Frames 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<void>;
signal?: AbortSignal;
}
export const DEFAULT_POLL_INTERVAL_MS = 10_000;
export const DEFAULT_MAX_WAIT_MS = 60 * 60 * 1000;
const TERMINAL_STATUSES: ReadonlySet<HyperframesRenderStatus> = 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/frames/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<HyperframesRenderDetail> {
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<void> {
// fallow-ignore-next-line complexity
return (ms: number) =>
new Promise<void>((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 });
});
}
-21
View File
@@ -1,21 +0,0 @@
/**
* 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);
}
}
-234
View File
@@ -1,234 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { createHash } from "node:crypto";
import { HyperframesApiError } from "./_gen/client.js";
import type { HyperframesCloudClient } from "./_gen/client.js";
import { uploadZipViaDirectUpload } from "./upload.js";
function sha256Hex(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
function makeClient(overrides: Partial<HyperframesCloudClient> = {}): HyperframesCloudClient {
return {
createAssetUpload: vi.fn(async () => ({
asset_id: "asset_xyz",
upload_url: "https://s3.example/asset_xyz?sig=abc",
upload_headers: { "x-amz-checksum-sha256-b64": "..." },
expires_in_seconds: 3600,
max_bytes: 200 * 1024 * 1024,
status: "pending_upload" as const,
})),
completeAssetUpload: vi.fn(async () => ({
asset_id: "asset_xyz",
url: "https://files.heygen.com/document/asset_xyz/original.zip",
mime_type: "application/zip",
size_bytes: 42,
status: "processing" as const,
})),
...overrides,
} as unknown as HyperframesCloudClient;
}
function makeFetchOk(): typeof fetch {
return vi.fn(async () => new Response("", { status: 200 })) as unknown as typeof fetch;
}
describe("uploadZipViaDirectUpload", () => {
it("sends the correct filename, content_type, size_bytes, and SHA256 to createAssetUpload", async () => {
const bytes = new TextEncoder().encode("hello-frames-zip");
const expectedSha = sha256Hex(bytes);
const client = makeClient();
const fetchImpl = makeFetchOk();
await uploadZipViaDirectUpload({
client,
bytes,
filename: "my-comp.zip",
fetchImpl,
});
expect(client.createAssetUpload).toHaveBeenCalledOnce();
const arg = (client.createAssetUpload as ReturnType<typeof vi.fn>).mock.calls[0]![0];
expect(arg.body).toEqual({
filename: "my-comp.zip",
content_type: "application/zip",
size_bytes: bytes.byteLength,
checksum_sha256: expectedSha,
});
});
it("PUTs to the returned upload_url with the returned upload_headers verbatim + content-type", async () => {
const bytes = new Uint8Array([1, 2, 3, 4]);
const client = makeClient({
createAssetUpload: vi.fn(async () => ({
asset_id: "asset_xyz",
upload_url: "https://s3.example/target?sig=xxx",
upload_headers: { "x-signed-header": "signed-value", "x-other": "other-value" },
expires_in_seconds: 3600,
max_bytes: 200 * 1024 * 1024,
status: "pending_upload" as const,
})) as HyperframesCloudClient["createAssetUpload"],
});
const fetchImpl = makeFetchOk();
await uploadZipViaDirectUpload({ client, bytes, filename: "x.zip", fetchImpl });
expect(fetchImpl).toHaveBeenCalledOnce();
const [url, init] = (fetchImpl as ReturnType<typeof vi.fn>).mock.calls[0]!;
expect(url).toBe("https://s3.example/target?sig=xxx");
expect(init.method).toBe("PUT");
expect(init.body).toBe(bytes);
expect(init.headers).toEqual({
"content-type": "application/zip",
"x-signed-header": "signed-value",
"x-other": "other-value",
});
});
it("does NOT attach CLI auth headers to the S3 PUT — presigned URL carries auth", async () => {
const client = makeClient();
const fetchImpl = makeFetchOk();
await uploadZipViaDirectUpload({
client,
bytes: new Uint8Array([0]),
filename: "x.zip",
fetchImpl,
});
const init = (fetchImpl as ReturnType<typeof vi.fn>).mock.calls[0]![1];
// No Authorization / x-api-key / Bearer header should be present.
const headerKeys = Object.keys(init.headers as Record<string, string>).map((k) =>
k.toLowerCase(),
);
expect(headerKeys).not.toContain("authorization");
expect(headerKeys).not.toContain("x-api-key");
});
it("calls completeAssetUpload with the initialize's asset_id + same checksum", async () => {
const bytes = new TextEncoder().encode("determinism");
const expectedSha = sha256Hex(bytes);
const client = makeClient();
const fetchImpl = makeFetchOk();
const result = await uploadZipViaDirectUpload({
client,
bytes,
filename: "x.zip",
fetchImpl,
});
expect(client.completeAssetUpload).toHaveBeenCalledOnce();
const completeArg = (client.completeAssetUpload as ReturnType<typeof vi.fn>).mock.calls[0]![0];
expect(completeArg.asset_id).toBe("asset_xyz");
expect(completeArg.body).toEqual({ checksum_sha256: expectedSha });
expect(result.asset_id).toBe("asset_xyz");
expect(result.size_bytes).toBe(bytes.byteLength);
});
it("retries completeAssetUpload on 409 and succeeds on a later attempt", async () => {
let completeCalls = 0;
const completeAssetUpload = vi.fn(async () => {
completeCalls++;
if (completeCalls < 3) {
throw new HyperframesApiError({
status: 409,
message: "Uploaded object not found yet. Retry after upload PUT returns 200.",
code: "conflict",
});
}
return {
asset_id: "asset_xyz",
url: "u",
mime_type: "application/zip",
size_bytes: 1,
status: "processing" as const,
};
});
const client = makeClient({
completeAssetUpload:
completeAssetUpload as unknown as HyperframesCloudClient["completeAssetUpload"],
});
const result = await uploadZipViaDirectUpload({
client,
bytes: new Uint8Array([0]),
filename: "x.zip",
fetchImpl: makeFetchOk(),
});
expect(completeCalls).toBe(3);
expect(result.asset_id).toBe("asset_xyz");
});
it("surfaces non-409 errors from complete without retrying", async () => {
let completeCalls = 0;
const completeAssetUpload = vi.fn(async () => {
completeCalls++;
throw new HyperframesApiError({
status: 400,
message: "invalid checksum",
code: "invalid_parameter",
});
});
const client = makeClient({
completeAssetUpload:
completeAssetUpload as unknown as HyperframesCloudClient["completeAssetUpload"],
});
await expect(
uploadZipViaDirectUpload({
client,
bytes: new Uint8Array([0]),
filename: "x.zip",
fetchImpl: makeFetchOk(),
}),
).rejects.toThrow(/invalid checksum/);
expect(completeCalls).toBe(1);
});
it("surfaces PUT failures with response body detail", async () => {
const client = makeClient();
const fetchImpl = vi.fn(
async () =>
new Response("SignatureDoesNotMatch: request signature we calculated does not match", {
status: 403,
}),
) as unknown as typeof fetch;
await expect(
uploadZipViaDirectUpload({
client,
bytes: new Uint8Array([0]),
filename: "x.zip",
fetchImpl,
}),
).rejects.toThrow(/Direct upload PUT failed: 403.*SignatureDoesNotMatch/);
});
it("passes idempotencyKey through to createAssetUpload", async () => {
const client = makeClient();
await uploadZipViaDirectUpload({
client,
bytes: new Uint8Array([0]),
filename: "x.zip",
idempotencyKey: "test-key-123",
fetchImpl: makeFetchOk(),
});
const arg = (client.createAssetUpload as ReturnType<typeof vi.fn>).mock.calls[0]![0];
expect(arg.idempotencyKey).toBe("test-key-123");
});
it("emits progress events in order", async () => {
const client = makeClient();
const events: Array<{ phase: string }> = [];
await uploadZipViaDirectUpload({
client,
bytes: new Uint8Array([0]),
filename: "x.zip",
fetchImpl: makeFetchOk(),
onProgress: (e) => events.push({ phase: e.phase }),
});
expect(events.map((e) => e.phase)).toEqual(["initialize", "upload", "upload", "complete"]);
});
});
-165
View File
@@ -1,165 +0,0 @@
/**
* Direct-upload flow for `frames cloud render` project asset uploads.
*
* The legacy `POST /v3/assets` path proxies bytes through the API and is
* capped at 32 MB in-memory. This module implements the three-step
* direct-to-S3 flow the CLI now uses instead, lifting the practical
* per-project ceiling to 200 MB (the direct-upload cap enforced by the
* signed presigned URL):
*
* 1. `client.createAssetUpload({filename, content_type, size_bytes,
* checksum_sha256})` → returns `{asset_id, upload_url,
* upload_headers, expires_in_seconds, max_bytes}`.
* 2. Raw `PUT` to `upload_url` with the zip bytes + `upload_headers`
* verbatim. No CLI auth headers on this call — the presigned URL
* signature carries authorization.
* 3. `client.completeAssetUpload({asset_id, body: {checksum_sha256}})`
* to finalize. Docs explicitly note a 409 "Uploaded object not
* found yet" is possible if the PUT hasn't been fully committed
* server-side; a small retry loop absorbs that.
*
* The returned `asset_id` is the same id namespace the legacy path
* produced, so `createRender({project: {type: "asset_id", asset_id}})`
* on the render side is a drop-in swap.
*/
import { createHash } from "node:crypto";
import type { HyperframesCloudClient } from "./_gen/client.js";
import { HyperframesApiError } from "./_gen/client.js";
export interface UploadZipViaDirectResult {
asset_id: string;
size_bytes: number;
duration_ms: number;
}
export type UploadProgressEvent =
| { phase: "initialize" }
| { phase: "upload"; percent: number }
| { phase: "complete" };
export interface UploadZipViaDirectOptions {
client: HyperframesCloudClient;
bytes: Uint8Array;
filename: string;
idempotencyKey?: string;
fetchImpl?: typeof fetch;
onProgress?: (event: UploadProgressEvent) => void;
}
// Per api-docs: completeAssetUpload can return 409 "Uploaded object not found
// yet" if the S3 PUT's write hasn't propagated to the read plane by the time
// complete runs. Small backoff loop absorbs it.
const COMPLETE_MAX_RETRIES = 5;
const COMPLETE_RETRY_BASE_MS = 500;
const CONTENT_TYPE_ZIP = "application/zip";
function sha256Hex(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
// upload_headers is generated as `Record<string, unknown>` from the
// OpenAPI spec, but the values are always strings at runtime (HTTP header
// values). Coerce defensively so a spec quirk can't slip a non-string in.
function normalizeUploadHeaders(raw: Record<string, unknown>): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(raw ?? {})) {
if (typeof v === "string") out[k] = v;
else if (typeof v === "number" || typeof v === "boolean") out[k] = String(v);
// Skip anything else — better to omit than send `[object Object]`.
}
return out;
}
// PUT bytes to the presigned URL. Do NOT attach CLI auth headers; the
// presigned URL signature carries authorization and the signature is
// bound to the *headers signed at presign time* — extra headers invalidate
// the signature (S3 returns 403). Content-Type must match the declared
// content_type from step 1 (also signed).
async function putBytesToPresignedUrl(
fetchImpl: typeof fetch,
uploadUrl: string,
uploadHeaders: Record<string, unknown>,
bytes: Uint8Array,
): Promise<void> {
const headers: Record<string, string> = {
"content-type": CONTENT_TYPE_ZIP,
...normalizeUploadHeaders(uploadHeaders),
};
// `Uint8Array<ArrayBufferLike>` is a valid `BodyInit` at runtime but
// not strictly assignable per lib.dom.d.ts — cast rather than copy,
// since a 200MB buffer copy would be wasteful.
const res = await fetchImpl(uploadUrl, {
method: "PUT",
headers,
body: bytes as unknown as BodyInit,
});
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new Error(
`Direct upload PUT failed: ${res.status} ${res.statusText}${
detail ? ` — ${detail.slice(0, 300)}` : ""
}`,
);
}
}
// Complete with retry-on-409. Retry ONLY on the documented "PUT not
// visible yet" race between S3 write consistency and finalize; any other
// error surfaces immediately. `completeAssetUpload` itself is idempotent,
// so retrying an already-succeeded call is safe.
async function completeWithRetry(
client: HyperframesCloudClient,
asset_id: string,
checksum_sha256: string,
): Promise<{ asset_id: string }> {
let lastErr: unknown;
for (let attempt = 0; attempt < COMPLETE_MAX_RETRIES; attempt++) {
try {
return await client.completeAssetUpload({
asset_id,
body: { checksum_sha256 },
});
} catch (err) {
const retryable = err instanceof HyperframesApiError && err.status === 409;
if (!retryable || attempt === COMPLETE_MAX_RETRIES - 1) {
throw err;
}
lastErr = err;
await sleep(COMPLETE_RETRY_BASE_MS * (attempt + 1));
}
}
throw lastErr ?? new Error("completeAssetUpload retries exhausted");
}
export async function uploadZipViaDirectUpload(
opts: UploadZipViaDirectOptions,
): Promise<UploadZipViaDirectResult> {
const start = Date.now();
const { bytes, filename, idempotencyKey } = opts;
const size_bytes = bytes.byteLength;
const checksum_sha256 = sha256Hex(bytes);
const fetchImpl = opts.fetchImpl ?? fetch;
opts.onProgress?.({ phase: "initialize" });
const initialize = await opts.client.createAssetUpload({
body: { filename, content_type: CONTENT_TYPE_ZIP, size_bytes, checksum_sha256 },
idempotencyKey,
});
opts.onProgress?.({ phase: "upload", percent: 0 });
await putBytesToPresignedUrl(fetchImpl, initialize.upload_url, initialize.upload_headers, bytes);
opts.onProgress?.({ phase: "upload", percent: 100 });
opts.onProgress?.({ phase: "complete" });
const completed = await completeWithRetry(opts.client, initialize.asset_id, checksum_sha256);
return {
asset_id: completed.asset_id,
size_bytes,
duration_ms: Date.now() - start,
};
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { RegistryItem, RegistryManifest } from "@frames/core";
import type { RegistryItem, RegistryManifest } from "@hanzo/frame-core";
import { AddError, buildSnippet, remapTarget, runAdd } from "./add.js";
// ── Fixtures ────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -13,7 +13,7 @@ export const examples: Example[] = [
import { existsSync } from "node:fs";
import { resolve, relative } from "node:path";
import { ITEM_TYPE_DIRS, type RegistryItem } from "@frames/core";
import { ITEM_TYPE_DIRS, type RegistryItem } from "@hanzo/frame-core";
import { c } from "../ui/colors.js";
import { installItem, resolveItemsByTag } from "../registry/index.js";
import { resolveItemWithDependencies } from "../registry/resolver.js";
+14 -30
View File
@@ -1,14 +1,8 @@
/**
* `frames auth` — credential management for HeyGen.
* `frames auth` — the credential this CLI presents to api.hanzo.ai.
*
* Subverbs:
* - `login` sign in via API key (OAuth coming next)
* - `status` show the active credential + identity
* - `logout` remove the stored credential
*
* Each subverb lives in `./auth/<name>.ts` and is dynamic-imported on
* demand. Keeps cold-start fast and lets the auth library load only
* when the user is doing auth work.
* Hanzo IAM issues credentials and `hanzo login` performs the sign-in; this
* CLI only reads what they leave behind, so `status` is the whole surface.
*/
import { defineCommand } from "citty";
@@ -16,41 +10,31 @@ import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";
export const examples: Example[] = [
["Sign in via browser (OAuth)", "frames auth login"],
["Save an API key (interactive)", "frames auth login --api-key"],
["Save an API key from stdin", "echo $HEYGEN_API_KEY | frames auth login --api-key"],
["Check who you're signed in as", "frames auth status"],
["Force-refresh the OAuth access token", "frames auth refresh"],
["Sign out", "frames auth logout"],
["Check which credential is active", "frames auth status"],
["Machine-readable status", "frames auth status --json"],
];
const HELP = `
${c.bold("frames auth")} ${c.dim("<subcommand> [args]")}
Manage HeyGen credentials. Credentials live in
${c.accent("~/.heygen/credentials")} and are shared with heygen-cli.
Show the credential used for ${c.accent("api.hanzo.ai")}. Sign in with
${c.accent("hanzo login")} — it runs the Hanzo IAM device flow and writes
${c.accent("~/.hanzo/credentials.json")}, which this CLI reads.
${c.bold("SUBCOMMANDS:")}
${c.accent("login")} ${c.dim("Sign in via browser (default) or --api-key for a long-lived key.")}
${c.accent("status")} ${c.dim("Show the active credential's source, type, and identity.")}
${c.accent("refresh")} ${c.dim("Force-refresh the OAuth access token.")}
${c.accent("logout")} ${c.dim("Remove the stored credential (--keep-api-key for OAuth-only).")}
${c.accent("status")} ${c.dim("Show the active credential's source and whether the gateway accepts it.")}
${c.bold("ENV VARS:")}
${c.accent("HEYGEN_API_KEY")} Override the stored credential.
${c.accent("FRAMES_API_KEY")} Alias for HEYGEN_API_KEY.
${c.accent("HEYGEN_API_URL")} Override the API base URL (default https://api.heygen.com).
${c.accent("HEYGEN_CONFIG_DIR")} Override the credentials directory (default ~/.heygen).
${c.accent("FRAMES_OAUTH_CLIENT_ID")} Override the OAuth client_id (for dev/test).
${c.accent("HANZO_API_KEY")} Platform key, delivered from Hanzo KMS. Wins over everything.
${c.accent("HANZO_TOKEN")} IAM access token. Wins over the stored credential.
${c.accent("HANZO_HOME")} Override the credential directory (default ~/.hanzo).
${c.accent("HANZO_API_URL")} Override the API base URL (default https://api.hanzo.ai).
`;
export default defineCommand({
meta: { name: "auth", description: "Sign in to HeyGen and manage credentials" },
meta: { name: "auth", description: "Show the credential used for api.hanzo.ai" },
subCommands: {
login: () => import("./auth/login.js").then((m) => m.default),
status: () => import("./auth/status.js").then((m) => m.default),
logout: () => import("./auth/logout.js").then((m) => m.default),
refresh: () => import("./auth/refresh.js").then((m) => m.default),
},
async run({ args }) {
if (!args._?.[0]) console.log(HELP);
@@ -1,201 +0,0 @@
import { promises as fs } from "node:fs";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readStore, writeStore } from "../../auth/store.js";
import { setupTempAuthEnv, type EnvFixture } from "../../auth/_test-utils.js";
import { CliRuntimeError } from "../../utils/commandResult.js";
// Mock only AuthClient — keep the real store/resolver so the test
// exercises the actual on-disk rollback / persistence behavior.
// `verifyState` controls what `getCurrentUser` returns per test:
// - reject: throw ErrUnauthenticated (invalid key path)
// - user: the /v3/users/me identity returned on success
const verifyState = vi.hoisted(
() =>
({ reject: false, user: { email: "[email protected]" } }) as {
reject: boolean;
user: Record<string, unknown>;
},
);
vi.mock("../../auth/index.js", async (orig) => {
const actual = await orig<typeof import("../../auth/index.js")>();
class MockAuthClient {
async getCurrentUser(): Promise<Record<string, unknown>> {
if (verifyState.reject) {
const { ErrUnauthenticated: rej } = await import("../../auth/errors.js");
throw rej("invalid key");
}
return verifyState.user;
}
}
return { ...actual, AuthClient: MockAuthClient };
});
// Spy on the telemetry the login flow emits, so we can assert the identity is
// attributed on success. login.ts imports these via a dynamic import of
// telemetry/index.js; the mock intercepts it.
const telemetry = vi.hoisted(() => ({
trackAuthLoginStarted: vi.fn(),
trackAuthLoginCompleted: vi.fn(),
trackAuthLoginFailed: vi.fn(),
identifyUser: vi.fn(),
}));
vi.mock("../../telemetry/index.js", () => telemetry);
describe("auth login --api-key rollback", () => {
let dir: string;
let envFixture: EnvFixture;
beforeEach(async () => {
envFixture = await setupTempAuthEnv("hf-login-");
dir = envFixture.dir;
verifyState.reject = false;
verifyState.user = { email: "[email protected]" };
for (const fn of Object.values(telemetry)) fn.mockClear();
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
await envFixture.restore();
});
async function runLogin(apiKey: string): Promise<void> {
const cmd = (await import("./login.js")).default;
// citty command run only reads `args` here.
await (cmd.run as (ctx: { args: Record<string, unknown> }) => Promise<void>)({
args: { "api-key": apiKey },
});
}
it("removes the rejected key on a failed FIRST login (no prior credential)", async () => {
verifyState.reject = true;
await expect(runLogin("hg_badkey123")).rejects.toThrow(CliRuntimeError);
// The store must NOT retain the rejected key — otherwise the next
// command would silently resolve a known-bad credential.
const { source } = await readStore();
expect(source).toBe("absent");
});
it("restores the previous credential on a failed re-login", async () => {
await writeStore({ api_key: "hg_previous_good" });
verifyState.reject = true;
await expect(runLogin("hg_newbadkey99")).rejects.toThrow(CliRuntimeError);
const { credentials } = await readStore();
expect(credentials.api_key).toBe("hg_previous_good");
});
it("persists the key on a successful login", async () => {
verifyState.reject = false;
await runLogin("hg_goodkey456");
const { credentials } = await readStore();
expect(credentials.api_key).toBe("hg_goodkey456");
});
it("persists the friendly user block from /v3/users/me on a successful login", async () => {
verifyState.user = {
email: "[email protected]",
first_name: "Jane",
last_name: "Doe",
username: "jdoe",
};
await runLogin("hg_goodkey456");
const { credentials } = await readStore();
expect(credentials.api_key).toBe("hg_goodkey456");
expect(credentials.user).toEqual({
email: "[email protected]",
first_name: "Jane",
last_name: "Doe",
username: "jdoe",
});
});
it("clears a stale user block when the new key's identity probe returns nothing", async () => {
// Prior login left a user block on disk. The new key is valid but
// /v3/users/me returns no identity fields — the stale block must be
// cleared so `auth status` can't surface the previous account.
await writeStore({ api_key: "hg_old", user: { email: "[email protected]" } });
verifyState.user = {}; // verified, but no identity returned
await runLogin("hg_newgoodkey");
const { credentials } = await readStore();
expect(credentials.api_key).toBe("hg_newgoodkey");
expect(credentials.user).toBeUndefined();
});
it("rollback on a rejected key restores the previous user block too", async () => {
await writeStore({ api_key: "hg_prev", user: { email: "[email protected]" } });
verifyState.reject = true;
await expect(runLogin("hg_badnewkey")).rejects.toThrow(CliRuntimeError);
const { credentials } = await readStore();
expect(credentials.api_key).toBe("hg_prev");
expect(credentials.user).toEqual({ email: "[email protected]" });
});
it("rollback on a rejected key preserves a prior foreign top-level key (no known credential)", async () => {
// The prior file held ONLY a future/foreign top-level key — no
// api_key, no oauth. A rejected new key must roll back WITHOUT
// deleting the file, or the foreign credential another CLI owns is
// clobbered. (Before the fix, rollback deleted the file because
// neither api_key nor oauth was present.)
await fs.writeFile(
join(dir, "credentials"),
JSON.stringify({ future_credential: { token: "owned_by_other_cli" } }),
{ mode: 0o600 },
);
verifyState.reject = true;
await expect(runLogin("hg_badnewkey")).rejects.toThrow(CliRuntimeError);
const onDisk = JSON.parse(await fs.readFile(join(dir, "credentials"), "utf8"));
expect(onDisk.api_key).toBeUndefined();
expect(onDisk.future_credential).toEqual({ token: "owned_by_other_cli" });
});
it("attributes a successful login to the account email", async () => {
verifyState.user = { email: "[email protected]", username: "alice" };
await runLogin("hg_goodkey456");
expect(telemetry.identifyUser).toHaveBeenCalledWith("[email protected]");
expect(telemetry.trackAuthLoginCompleted).toHaveBeenCalledWith("api_key", "[email protected]");
});
it("falls back to username when the account has no email", async () => {
verifyState.user = { username: "alice" };
await runLogin("hg_goodkey456");
expect(telemetry.identifyUser).toHaveBeenCalledWith("alice");
expect(telemetry.trackAuthLoginCompleted).toHaveBeenCalledWith("api_key", "alice");
});
it("does not identify when the identity probe returns nothing", async () => {
verifyState.user = {}; // verified key, but no identity fields
await runLogin("hg_goodkey456");
expect(telemetry.identifyUser).not.toHaveBeenCalled();
expect(telemetry.trackAuthLoginCompleted).toHaveBeenCalledWith("api_key", undefined);
});
it("records a rejected key as failed and never identifies", async () => {
verifyState.reject = true;
await expect(runLogin("hg_badkey123")).rejects.toThrow(CliRuntimeError);
expect(telemetry.identifyUser).not.toHaveBeenCalled();
expect(telemetry.trackAuthLoginFailed).toHaveBeenCalledWith("api_key", "rejected");
});
it("preserves an unknown/foreign top-level key across a successful re-login", async () => {
// Cross-CLI invariant end-to-end: a key heygen-cli (or a future
// version) wrote must survive a frames-cli login round-trip.
await fs.writeFile(join(dir, "credentials"), JSON.stringify({ future_field: { x: 1 } }), {
mode: 0o600,
});
verifyState.user = { email: "[email protected]" };
await runLogin("hg_goodkey456");
const onDisk = JSON.parse(await fs.readFile(join(dir, "credentials"), "utf8"));
expect(onDisk.api_key).toBe("hg_goodkey456");
expect(onDisk.user).toEqual({ email: "[email protected]" });
expect(onDisk.future_field).toEqual({ x: 1 });
});
});
-356
View File
@@ -1,356 +0,0 @@
import { failCommand } from "../../utils/commandResult.js";
/**
* `frames auth login` — sign in to HeyGen.
*
* Default: OAuth 2.0 + PKCE via a loopback callback. The CLI opens
* the user's browser, captures the authorization code on an
* ephemeral 127.0.0.1 port, exchanges it for tokens, and persists
* them to `~/.heygen/credentials`.
*
* `--api-key`: opts into the legacy long-lived API-key path.
*
* Write semantics:
* - Snapshot existing credentials first; merge so a new OAuth session
* preserves an existing API key (and vice versa).
* - Sanity-check that the input is non-empty and header-safe (no
* CR/LF) before touching disk. The backend's `/v3/users/me` is the
* source of truth for whether the key is actually valid — we do
* NOT shape-check the prefix (real keys come in multiple formats:
* `sk_V2_…`, `hg_…`, partner keys, etc.).
* - Verify via `GET /v3/users/me`. On 401, roll back to the previous
* state. Network/5xx errors keep the new credential in place per
* the transient-blip rationale.
*/
import { defineCommand } from "citty";
import { stdin as input } from "node:process";
import {
AuthClient,
assertOAuthConfiguredOrExit,
clearUserInfo,
deleteStore,
hasPreservedUnknownData,
isAuthError,
isHeaderSafe,
isUserInfoEmpty,
readStore,
refreshTokens,
saveUserInfo,
startAuthorizationCodeFlow,
tryResolveCredential,
userDisplayName,
writeStore,
type Credentials,
type StoredUserInfo,
type UserInfo,
} from "../../auth/index.js";
import { c } from "../../ui/colors.js";
const STDIN_TIMEOUT_MS = 30_000;
// Smallest plausible length for a real API key. We don't validate the
// prefix or character set — the backend's /v3/users/me is the source
// of truth and rolls back on rejection. The only must-check is
// header-safety (CR/LF), which `isHeaderSafe` covers.
const MIN_KEY_LENGTH = 8;
export default defineCommand({
meta: {
name: "login",
description: "Sign in to HeyGen (OAuth by default; --api-key for long-lived keys)",
},
args: {
"api-key": {
type: "string",
description: "API key value, or pass `--api-key` with no value to read from stdin / prompt.",
},
},
// fallow-ignore-next-line complexity
async run({ args }) {
const inlineKey = args["api-key"];
if (inlineKey !== undefined) {
await runApiKeyLogin(inlineKey);
return;
}
await runOAuthLogin();
},
});
// fallow-ignore-next-line complexity
async function runOAuthLogin(): Promise<void> {
assertOAuthConfiguredOrExit();
const { trackAuthLoginStarted, trackAuthLoginFailed } = await import("../../telemetry/index.js");
trackAuthLoginStarted("oauth");
try {
await startAuthorizationCodeFlow();
} catch (err) {
const message = (err as Error).message ?? "";
// The loopback server rejects with "OAuth callback timed out after …" when
// the user never completes the browser step (closed the tab / walked away).
// That is the dominant non-error dropout, so split it from real failures
// (IdP misconfig, network) instead of lumping everything as flow_error.
trackAuthLoginFailed("oauth", /timed out/i.test(message) ? "flow_timeout" : "flow_error");
console.error(c.error(`Sign-in failed: ${message}`));
failCommand();
}
await reportIdentity();
}
// fallow-ignore-next-line complexity
async function reportIdentity(): Promise<void> {
const { trackAuthLoginCompleted, trackAuthLoginFailed, identifyUser } =
await import("../../telemetry/index.js");
const credential = await tryResolveCredential();
if (!credential) {
trackAuthLoginFailed("oauth", "no_credential");
console.error(c.warn("Sign-in completed but no credential was persisted."));
failCommand();
}
// Wire the refresh hook here too — a freshly-minted token shouldn't
// need it, but a fast IdP-side rotation (or a misconfigured short
// TTL) shouldn't punish the user with a hard failure when the
// refresh_token would have transparently fixed it.
const client = new AuthClient({
onUnauthenticatedRefresh: async (rt) => await refreshTokens(rt),
});
try {
const user = await client.getCurrentUser(credential);
// Persist the friendly-display block alongside the OAuth tokens so
// `auth status` can show "Logged in as ..." without re-hitting
// /v3/users/me. Best-effort — a persist failure never fails the login.
await persistUserInfo(user);
// Attribute this install to the signed-in account (and stitch its prior
// anonymous usage) before recording completion, so the completed event
// carries the identity. Both no-op under the telemetry opt-out.
const id = identityKey(user);
if (id) identifyUser(id);
trackAuthLoginCompleted("oauth", id);
const identity = userDisplayName(toStoredUserInfo(user)) ?? "(unknown user)";
console.log(c.success(`✓ Signed in as ${identity}.`));
} catch (err) {
// Don't roll back — the OAuth tokens are valid on disk; this is a
// transient verify-side issue. The credential is persisted and usable, so
// the sign-in still COMPLETED; we just have no resolved identity to
// attribute it to. The stale user block from a prior login (possibly a
// DIFFERENT account) is cleared so `auth status` can't surface it.
await clearUserInfoBestEffort();
trackAuthLoginCompleted("oauth");
console.error(
c.warn(`Signed in. Identity check failed (transient): ${(err as Error).message}`),
);
}
}
/**
* The stable key we associate this install with in telemetry after sign-in.
* `/v3/users/me` exposes no opaque user_id, so we key on the HeyGen account
* EMAIL — the canonical account identifier and the reliable join key back to
* billing — falling back to username only when the account exposes no email.
* (Username is NOT a privacy win — HeyGen usernames are frequently email-shaped
* — it is purely a fallback so an emailless account is still attributable.)
* The privacy notice (showTelemetryNotice) and docs/packages/cli.mdx disclose
* both, so keep them in sync with whatever this returns.
*/
function identityKey(user: UserInfo): string | undefined {
return user.email ?? user.username;
}
/** Project the API `/v3/users/me` view onto the on-disk identity block. */
function toStoredUserInfo(user: UserInfo): StoredUserInfo {
const out: StoredUserInfo = {};
if (user.email) out.email = user.email;
if (user.first_name) out.first_name = user.first_name;
if (user.last_name) out.last_name = user.last_name;
if (user.username) out.username = user.username;
return out;
}
/**
* Persist the friendly-display block (best-effort). A non-empty block is
* saved; an empty one (the API returned no identity fields) clears any
* stale block so a wrong account can't surface in `auth status`. A
* persist/clear failure is warned, never fatal — the credential is valid
* on disk and that's what matters.
*/
async function persistUserInfo(user: UserInfo): Promise<void> {
const stored = toStoredUserInfo(user);
try {
if (isUserInfoEmpty(stored)) {
await clearUserInfo();
} else {
await saveUserInfo(stored);
}
} catch (err) {
console.error(c.dim(`(warning: could not persist user info: ${(err as Error).message})`));
}
}
/** Drop any stale user block; best-effort, never fatal. */
async function clearUserInfoBestEffort(): Promise<void> {
try {
await clearUserInfo();
} catch (err) {
console.error(c.dim(`(warning: could not clear stale user info: ${(err as Error).message})`));
}
}
// fallow-ignore-next-line complexity
async function runApiKeyLogin(inlineKey: string): Promise<void> {
const { trackAuthLoginStarted, trackAuthLoginCompleted, trackAuthLoginFailed, identifyUser } =
await import("../../telemetry/index.js");
trackAuthLoginStarted("api_key");
// collectApiKey throws when the user cancels the interactive prompt (Ctrl-C)
// or when no key arrives on stdin before the timeout — both are "user walked
// away", the abandonment signal we most want. Record it before the error
// propagates so `started` still reconciles to `completed + failed`.
let key: string;
try {
key = await collectApiKey(inlineKey);
} catch (err) {
trackAuthLoginFailed("api_key", "aborted");
console.error(c.error((err as Error).message || "Sign-in aborted."));
failCommand();
}
if (!key) {
trackAuthLoginFailed("api_key", "invalid_input");
console.error(c.error("No API key provided."));
failCommand();
}
if (!isHeaderSafe(key)) {
// CR/LF in the value would smuggle headers when the key is sent
// via `x-api-key`. The backend handles "wrong key" itself, but
// header-injection has to be caught here.
trackAuthLoginFailed("api_key", "invalid_input");
console.error(c.error("API key must not contain newline or control characters."));
failCommand();
}
if (key.length < MIN_KEY_LENGTH) {
trackAuthLoginFailed("api_key", "invalid_input");
console.error(c.error(`API key looks too short (got ${key.length} chars).`));
failCommand();
}
const previous = await snapshotStore();
const next: Credentials = { ...previous, api_key: key };
await writeStore(next);
const user = await verifyAndReport(key);
if (!user) {
trackAuthLoginFailed("api_key", "rejected");
await rollback(previous);
failCommand();
}
const id = identityKey(user);
if (id) identifyUser(id);
trackAuthLoginCompleted("api_key", id);
}
async function snapshotStore(): Promise<Credentials> {
try {
const { credentials } = await readStore();
return { ...credentials };
} catch {
return {};
}
}
async function rollback(previous: Credentials): Promise<void> {
try {
if (previous.api_key || previous.oauth || hasPreservedUnknownData(previous)) {
// Restore the prior state. This branch also covers the case where
// the only prior content was an unknown/foreign top-level key (a
// future credential another CLI owns): writing `previous` back
// re-emits that key, so the rollback doesn't clobber cross-CLI data
// the file had before this login attempt.
await writeStore(previous);
console.error(c.dim("Rolled back to the previous credential."));
} else {
// No prior credential and nothing worth preserving — restore true
// absence. Leaving the rejected key on disk would make the next
// `auth status` / command silently resolve a known-bad key.
await deleteStore();
console.error(c.dim("Removed the rejected credential."));
}
} catch (err) {
console.error(c.error(`Failed to roll back: ${(err as Error).message}`));
}
}
// Returns the verified user on success (so the caller can attribute the
// completed sign-in to that identity), or null when the backend rejects the
// key. Other errors propagate.
// fallow-ignore-next-line complexity
async function verifyAndReport(key: string): Promise<UserInfo | null> {
const client = new AuthClient();
try {
const user = await client.getCurrentUser({ type: "api_key", key, source: "file_json" });
// Persist the friendly-display block next to the now-verified api_key
// so `auth status` can show a recognizable identity. Best-effort.
await persistUserInfo(user);
const identity = userDisplayName(toStoredUserInfo(user)) ?? "(unknown user)";
console.log(c.success(`✓ API key saved. Authenticated as ${identity}.`));
return user;
} catch (err) {
if (isAuthError(err) && err.code === "UNAUTHENTICATED") {
console.error(
`${c.warn("HeyGen rejected the API key.")}\n` +
` ${c.dim(err.message)}\n` +
`Run ${c.accent("frames auth login --api-key")} again with a valid key.`,
);
return null;
}
throw err;
}
}
async function collectApiKey(inline: string): Promise<string> {
if (inline.length > 0) return inline.trim();
if (!input.isTTY) {
return (await readAllWithTimeout(input, STDIN_TIMEOUT_MS)).trim();
}
return await promptForKey();
}
async function readAllWithTimeout(
stream: NodeJS.ReadableStream,
timeoutMs: number,
): Promise<string> {
return await new Promise<string>((resolve, reject) => {
const chunks: Buffer[] = [];
const timer = setTimeout(() => {
reject(new Error(`Timed out waiting for stdin (${timeoutMs}ms). Pipe the key explicitly.`));
}, timeoutMs);
stream.on("data", (chunk: Buffer | string) => {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
});
stream.on("end", () => {
clearTimeout(timer);
resolve(Buffer.concat(chunks).toString("utf8"));
});
stream.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
});
}
async function promptForKey(): Promise<string> {
const clack = await import("@clack/prompts");
const value = await clack.password({
message: "Enter HeyGen API key",
validate: (v) => {
if (!v || v.length < MIN_KEY_LENGTH) return "API key looks too short";
if (!isHeaderSafe(v)) return "API key must not contain newline or control characters";
return undefined;
},
});
if (clack.isCancel(value)) {
// Throw rather than exit here so the single catch in runApiKeyLogin records
// the abandonment (auth_login_failed: aborted) and then exits.
throw new Error("Aborted.");
}
return value.trim();
}
-106
View File
@@ -1,106 +0,0 @@
import { failCommand } from "../../utils/commandResult.js";
/**
* `frames auth logout` — remove the credential file. With
* `--keep-api-key`, only the OAuth block is cleared (no-op for
* API-key-only stores).
*
* Env-only credentials (`HEYGEN_API_KEY`, `FRAMES_API_KEY`) can't
* be cleared by this command — we tell the user to unset them.
*/
import { defineCommand } from "citty";
import {
clearOAuth,
configDir,
credentialPath,
deleteStore,
readStore,
revokeTokens,
} from "../../auth/index.js";
import { c } from "../../ui/colors.js";
export default defineCommand({
meta: { name: "logout", description: "Remove the stored HeyGen credential" },
args: {
"keep-api-key": {
type: "boolean",
description: "Only clear the OAuth session; preserve the API key.",
default: false,
},
yes: {
type: "boolean",
description: "Skip the confirmation prompt.",
default: false,
},
},
async run({ args }) {
warnIfEnvCredentialActive();
const keepApiKey = Boolean(args["keep-api-key"]);
if (!(await ensureConfirmed(Boolean(args.yes), keepApiKey))) {
console.log("Aborted.");
failCommand();
}
// Best-effort revoke before we wipe local state. RFC 7009 says
// success is empty 200 — we ignore failure either way.
await bestEffortRevoke();
if (keepApiKey) {
await clearOAuth();
console.log(c.success("✓ OAuth session removed. API key retained."));
return;
}
await deleteStore();
console.log(c.success(`✓ Signed out. Removed ${credentialPath()}.`));
},
});
function warnIfEnvCredentialActive(): void {
if (process.env["HEYGEN_API_KEY"] || process.env["FRAMES_API_KEY"]) {
console.log(
c.warn(
"An env-var credential is active. Unset HEYGEN_API_KEY / FRAMES_API_KEY to remove it.",
),
);
}
}
async function ensureConfirmed(yes: boolean, keepApiKey: boolean): Promise<boolean> {
if (yes) return true;
const prompt = keepApiKey
? `This will sign out of any active OAuth session on this machine (~/.heygen lives at ${configDir()}). Continue? [y/N] `
: `This will sign out of HeyGen on this machine (~/.heygen lives at ${configDir()}). Continue? [y/N] `;
return confirmInteractive(prompt);
}
// fallow-ignore-next-line complexity
async function bestEffortRevoke(): Promise<void> {
try {
const { credentials, source } = await readStore();
if (source === "absent" || !credentials.oauth) return;
const { access_token, refresh_token } = credentials.oauth;
// Revoke the refresh_token first (per RFC 7009, that typically
// invalidates all derived access tokens), but also revoke the
// access_token explicitly to cover servers that don't cascade.
if (refresh_token) {
await revokeTokens(refresh_token, { token_type_hint: "refresh_token" });
}
if (access_token) {
await revokeTokens(access_token, { token_type_hint: "access_token" });
}
} catch {
/* Best-effort — never block local wipe on a network/IdP issue. */
}
}
async function confirmInteractive(prompt: string): Promise<boolean> {
if (!process.stdin.isTTY) return false;
const { createInterface } = await import("node:readline");
const rl = createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise<string>((resolve) => {
rl.question(prompt, (line) => resolve(line));
});
rl.close();
return /^y(es)?$/i.test(answer.trim());
}
-49
View File
@@ -1,49 +0,0 @@
import { failCommand } from "../../utils/commandResult.js";
/**
* `frames auth refresh` — force-refresh the OAuth access_token
* using the stored refresh_token.
*
* Mostly useful for testing the refresh path or for users on flaky
* networks who want to pre-emptively refresh before a long render
* job. Status's 401-retry path already does this automatically.
*/
import { defineCommand } from "citty";
import {
assertOAuthConfiguredOrExit,
isAuthError,
readStore,
refreshTokens,
} from "../../auth/index.js";
import { c } from "../../ui/colors.js";
export default defineCommand({
meta: { name: "refresh", description: "Force-refresh the OAuth access token" },
args: {},
// fallow-ignore-next-line complexity
async run() {
assertOAuthConfiguredOrExit();
const { credentials, source } = await readStore();
if (source === "absent" || !credentials.oauth?.refresh_token) {
console.error(c.warn("No OAuth refresh token to use. Run `frames auth login` first."));
failCommand();
}
try {
// refreshTokens persists via oauth.ts:persistOAuth, which merges
// into a freshly-read store (preserving api_key + any
// refresh_token the server didn't rotate). Re-writing here would
// use a stale snapshot and risks clobbering concurrent writes.
await refreshTokens(credentials.oauth.refresh_token);
console.log(c.success("✓ Refreshed OAuth access token."));
} catch (err) {
if (isAuthError(err) && err.code === "REFRESH_FAILED") {
console.error(c.error(err.message));
if (err.hint) console.error(c.dim(err.hint));
failCommand();
}
throw err;
}
},
});
@@ -19,14 +19,14 @@ export interface OfflineEngineLine {
capability: "voice" | "music";
/** Engine label, e.g. "Kokoro" / "MusicGen". */
label: string;
/** Deps installed (local) or key present (cloud) — usable right now. */
/** Deps installed (local) or credential present (hosted) — usable right now. */
ready: boolean;
/** How to make it ready, shown when `ready` is false. */
setupHint?: string;
}
/** The recommended first step; sign-in and sign-up are the same OAuth flow. */
const RECOMMENDED_ACTION = "npx frames auth login";
/** The recommended first step. Hanzo IAM issues the credential, not this CLI. */
const RECOMMENDED_ACTION = "hanzo login";
/**
* Render the "what offline will use" block from probed engine readiness.
@@ -56,13 +56,11 @@ function offlineEngineLines(engines?: OfflineEngineLine[]): string[] {
}
/**
* Human guidance for an unconfigured machine — registration-first.
* Both paths use `npx frames` (zero-install via npm): browser OAuth
* (sign-in / sign-up) and `--api-key` both write `~/.heygen`. The separate
* `heygen` CLI shares that file but needs its own install (no `npx heygen`),
* so it's left to the docs — not dangled here as a command a fresh machine
* can't run. Names the local fallback so "no key" never reads as a failure,
* and never steers users toward a per-repo `.env`. Mirrors the
* Human guidance for an unconfigured machine — sign-in first. `hanzo login`
* runs the IAM device flow and writes `~/.hanzo/credentials.json`, which this
* CLI reads; a platform key from Hanzo KMS in `HANZO_API_KEY` is the
* unattended equivalent. Names the local fallback so "no key" never reads as
* a failure, and never steers users toward a per-repo `.env`. Mirrors the
* media-use skill's Preflight section.
*/
export function buildUnconfiguredLines(
@@ -71,20 +69,20 @@ export function buildUnconfiguredLines(
): string[] {
if (!ctx.interactive) {
return [
c.warn("Not signed in to HeyGen (non-interactive)."),
c.warn("No Hanzo credential (non-interactive)."),
c.dim(
"Set HEYGEN_API_KEY to use HeyGen, or workflows fall back to local engines (Kokoro voice · MusicGen music).",
"Set HANZO_API_KEY from Hanzo KMS to use hosted speech, or workflows fall back to local engines (Kokoro voice · MusicGen music).",
),
];
}
return [
c.warn("Not signed in to HeyGen — voice & music will use local engines (free, offline)."),
c.warn("No Hanzo credential — voice & music will use local engines (free, offline)."),
"",
"Sign in or sign up (browser OAuth, writes ~/.heygen — no per-repo .env):",
` ${c.accent("npx frames auth login")} ${c.dim("# browser sign-in / sign-up")}`,
"Sign in with the Hanzo CLI (IAM device flow, writes ~/.hanzo — no per-repo .env):",
` ${c.accent("hanzo login")} ${c.dim("# opens hanzo.id, then stores the token")}`,
"",
"Or paste an existing HeyGen API key (get one at app.heygen.com/settings/api):",
` ${c.accent("npx frames auth login --api-key")} ${c.dim("# paste at the prompt")}`,
"Unattended runs read a platform key delivered from Hanzo KMS:",
` ${c.accent("HANZO_API_KEY")} ${c.dim("# exported by the environment, never committed")}`,
"",
...offlineEngineLines(engines),
];
@@ -1,121 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { writeStore } from "../../auth/store.js";
import { setupTempAuthEnv, type EnvFixture } from "../../auth/_test-utils.js";
import { consumeCommandResult } from "../../utils/commandResult.js";
// Mock only AuthClient so the live /v3/users/me probe is controllable;
// keep the real store/resolver/user helpers so the test exercises the
// actual on-disk persisted-user-block surfacing.
// - apiReject: throw ErrApi on getCurrentUser (simulates an API blip)
// - user: the live identity returned on success
const probeState = vi.hoisted(
() =>
({ apiReject: false, user: { email: "[email protected]" } }) as {
apiReject: boolean;
user: Record<string, unknown>;
},
);
vi.mock("../../auth/index.js", async (orig) => {
const actual = await orig<typeof import("../../auth/index.js")>();
class MockAuthClient {
async getCurrentUser(): Promise<Record<string, unknown>> {
if (probeState.apiReject) {
const { ErrApi } = await import("../../auth/errors.js");
throw ErrApi(503, "service unavailable");
}
return probeState.user;
}
}
return { ...actual, AuthClient: MockAuthClient };
});
describe("auth status — persisted user block surface", () => {
let envFixture: EnvFixture;
let stdout: string[];
beforeEach(async () => {
envFixture = await setupTempAuthEnv("hf-status-");
probeState.apiReject = false;
probeState.user = { email: "[email protected]" };
stdout = [];
consumeCommandResult();
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
stdout.push(args.join(" "));
});
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(async () => {
consumeCommandResult();
vi.restoreAllMocks();
await envFixture.restore();
});
async function runStatus(asJson: boolean): Promise<number> {
const cmd = (await import("./status.js")).default;
await (cmd.run as (ctx: { args: Record<string, unknown> }) => Promise<void>)({
args: { json: asJson },
});
return consumeCommandResult().exitCode;
}
function lastJson(): Record<string, unknown> {
return JSON.parse(stdout[stdout.length - 1] ?? "{}");
}
it("surfaces the persisted user block (with resolved display_name) for a file credential", async () => {
await writeStore({
api_key: "hg_x",
user: { email: "[email protected]", first_name: "Jane", last_name: "Doe", username: "jdoe" },
});
const code = await runStatus(true);
expect(code).toBe(0);
const payload = lastJson();
expect(payload["source"]).toBe("file_json");
expect(payload["persisted_user"]).toEqual({
email: "[email protected]",
first_name: "Jane",
last_name: "Doe",
username: "jdoe",
display_name: "[email protected]",
});
});
it("backwards-compat: a file credential with no user block reports persisted_user: null", async () => {
await writeStore({ api_key: "hg_legacy" });
const code = await runStatus(true);
expect(code).toBe(0);
const payload = lastJson();
expect(payload["persisted_user"]).toBeNull();
// The live `user` field (from the API probe) is unchanged / additive.
expect(payload["user"]).toEqual({ email: "[email protected]" });
});
it("skips the persisted block for an env-sourced credential (could be a different key)", async () => {
// Seed a file-side user block, then resolve via the env key — the
// active credential is env, so the file block must NOT be surfaced.
await writeStore({ api_key: "hg_file", user: { email: "[email protected]" } });
process.env["HEYGEN_API_KEY"] = "hg_env_key";
const code = await runStatus(true);
expect(code).toBe(0);
const payload = lastJson();
expect(payload["source"]).toBe("env");
expect(payload["persisted_user"]).toBeNull();
});
it("falls back to the cached identity in human output when the live probe fails", async () => {
await writeStore({ api_key: "hg_x", user: { email: "[email protected]" } });
probeState.apiReject = true;
const code = await runStatus(false);
expect(code).toBe(1); // API failure → non-zero exit
const text = stdout.join("\n");
expect(text).toContain("API check failed");
expect(text).toContain("[email protected]");
expect(text).toContain("cached");
});
});
+12 -14
View File
@@ -16,10 +16,9 @@ function joined(ctx: UnconfiguredContext, engines?: OfflineEngineLine[]): string
describe("buildUnconfiguredLines — interactive (TTY / agent-driven)", () => {
const text = joined(INTERACTIVE);
it("makes browser OAuth the frames path", () => {
expect(text).toContain("frames auth login");
expect(text).toMatch(/browser oauth/i);
expect(text).toMatch(/sign in or sign up/i);
it("sends the user to the Hanzo CLI for sign-in", () => {
expect(text).toContain("hanzo login");
expect(text).toMatch(/iam device flow/i);
});
it("never steers users toward a per-repo .env", () => {
@@ -35,14 +34,13 @@ describe("buildUnconfiguredLines — interactive (TTY / agent-driven)", () => {
expect(text).toMatch(/free, offline/i);
});
it("shows only zero-install `npx frames` paths, not the separately-installed heygen CLI", () => {
expect(text).not.toMatch(/heygen auth login/);
expect(text).toContain("npx frames auth login");
expect(text).toContain("npx frames auth login --api-key");
it("never asks the CLI itself to log the user in", () => {
expect(text).not.toMatch(/frames auth login/);
});
it("offers the --api-key path as a secondary option", () => {
expect(text).toContain("frames auth login --api-key");
it("names the KMS-delivered key as the unattended path", () => {
expect(text).toContain("HANZO_API_KEY");
expect(text).toMatch(/hanzo kms/i);
});
});
@@ -55,8 +53,8 @@ describe("buildUnconfiguredLines — non-interactive (CI / piped)", () => {
expect(text).not.toMatch(/opens your browser/i);
});
it("points at HEYGEN_API_KEY and the local fallback", () => {
expect(text).toContain("HEYGEN_API_KEY");
it("points at HANZO_API_KEY and the local fallback", () => {
expect(text).toContain("HANZO_API_KEY");
expect(text).toMatch(/local engines/i);
});
});
@@ -98,13 +96,13 @@ describe("buildUnconfiguredLines — offline engine readiness", () => {
});
describe("buildUnconfiguredJson", () => {
it("recommends auth login and reports the local fallback", () => {
it("recommends hanzo login and reports the local fallback", () => {
for (const ctx of [INTERACTIVE, NON_INTERACTIVE]) {
const payload = buildUnconfiguredJson(ctx);
expect(payload).toMatchObject({
configured: false,
interactive: ctx.interactive,
recommended_action: "npx frames auth login",
recommended_action: "hanzo login",
fallback: "local",
});
}
+39 -240
View File
@@ -1,32 +1,22 @@
import { failCommand, setCommandExitCode } from "../../utils/commandResult.js";
import { failCommand } from "../../utils/commandResult.js";
/**
* `frames auth status` — print the active credential's source,
* type, and identity (verified against `GET /v3/users/me`).
* `frames auth status` — print which credential this machine presents to
* api.hanzo.ai and where it came from.
*
* Exits non-zero when nothing is configured or the API rejects the
* credential, so scripts can check "am I logged in?" with `$?`.
* Exits non-zero when nothing is configured, so scripts can check "do I have a
* credential?" with `$?`. Whether the API accepts it is answered by the call
* that uses it — there is no separate probe to disagree with.
*
* When nothing is configured the output is onboarding-first: an
* interactive session (a TTY, or a coding agent driving the CLI) gets
* registration guidance led by `frames auth login` — sign-in and
* sign-up are the same OAuth step — while CI / non-interactive runs get
* a terse note and continue on local fallbacks. This is the shared
* preflight every TTS/BGM workflow relays, so the wording lives in one
* place instead of each workflow improvising its own.
* When nothing is configured the output is onboarding-first: an interactive
* session (a TTY, or a coding agent driving the CLI) gets guidance led by
* `hanzo login`, while CI / non-interactive runs get a terse note and continue
* on local engines. This is the shared preflight every TTS/BGM workflow
* relays, so the wording lives in one place instead of each workflow
* improvising its own.
*/
import { defineCommand } from "citty";
import {
AuthClient,
isAuthError,
loadUserInfo,
refreshTokens,
tryResolveCredential,
userDisplayName,
type ResolvedCredential,
type StoredUserInfo,
type UserInfo,
} from "../../auth/index.js";
import { credential, describeSource, type Credential } from "../../api.js";
import { getSystemMeta } from "../../telemetry/system.js";
import { c } from "../../ui/colors.js";
import { resolveMusic, resolveVoice } from "../../audio/providers.js";
@@ -37,26 +27,8 @@ import {
type UnconfiguredContext,
} from "./status-guidance.js";
interface VerifiedStatus {
credential: ResolvedCredential;
user: UserInfo | null;
/**
* The friendly-display block persisted at login time, when the active
* credential is file-sourced and a block is on disk. `null` for
* env-sourced credentials (the on-disk block could belong to a
* different key) and for pre-this-change credentials files.
*/
persistedUser: StoredUserInfo | null;
apiError: string | null;
}
/** True for credentials resolved from the shared file (not env). */
function isFileSource(source: ResolvedCredential["source"]): boolean {
return source === "file_json" || source === "file_legacy";
}
export default defineCommand({
meta: { name: "status", description: "Show the active HeyGen credential" },
meta: { name: "status", description: "Show the credential used for api.hanzo.ai" },
args: {
json: {
type: "boolean",
@@ -64,25 +36,16 @@ export default defineCommand({
default: false,
},
},
// fallow-ignore-next-line complexity
async run({ args }) {
const asJson = Boolean(args.json);
let credential;
try {
credential = await tryResolveCredential();
} catch (err) {
handleResolveError(err, asJson);
return;
}
if (!credential) {
const cred = await credential();
if (!cred) {
handleUnconfigured(asJson);
return;
}
const status = await verify(credential);
if (asJson) printJsonStatus(status);
else printHumanStatus(status);
setCommandExitCode(status.apiError ? 1 : 0);
if (asJson) printJsonStatus(cred);
else printHumanStatus(cred);
},
});
@@ -97,14 +60,13 @@ function detectUnconfiguredContext(): UnconfiguredContext {
}
/**
* Probe the local voice/music engines a workflow would fall back to.
* `hasHeygen` is false here by construction — we only reach this when no
* credential resolved — so this reports the offline engines and whether
* their Python deps are installed.
* Probe the local voice/music engines a workflow would fall back to. We only
* reach this with no credential, so speech is not hosted and this reports the
* offline engines and whether their Python deps are installed.
*/
function collectOfflineEngines(): OfflineEngineLine[] {
const voice = resolveVoice(false);
const music = resolveMusic(false);
const music = resolveMusic();
return [
{ capability: "voice", label: voice.label, ready: voice.ready, ...hint(voice.setupHint) },
{ capability: "music", label: music.label, ready: music.ready, ...hint(music.setupHint) },
@@ -127,187 +89,24 @@ function handleUnconfigured(asJson: boolean): never {
failCommand();
}
// fallow-ignore-next-line complexity
function handleResolveError(err: unknown, asJson: boolean): never {
if (!isAuthError(err)) throw err;
if (asJson) {
console.log(JSON.stringify({ configured: false, error: err.message, hint: err.hint ?? null }));
} else {
console.error(c.error(err.message));
if (err.hint) console.error(c.dim(err.hint));
}
failCommand();
function printJsonStatus(cred: Credential): void {
console.log(
JSON.stringify(
{
configured: true,
source: cred.source,
account: cred.subject ?? null,
owner: cred.owner ?? null,
},
null,
2,
),
);
}
async function verify(credential: ResolvedCredential): Promise<VerifiedStatus> {
const client = new AuthClient({
// Return the full new token set so the retry's credential carries
// a rotated refresh_token forward (defends against IdPs that
// invalidate the old RT on every refresh).
onUnauthenticatedRefresh: async (rt) => await refreshTokens(rt),
});
const persistedUser = await loadPersistedUser(credential);
try {
const user = await client.getCurrentUser(credential);
return { credential, user, persistedUser, apiError: null };
} catch (err) {
if (!isAuthError(err)) throw err;
return {
credential,
user: null,
persistedUser,
apiError: err instanceof Error ? err.message : String(err),
};
}
}
/**
* Load the persisted friendly-display block, but only for file-sourced
* credentials. An env credential (`HEYGEN_API_KEY` / `FRAMES_API_KEY`)
* could belong to a different key than the on-disk block, so surfacing
* that block would mislabel the active account. A read error is swallowed
* — the block is purely cosmetic and must never break `auth status`.
*/
async function loadPersistedUser(credential: ResolvedCredential): Promise<StoredUserInfo | null> {
if (!isFileSource(credential.source)) return null;
try {
return await loadUserInfo();
} catch {
return null;
}
}
function printJsonStatus(s: VerifiedStatus): void {
const payload: Record<string, unknown> = {
configured: true,
source: s.credential.source,
type: s.credential.type,
user: s.user,
// The friendly-display block persisted at login (file-sourced creds
// only). Strictly additive — the live `user` field above is
// unchanged. Lets callers read identity offline / on an API blip.
persisted_user: persistedUserJson(s.persistedUser),
api_error: s.apiError,
};
if (s.credential.type === "oauth") {
payload["expires_at"] = s.credential.expires_at?.toISOString() ?? null;
payload["refreshable"] = s.credential.refreshable;
payload["scope"] = s.credential.scope ?? null;
}
console.log(JSON.stringify(payload, null, 2));
}
/**
* Shape the persisted block for JSON: the four optional fields plus the
* resolved `display_name` (email > "first last" > username). Returns
* `null` when nothing is persisted so the field is an explicit null
* rather than an empty object.
*/
function persistedUserJson(u: StoredUserInfo | null): Record<string, unknown> | null {
if (!u) return null;
const display = userDisplayName(u);
return {
email: u.email ?? null,
first_name: u.first_name ?? null,
last_name: u.last_name ?? null,
username: u.username ?? null,
display_name: display ?? null,
};
}
function printHumanStatus(s: VerifiedStatus): void {
const rows = collectStatusRows(s);
function printHumanStatus(cred: Credential): void {
const rows: [string, string][] = [["Source:", describeSource(cred.source)]];
if (cred.subject) rows.push(["Account:", cred.subject]);
if (cred.owner) rows.push(["Org: ", cred.owner]);
for (const [label, value] of rows) console.log(`${c.bold(label)} ${value}`);
}
// fallow-ignore-next-line complexity
function collectStatusRows(s: VerifiedStatus): [string, string][] {
const rows: [string, string][] = [
["Source:", describeSource(s.credential.source)],
["Type: ", s.credential.type === "oauth" ? "oauth" : "api_key"],
];
if (s.credential.type === "oauth") rows.push(...oauthRows(s.credential));
if (s.apiError) {
rows.push([c.error("API check failed:"), s.apiError]);
// Fall back to the persisted identity so the user still sees who
// they're logged in as when the live probe is unreachable.
const cached = s.persistedUser && userDisplayName(s.persistedUser);
if (cached) rows.push(["Account:", `${cached} ${c.dim("(cached)")}`]);
return rows;
}
if (s.user) rows.push(...identityRows(s.user));
return rows;
}
// fallow-ignore-next-line complexity
function oauthRows(credential: Extract<ResolvedCredential, { type: "oauth" }>): [string, string][] {
const rows: [string, string][] = [];
if (credential.expires_at) {
const fresh = credential.expires_at.getTime() > Date.now();
const tag = fresh ? c.success("(valid)") : c.warn("(expired)");
const refresh = credential.refreshable ? c.dim(" · refreshable") : "";
rows.push(["Expires:", `${credential.expires_at.toISOString()} ${tag}${refresh}`]);
}
if (credential.scope) rows.push(["Scope: ", credential.scope]);
return rows;
}
function identityRows(user: UserInfo): [string, string][] {
const identity = user.email ?? user.username ?? "(unknown user)";
return [["Account:", identity], ...billingRows(user)];
}
const SOURCE_LABELS: Record<ResolvedCredential["source"], string> = {
env: "env (HEYGEN_API_KEY)",
env_alias: "env (FRAMES_API_KEY)",
file_legacy: "file (~/.heygen/credentials — legacy plaintext)",
file_json: "file (~/.heygen/credentials)",
};
function describeSource(source: ResolvedCredential["source"]): string {
return SOURCE_LABELS[source];
}
function billingRows(user: UserInfo): [string, string][] {
const rows: [string, string][] = [];
if (user.billing_type) rows.push(["Billing:", user.billing_type]);
pushWalletRow(rows, user);
pushSubscriptionRows(rows, user);
pushUsageRow(rows, user);
return rows;
}
// fallow-ignore-next-line complexity
function pushWalletRow(rows: [string, string][], user: UserInfo): void {
const balance = user.wallet?.remaining_balance;
if (balance === undefined) return;
const currency = user.wallet?.currency ? ` ${user.wallet.currency}` : "";
rows.push(["Wallet: ", `${balance}${currency}`]);
}
// fallow-ignore-next-line complexity
function pushSubscriptionRows(rows: [string, string][], user: UserInfo): void {
if (user.subscription?.plan) rows.push(["Plan: ", user.subscription.plan]);
pushCreditRow(rows, "Premium credits:", user.subscription?.credits?.premium_credits);
pushCreditRow(rows, "Add-on credits: ", user.subscription?.credits?.add_on_credits);
}
// fallow-ignore-next-line complexity
function pushCreditRow(
rows: [string, string][],
label: string,
credit: { remaining?: number; resets_at?: string } | undefined,
): void {
if (!credit || credit.remaining === undefined) return;
const resets = credit.resets_at ? ` (resets ${credit.resets_at.slice(0, 10)})` : "";
rows.push([label, `${credit.remaining}${resets}`]);
}
// fallow-ignore-next-line complexity
function pushUsageRow(rows: [string, string][], user: UserInfo): void {
const current = user.usage_based?.spending_current_usd;
if (current === undefined) return;
const cap = user.usage_based?.spending_cap_usd;
const capPart = cap !== undefined ? ` / $${cap}` : "";
rows.push(["Usage: ", `$${current}${capPart}`]);
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { failCommand } from "../utils/commandResult.js";
import { defineCommand } from "citty";
import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
import { resolve, join, dirname } from "node:path";
import { findMusicAudioSrc, audioRelPathForSrc, serializeBeats } from "@frames/core/beats";
import { findMusicAudioSrc, audioRelPathForSrc, serializeBeats } from "@hanzo/frame-core/beats";
import type { Example } from "./_examples.js";
import { resolveProject, type ProjectDir } from "../utils/project.js";
import { analyzeBeatsHeadless, type HeadlessBeatResult } from "../beats/headlessAnalyzer.js";
+5 -5
View File
@@ -15,11 +15,11 @@ import { c } from "../ui/colors.js";
import { formatBytes, formatDuration, errorBox } from "../ui/format.js";
import * as clack from "@clack/prompts";
import { withMeta } from "../utils/updateCheck.js";
import { fpsToFfmpegArg } from "@frames/core";
import { fpsToFfmpegArg } from "@hanzo/frame-core";
interface BenchmarkConfig {
label: string;
fps: import("@frames/core").Fps;
fps: import("@hanzo/frame-core").Fps;
quality: "draft" | "standard" | "high";
workers: number;
}
@@ -37,8 +37,8 @@ interface ConfigResult {
avgSize: number | null;
}
const FPS_30: import("@frames/core").Fps = { num: 30, den: 1 };
const FPS_60: import("@frames/core").Fps = { num: 60, den: 1 };
const FPS_30: import("@hanzo/frame-core").Fps = { num: 30, den: 1 };
const FPS_60: import("@hanzo/frame-core").Fps = { num: 60, den: 1 };
const DEFAULT_CONFIGS: BenchmarkConfig[] = [
{ label: "30fps \u00B7 draft \u00B7 2w", fps: FPS_30, quality: "draft", workers: 2 },
{ label: "30fps \u00B7 standard \u00B7 2w", fps: FPS_30, quality: "standard", workers: 2 },
@@ -86,7 +86,7 @@ export default defineCommand({
errorBox(
"Producer module not available",
"The rendering pipeline could not be loaded.",
"Ensure @frames/producer is built and linked.",
"Ensure @hanzo/frame-producer is built and linked.",
);
}
failCommand();
+1 -1
View File
@@ -11,7 +11,7 @@ export const examples: Example[] = [
];
import * as clack from "@clack/prompts";
import { type ItemType } from "@frames/core";
import { type ItemType } from "@hanzo/frame-core";
import { c } from "../ui/colors.js";
import { listRegistryItems, loadAllItems } from "../registry/resolver.js";
import { loadProjectConfig, DEFAULT_PROJECT_CONFIG } from "../utils/projectConfig.js";
@@ -28,20 +28,4 @@ describe("Send-to guide fidelity contract", () => {
expect(GUIDE).not.toContain("lossy by nature");
expect(GUIDE).not.toContain("content match the brief exactly");
});
// Pricing is LLM-facing contract too: the guide once labeled Enhance "the paid step", which
// teaches Claude the wrong billing boundary. The shipped model (heygen-server
// magic_edit/logic/usage_limits.py) is: import + enhance turns free; only Render is billed —
// FREE accounts get 3 renders/month, paid plans 20 credits/rendered-minute. Pin the concept,
// not an exact sentence, and block the retired Enhance-as-paid wording from returning.
it("identifies Enhance as free and Render as the paid/billed step, with the tiered contract", () => {
expect(GUIDE).toContain("Enhance turns are free");
expect(GUIDE).toContain("Render is the paid step");
expect(GUIDE).toContain("3 renders per month");
expect(GUIDE).toContain("20 credits per rendered minute");
});
it("does not restore the retired 'Enhance ... paid step' wording", () => {
expect(GUIDE).not.toContain("HeyGen media. This is the paid step");
});
});
-70
View File
@@ -1,70 +0,0 @@
/**
* `frames cloud` — top-level dispatcher for cloud-render subverbs.
*
* Each subverb lives in `./cloud/<name>.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", "frames cloud render"],
["Render a specific project", "frames cloud render ./my-video"],
["Inspect the project zip without uploading", "frames cloud render --dry-run"],
[
"Render at 60fps + high quality, save to a path",
"frames cloud render ./my-video --fps 60 --quality high -o ./out.mp4",
],
[
"Fire-and-forget with a webhook",
"frames cloud render ./my-video --callback-url https://example.com/hf-hook --no-wait",
],
["Resubmit an already-uploaded zip", "frames cloud render --asset-id asst_abc123"],
[
"Render from a public HTTPS zip",
"frames cloud render --url https://cdn.example.com/site.zip",
],
["List recent cloud renders", "frames cloud list"],
["Fetch one render's status + signed URLs", "frames cloud get hfr_abc123"],
["Soft-delete a render", "frames cloud delete hfr_abc123"],
];
const HELP = `
${c.bold("frames cloud")} ${c.dim("<subcommand> [args]")}
Render Frames 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("frames 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 Frames 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);
},
});

Some files were not shown because too many files have changed in this diff Show More