mirror of
https://github.com/hanzoai/frames.git
synced 2026-09-24 00:08:03 +00:00
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:
@@ -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,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]",
|
||||
|
||||
@@ -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
@@ -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**.
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
{
|
||||
"package": "@frames/aws-lambda",
|
||||
"package": "@hanzo/frame-aws-lambda",
|
||||
"subpaths": {
|
||||
".": {
|
||||
"source": "./src/index.ts",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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/**/*"],
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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")],
|
||||
|
||||
@@ -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})`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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(/=+$/, "");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>"');
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]" });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}`]);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user