feat(cli): use dedicated HyperFrames OAuth clients (#6687)

* feat(cli): use dedicated HyperFrames OAuth clients

* docs(cli): shorten credential store contract comment

* fix(cli): respect Windows modes in OAuth media test

* fix(cli): keep media workflows on HyperFrames OAuth

* test(cli): scope billing status assertions to wallet rows

* test(cli): align telemetry fixtures and CI auth inputs
This commit is contained in:
James Russo
2026-10-29 21:25:20 +00:00
committed by GitHub
parent 4e2a1c215a
commit 3e462f4277
49 changed files with 859 additions and 271 deletions
+1 -1
View File
@@ -238,7 +238,7 @@ jobs:
- "packages/cli/src/{background-removal,registry,telemetry}/**"
- "packages/cli/src/capture/{captureCompositionFrame,captureTimeout,navigateForCapture,readBoundedResponse}.ts"
- "packages/cli/src/media-use/lib/config-lock.mjs"
- "packages/cli/src/auth/{paths,telemetryIdentity}.ts"
- "packages/cli/src/auth/{config,errors,paths,telemetryIdentity}.ts"
- "packages/cli/scripts/**"
- "packages/core/stubs/**"
- "packages/engine/src/**"
+10 -7
View File
@@ -37,7 +37,9 @@ Signing in is the same OAuth step as creating an account — new users land on t
</Step>
</Steps>
The credential lives in `~/.heygen/credentials` (mode `0600`) — no per-repo `.env` to manage. Browser OAuth is a `hyperframes auth login` feature. The separate [`heygen` CLI](https://github.com/heygen-com/heygen-cli) (its own install — there's no `npx heygen`) is API-key-only, so `heygen auth login` just stores a key you paste. Both read the same `~/.heygen/credentials`, so signing in with one carries to the other.
HyperFrames uses its own public OAuth client and stores credentials in `~/.hyperframes/credentials` (mode `0600`). Sign in again after upgrading from the shared HeyGen login. The separate [`heygen` CLI](https://github.com/heygen-com/heygen-cli) keeps its own login in `~/.heygen`; HyperFrames does not move or delete it. An expired HyperFrames OAuth session requires refresh or sign-in and never selects a saved API key instead. An explicit API-key login or environment key selects API-key billing.
`auth status` verifies identity. For a dedicated HyperFrames OAuth login it reports the billing product, but the HyperFrames balance is unavailable here: `/v3/users/me` returns generic HeyGen account billing fields. Those raw fields remain in JSON and are not presented as the HyperFrames balance. Host-injected or custom-client OAuth is reported as an unverified billing product.
<Tip>
No account is needed to try HyperFrames locally. With no credential, voice can
@@ -54,12 +56,11 @@ sound retrieval. It resolves first-match-wins:
2. `HEYGEN_ACCESS_TOKEN` — an OAuth token a host app injects
3. `HEYGEN_API_KEY` — environment variable
4. `HYPERFRAMES_API_KEY` — alias, for parity with other tools
5. `~/.heygen/credentials` — written by `hyperframes auth login` (or `heygen auth login`)
5. `~/.hyperframes/credentials` — written by `hyperframes auth login`
A nearby project `.env` can supply the keys, but never `HEYGEN_API_BASE`. Point at a different config directory
with `HEYGEN_CONFIG_DIR`. The `hyperframes` CLI's own commands check the API keys before `HEYGEN_ACCESS_TOKEN`; see
the [CLI reference](/packages/cli). `media-use resolve` searches through the separate `heygen` CLI, which resolves its
own credential.
with `HYPERFRAMES_CONFIG_DIR`. The `hyperframes` CLI's own commands check the API keys before `HEYGEN_ACCESS_TOKEN`; see
the [CLI reference](/packages/cli). When `media-use` invokes the separate `heygen` executable, it supplies an isolated temporary credential containing the selected access token. That helper cannot refresh a HyperFrames grant or pick up another HeyGen login.
## Providers used by agent workflows
@@ -70,7 +71,7 @@ vision key is available.
| Capability | Provider order | Key(s) — first match wins | Local dependency |
|------------|----------------|---------------------------|------------------|
| **Voice (TTS)** | HeyGen → ElevenLabs → Kokoro | `HEYGEN_API_KEY` → `HYPERFRAMES_API_KEY` → `~/.heygen` · then `ELEVENLABS_API_KEY` | Kokoro: `pip install kokoro-onnx soundfile` |
| **Voice (TTS)** | HeyGen → ElevenLabs → Kokoro | `HEYGEN_API_KEY` → `HYPERFRAMES_API_KEY` → `~/.hyperframes` · then `ELEVENLABS_API_KEY` | Kokoro: `pip install kokoro-onnx soundfile` |
| **Music (BGM)** | HeyGen library → Lyria → MusicGen | HeyGen credential (above) · then `GEMINI_API_KEY` → `GOOGLE_API_KEY` | MusicGen: `pip install transformers torch soundfile numpy` |
| **Sound effects** | HeyGen library → bundled library | HeyGen credential (above) | bundled — no deps |
| **Capture descriptions** | OpenRouter → Gemini | `OPENROUTER_API_KEY` → `GEMINI_API_KEY` | None; optional for [website capture](/guides/product-launch-video) |
@@ -145,7 +146,9 @@ to a shared space with `--space`.
| `HEYGEN_ACCESS_TOKEN` | OAuth token a host app injects; never refreshed or saved. |
| `HEYGEN_API_BASE` | A host app's gateway for media workflows; never read from a project `.env`. |
| `HEYGEN_API_URL` | API base URL for `hyperframes` CLI commands (default `https://api.heygen.com`). |
| `HEYGEN_CONFIG_DIR` | Credentials directory (default `~/.heygen`). |
| `HYPERFRAMES_CONFIG_DIR` | HyperFrames credentials root (default `~/.hyperframes`). |
| `HYPERFRAMES_ENV` | `prod` (default) or `dev`; switches OAuth and API defaults together. Dev credentials live under the root’s `dev/` directory. |
| `HYPERFRAMES_OAUTH_CLIENT_ID` | Override the public client ID for a controlled OAuth environment. |
| `ELEVENLABS_API_KEY` | ElevenLabs TTS, used when no HeyGen credential is present. |
| `GEMINI_API_KEY` / `GOOGLE_API_KEY` | Explicit Gemini TTS selection and Lyria music generation; capture descriptions use `GEMINI_API_KEY`. |
| `GOOGLE_APPLICATION_CREDENTIALS` / `GCS_CREDS` | Gemini TTS service-account JSON file path / injected JSON. Used when neither API key is set. |
+12 -9
View File
@@ -687,10 +687,11 @@ and are copied to `~/.media/` so other projects can reuse them. Sound effects
under CC BY get their credit written to `.media/audio/sfx/CREDITS.md`.
**What needs an account, and what can cost money.** The HeyGen sources run
through the separate `heygen` CLI, signed in with `heygen auth login --oauth`.
Searching the music, image, and icon catalogs is free. Text to speech and avatar
video draw on a free allowance when you are signed in with OAuth, then on your
HeyGen billing, so an agent should ask before generating them. When a
through the separate `heygen` executable using the login selected by
`hyperframes auth login`. It receives a temporary access-token credential and
cannot read the separate HeyGen login or refresh a HyperFrames grant.
The server decides feature eligibility and charges. An explicit API-key credential
uses API-key billing. When a
host app sets `HEYGEN_API_BASE` and `HEYGEN_API_KEY`, HeyGen calls go through that
host instead and are charged to its key. The `codex` image fallback uses your
ChatGPT subscription. Openverse, theSVG, GitHub, favicons, color grades, and the
@@ -2188,16 +2189,16 @@ it is anonymous until you sign in. Nothing calls it by hand.
## hyperframes auth
Sign in to HeyGen and manage credentials. They live in `~/.heygen/credentials`
(mode `0600`) and are **shared with the `heygen` CLI** — sign in with one and
the other picks up the session.
Sign into your HeyGen account through the dedicated HyperFrames OAuth client.
Credentials live in `~/.hyperframes/credentials` (mode `0600`), independently
of the `heygen` CLI. A previous shared login requires a fresh HyperFrames sign-in.
First match wins:
1. `HEYGEN_API_KEY`
2. `HYPERFRAMES_API_KEY` (a HyperFrames alias for the same thing)
3. `HEYGEN_ACCESS_TOKEN` (an OAuth token a host app injects; never refreshed or saved)
4. `~/.heygen/credentials`
4. `~/.hyperframes/credentials`
### `auth login`
@@ -2257,7 +2258,9 @@ hyperframes auth logout --yes # no prompt
| `HYPERFRAMES_API_KEY` | Alias for `HEYGEN_API_KEY`. |
| `HEYGEN_ACCESS_TOKEN` | OAuth token from a host app; never refreshed. |
| `HEYGEN_API_URL` | API base URL (default `https://api.heygen.com`). |
| `HEYGEN_CONFIG_DIR` | Credentials directory (default `~/.heygen`). |
| `HYPERFRAMES_CONFIG_DIR` | Credentials root (default `~/.hyperframes`). |
| `HYPERFRAMES_ENV` | `prod` (default) or `dev`; selects matching OAuth/API hosts and a separate dev credential directory. |
| `HYPERFRAMES_OAUTH_CLIENT_ID` | Override the selected public OAuth client. |
For the keys other capabilities use — ElevenLabs and Gemini for voice and music
fallback, OpenRouter and Gemini for capture — and how the skills prioritize
+1
View File
@@ -18,6 +18,7 @@ for (const name of [
"CODEX_HOME",
"CLAUDE_CONFIG_DIR",
"HEYGEN_CONFIG_DIR",
"HYPERFRAMES_CONFIG_DIR",
"HYPERFRAMES_CATALOG_ARTIFACT_DIR",
"HYPERFRAMES_MEDIA_HOME",
"HF_HOME",
+9 -2
View File
@@ -14,9 +14,16 @@ const ENV_KEYS = [
"HEYGEN_API_KEY",
"HEYGEN_ACCESS_TOKEN",
"HYPERFRAMES_API_KEY",
"HYPERFRAMES_CONFIG_DIR",
"HEYGEN_CONFIG_DIR",
"HEYGEN_API_BASE",
"HEYGEN_API_URL",
"HYPERFRAMES_OAUTH_CLIENT_ID",
"HYPERFRAMES_ENV",
"HYPERFRAMES_OAUTH_TOKEN_URL",
"HYPERFRAMES_OAUTH_AUTHORIZE_URL",
"HYPERFRAMES_OAUTH_REVOKE_URL",
"HYPERFRAMES_OAUTH_DEVICE_URL",
] as const;
type EnvKey = (typeof ENV_KEYS)[number];
@@ -30,7 +37,7 @@ export interface EnvFixture {
/**
* Take a snapshot of the auth-related env, clear them, make a tmp
* `HEYGEN_CONFIG_DIR`, and return a `restore()` that undoes all of
* `HYPERFRAMES_CONFIG_DIR`, and return a `restore()` that undoes all of
* the above.
*/
export async function setupTempAuthEnv(prefix = "hf-auth-test-"): Promise<EnvFixture> {
@@ -40,7 +47,7 @@ export async function setupTempAuthEnv(prefix = "hf-auth-test-"): Promise<EnvFix
saved[k] = process.env[k];
delete process.env[k];
}
process.env["HEYGEN_CONFIG_DIR"] = dir;
process.env["HYPERFRAMES_CONFIG_DIR"] = dir;
const restore = async (): Promise<void> => {
for (const k of ENV_KEYS) {
+4 -2
View File
@@ -19,8 +19,8 @@ import { ErrApi, ErrUnauthenticated, isAuthError } from "./errors.js";
import type { ResolvedCredential } from "./resolver.js";
import { scrubCredentials } from "./scrub.js";
import type { OAuthTokens } from "./store.js";
import { oauthEnvironment } from "./config.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
@@ -32,7 +32,9 @@ export const HEYGEN_CLIENT_SOURCE = "hyperframes";
export function apiBaseUrl(): string {
const override = process.env["HEYGEN_API_URL"];
return override && override.length > 0 ? override.replace(/\/+$/, "") : DEFAULT_BASE_URL;
return override && override.length > 0
? override.replace(/\/+$/, "")
: `https://api.${oauthEnvironment() === "dev" ? "dev." : ""}heygen.com`;
}
export type BillingType = "wallet" | "subscription" | "usage_based" | string;
+51
View File
@@ -0,0 +1,51 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { setupTempAuthEnv } from "./_test-utils.js";
import {
assertOAuthIdentity,
authorizeEndpoint,
deviceAuthorizationEndpoint,
oauthIdentity,
resolveClientId,
revokeEndpoint,
tokenEndpoint,
} from "./config.js";
import { apiBaseUrl } from "./client.js";
describe("HyperFrames OAuth configuration", () => {
let fixture: Awaited<ReturnType<typeof setupTempAuthEnv>>;
beforeEach(async () => {
fixture = await setupTempAuthEnv();
});
afterEach(async () => {
await fixture.restore();
});
it("uses the dedicated public production client", () => {
expect(resolveClientId()).toBe("Zg3LF4FCLoeJoGs2YqMtZ17g");
expect(authorizeEndpoint()).toBe("https://app.heygen.com/oauth/authorize");
expect(tokenEndpoint()).toBe("https://api2.heygen.com/v1/oauth/token");
expect(apiBaseUrl()).toBe("https://api.heygen.com");
});
it("switches every default host together for development", () => {
process.env["HYPERFRAMES_ENV"] = "dev";
expect(resolveClientId()).toBe("9QoaiwCZov9O0cAkC4OZbR4p");
expect(authorizeEndpoint()).toBe("https://api2.dev.heygen.com/v1/oauth/authorize");
expect(tokenEndpoint()).toBe("https://api2.dev.heygen.com/v1/oauth/token");
expect(revokeEndpoint()).toBe("https://api2.dev.heygen.com/v1/oauth/revoke");
expect(deviceAuthorizationEndpoint()).toBe(
"https://api2.dev.heygen.com/v1/oauth/device_authorization",
);
expect(apiBaseUrl()).toBe("https://api.dev.heygen.com");
});
it("rejects a session after either its client or token server changes", () => {
const identity = oauthIdentity();
expect(() => assertOAuthIdentity(identity)).not.toThrow();
process.env["HYPERFRAMES_OAUTH_CLIENT_ID"] = "another-client";
expect(() => assertOAuthIdentity(identity)).toThrow("different OAuth client or environment");
delete process.env["HYPERFRAMES_OAUTH_CLIENT_ID"];
process.env["HYPERFRAMES_OAUTH_TOKEN_URL"] = "https://other.example/token";
expect(() => assertOAuthIdentity(identity)).toThrow("different OAuth client or environment");
});
});
+67
View File
@@ -0,0 +1,67 @@
import { AuthError, ErrOAuthNotConfigured } from "./errors.js";
const PROD_CLIENT_ID = "Zg3LF4FCLoeJoGs2YqMtZ17g";
export function oauthEnvironment(): "prod" | "dev" {
const value = process.env["HYPERFRAMES_ENV"] || "prod";
if (value === "prod" || value === "dev") return value;
throw new AuthError("OAUTH_NOT_CONFIGURED", "HYPERFRAMES_ENV must be prod or dev");
}
export function defaultClientId(): string {
return oauthEnvironment() === "prod" ? PROD_CLIENT_ID : "9QoaiwCZov9O0cAkC4OZbR4p";
}
export function resolveClientId(): string {
const id = process.env["HYPERFRAMES_OAUTH_CLIENT_ID"] || defaultClientId();
if (!id) throw ErrOAuthNotConfigured();
return id;
}
export function tokenEndpoint(): string {
return (
process.env["HYPERFRAMES_OAUTH_TOKEN_URL"] ||
`https://api2.${oauthEnvironment() === "dev" ? "dev." : ""}heygen.com/v1/oauth/token`
);
}
export function authorizeEndpoint(): string {
return (
process.env["HYPERFRAMES_OAUTH_AUTHORIZE_URL"] ||
(oauthEnvironment() === "dev"
? "https://api2.dev.heygen.com/v1/oauth/authorize"
: "https://app.heygen.com/oauth/authorize")
);
}
export function revokeEndpoint(): string {
return (
process.env["HYPERFRAMES_OAUTH_REVOKE_URL"] ||
`https://api2.${oauthEnvironment() === "dev" ? "dev." : ""}heygen.com/v1/oauth/revoke`
);
}
export function deviceAuthorizationEndpoint(): string {
return (
process.env["HYPERFRAMES_OAUTH_DEVICE_URL"] ||
`https://api2.${oauthEnvironment() === "dev" ? "dev." : ""}heygen.com/v1/oauth/device_authorization`
);
}
export function oauthIdentity(): { client_id: string; token_endpoint: string } {
return { client_id: resolveClientId(), token_endpoint: tokenEndpoint() };
}
export function assertOAuthIdentity(tokens: { client_id?: string; token_endpoint?: string }): void {
const identity = oauthIdentity();
if (
tokens.client_id !== identity.client_id ||
tokens.token_endpoint !== identity.token_endpoint
) {
throw new AuthError(
"LOGIN_EXPIRED",
"This login belongs to a different OAuth client or environment",
"Run `hyperframes auth login` to sign into the selected HyperFrames environment.",
);
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ export const ErrInvalidStore = (detail: string) =>
new AuthError(
"INVALID_STORE",
`Credential file is unreadable: ${detail}`,
"Delete ~/.heygen/credentials and run `hyperframes auth login` to re-create it.",
"Delete ~/.hyperframes/credentials and run `hyperframes auth login` to re-create it.",
);
export const ErrUnauthenticated = (detail?: string) =>
@@ -0,0 +1,107 @@
import { promises as fs } from "node:fs";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { setupTempAuthEnv } from "./_test-utils.js";
import { persistFreshOAuth } from "./oauth.js";
import { withMediaAuthEnvironment } from "./media-environment.js";
describe("media-use authentication", () => {
let fixture: Awaited<ReturnType<typeof setupTempAuthEnv>>;
beforeEach(async () => {
fixture = await setupTempAuthEnv();
});
afterEach(async () => {
vi.unstubAllGlobals();
await fixture.restore();
});
it("passes a fresh access token through a private, temporary Go credential file", async () => {
await persistFreshOAuth({ access_token: "hf-access", refresh_token: "hf-refresh" });
let path = "";
await withMediaAuthEnvironment(async (env) => {
path = join(env.HEYGEN_CONFIG_DIR!, "credentials");
expect(env.HEYGEN_ACCESS_TOKEN).toBe("hf-access");
expect(env.HEYGEN_API_BASE).toBe("https://api.heygen.com");
expect(env.HEYGEN_API_KEY).toBeUndefined();
const copied = JSON.parse(await fs.readFile(path, "utf8"));
expect(copied.oauth.access_token).toBe("hf-access");
expect(copied.oauth.refresh_token).toBeUndefined();
if (process.platform !== "win32") {
expect((await fs.stat(path)).mode & 0o777).toBe(0o600);
expect((await fs.stat(env.HEYGEN_CONFIG_DIR!)).mode & 0o777).toBe(0o700);
}
});
await expect(fs.stat(path)).rejects.toMatchObject({ code: "ENOENT" });
expect(await fs.readFile(join(fixture.dir, "credentials"), "utf8")).toContain("hf-refresh");
});
it("refreshes a token before a helper could outlive it", async () => {
await persistFreshOAuth({
access_token: "near-expiry",
refresh_token: "hf-refresh",
expires_at: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
});
const fetchMock = vi.fn(
async () =>
new Response(
JSON.stringify({ access_token: "refreshed", refresh_token: "rotated", expires_in: 3600 }),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
vi.stubGlobal("fetch", fetchMock);
await withMediaAuthEnvironment(async (env) => {
expect(fetchMock).toHaveBeenCalledOnce();
expect(env.HEYGEN_ACCESS_TOKEN).toBe("refreshed");
const copied = JSON.parse(
await fs.readFile(join(env.HEYGEN_CONFIG_DIR!, "credentials"), "utf8"),
);
expect(copied.oauth.access_token).toBe("refreshed");
expect(copied.oauth.refresh_token).toBeUndefined();
});
expect(await fs.readFile(join(fixture.dir, "credentials"), "utf8")).toContain("rotated");
});
it("refuses to send OAuth to a non-HeyGen media gateway", async () => {
await persistFreshOAuth({ access_token: "hf-access", refresh_token: "hf-refresh" });
process.env["HEYGEN_API_BASE"] = "https://gateway.example";
try {
await expect(withMediaAuthEnvironment(() => undefined)).rejects.toThrow(
"requires its own API credential",
);
} finally {
delete process.env["HEYGEN_API_BASE"];
}
});
it("isolates an anonymous operation from an existing HeyGen login", async () => {
process.env["HEYGEN_CONFIG_DIR"] = join(fixture.dir, "heygen");
await fs.mkdir(process.env["HEYGEN_CONFIG_DIR"]);
const legacyPath = join(process.env["HEYGEN_CONFIG_DIR"], "credentials");
await fs.writeFile(legacyPath, "hg_other_wallet");
await withMediaAuthEnvironment(async (env) => {
expect(env.HEYGEN_CONFIG_DIR).not.toBe(process.env["HEYGEN_CONFIG_DIR"]);
expect(await fs.readdir(env.HEYGEN_CONFIG_DIR!)).toEqual([]);
expect(env.HEYGEN_API_KEY).toBeUndefined();
});
expect(await fs.readFile(legacyPath, "utf8")).toBe("hg_other_wallet");
});
it("preserves an explicit host gateway and cleans up after a subprocess error", async () => {
process.env["HEYGEN_API_BASE"] = "https://gateway.example";
process.env["HEYGEN_API_KEY"] = "gateway-key";
let dir = "";
try {
await expect(
withMediaAuthEnvironment((env) => {
dir = env.HEYGEN_CONFIG_DIR!;
expect(env.HEYGEN_API_BASE).toBe("https://gateway.example");
expect(env.HEYGEN_API_KEY).toBe("gateway-key");
throw new Error("subprocess failed");
}),
).rejects.toThrow("subprocess failed");
await expect(fs.stat(dir)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
delete process.env["HEYGEN_API_BASE"];
}
});
});
@@ -0,0 +1,73 @@
import { promises as fs } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { apiBaseUrl } from "./client.js";
import { refreshTokens } from "./oauth.js";
import { AuthError, isAuthError } from "./errors.js";
import { resolveCredential, tryResolveCredential } from "./resolver.js";
import { writeStore } from "./store.js";
// Avatar helpers can poll for five minutes after submitting paid work.
const MIN_HELPER_TOKEN_LIFETIME_MS = 10 * 60 * 1000;
/** The Go CLI accepts OAuth through a credential file, but cannot refresh a HyperFrames grant. */
export async function withMediaAuthEnvironment<T>(run: (env: NodeJS.ProcessEnv) => T): Promise<T> {
let credential;
try {
credential = await tryResolveCredential();
} catch (error) {
// An expired login must not stop anonymous/local providers or select another wallet.
if (!isAuthError(error) || error.code !== "LOGIN_EXPIRED") throw error;
}
const expiresDuringHelper =
credential?.type === "oauth" &&
credential.expires_at !== undefined &&
credential.expires_at.getTime() - Date.now() < MIN_HELPER_TOKEN_LIFETIME_MS;
if (
credential?.type === "oauth" &&
credential.refresh_token &&
(credential.refreshable || expiresDuringHelper)
) {
await refreshTokens(credential.refresh_token);
credential = await resolveCredential();
}
const dir = await fs.mkdtemp(join(tmpdir(), "hyperframes-media-auth-"));
try {
await fs.chmod(dir, 0o700);
const env: NodeJS.ProcessEnv = { ...process.env, HEYGEN_CONFIG_DIR: dir };
env.HEYGEN_API_BASE ||= apiBaseUrl();
if (credential?.type === "oauth") {
const host = new URL(env.HEYGEN_API_BASE).hostname;
if (host !== "heygen.com" && !host.endsWith(".heygen.com")) {
throw new AuthError(
"OAUTH_NOT_CONFIGURED",
"A media gateway requires its own API credential",
);
}
delete env.HEYGEN_API_KEY;
delete env.HYPERFRAMES_API_KEY;
env.HEYGEN_ACCESS_TOKEN = credential.access_token;
await writeStore(
{
oauth: {
access_token: credential.access_token,
expires_at: credential.expires_at?.toISOString(),
token_type: "Bearer",
},
},
join(dir, "credentials"),
);
} else if (credential?.type === "api_key") {
env.HEYGEN_API_KEY = credential.key;
delete env.HEYGEN_ACCESS_TOKEN;
} else {
delete env.HEYGEN_ACCESS_TOKEN;
delete env.HEYGEN_API_KEY;
delete env.HYPERFRAMES_API_KEY;
}
return await run(env);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
}
+48 -2
View File
@@ -1,3 +1,4 @@
import { oauthIdentity } from "./config.js";
import { promises as fs } from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { setupTempAuthEnv } from "./_test-utils.js";
@@ -12,7 +13,7 @@ import {
startAuthorizationCodeFlow,
startDeviceAuthorizationFlow,
} from "./oauth.js";
import { readStore, writeStore } from "./store.js";
import { readStore, writeStore as writeUnboundStore, type Credentials } from "./store.js";
// Mock the interactive bits so startAuthorizationCodeFlow runs headless.
vi.mock("./loopback.js", () => ({
@@ -68,6 +69,13 @@ async function loginWithTokens(body: Record<string, unknown>) {
return (await readStore()).credentials;
}
async function writeStore(credentials: Credentials): Promise<void> {
await writeUnboundStore({
...credentials,
...(credentials.oauth ? { oauth: { ...oauthIdentity(), ...credentials.oauth } } : {}),
});
}
describe("auth/oauth", () => {
let fixture: Awaited<ReturnType<typeof setupTempAuthEnv>>;
@@ -170,6 +178,26 @@ describe("auth/oauth", () => {
});
describe("refreshTokens", () => {
it.each([undefined, "foreign-client"])(
"does not send a refresh token with client binding %s",
async (client_id) => {
await writeUnboundStore({
oauth: {
access_token: "at",
refresh_token: "rt",
client_id,
token_endpoint: oauthIdentity().token_endpoint,
},
});
const fetchImpl = vi.fn<typeof fetch>();
await expect(refreshTokens("rt", { fetchImpl })).rejects.toMatchObject({
code: "LOGIN_EXPIRED",
});
expect(fetchImpl).not.toHaveBeenCalled();
expect((await readStore()).credentials.oauth?.refresh_token).toBe("rt");
},
);
it("posts grant_type=refresh_token and persists the response", async () => {
process.env["HEYGEN_API_URL"] = "https://api.test.example";
await writeStore({ oauth: { access_token: "old_at", refresh_token: "old_rt" } });
@@ -196,7 +224,7 @@ describe("auth/oauth", () => {
// Should have persisted.
const { credentials } = await readStore();
expect(credentials.oauth?.access_token).toBe("new_at");
expect(credentials.oauth).toMatchObject({ ...oauthIdentity(), access_token: "new_at" });
});
it("preserves the prior refresh_token when the server omits it (no rotation)", async () => {
@@ -264,6 +292,7 @@ describe("auth/oauth", () => {
path,
JSON.stringify({
oauth: {
...oauthIdentity(),
access_token: "old_at",
refresh_token: "keep_me_rt",
id_token: "future_id_token_value",
@@ -281,6 +310,7 @@ describe("auth/oauth", () => {
});
it("throws REFRESH_FAILED on 400/401", async () => {
await writeStore({ oauth: { access_token: "at", refresh_token: "bad_rt" } });
const fetchImpl = (async () =>
new Response("invalid_grant", {
status: 400,
@@ -291,6 +321,7 @@ describe("auth/oauth", () => {
});
it("throws API_ERROR on 5xx", async () => {
await writeStore({ oauth: { access_token: "at", refresh_token: "rt" } });
const fetchImpl = (async () =>
new Response("upstream", { status: 503 })) as unknown as typeof fetch;
await expect(refreshTokens("rt", { fetchImpl })).rejects.toSatisfy((err) => {
@@ -300,6 +331,20 @@ describe("auth/oauth", () => {
});
describe("revokeTokens", () => {
it("does not revoke another client's stored grant", async () => {
await writeUnboundStore({
oauth: {
access_token: "foreign-at",
refresh_token: "foreign-rt",
client_id: "another-client",
token_endpoint: oauthIdentity().token_endpoint,
},
});
const fetchImpl = vi.fn<typeof fetch>();
await revokeTokens("foreign-rt", { fetchImpl });
expect(fetchImpl).not.toHaveBeenCalled();
});
it("never throws on network failure (best-effort)", async () => {
const fetchImpl = (async () => {
throw new Error("connection refused");
@@ -348,6 +393,7 @@ describe("auth/oauth", () => {
describe("error scrubbing", () => {
it("refreshTokens does not leak token-shaped secrets from the error body", async () => {
await writeStore({ oauth: { access_token: "at", refresh_token: "rt_leak_secret" } });
const fetchImpl = (async () =>
new Response(
'{"error":"invalid_grant","echoed":"refresh_token=rt_leak_secret&code_verifier=cv_leak"}',
+26 -38
View File
@@ -38,7 +38,6 @@ import {
ErrApi,
ErrDeviceAuthFailed,
ErrLoginChanged,
ErrOAuthNotConfigured,
ErrRefreshFailed,
isAuthError,
} from "./errors.js";
@@ -55,25 +54,23 @@ import {
type StoredUserInfo,
} from "./store.js";
import { c } from "../ui/colors.js";
import {
assertOAuthIdentity,
authorizeEndpoint,
deviceAuthorizationEndpoint,
oauthIdentity,
resolveClientId,
revokeEndpoint,
tokenEndpoint,
} from "./config.js";
export { resolveClientId } from "./config.js";
const REVOKE_TIMEOUT_MS = 5_000;
const MIN_EXPIRES_IN_SECONDS = 30;
/**
* Default OAuth client_id baked at build time. Override with the
* `HYPERFRAMES_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";
const DEFAULT_DEVICE_AUTHORIZATION_URL = "https://api2.heygen.com/v1/oauth/device_authorization";
const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
const MAX_DEVICE_FLOW_SECONDS = 30 * 60;
const MIN_DEVICE_POLL_SECONDS = 5;
@@ -81,19 +78,6 @@ const MAX_DEVICE_POLL_SECONDS = 60;
const MAX_DEVICE_RESPONSE_BYTES = 64 * 1024;
const DEVICE_REQUEST_TIMEOUT_MS = 15_000;
function authorizeEndpoint(): string {
return process.env["HYPERFRAMES_OAUTH_AUTHORIZE_URL"] || DEFAULT_AUTHORIZE_URL;
}
function tokenEndpoint(): string {
return process.env["HYPERFRAMES_OAUTH_TOKEN_URL"] || DEFAULT_TOKEN_URL;
}
function revokeEndpoint(): string {
return process.env["HYPERFRAMES_OAUTH_REVOKE_URL"] || DEFAULT_REVOKE_URL;
}
function deviceAuthorizationEndpoint(): string {
return process.env["HYPERFRAMES_OAUTH_DEVICE_URL"] || DEFAULT_DEVICE_AUTHORIZATION_URL;
}
export interface AuthorizeFlowOptions {
/** Override scopes (default `openid profile email`). */
scope?: string;
@@ -134,14 +118,6 @@ export interface DeviceAuthorizationFlowOptions {
onChallenge?: (challenge: DeviceAuthorizationChallenge) => void | Promise<void>;
}
/** Read the client_id, throwing `ErrOAuthNotConfigured` when unset. */
export function resolveClientId(): string {
const override = process.env["HYPERFRAMES_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
@@ -214,7 +190,7 @@ export async function startAuthorizationCodeFlow(
* RFC 8628 attended device flow. This function deliberately returns an
* unpersisted token set: the command must verify `/v3/users/me` first and only
* then call `persistFreshOAuth`. That ordering prevents a token for the wrong
* account/resource from ever becoming the active shared credential.
* account/resource from ever becoming the active HyperFrames credential.
*/
export async function startDeviceAuthorizationFlow(
opts: DeviceAuthorizationFlowOptions = {},
@@ -385,6 +361,9 @@ export async function refreshTokens(
refresh_token: string,
opts: RefreshOptions = {},
): Promise<OAuthTokens> {
const { credentials } = await readStore();
if (credentials.oauth?.refresh_token !== refresh_token) throw ErrLoginChanged();
assertOAuthIdentity(credentials.oauth);
const clientId = resolveClientId();
const fetchImpl = opts.fetchImpl ?? fetch;
const body = new URLSearchParams({
@@ -432,6 +411,13 @@ export async function revokeTokens(token: string, opts: RevokeOptions = {}): Pro
let clientId: string;
try {
clientId = resolveClientId();
const { credentials } = await readStore();
if (
credentials.oauth &&
(credentials.oauth.access_token === token || credentials.oauth.refresh_token === token)
) {
assertOAuthIdentity(credentials.oauth);
}
} catch {
// OAuth not configured — nothing to revoke server-side.
return;
@@ -622,7 +608,9 @@ async function persistOAuth(tokens: OAuthTokens, opts: { refreshed?: string } =
throw ErrLoginChanged();
}
const oauth: OAuthTokens =
opts.refreshed !== undefined ? { ...existing.oauth, ...tokens } : { ...tokens };
opts.refreshed !== undefined
? { ...existing.oauth, ...tokens, ...oauthIdentity() }
: { ...tokens, ...oauthIdentity() };
// 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).
@@ -656,7 +644,7 @@ export async function persistVerifiedOAuthSession(
}
const next: Credentials = {
...credentials,
oauth: { ...tokens },
oauth: { ...tokens, ...oauthIdentity() },
};
if (user.email || user.first_name || user.last_name || user.username) {
// Preserve only unknown/foreign user fields from the existing record.
+20 -12
View File
@@ -1,32 +1,40 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } 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"];
const original = process.env["HYPERFRAMES_CONFIG_DIR"];
beforeEach(() => {
delete process.env["HEYGEN_CONFIG_DIR"];
delete process.env["HYPERFRAMES_CONFIG_DIR"];
});
afterEach(() => {
if (original !== undefined) process.env["HEYGEN_CONFIG_DIR"] = original;
else delete process.env["HEYGEN_CONFIG_DIR"];
vi.unstubAllEnvs();
if (original !== undefined) process.env["HYPERFRAMES_CONFIG_DIR"] = original;
else delete process.env["HYPERFRAMES_CONFIG_DIR"];
});
it("defaults to ~/.heygen", () => {
expect(configDir()).toBe(join(homedir(), ".heygen"));
it("defaults to ~/.hyperframes", () => {
expect(configDir()).toBe(join(homedir(), ".hyperframes"));
});
it("honors HEYGEN_CONFIG_DIR override", () => {
process.env["HEYGEN_CONFIG_DIR"] = "/tmp/some-test-dir";
it("honors HYPERFRAMES_CONFIG_DIR override", () => {
process.env["HYPERFRAMES_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"));
it("treats empty HYPERFRAMES_CONFIG_DIR as unset", () => {
process.env["HYPERFRAMES_CONFIG_DIR"] = "";
expect(configDir()).toBe(join(homedir(), ".hyperframes"));
});
it("ignores the HeyGen CLI directory and isolates the dev environment", () => {
vi.stubEnv("HEYGEN_CONFIG_DIR", "/tmp/heygen-other-account");
expect(configDir()).toBe(join(homedir(), ".hyperframes"));
vi.stubEnv("HYPERFRAMES_ENV", "dev");
expect(configDir()).toBe(join(homedir(), ".hyperframes", "dev"));
});
});
+5 -9
View File
@@ -1,23 +1,19 @@
/**
* 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.
* HyperFrames credentials are isolated from the HeyGen CLI and between environments.
*/
import { homedir } from "node:os";
import { join } from "node:path";
import { oauthEnvironment } from "./config.js";
/**
* 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`.
* Filename for the HyperFrames credential store (JSON, no extension).
*/
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");
const root = process.env["HYPERFRAMES_CONFIG_DIR"] || join(homedir(), ".hyperframes");
return oauthEnvironment() === "dev" ? join(root, "dev") : root;
}
export function credentialPath(): string {
+18 -5
View File
@@ -1,10 +1,18 @@
import { oauthIdentity } from "./config.js";
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";
import { writeStore as writeUnboundStore, type Credentials } from "./store.js";
async function writeStore(credentials: Credentials): Promise<void> {
await writeUnboundStore({
...credentials,
...(credentials.oauth ? { oauth: { ...oauthIdentity(), ...credentials.oauth } } : {}),
});
}
describe("auth/resolver", () => {
let fixture: Awaited<ReturnType<typeof setupTempAuthEnv>>;
@@ -102,15 +110,20 @@ describe("auth/resolver", () => {
if (r.type === "oauth") expect(r.refreshable).toBe(true);
});
it("skips expired non-refreshable oauth and falls through to api_key", async () => {
it("refuses an expired OAuth login without switching to the stored API wallet", 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");
await expect(resolveCredential()).rejects.toMatchObject({ code: "LOGIN_EXPIRED" });
});
it("requires a new login for an unbound or foreign OAuth credential", async () => {
await writeUnboundStore({ api_key: "fallback", oauth: { access_token: "legacy" } });
await expect(resolveCredential()).rejects.toMatchObject({ code: "LOGIN_EXPIRED" });
await writeStore({ oauth: { access_token: "foreign", client_id: "shared-heygen-cli" } });
await expect(resolveCredential()).rejects.toMatchObject({ code: "LOGIN_EXPIRED" });
});
it("reports an expired login with no refresh token as expired, not as never signed in", async () => {
+7 -4
View File
@@ -1,11 +1,11 @@
/**
* Chain resolver for HeyGen credentials.
* Chain resolver for HyperFrames credentials.
*
* Priority — first non-empty wins:
* 1. `HEYGEN_API_KEY` env (matches heygen-cli)
* 2. `HYPERFRAMES_API_KEY` env (alias for parity with other tools)
* 3. `HEYGEN_ACCESS_TOKEN` env (host-managed OAuth)
* 4. `~/.heygen/credentials` (JSON) — unexpired OAuth, else api_key
* 4. `~/.hyperframes/credentials` (JSON) — bound OAuth or an explicit API key
*
* Absent sources fall through. Broken files surface `ErrInvalidStore` immediately;
* silently falling back would mask user configuration errors.
@@ -13,11 +13,12 @@
* 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, else `ErrLoginExpired`, not `ErrNotConfigured`.
* session requires sign-in; a saved API key never replaces expired OAuth.
*/
import { isHeaderSafe, readStore } from "./store.js";
import { ErrInvalidStore, ErrLoginExpired, ErrNotConfigured, isAuthError } from "./errors.js";
import { assertOAuthIdentity } from "./config.js";
type EnvSource = "env" | "env_alias" | "env_oauth";
type CredentialSource = EnvSource | "file_json" | "file_legacy";
@@ -79,12 +80,14 @@ export async function resolveCredential(opts: ResolveOptions = {}): Promise<Reso
const fileSource: CredentialSource = source === "file_legacy" ? "file_legacy" : "file_json";
if (credentials.oauth) assertOAuthIdentity(credentials.oauth);
const oauth = credentials.oauth ? pickOAuth(credentials.oauth, now, fileSource) : null;
if (oauth) return oauth;
if (credentials.oauth) throw ErrLoginExpired();
if (credentials.api_key) {
return { type: "api_key", key: credentials.api_key, source: fileSource };
}
throw credentials.oauth ? ErrLoginExpired() : ErrNotConfigured();
throw ErrNotConfigured();
}
function headerSafeEnv(name: string): string | undefined {
+14 -42
View File
@@ -1,47 +1,11 @@
/**
* Read/write the shared `~/.heygen/credentials` file (JSON contents,
* no `.json` extension — the path matches heygen-cli).
* The HyperFrames store accepts legacy API-key text and JSON credentials.
* JSON keeps an optional API key, OAuth grant and display-only user metadata.
*
* 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.
* Atomic rename plus 0600 files/0700 directories protects credential writes.
* OAuth callers verify the client/environment binding before using stored grants.
* Unknown JSON fields survive round-trips so older versions preserve newer data;
* known fields are validated before they can enter an HTTP header.
*/
import { promises as fs } from "node:fs";
@@ -70,6 +34,8 @@ const KNOWN_OAUTH_KEYS = new Set([
"expires_at",
"scope",
"token_type",
"client_id",
"token_endpoint",
]);
/** Keys this CLI version models inside the `user` sub-object. */
const KNOWN_USER_KEYS = new Set(["email", "first_name", "last_name", "username"]);
@@ -81,6 +47,8 @@ export interface OAuthTokens {
expires_at?: string;
scope?: string;
token_type?: string;
client_id?: string;
token_endpoint?: string;
/** Unknown/future keys captured for cross-CLI round-trip. */
[UNKNOWN]?: Record<string, unknown>;
}
@@ -284,6 +252,8 @@ function assignOptionalStrings(
}
const OAUTH_OPTIONAL: readonly [string, FieldPicker][] = [
["client_id", "header_safe"],
["token_endpoint", "non_empty"],
["refresh_token", "header_safe"],
["expires_at", "non_empty"],
["scope", "non_empty"],
@@ -433,6 +403,8 @@ function serializeOAuth(o: OAuthTokens): Record<string, unknown> {
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;
if (o.client_id) oauth["client_id"] = o.client_id;
if (o.token_endpoint) oauth["token_endpoint"] = o.token_endpoint;
return oauth;
}
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* Persistence + display helpers for the friendly-display `user` block in
* the shared `~/.heygen/credentials` file.
* the HyperFrames `~/.hyperframes/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
+4 -3
View File
@@ -28,8 +28,8 @@ export const examples: Example[] = [
const HELP = `
${c.bold("hyperframes auth")} ${c.dim("<subcommand> [args]")}
Manage HeyGen credentials. Credentials live in
${c.accent("~/.heygen/credentials")} and are shared with heygen-cli.
Manage HyperFrames credentials. Credentials live in
${c.accent("~/.hyperframes/credentials")}, independently of heygen-cli.
${c.bold("SUBCOMMANDS:")}
${c.accent("login")} ${c.dim("Sign in via browser, --device for SSH, or --api-key for a long-lived key.")}
@@ -42,7 +42,8 @@ ${c.bold("ENV VARS:")}
${c.accent("HYPERFRAMES_API_KEY")} Alias for HEYGEN_API_KEY.
${c.accent("HEYGEN_ACCESS_TOKEN")} OAuth token from a host app; used after the API keys, never refreshed or saved.
${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("HYPERFRAMES_ENV")} prod (default) or dev; selects OAuth/API hosts together.
${c.accent("HYPERFRAMES_CONFIG_DIR")} Override the credentials directory (default ~/.hyperframes).
${c.accent("HYPERFRAMES_OAUTH_CLIENT_ID")} Override the OAuth client_id (for dev/test).
${c.accent("HYPERFRAMES_OAUTH_DEVICE_URL")} Override the RFC 8628 device endpoint (for dev/test).
`;
+1 -1
View File
@@ -6,7 +6,7 @@ import { envFlagEnabled, isAttendedTerminal } from "../../utils/attendedTerminal
* 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`.
* them to `~/.hyperframes/credentials`.
*
* `--api-key`: opts into the legacy long-lived API-key path.
*
+2 -2
View File
@@ -69,8 +69,8 @@ function warnIfEnvCredentialActive(): void {
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] `;
? `This will sign out of any active OAuth session on this machine (~/.hyperframes lives at ${configDir()}). Continue? [y/N] `
: `This will sign out of HeyGen on this machine (~/.hyperframes lives at ${configDir()}). Continue? [y/N] `;
return confirmInteractive(prompt);
}
@@ -58,10 +58,8 @@ function offlineEngineLines(engines?: OfflineEngineLine[]): string[] {
/**
* Human guidance for an unconfigured machine — registration-first.
* Both paths use `npx hyperframes` (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,
* (sign-in / sign-up) and `--api-key` both write the isolated HyperFrames store.
* 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.
*/
@@ -80,7 +78,7 @@ export function buildUnconfiguredLines(
return [
c.warn("Not signed in to HeyGen — voice & music will use local engines (free, offline)."),
"",
"Sign in or sign up (browser OAuth, writes ~/.heygen — no per-repo .env):",
"Sign in or sign up (browser OAuth, writes ~/.hyperframes — no per-repo .env):",
` ${c.accent("npx hyperframes auth login")} ${c.dim("# browser sign-in / sign-up")}`,
"",
"Or paste an existing HeyGen API key (get one at app.heygen.com/settings/api):",
@@ -1,6 +1,8 @@
import { join } from "node:path";
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 { persistFreshOAuth } from "../../auth/oauth.js";
import { consumeCommandResult } from "../../utils/commandResult.js";
// Mock only AuthClient so the live /v3/users/me probe is controllable;
@@ -35,7 +37,7 @@ describe("auth status — persisted user block surface", () => {
let stdout: string[];
beforeEach(async () => {
envFixture = await setupTempAuthEnv("hf-status-");
envFixture = await setupTempAuthEnv("hf-status-99-");
probeState.apiReject = false;
probeState.user = { email: "live@example.com" };
stdout = [];
@@ -75,6 +77,60 @@ describe("auth status — persisted user block surface", () => {
expect(stdout.join("\n")).not.toContain("stored@example.com");
});
async function configureBillingCredential(mode: string): Promise<void> {
if (mode === "api_key") {
await writeStore({ api_key: "hg_x" });
return;
}
if (mode === "host") {
process.env["HEYGEN_ACCESS_TOKEN"] = "host-at";
return;
}
if (mode === "hyperframes_dev") process.env["HYPERFRAMES_ENV"] = "dev";
if (mode === "custom") process.env["HYPERFRAMES_OAUTH_CLIENT_ID"] = "custom-client";
await persistFreshOAuth({ access_token: "at", refresh_token: "rt" });
}
it.each([
["hyperframes", "hyperframes"],
["hyperframes_dev", "hyperframes"],
["custom", "unknown"],
["host", "unknown"],
["api_key", "heygen"],
])("keeps %s billing distinct from generic account balances", async (mode, product) => {
probeState.user = {
email: "live@example.com",
billing_type: "subscription",
wallet: { remaining_balance: 99 },
subscription: { plan: "HeyGen Pro" },
};
await configureBillingCredential(mode);
expect(await runStatus(true)).toBe(0);
const payload = lastJson();
expect(payload["billing_product"]).toBe(product);
expect(payload["user"]).toEqual(probeState.user);
if (mode !== "api_key") {
expect(payload["hyperframes_balance"]).toBeNull();
expect(payload["billing_notice"]).toContain("HeyGen account");
}
stdout = [];
expect(await runStatus(false)).toBe(0);
const text = stdout.join("\n");
if (mode === "hyperframes_dev") expect(text).toContain(join("dev", "credentials"));
if (mode === "api_key") {
expect(text).toContain("Wallet:");
expect(text).toMatch(/^Wallet:\s+99$/m);
} else {
expect(text).not.toMatch(/^Wallet:/m);
expect(text).not.toContain("HeyGen Pro");
expect(text).toContain(
mode.startsWith("hyperframes")
? "HyperFrames balance unavailable"
: "OAuth product not verified",
);
}
});
it("surfaces the persisted user block (with resolved display_name) for a file credential", async () => {
await writeStore({
api_key: "hg_x",
+40 -7
View File
@@ -18,6 +18,7 @@ import { failCommand, setCommandExitCode } from "../../utils/commandResult.js";
import { defineCommand } from "../../utils/defineCommand.js";
import {
ENV_CREDENTIAL_VAR,
credentialPath,
AuthClient,
isAuthError,
loadUserInfo,
@@ -38,6 +39,16 @@ import {
type UnconfiguredContext,
} from "./status-guidance.js";
import { jsonFailure } from "../../utils/jsonError.js";
import { defaultClientId, resolveClientId } from "../../auth/config.js";
type BillingProduct = "hyperframes" | "heygen" | "unknown";
function billingProduct(credential: ResolvedCredential): BillingProduct {
if (credential.type === "api_key") return "heygen";
if (isFileSource(credential.source) && resolveClientId() === defaultClientId())
return "hyperframes";
return "unknown";
}
interface VerifiedStatus {
credential: ResolvedCredential;
@@ -52,7 +63,7 @@ interface VerifiedStatus {
apiError: string | null;
}
/** True for credentials resolved from the shared file (not env). */
/** True for credentials resolved from the HyperFrames store (not env). */
function isFileSource(source: ResolvedCredential["source"]): boolean {
return source === "file_json" || source === "file_legacy";
}
@@ -182,10 +193,12 @@ async function loadPersistedUser(credential: ResolvedCredential): Promise<Stored
}
function printJsonStatus(s: VerifiedStatus): void {
const product = billingProduct(s.credential);
const payload: Record<string, unknown> = {
configured: true,
source: s.credential.source,
type: s.credential.type,
billing_product: product,
user: s.user,
// The friendly-display block persisted at login (file-sourced creds
// only). Strictly additive — the live `user` field above is
@@ -194,6 +207,11 @@ function printJsonStatus(s: VerifiedStatus): void {
api_error: s.apiError,
};
if (s.credential.type === "oauth") {
payload["hyperframes_balance"] = null;
payload["billing_notice"] =
product === "hyperframes"
? "HyperFrames balance is unavailable here; user billing fields describe the HeyGen account."
: "OAuth billing product is not verified here; user billing fields describe the HeyGen account.";
payload["expires_at"] = s.credential.expires_at?.toISOString() ?? null;
payload["refreshable"] = s.credential.refreshable;
payload["scope"] = s.credential.scope ?? null;
@@ -239,7 +257,7 @@ function collectStatusRows(s: VerifiedStatus): [string, string][] {
if (cached) rows.push(["Account:", `${cached} ${c.dim("(cached)")}`]);
return rows;
}
if (s.user) rows.push(...identityRows(s.user));
if (s.user) rows.push(...identityRows(s.user, billingProduct(s.credential)));
return rows;
}
@@ -256,20 +274,35 @@ function oauthRows(credential: Extract<ResolvedCredential, { type: "oauth" }>):
return rows;
}
function identityRows(user: UserInfo): [string, string][] {
function identityRows(user: UserInfo, product: BillingProduct): [string, string][] {
const identity = user.email ?? user.username ?? "(unknown user)";
return [["Account:", identity], ...billingRows(user)];
const rows: [string, string][] = [["Account:", identity]];
if (product === "hyperframes")
return [
...rows,
["Billing:", "HyperFrames"],
["Balance:", "HyperFrames balance unavailable here."],
];
if (product === "unknown")
return [
...rows,
[
"Billing:",
"OAuth product not verified; HeyGen account balances are not the active-payer balance.",
],
];
return [...rows, ...billingRows(user)];
}
const SOURCE_LABELS: Record<ResolvedCredential["source"], string> = {
const SOURCE_LABELS = {
env: `env (${ENV_CREDENTIAL_VAR.env})`,
env_alias: `env (${ENV_CREDENTIAL_VAR.env_alias})`,
env_oauth: `env (${ENV_CREDENTIAL_VAR.env_oauth})`,
file_legacy: "file (~/.heygen/credentials — legacy plaintext)",
file_json: "file (~/.heygen/credentials)",
};
function describeSource(source: ResolvedCredential["source"]): string {
if (source === "file_legacy") return `file (${credentialPath()} — legacy plaintext)`;
if (source === "file_json") return `file (${credentialPath()})`;
return SOURCE_LABELS[source];
}
+7 -7
View File
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { finishCommand } from "../utils/commandResult.js";
import { withMediaAuthEnvironment } from "../auth/media-environment.js";
const MEDIA_USE_ARGS = {
type: { type: "string" },
@@ -68,18 +69,17 @@ export function mediaUseVerbFlags(verb: MediaUseVerb): string[] {
return verb === "resolve" ? [] : [`--${verb}`];
}
type InvokeMediaUse = (verb: MediaUseVerb) => never;
type InvokeMediaUse = (verb: MediaUseVerb) => never | Promise<never>;
function invokeEngine(verb: MediaUseVerb): never {
async function invokeEngine(verb: MediaUseVerb): Promise<never> {
const here = dirname(fileURLToPath(import.meta.url));
const passed = mediaUsePassthroughArgs(process.argv);
const flag = mediaUseVerbFlags(verb);
const result = spawnSync(
process.execPath,
[resolveMediaUseEnginePath(here), ...flag, ...passed],
{
const result = await withMediaAuthEnvironment((env) =>
spawnSync(process.execPath, [resolveMediaUseEnginePath(here), ...flag, ...passed], {
stdio: "inherit",
},
env,
}),
);
if (result.error) throw result.error;
finishCommand(result.status ?? 1);
+14 -3
View File
@@ -12,9 +12,20 @@ vi.mock("../utils/publishProject.js", async (importOriginal) => ({
import publishCommand, { examples, parseUpdateTarget } from "./publish.js";
import { ensureProjectId } from "../utils/projectLink.js";
import { writeStore } from "../auth/store.js";
import { writeStore as writeUnboundStore, type Credentials } from "../auth/store.js";
import { oauthIdentity } from "../auth/config.js";
import { consumeCommandResult } from "../utils/commandResult.js";
async function writeStore(credentials: Credentials, path?: string): Promise<void> {
await writeUnboundStore(
{
...credentials,
...(credentials.oauth ? { oauth: { ...oauthIdentity(), ...credentials.oauth } } : {}),
},
path,
);
}
const VISIBLE_INDEX = `<html><body><div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="5"><div class="clip" data-start="0" data-duration="5">Visible</div></div></body></html>`;
function mockPublished(result: Record<string, unknown>): void {
@@ -193,7 +204,7 @@ describe("publish --update / --space ownership preflight", () => {
const project = mkdtempSync(join(tmpdir(), "hf-publish-owner-"));
const config = mkdtempSync(join(tmpdir(), "hf-publish-owner-config-"));
writeFileSync(join(project, "index.html"), VISIBLE_INDEX);
vi.stubEnv("HEYGEN_CONFIG_DIR", config);
vi.stubEnv("HYPERFRAMES_CONFIG_DIR", config);
vi.stubEnv("HEYGEN_API_KEY", credentials.env ?? "");
vi.stubEnv("HYPERFRAMES_API_KEY", "");
if (credentials.stored) await writeStore(credentials.stored, join(config, "credentials"));
@@ -352,7 +363,7 @@ describe("publish --update / --space ownership preflight", () => {
expect(stored?.credentials.oauth).toMatchObject(loginB);
expect(stored?.credentials.user?.email).toBe("b@example.com");
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual(["https://auth.test/token"]);
expect(fetchMock).not.toHaveBeenCalled();
expect(exitCode).toBe(1);
expect(errors.join("\n")).toContain("Your login changed during publish. Run publish again.");
} finally {
@@ -10,8 +10,8 @@ export const HEYGEN_MIN_VERSION = "0.3.0";
// installer text out of the runtime module; the docs are the safer source of
// truth for platform-specific setup.
export const HEYGEN_INSTALL_COMMAND =
"Install the CLI from https://developers.heygen.com/cli, then run: heygen auth login --oauth";
export const HEYGEN_AUTH_COMMAND = "heygen auth login --oauth";
"Install the CLI from https://developers.heygen.com/cli, then run: hyperframes auth login";
export const HEYGEN_AUTH_COMMAND = "hyperframes auth login";
export const HEYGEN_UPDATE_COMMAND = "heygen update";
export const HEYGEN_CLIENT_SOURCE_ARGV = ["--headers", "X-HeyGen-Client-Source: media-use"];
+2 -1
View File
@@ -143,7 +143,8 @@ export function heygenUserId(username) {
// casing never splits one person). `heygen_username` is the cross-project join
// column for the Snowflake event table.
function heygenAccount() {
const file = join(process.env.HEYGEN_CONFIG_DIR || join(homedir(), ".heygen"), "credentials");
const root = process.env.HYPERFRAMES_CONFIG_DIR || join(homedir(), ".hyperframes");
const file = join(root, ...(process.env.HYPERFRAMES_ENV === "dev" ? ["dev"] : []), "credentials");
try {
if (!existsSync(file)) return null;
const raw = readFileSync(file, "utf8").trim();
@@ -19,6 +19,8 @@ function sandbox() {
const home = join(root, "home");
mkdirSync(home, { recursive: true });
process.env.HOME = home;
delete process.env.HYPERFRAMES_CONFIG_DIR;
delete process.env.HYPERFRAMES_ENV;
process.env.HYPERFRAMES_MEDIA_HOME = home;
return { root, home };
}
@@ -181,9 +183,9 @@ test("track identifies a signed-in HeyGen account once and still sends events",
join(home, ".hyperframes/config.json"),
JSON.stringify({ anonymousId: "anon-1" }),
);
mkdirSync(join(home, ".heygen"), { recursive: true });
mkdirSync(join(home, ".hyperframes"), { recursive: true });
writeFileSync(
join(home, ".heygen/credentials"),
join(home, ".hyperframes/credentials"),
JSON.stringify({
api_key: "hg_test",
user: { email: "alice@example.com", username: "alice" },
@@ -217,6 +219,49 @@ test("track identifies a signed-in HeyGen account once and still sends events",
}
});
test("track uses the selected dev HyperFrames account instead of a HeyGen or production login", async () => {
const savedEnv = { ...process.env };
const originalFetch = globalThis.fetch;
const { root, home } = sandbox();
const calls = [];
globalThis.fetch = async (url, options) => {
calls.push({ url, options });
return { ok: true };
};
try {
withoutTelemetryOptOut();
process.env.HYPERFRAMES_CONFIG_DIR = join(home, "hf-auth");
process.env.HYPERFRAMES_ENV = "dev";
process.env.HEYGEN_CONFIG_DIR = join(home, "heygen");
for (const [dir, email] of [
[process.env.HEYGEN_CONFIG_DIR, "other@example.com"],
[process.env.HYPERFRAMES_CONFIG_DIR, "prod@example.com"],
[join(process.env.HYPERFRAMES_CONFIG_DIR, "dev"), "dev@example.com"],
]) {
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, "credentials"),
JSON.stringify({
oauth: { access_token: "test-access" },
user: { email, username: email },
}),
);
}
await track("media_use_resolve", { type: "voice" });
const identify = parseFetchBodies(calls).filter((item) => item.event === "$identify");
assert.equal(identify.length, 1);
assert.equal(identify[0].properties.$set.heygen_username, "dev@example.com");
const event = parseFetchBodies(calls).find((item) => item.event === "media_use_resolve");
assert.equal(event.properties.heygen_username, "dev@example.com");
assert.equal(event.distinct_id, identify[0].distinct_id);
} finally {
globalThis.fetch = originalFetch;
restoreEnv(savedEnv);
rmSync(root, { recursive: true, force: true });
__resetTelemetryForTest();
}
});
test("track falls back to a lowercased email when the account has no username", async () => {
const savedEnv = { ...process.env };
const originalFetch = globalThis.fetch;
@@ -233,9 +278,9 @@ test("track falls back to a lowercased email when the account has no username",
join(home, ".hyperframes/config.json"),
JSON.stringify({ anonymousId: "anon-3" }),
);
mkdirSync(join(home, ".heygen"), { recursive: true });
mkdirSync(join(home, ".hyperframes"), { recursive: true });
writeFileSync(
join(home, ".heygen/credentials"),
join(home, ".hyperframes/credentials"),
JSON.stringify({ api_key: "hg_test", user: { email: "Alice@Example.com" } }),
);
@@ -1,26 +1,27 @@
import { mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { signedInHeygenUsername } from "./signedInUser.js";
describe("signedInHeygenUsername", () => {
let dir: string;
const saved = process.env["HEYGEN_CONFIG_DIR"];
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "hf-signed-in-"));
process.env["HEYGEN_CONFIG_DIR"] = dir;
vi.stubEnv("HYPERFRAMES_CONFIG_DIR", dir);
vi.stubEnv("HYPERFRAMES_ENV", "prod");
vi.stubEnv("HEYGEN_CONFIG_DIR", join(dir, "heygen"));
});
afterEach(() => {
if (saved === undefined) delete process.env["HEYGEN_CONFIG_DIR"];
else process.env["HEYGEN_CONFIG_DIR"] = saved;
vi.unstubAllEnvs();
rmSync(dir, { recursive: true, force: true });
});
const write = (contents: unknown, mtime: number) => {
const file = join(dir, "credentials");
const write = (contents: unknown, mtime: number, root = dir) => {
mkdirSync(root, { recursive: true });
const file = join(root, "credentials");
writeFileSync(file, typeof contents === "string" ? contents : JSON.stringify(contents));
utimesSync(file, mtime, mtime);
};
@@ -43,4 +44,20 @@ describe("signedInHeygenUsername", () => {
rmSync(join(dir, "credentials"));
expect(signedInHeygenUsername()).toBeUndefined();
});
it("uses the selected OAuth environment without falling back to HeyGen or production", () => {
write({ api_key: "hg_other", user: { username: "Other" } }, 2000, join(dir, "heygen"));
expect(signedInHeygenUsername()).toBeUndefined();
write({ oauth: { access_token: "test-prod" }, user: { username: "Alice" } }, 2000);
write(
{ oauth: { access_token: "test-dev" }, user: { username: "Bob" } },
2000,
join(dir, "dev"),
);
expect(signedInHeygenUsername()).toBe("Alice");
vi.stubEnv("HYPERFRAMES_ENV", "dev");
expect(signedInHeygenUsername()).toBe("Bob");
rmSync(join(dir, "dev", "credentials"));
expect(signedInHeygenUsername()).toBeUndefined();
});
});
@@ -738,6 +738,7 @@ beforeEach(() => {
linkMocks.writeProjectLink.mockReset();
vi.stubEnv("HYPERFRAMES_PUBLISHED_PROJECTS_API_URL", "");
vi.stubEnv("HEYGEN_API_URL", "");
vi.stubEnv("HYPERFRAMES_ENV", "prod");
});
afterEach(() => {
@@ -750,6 +751,11 @@ const signedStagedS3Url =
"https://s3.example.com/upload?X-Amz-SignedHeaders=content-length;content-type;host;x-amz-server-side-encryption";
describe("publishProjectArchive", () => {
it("uses the development publish service with the development login", () => {
vi.stubEnv("HYPERFRAMES_ENV", "dev");
expect(getPublishApiBaseUrl()).toBe("https://api2.dev.heygen.com");
});
it("uploads through the staged publish flow and returns the stable project URL", async () => {
const dir = makeProjectDir();
const fetchMock = stagedFetch(
+2 -1
View File
@@ -16,6 +16,7 @@ import ignore, { type Ignore } from "ignore";
import { CSS_URL_RE, isNonRelativeUrl, isPathInside, rewriteSrcsetUrls } from "@hyperframes/core";
import { decodeUrlPathVariants, encodeUrlPath } from "@hyperframes/parsers";
import { buildAuthHeaders } from "../auth/client.js";
import { oauthEnvironment } from "../auth/config.js";
import { tryResolveCredential } from "../auth/index.js";
import { isAuthError } from "../auth/errors.js";
import { envCredentialVar, isTokenExpired, type ResolvedCredential } from "../auth/resolver.js";
@@ -700,7 +701,7 @@ export function getPublishApiBaseUrl(): string {
return (
process.env["HYPERFRAMES_PUBLISHED_PROJECTS_API_URL"] ||
process.env["HEYGEN_API_URL"] ||
"https://api2.heygen.com"
`https://api2.${oauthEnvironment() === "dev" ? "dev." : ""}heygen.com`
).replace(/\/$/, "");
}
+2 -2
View File
@@ -10,7 +10,7 @@
"files": 77
},
"hyperframes-cli": {
"hash": "7a49a6eb1161315f",
"hash": "0f04eecc7db86c68",
"files": 13
},
"hyperframes-registry": {
@@ -18,7 +18,7 @@
"files": 13
},
"media-use": {
"hash": "3755ab80e5df5341",
"hash": "f61a33f8b0f8e260",
"files": 116
},
"remotion-to-hyperframes": {
+4 -4
View File
@@ -1,6 +1,6 @@
# cloud — HeyGen-hosted rendering (zero-infra)
`hyperframes cloud render` renders a composition on HeyGen's managed cloud. The CLI zips the project, uploads it, runs the render on HeyGen's infrastructure (Chromium + FFmpeg), and downloads the finished video. Nothing to deploy, and no Chrome/FFmpeg/AWS to manage; you pay per credit.
`hyperframes cloud render` renders a composition on HeyGen's managed cloud. The CLI zips the project, uploads it, runs the render on HeyGen's infrastructure (Chromium + FFmpeg), and downloads the finished video. Nothing to deploy, and no Chrome/FFmpeg/AWS to manage; the server applies the selected credential's render allowance or billing.
```bash
npx hyperframes auth login # one-time sign-in
@@ -10,13 +10,13 @@ npx hyperframes cloud render # zip, upload, render, download
## When to use managed cloud, Lambda, Cloud Run, or local
- **`hyperframes render`** (local): fastest iteration loop, use while authoring.
- **`hyperframes cloud render`**: zero-infra. HeyGen runs the render and you pay per credit. This is the default answer to "render in the cloud" when you don't want to manage Chrome/FFmpeg/AWS.
- **`hyperframes cloud render`**: zero-infra. HeyGen runs the render under the selected credential's allowance or billing. This is the default answer to "render in the cloud" when you don't want to manage Chrome/FFmpeg/AWS.
- **`hyperframes lambda render`**: bring-your-own-AWS distributed rendering with chunked parallelism. Only worth it when you've already invested in AWS (see `lambda.md`).
- **`hyperframes cloudrun render`**: bring-your-own-GCP distributed rendering through Cloud Run and Workflows. Use only when GCP ownership is explicit (see `cloudrun.md`).
## Authentication
Cloud rendering needs a HeyGen credential, stored at `~/.heygen/credentials` (`0600`) and shared with the [`heygen` CLI](https://github.com/heygen-com/heygen-cli): sign in with one and the other picks up the session.
Cloud rendering uses the dedicated HyperFrames OAuth client and isolated `~/.hyperframes/credentials` store. A previous shared HeyGen login requires a fresh `hyperframes auth login`; the separate `heygen` CLI keeps its own login. An explicit API key uses API billing.
```bash
npx hyperframes auth login # OAuth 2.0 + PKCE, opens the browser
@@ -30,7 +30,7 @@ npx hyperframes auth refresh # force-refresh an OAuth token before a
npx hyperframes auth logout # clear the stored credential
```
Credential resolution order (first match wins): `HEYGEN_API_KEY`, then `HYPERFRAMES_API_KEY`, then `HEYGEN_ACCESS_TOKEN` (an OAuth token a host app injects; never refreshed or saved), then `~/.heygen/credentials`. Point at a different backend with `HEYGEN_API_URL` (default `https://api.heygen.com`).
Credential resolution order (first match wins): `HEYGEN_API_KEY`, then `HYPERFRAMES_API_KEY`, then `HEYGEN_ACCESS_TOKEN` (an OAuth token a host app injects; never refreshed or saved), then `~/.hyperframes/credentials`. `HYPERFRAMES_ENV=dev` selects the dev client, OAuth/API hosts and `~/.hyperframes/dev/credentials` together. `HYPERFRAMES_CONFIG_DIR` overrides the credentials root. `HEYGEN_API_URL` overrides the API host.
## The render pipeline
+3 -1
View File
@@ -31,7 +31,9 @@ It prints one line, `resolved <id> → <path> (<type>, <metadata>)`. List reusab
**Host apps.** Inside an app that gives you its own media tools, use those first. When `HEYGEN_API_BASE` is set, the host pays for HeyGen: don't ask for the `heygen` CLI or a sign-in, and when a call is refused, relay the host's message as written and stop.
**HeyGen.** Install the [`heygen` CLI](https://developers.heygen.com/cli) and sign in with `heygen auth login --oauth`: the OAuth session carries a free allowance on subscription credits; an API key (`--api-key`) bills API credits. Check setup with `npx hyperframes media-use resolve --doctor`. When a request needs HeyGen (a presenter or avatar, a HeyGen voice, catalog music) and `heygen` is missing or signed out, tell the person how to get it: the install link, `heygen auth login --oauth` for the free allowance, or an API key for credits. Then offer the free path or wait. Beyond `resolve`, the same CLI makes text-to-video (`heygen video-agent create`), avatar videos with a recorded track (`heygen video create`), cloned voices (`heygen voice clone create`), dubbing (`heygen lipsync`), translated videos (`heygen video-translate`) and short clips from a long video (`heygen ai-clipping`): recipes in `references/operations.md`.
**HeyGen.** Install the [`heygen` CLI](https://developers.heygen.com/cli), then sign in with `npx hyperframes auth login`. HyperFrames supplies its credential to the helper; it does not read the separate HeyGen CLI login. An explicit API key bills API credits, and the server decides OAuth eligibility and charges. Check setup with `npx hyperframes media-use resolve --doctor`. If a required provider is missing or signed out, give the install link and HyperFrames sign-in command, then offer the free path or wait.
**Direct HeyGen recipes.** Commands such as `heygen video-agent create`, `heygen video create`, `heygen voice clone create`, `heygen lipsync`, `heygen video-translate` and `heygen ai-clipping` in `references/operations.md` use the HeyGen CLI's own credential and HeyGen/API billing. They do not inherit HyperFrames OAuth or its subscription. Use only when that separate payer is selected.
**Free.** Every tool in the free column needs no account; local models are opt-in installs (`references/setup-providers.md`). Without `heygen`, `resolve` for music, images and icons fails and says so; credit CC BY sounds (`audio/references/sfx.md`). `--local-only` skips every network provider.
+1 -1
View File
@@ -2,7 +2,7 @@
One music bed per composition, produced by the shared audio engine (`scripts/audio.mjs` → `scripts/lib/bgm.mjs`). Two routes, chosen by the engine's one switch — whether a HeyGen credential is present:
- **HeyGen retrieval — the default when credentialed.** Search HeyGen's music catalog by mood, download the top track. No generation; same `~/.heygen` / `$HEYGEN_API_KEY` credential as TTS.
- **HeyGen retrieval — the default when credentialed.** Search HeyGen's music catalog by mood, download the top track. No generation; same isolated HyperFrames credential or explicit API key as TTS.
- **Local generation (Lyria → MusicGen) — the fallback when there is no credential** (or when asked for explicitly). Generate a WAV from a mood prompt. There is **no `npx hyperframes bgm` command**; the engine spawns `scripts/lyria-recipe.py` or an inline MusicGen script directly.
> **Run the Preflight first — no credential is not a green light to silently generate locally.** Before generating, complete the sign-in **Preflight** (see `../../SKILL.md` → Preflight): run `npx hyperframes auth status`, recommend signing in, and **STOP for the user's choice** (sign in for HeyGen's music library, or continue offline with local generation). This applies to a one-off "generate a BGM" request just as much as inside a full workflow.
@@ -4,21 +4,21 @@
Run `npx hyperframes auth status` to see what's configured and which engines a workflow will use (see the skill's **Preflight** section). Keys resolve in this order — **first match wins**:
| Provider | Resolution order (first non-empty wins) | Local deps when used |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| **HeyGen** (TTS + BGM retrieval) | `$HEYGEN_API_KEY` → `$HYPERFRAMES_API_KEY` → `~/.heygen/credentials` (shared with heygen-cli; `$HEYGEN_CONFIG_DIR` overrides the dir; written by `hyperframes auth login`) | none (REST) |
| **ElevenLabs** (TTS fallback) | `$ELEVENLABS_API_KEY` | `pip install elevenlabs` |
| **Lyria** (BGM fallback) | `$GEMINI_API_KEY` → `$GOOGLE_API_KEY` | `pip install google-genai` |
| **Kokoro** (TTS, no key) | always — final voice fallback | `pip install kokoro-onnx soundfile` |
| **MusicGen** (BGM, no key) | always — final music fallback | `pip install transformers torch soundfile numpy` |
| Provider | Resolution order (first non-empty wins) | Local deps when used |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ |
| **HeyGen** (TTS + BGM retrieval) | `$HEYGEN_ACCESS_TOKEN` → `$HEYGEN_API_KEY` → `$HYPERFRAMES_API_KEY` → `~/.hyperframes/credentials` (`$HYPERFRAMES_CONFIG_DIR` overrides the root; dev uses `dev/`) | none (REST) |
| **ElevenLabs** (TTS fallback) | `$ELEVENLABS_API_KEY` | `pip install elevenlabs` |
| **Lyria** (BGM fallback) | `$GEMINI_API_KEY` → `$GOOGLE_API_KEY` | `pip install google-genai` |
| **Kokoro** (TTS, no key) | always — final voice fallback | `pip install kokoro-onnx soundfile` |
| **MusicGen** (BGM, no key) | always — final music fallback | `pip install transformers torch soundfile numpy` |
`hyperframes auth login` (browser OAuth) is the recommended setup: one sign-in, every project, no per-repo `.env`. An OAuth login is sent as `Authorization: Bearer`; an API key as `X-Api-Key`; both are tagged with `X-HeyGen-Source: cli`. OAuth CLI users can consume the web-plan free allowance for HeyGen TTS (10 min/month); API keys follow the normal API billing path. With no HeyGen credential, voice/BGM run fully locally (Kokoro / MusicGen) — `hyperframes auth status` and `hyperframes doctor` both report whether those local deps are installed.
`hyperframes auth login` (browser OAuth) is the recommended setup: one sign-in, every project, no per-repo `.env`. An OAuth login is sent as `Authorization: Bearer`; an API key as `X-Api-Key`; only OAuth is tagged with `X-HeyGen-Source: cli`. The server decides HyperFrames eligibility and charges; API keys use API billing. An expired or foreign OAuth session requires refresh/sign-in and never selects a saved API key. With no HeyGen credential, voice/BGM run fully locally (Kokoro / MusicGen) — `hyperframes auth status` and `hyperframes doctor` both report whether those local deps are installed.
## Model caches & system dependencies
Each command downloads its own model on first run and caches it under `~/.cache/hyperframes/`:
- **TTS (HeyGen)** — no local deps; needs a HeyGen credential + `ffmpeg` on PATH (to transcode the mp3 response to `.wav`). Credential resolves like the CLI: `$HEYGEN_API_KEY` → `$HYPERFRAMES_API_KEY` → `~/.heygen/credentials` (shared with heygen-cli; run `npx hyperframes auth login`). An OAuth login is sent as `Authorization: Bearer`; an API key as `X-Api-Key`; both include `X-HeyGen-Source: cli` so the backend can apply CLI OAuth free usage.
- **TTS (HeyGen)** — no local deps; needs a HeyGen credential + `ffmpeg` on PATH (to transcode the mp3 response to `.wav`). Credential resolves like the CLI: the priority above; run `npx hyperframes auth login` for its isolated OAuth store. An OAuth login is sent as `Authorization: Bearer`; an API key as `X-Api-Key`; only OAuth includes `X-HeyGen-Source: cli`; the server selects billing from the verified credential.
- **TTS (ElevenLabs)** — same as HeyGen: API key + `ffmpeg`.
- **TTS (Kokoro)** — Kokoro-82M (~311 MB) + voices (~27 MB) in `tts/`. Requires Python 3.8+ with `kokoro-onnx` and `soundfile` (`pip install kokoro-onnx soundfile`). Non-English text also needs `espeak-ng` system-wide.
- **BGM (Lyria)** — needs `$GEMINI_API_KEY` or `$GOOGLE_API_KEY` + `pip install google-genai`. No local model cache.
+11 -11
View File
@@ -40,11 +40,11 @@ Gemini is an explicit alternative to the automatic provider order below. A
request to use Gemini already chooses the provider; do not redirect that user
to HeyGen sign-in. Read the Gemini section for its credential requirement.
| Order | Provider | Env trigger | Voice IDs | Word timestamps | Audio format |
| ----- | ----------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ----------------------------------------- | -------------------- |
| 1 | HeyGen (Starfish) | `$HEYGEN_ACCESS_TOKEN` / `$HEYGEN_API_KEY` / `~/.heygen/credentials` | UUIDs from `GET /v3/voices?engine=starfish`, or your cloned voice ids (`heygen-voice.mjs`) | **Yes** (`word_timestamps[]` in response) | mp3 → wav via ffmpeg |
| 2 | ElevenLabs | `$ELEVENLABS_API_KEY` | UUIDs from elevenlabs.io dashboard | No | mp3 → wav via ffmpeg |
| 3 | Kokoro-82M | always (local fallback) | `am_michael`, `af_heart`, … (54 voices) | No | wav direct |
| Order | Provider | Env trigger | Voice IDs | Word timestamps | Audio format |
| ----- | ----------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ----------------------------------------- | -------------------- |
| 1 | HeyGen (Starfish) | `$HEYGEN_ACCESS_TOKEN` / `$HEYGEN_API_KEY` / `~/.hyperframes/credentials` | UUIDs from `GET /v3/voices?engine=starfish`, or your cloned voice ids (`heygen-voice.mjs`) | **Yes** (`word_timestamps[]` in response) | mp3 → wav via ffmpeg |
| 2 | ElevenLabs | `$ELEVENLABS_API_KEY` | UUIDs from elevenlabs.io dashboard | No | mp3 → wav via ffmpeg |
| 3 | Kokoro-82M | always (local fallback) | `am_michael`, `af_heart`, … (54 voices) | No | wav direct |
```bash
# Local Kokoro CLI
@@ -61,12 +61,12 @@ no CLI provider plumbing:
The script resolves a HeyGen credential the same way the CLI does — first source
wins: a host-injected OAuth `$HEYGEN_ACCESS_TOKEN` → `$HEYGEN_API_KEY` →
`$HYPERFRAMES_API_KEY` → a project `.env` (auto-loaded,
walks up ≤5 dirs) → `~/.heygen/credentials` (shared with heygen-cli;
`$HEYGEN_CONFIG_DIR` overrides the dir). An OAuth login is sent as
`Authorization: Bearer`; an API key as `X-Api-Key`; both include
`X-HeyGen-Source: cli`. OAuth CLI users can consume the web-plan free allowance
(10 min/month) before paid usage; API keys follow normal API billing. If the
only credential is an expired OAuth token it stops with a hint to run
walks up ≤5 dirs) → `~/.hyperframes/credentials` (isolated from heygen-cli;
`$HYPERFRAMES_CONFIG_DIR` overrides the root; `HYPERFRAMES_ENV=dev` uses `dev/`). An OAuth login is sent as
`Authorization: Bearer`; an API key as `X-Api-Key`; only OAuth includes
`X-HeyGen-Source: cli`. The server decides HyperFrames eligibility and charges;
explicit API keys use API billing. An expired or foreign OAuth session stops
rather than selecting a saved API key, with a hint to run
`npx hyperframes auth refresh`.
```bash
@@ -90,7 +90,7 @@ fs.writeFileSync(path.join(args[args.indexOf('--dir')+1], 'transcript.json'), JS
encoding: "utf8",
env: {
...process.env,
HEYGEN_CONFIG_DIR: config,
HYPERFRAMES_CONFIG_DIR: config,
HEYGEN_API_KEY: "",
HYPERFRAMES_API_KEY: "",
GEMINI_API_KEY: "fixture-key",
@@ -13,7 +13,7 @@
//
// Flags: -o/--output (.wav → ffmpeg transcode; .mp3 → raw bytes), --words,
// --voice (starfish id), --speed, --lang, --list.
// Requires: $HEYGEN_API_KEY / OAuth ~/.heygen credentials and ffmpeg for .wav output.
// Requires: $HEYGEN_API_KEY / OAuth ~/.hyperframes credentials and ffmpeg for .wav output.
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
@@ -124,7 +124,7 @@ test("CLI partial run retains unselected voices, BGM and SFX", (t) => {
encoding: "utf8",
env: {
...process.env,
HEYGEN_CONFIG_DIR: dir,
HYPERFRAMES_CONFIG_DIR: dir,
HEYGEN_API_KEY: "",
HYPERFRAMES_API_KEY: "",
},
+23 -7
View File
@@ -3,8 +3,8 @@ import { fetchMedia } from "../../../scripts/lib/media-fetch.mjs";
// pipeline. The credential resolver matches the hyperframes CLI auth: first
// usable source wins — a host gateway ($HEYGEN_API_BASE with its own
// $HEYGEN_API_KEY) → a host-injected OAuth $HEYGEN_ACCESS_TOKEN (Bearer) →
// $HEYGEN_API_KEY / $HYPERFRAMES_API_KEY → a nearby .env → ~/.heygen/
// credentials (oauth → Bearer, else api_key → X-Api-Key; $HEYGEN_CONFIG_DIR
// $HEYGEN_API_KEY / $HYPERFRAMES_API_KEY → a nearby .env → ~/.hyperframes/
// credentials (OAuth never falls back to an API key; $HYPERFRAMES_CONFIG_DIR
// overrides the dir). $HEYGEN_API_BASE moves every request to that host, as it
// does for the heygen CLI; plain HTTP needs $HEYGEN_ALLOW_HTTP=1, as there.
// Vendored so the skill ships standalone. Pure node.
@@ -20,7 +20,8 @@ export const HEYGEN_BASE = "https://api.heygen.com/v3";
// in the clear, so it needs $HEYGEN_ALLOW_HTTP=1, the heygen CLI's own rule.
export function heygenBase() {
const host = process.env.HEYGEN_API_BASE?.trim().replace(/\/+$/, "");
if (!host) return HEYGEN_BASE;
if (!host)
return process.env.HYPERFRAMES_ENV === "dev" ? "https://api.dev.heygen.com/v3" : HEYGEN_BASE;
if (host.startsWith("http://") && process.env.HEYGEN_ALLOW_HTTP !== "1")
throw new Error(
`HEYGEN_API_BASE (${host}) uses HTTP, which sends the key in plaintext. Set HEYGEN_ALLOW_HTTP=1 to allow it.`,
@@ -104,7 +105,7 @@ export function heygenCredential() {
}
// heygenCredential's answer, or { unreadable: { file, code } } when the credentials path exists but cannot be read
// (a folder, a locked ~/.heygen), so heygenAuthHeaders can say to fix that path: logging in again would fail there too.
// (a folder, a locked ~/.hyperframes), so heygenAuthHeaders can say to fix that path: logging in again would fail there too.
// Read without checking first, so the file cannot change between a check and the read.
function resolveCredential() {
const gatewayKey = hostGatewayKey();
@@ -116,7 +117,8 @@ function resolveCredential() {
const envKey = process.env.HEYGEN_API_KEY || process.env.HYPERFRAMES_API_KEY;
if (envKey) return { headers: { "X-Api-Key": envKey } };
const file = join(process.env.HEYGEN_CONFIG_DIR || join(homedir(), ".heygen"), "credentials");
const root = process.env.HYPERFRAMES_CONFIG_DIR || join(homedir(), ".hyperframes");
const file = join(root, ...(process.env.HYPERFRAMES_ENV === "dev" ? ["dev"] : []), "credentials");
let raw;
try {
raw = readFileSync(file, "utf8").trim();
@@ -136,9 +138,23 @@ function resolveCredential() {
}
const oauth = cred.oauth;
if (oauth?.access_token) {
const expectedClient =
process.env.HYPERFRAMES_OAUTH_CLIENT_ID ||
(process.env.HYPERFRAMES_ENV === "dev"
? "9QoaiwCZov9O0cAkC4OZbR4p"
: "Zg3LF4FCLoeJoGs2YqMtZ17g");
const expectedTokenEndpoint =
process.env.HYPERFRAMES_OAUTH_TOKEN_URL ||
`https://api2.${process.env.HYPERFRAMES_ENV === "dev" ? "dev." : ""}heygen.com/v1/oauth/token`;
if (
!expectedClient ||
oauth.client_id !== expectedClient ||
oauth.token_endpoint !== expectedTokenEndpoint
)
return { expired: true };
const expired = oauth.expires_at && new Date(oauth.expires_at).getTime() - 60_000 < Date.now();
if (!expired) return { headers: { Authorization: `Bearer ${oauth.access_token}` } };
if (!cred.api_key) return { expired: true };
return { expired: true };
}
if (cred.api_key) return { headers: { "X-Api-Key": cred.api_key } };
return null;
@@ -176,7 +192,7 @@ export function heygenAuthHeaders() {
"HeyGen OAuth token expired — run `npx hyperframes auth refresh` (or `npx hyperframes auth login`)",
);
throw new Error(
"no HeyGen credentials — set $HEYGEN_API_KEY, or run `npx hyperframes auth login` (writes ~/.heygen/credentials)",
"no HyperFrames credentials — set $HEYGEN_API_KEY, or run `npx hyperframes auth login` (writes ~/.hyperframes/credentials)",
);
}
@@ -17,7 +17,10 @@ const HEYGEN_ENV = [
"HEYGEN_ACCESS_TOKEN",
"HEYGEN_API_KEY",
"HYPERFRAMES_API_KEY",
"HEYGEN_CONFIG_DIR",
"HYPERFRAMES_CONFIG_DIR",
"HYPERFRAMES_ENV",
"HYPERFRAMES_OAUTH_CLIENT_ID",
"HYPERFRAMES_OAUTH_TOKEN_URL",
"HEYGEN_API_BASE",
"HEYGEN_ALLOW_HTTP",
];
@@ -61,11 +64,13 @@ test("heygenAuthHeaders tags OAuth requests as CLI traffic and with the media-us
withCleanHeygenEnv(() => {
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
try {
process.env.HEYGEN_CONFIG_DIR = dir;
process.env.HYPERFRAMES_CONFIG_DIR = dir;
writeFileSync(
join(dir, "credentials"),
JSON.stringify({
oauth: {
client_id: "Zg3LF4FCLoeJoGs2YqMtZ17g",
token_endpoint: "https://api2.heygen.com/v1/oauth/token",
access_token: "at_test",
expires_at: "2099-01-01T00:00:00Z",
},
@@ -82,6 +87,31 @@ test("heygenAuthHeaders tags OAuth requests as CLI traffic and with the media-us
});
});
test("development audio uses the development client and isolated store", () => {
withCleanHeygenEnv(() => {
const dir = mkdtempSync(join(tmpdir(), "hf-dev-cred-"));
try {
process.env.HYPERFRAMES_CONFIG_DIR = dir;
process.env.HYPERFRAMES_ENV = "dev";
mkdirSync(join(dir, "dev"));
writeFileSync(
join(dir, "dev", "credentials"),
JSON.stringify({
oauth: {
access_token: "dev-at",
client_id: "9QoaiwCZov9O0cAkC4OZbR4p",
token_endpoint: "https://api2.dev.heygen.com/v1/oauth/token",
},
}),
);
assert.equal(heygenBase(), "https://api.dev.heygen.com/v3");
assert.equal(heygenAuthHeaders().Authorization, "Bearer dev-at");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
test("a host-injected HEYGEN_ACCESS_TOKEN wins over an API key and is treated as OAuth", () => {
withCleanHeygenEnv(() => {
process.env.HEYGEN_ACCESS_TOKEN = "at_host";
@@ -106,11 +136,13 @@ test("heygenAuthMethod returns oauth for a live OAuth credential", () => {
withCleanHeygenEnv(() => {
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
try {
process.env.HEYGEN_CONFIG_DIR = dir;
process.env.HYPERFRAMES_CONFIG_DIR = dir;
writeFileSync(
join(dir, "credentials"),
JSON.stringify({
oauth: {
client_id: "Zg3LF4FCLoeJoGs2YqMtZ17g",
token_endpoint: "https://api2.heygen.com/v1/oauth/token",
access_token: "at_test",
expires_at: "2099-01-01T00:00:00Z",
},
@@ -127,7 +159,7 @@ test("heygenAuthMethod returns null with no credential at all", () => {
withCleanHeygenEnv(() => {
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
try {
process.env.HEYGEN_CONFIG_DIR = dir; // no credentials file written
process.env.HYPERFRAMES_CONFIG_DIR = dir; // no credentials file written
assert.equal(heygenAuthMethod(), null);
} finally {
rmSync(dir, { recursive: true, force: true });
@@ -135,6 +167,32 @@ test("heygenAuthMethod returns null with no credential at all", () => {
});
});
test("an expired or foreign OAuth login never falls back to a saved API wallet", () => {
withCleanHeygenEnv(() => {
const dir = mkdtempSync(join(tmpdir(), "hf-auth-payer-"));
try {
process.env.HYPERFRAMES_CONFIG_DIR = dir;
const credential = {
api_key: "other-wallet",
oauth: {
client_id: "Zg3LF4FCLoeJoGs2YqMtZ17g",
token_endpoint: "https://api2.heygen.com/v1/oauth/token",
access_token: "expired",
expires_at: "2000-01-01T00:00:00Z",
},
};
writeFileSync(join(dir, "credentials"), JSON.stringify(credential));
assert.deepEqual(heygenCredential(), { expired: true });
credential.oauth.expires_at = "2099-01-01T00:00:00Z";
credential.oauth.client_id = "shared-heygen-client";
writeFileSync(join(dir, "credentials"), JSON.stringify(credential));
assert.deepEqual(heygenCredential(), { expired: true });
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
test("loadEnvFromDir skips a .env folder and loads the .env file above it", () => {
const root = mkdtempSync(join(tmpdir(), "heygen-env-"));
const project = join(root, "project");
@@ -175,7 +233,7 @@ test("heygenAuthMethod returns null when the credentials path is a folder", () =
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
try {
mkdirSync(join(dir, "credentials"));
process.env.HEYGEN_CONFIG_DIR = dir;
process.env.HYPERFRAMES_CONFIG_DIR = dir;
assert.equal(heygenAuthMethod(), null);
} finally {
rmSync(dir, { recursive: true, force: true });
@@ -188,7 +246,7 @@ test("heygenAuthMethod returns null when the credentials path is a symlink loop"
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
try {
symlinkSync(join(dir, "credentials"), join(dir, "credentials"));
process.env.HEYGEN_CONFIG_DIR = dir;
process.env.HYPERFRAMES_CONFIG_DIR = dir;
assert.equal(heygenAuthMethod(), null);
} finally {
rmSync(dir, { recursive: true, force: true });
@@ -200,8 +258,8 @@ test("heygenAuthHeaders says to fix an unreadable credentials path, and to log i
withCleanHeygenEnv(() => {
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
try {
process.env.HEYGEN_CONFIG_DIR = dir;
assert.throws(() => heygenAuthHeaders(), /no HeyGen credentials/);
process.env.HYPERFRAMES_CONFIG_DIR = dir;
assert.throws(() => heygenAuthHeaders(), /no HyperFrames credentials/);
mkdirSync(join(dir, "credentials"));
assert.equal(heygenCredential(), null);
assert.throws(
@@ -276,12 +334,12 @@ test("a base that isn't HeyGen's gets no stored or host OAuth credential, only a
withCleanHeygenEnv(() => {
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
try {
process.env.HEYGEN_CONFIG_DIR = dir;
process.env.HYPERFRAMES_CONFIG_DIR = dir;
writeFileSync(join(dir, "credentials"), JSON.stringify({ api_key: "hg_stored" }));
process.env.HEYGEN_ACCESS_TOKEN = "at_host";
process.env.HEYGEN_API_BASE = "https://proxy.example.com";
assert.equal(heygenCredential(), null);
assert.throws(() => heygenAuthHeaders(), /no HeyGen credentials/);
assert.throws(() => heygenAuthHeaders(), /no HyperFrames credentials/);
// HeyGen's own hosts keep every credential source.
process.env.HEYGEN_API_BASE = "https://api-canary.heygen.com";
assert.equal(heygenAuthMethod(), "oauth");
+1 -1
View File
@@ -2,7 +2,7 @@ import { fetchMedia } from "../../../scripts/lib/media-fetch.mjs";
// tts.mjs — multi-provider TTS for the media audio engine. The provider chain,
// auto-detected from env, is the one documented in ../SKILL.md:
//
// 1. HeyGen (Starfish) — $HEYGEN_API_KEY / $HYPERFRAMES_API_KEY / ~/.heygen.
// 1. HeyGen (Starfish) — $HEYGEN_API_KEY / $HYPERFRAMES_API_KEY / ~/.hyperframes.
// Direct v3 REST (NOT `hyperframes tts`, which in the published build is
// Kokoro-only and silently ignores a HeyGen key). Returns word_timestamps
// in the same call, so no separate transcribe pass.
@@ -209,7 +209,7 @@ test(
);
test("pickProvider heygen with an unreadable credentials path says to fix that path", () => {
const saved = ["HEYGEN_API_KEY", "HYPERFRAMES_API_KEY", "HEYGEN_CONFIG_DIR"].map((k) => [
const saved = ["HEYGEN_API_KEY", "HYPERFRAMES_API_KEY", "HYPERFRAMES_CONFIG_DIR"].map((k) => [
k,
process.env[k],
]);
@@ -217,7 +217,7 @@ test("pickProvider heygen with an unreadable credentials path says to fix that p
try {
delete process.env.HEYGEN_API_KEY;
delete process.env.HYPERFRAMES_API_KEY;
process.env.HEYGEN_CONFIG_DIR = dir;
process.env.HYPERFRAMES_CONFIG_DIR = dir;
mkdirSync(join(dir, "credentials"));
assert.throws(() => pickProvider("heygen"), /fix or remove that path/);
} finally {
+11 -9
View File
@@ -6,8 +6,11 @@ command. Instead it points you at the right local tool (decision OP1). Run the
tool, then register the output with `resolve --from <output> --type <type>` so the
result lands in the ledger and the global cache like any other asset.
All tools below are local and free. ffmpeg is assumed present (it backs the
engine already).
The ffmpeg and local-model operations below run locally. Direct `heygen` recipes
use the HeyGen CLI's own login and HeyGen/API billing; they do not inherit a
HyperFrames subscription. Use those recipes only when that separate payer was
selected. `hyperframes media-use resolve` uses the HyperFrames-selected credential.
ffmpeg is assumed present (it backs the engine already).
## Cut / trim: keep a slice
@@ -238,21 +241,19 @@ GENERATES. Two paths, best-for-the-machine picked automatically:
## Generate: video (`resolve --type video`, HeyGen avatar first)
`resolve --type video "<intent>"` is the default path. It generates a
script-driven HeyGen avatar video first (the free-usage allowance — OAuth
sessions ride the web-plan free avatar-video quota where eligible, API keys
follow normal API billing), falling back to local generative LTX only when
script-driven HeyGen avatar video first using the HyperFrames-selected credential
(the server decides eligibility and charges; explicit API keys use API billing), falling back to local generative LTX only when
HeyGen is unavailable, uncredentialed, or `--local-only` is passed. The two
are non-substitutable outputs (a real presenter vs. a generic generative
clip), so treat the fallback as "HeyGen wasn't reachable," not "upgrade the
quality":
- **HeyGen avatar video (default, free for new API users):**
- **HeyGen avatar video (default):**
The CLI-bundled provider shells the
`heygen` CLI — never the raw API — auto-picking a public avatar and a
starfish voice (override with `--avatar-id`/`--voice-id`, threaded through
as `ctx.avatarId`/`ctx.voiceId`). If the CLI reports `not_authenticated`,
the provider prints an onboarding recommendation (avatar video is free for
new API users — sign in) to stderr and falls through to LTX instead of
the provider prints a HyperFrames sign-in recommendation to stderr and falls through to LTX instead of
hard-failing.
- **Local fallback: LTX 2.3 on MLX** via `dgrauet/ltx-2-mlx`, the `videogen`
ladder bundled in the HyperFrames CLI. Generative clips
@@ -268,7 +269,8 @@ Read-only discovery (`avatar list`, `voice list`) doesn't need it.
For structured bodies `resolve --type video` doesn't expose yet (a specific
`avatar_id`/`voice_id` combination beyond the ctx overrides, or a
pre-recorded `audio_url` instead of a script), the raw `heygen video create`
recipe below remains the escape hatch:
recipe below remains the escape hatch. This direct command uses the separate
HeyGen CLI credential and HeyGen/API billing, not the HyperFrames subscription:
```bash
# discover an avatar + a starfish voice, then create + wait
+30 -30
View File
@@ -1,15 +1,15 @@
# Setup and providers — install, auth, RAM ladders, forcing a provider
## Setup — install heygen first (free-usage path)
## Setup — install the helper and sign in to HyperFrames
Install the HeyGen CLI through its [verified release instructions](https://developers.heygen.com/cli), then run:
```bash
heygen update # free usage needs the OAuth-capable CLI (v0.3.0+)
heygen auth login --oauth # OAuth = free subscription credits; --api-key bills API credits
heygen update # requires v0.3.0+
npx hyperframes auth login # dedicated HyperFrames OAuth session
```
This unlocks the FREE path for bgm/image/icon catalog search, TTS (voice), and avatar videos. Sign in with `--oauth` — the free allowance rides on the OAuth session (an API key bills API credits instead). **media-use requires heygen >= v0.3.0 uniformly** (the OAuth free-usage path needs it), so `--doctor` nudges older CLIs to update even for API-key-only use. Before resolving anything, verify setup with:
HyperFrames owns the OAuth session in `~/.hyperframes/credentials` and supplies an access-only temporary credential to the HeyGen helper. Signing into `heygen` separately does not configure this path. The server decides subscription eligibility, free allowances and charges; an explicit API key uses API billing. **media-use requires heygen >= v0.3.0 uniformly.** Verify setup with:
```bash
npx hyperframes media-use resolve --doctor
@@ -30,25 +30,25 @@ message (for example "add a key in Settings > Account") as written.
## Providers
media-use holds no keys; every external tool owns its auth. Generation is
centered on the HeyGen CLI free-usage path. Install and authenticate `heygen`
before resolving bgm/sfx/image/icon/voice/avatar-video; music and sound effects from
HyperFrames selects the credential for its HeyGen media helpers. Install
`heygen` and sign in with `hyperframes auth login` before resolving
bgm/sfx/image/icon/voice/avatar-video; music and sound effects from
a host app's own tools need none of it. Local tools are opt-in
alternatives where they exist: mflux for image, Kokoro for voice, Parakeet for
transcription, and LTX for local video generation. `resolve` spec-checks
AVAILABLE RAM for those local ladders inside the CLI (the image tiers are listed
in `references/operations.md`).
| Type | Provider / path |
| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| bgm | heygen catalog free-usage path; a host app's own music tools come first |
| sfx | Openverse (CC0 / CC BY, no account), bundled library offline; a host app's own sound tools come first |
| image | heygen search free-usage path; optional local mflux; codex `image_gen` upsell |
| voice | heygen tts free-usage path; optional local **Kokoro** (free, on-device) |
| icon | heygen asset search free-usage path |
| logo | theSVG (thesvg.org), then GitHub org avatar, then domain favicon (all free) |
| grade/lut | local core-preset map, params/CDN look index, deterministic `buildCube` fallback |
| video | heygen avatar video free-usage path (sign-in nudge on auth failure); optional local LTX (`videogen` ladder). Image-to-video / photo-avatar / dub stay manual `heygen` recipes |
| Type | Provider / path |
| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| bgm | heygen catalog HyperFrames-authenticated path; a host app's own music tools come first |
| sfx | Openverse (CC0 / CC BY, no account), bundled library offline; a host app's own sound tools come first |
| image | heygen search HyperFrames-authenticated path; optional local mflux; codex `image_gen` upsell |
| voice | heygen tts HyperFrames-authenticated path; optional local **Kokoro** (free, on-device) |
| icon | heygen asset search HyperFrames-authenticated path |
| logo | theSVG (thesvg.org), then GitHub org avatar, then domain favicon (all free) |
| grade/lut | local core-preset map, params/CDN look index, deterministic `buildCube` fallback |
| video | heygen avatar video HyperFrames-authenticated path (sign-in nudge on auth failure); optional local LTX (`videogen` ladder). Image-to-video / photo-avatar / dub stay manual `heygen` recipes |
Local Kokoro (voice), mflux (image), and LTX (video) run on-device (free,
private, offline once cached). The `codex` CLI remains the ChatGPT-sub image
@@ -58,31 +58,31 @@ allowance) so an agent-initiated `resolve --type video` confirms first.
To force a specific generator (e.g. a user says "make this image with codex"),
pass `--provider codex`: it pins resolution to that provider and skips the
free-usage default. See `references/operations.md` for the RAM ladders and
default provider. See `references/operations.md` for the RAM ladders and
provider recipes.
`--local-only` skips every network provider, including the free HeyGen ones,
`--local-only` skips every network provider, including HeyGen,
leaving the project + global cache and any installed local provider. For
HeyGen-only types, that means no fresh resolve.
## CLI tools used (what to run, and how to enable each)
`resolve` auto-cascades; each provider shells one CLI. HeyGen is the
free-usage path for bgm/image/icon catalog search, TTS (voice), and avatar
video, so those capabilities need `heygen` installed and authenticated. Local
HyperFrames-authenticated path for bgm/image/icon catalog search, TTS (voice), and avatar
video, so those capabilities need `heygen` installed and a HyperFrames login. Local
tools are OPT-IN alternatives where they exist; install one to unlock its free,
private, on-device path instead of or ahead of HeyGen for that type. Only
`ffmpeg`/`ffprobe` are strictly required for the tool to run at all.
| Tool | Serves | Install |
| ------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ffmpeg`/`ffprobe` | adopt probing, smart-grade signalstats, cut, duck bake, loudnorm | system package (`brew install ffmpeg`) |
| `heygen` | catalog (bgm/image/icon) + TTS (voice) + avatar video — the free-usage path | install through [verified HeyGen release instructions](https://developers.heygen.com/cli), then `heygen auth login --oauth` (needs >= v0.3.0) |
| `mflux-generate` | local image gen (FLUX), best-for-RAM | `uv venv ~/.venvs/mflux && VIRTUAL_ENV=~/.venvs/mflux uv pip install mflux==0.9.6` |
| `codex` | image gen upsell (ChatGPT sub) | Codex CLI, logged in via ChatGPT (owns its own auth) |
| `parakeet-mlx` | local transcription (default ASR, best) on Apple Silicon | `uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx` |
| `ltx-2-mlx` | local video gen | `git clone https://github.com/dgrauet/ltx-2-mlx && cd ltx-2-mlx && uv sync --all-extras` |
| `npx hyperframes` | Kokoro TTS (voice), Parakeet or whisper.cpp (transcribe), remove-background | via the hyperframes CLI; Parakeet on macOS, Windows and glibc 2.32+ Linux: `npx hyperframes models install parakeet`; whisper.cpp is built on first use (Homebrew on macOS, else git+cmake) |
| Tool | Serves | Install |
| ------------------ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ffmpeg`/`ffprobe` | adopt probing, smart-grade signalstats, cut, duck bake, loudnorm | system package (`brew install ffmpeg`) |
| `heygen` | catalog (bgm/image/icon) + TTS (voice) + avatar video — the HyperFrames-authenticated path | install through [verified HeyGen release instructions](https://developers.heygen.com/cli), then `npx hyperframes auth login` (helper needs >= v0.3.0) |
| `mflux-generate` | local image gen (FLUX), best-for-RAM | `uv venv ~/.venvs/mflux && VIRTUAL_ENV=~/.venvs/mflux uv pip install mflux==0.9.6` |
| `codex` | image gen upsell (ChatGPT sub) | Codex CLI, logged in via ChatGPT (owns its own auth) |
| `parakeet-mlx` | local transcription (default ASR, best) on Apple Silicon | `uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx` |
| `ltx-2-mlx` | local video gen | `git clone https://github.com/dgrauet/ltx-2-mlx && cd ltx-2-mlx && uv sync --all-extras` |
| `npx hyperframes` | Kokoro TTS (voice), Parakeet or whisper.cpp (transcribe), remove-background | via the hyperframes CLI; Parakeet on macOS, Windows and glibc 2.32+ Linux: `npx hyperframes models install parakeet`; whisper.cpp is built on first use (Homebrew on macOS, else git+cmake) |
The RAM-graded local-model shortlist + exact per-tier install/invoke lives in
the CLI's bundled media-use engine, not in the standalone skill. Without a tool on PATH, its provider
+2 -1
View File
@@ -143,7 +143,8 @@ export function heygenUserId(username) {
// casing never splits one person). `heygen_username` is the cross-project join
// column for the Snowflake event table.
function heygenAccount() {
const file = join(process.env.HEYGEN_CONFIG_DIR || join(homedir(), ".heygen"), "credentials");
const root = process.env.HYPERFRAMES_CONFIG_DIR || join(homedir(), ".hyperframes");
const file = join(root, ...(process.env.HYPERFRAMES_ENV === "dev" ? ["dev"] : []), "credentials");
try {
if (!existsSync(file)) return null;
const raw = readFileSync(file, "utf8").trim();