mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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/**"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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"}',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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).
|
||||
`;
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"];
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(/\/$/, "");
|
||||
}
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: "",
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user