diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a6bcdf820..81d2bb8bb0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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/**" diff --git a/docs/guides/authentication.mdx b/docs/guides/authentication.mdx index 3a7120eb18..6ed5831efa 100644 --- a/docs/guides/authentication.mdx +++ b/docs/guides/authentication.mdx @@ -37,7 +37,9 @@ Signing in is the same OAuth step as creating an account — new users land on t -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. 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. | diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 6bcbba6a5f..1ba90e01e4 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -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 diff --git a/packages/cli/scripts/test-home.mjs b/packages/cli/scripts/test-home.mjs index 708c7ac013..07feafbfc6 100644 --- a/packages/cli/scripts/test-home.mjs +++ b/packages/cli/scripts/test-home.mjs @@ -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", diff --git a/packages/cli/src/auth/_test-utils.ts b/packages/cli/src/auth/_test-utils.ts index 4afd6cd224..bbaceb14e2 100644 --- a/packages/cli/src/auth/_test-utils.ts +++ b/packages/cli/src/auth/_test-utils.ts @@ -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 { @@ -40,7 +47,7 @@ export async function setupTempAuthEnv(prefix = "hf-auth-test-"): Promise => { for (const k of ENV_KEYS) { diff --git a/packages/cli/src/auth/client.ts b/packages/cli/src/auth/client.ts index 0bb3f92bb4..a485f1b278 100644 --- a/packages/cli/src/auth/client.ts +++ b/packages/cli/src/auth/client.ts @@ -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; diff --git a/packages/cli/src/auth/config.test.ts b/packages/cli/src/auth/config.test.ts new file mode 100644 index 0000000000..9e50b53fa3 --- /dev/null +++ b/packages/cli/src/auth/config.test.ts @@ -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>; + 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"); + }); +}); diff --git a/packages/cli/src/auth/config.ts b/packages/cli/src/auth/config.ts new file mode 100644 index 0000000000..28d2303caa --- /dev/null +++ b/packages/cli/src/auth/config.ts @@ -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.", + ); + } +} diff --git a/packages/cli/src/auth/errors.ts b/packages/cli/src/auth/errors.ts index bb307c9d3c..365afbc952 100644 --- a/packages/cli/src/auth/errors.ts +++ b/packages/cli/src/auth/errors.ts @@ -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) => diff --git a/packages/cli/src/auth/media-environment.test.ts b/packages/cli/src/auth/media-environment.test.ts new file mode 100644 index 0000000000..581809c9c6 --- /dev/null +++ b/packages/cli/src/auth/media-environment.test.ts @@ -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>; + 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"]; + } + }); +}); diff --git a/packages/cli/src/auth/media-environment.ts b/packages/cli/src/auth/media-environment.ts new file mode 100644 index 0000000000..f4cb56e26d --- /dev/null +++ b/packages/cli/src/auth/media-environment.ts @@ -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(run: (env: NodeJS.ProcessEnv) => T): Promise { + 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 }); + } +} diff --git a/packages/cli/src/auth/oauth.test.ts b/packages/cli/src/auth/oauth.test.ts index a391ef7f7d..d5d76a2740 100644 --- a/packages/cli/src/auth/oauth.test.ts +++ b/packages/cli/src/auth/oauth.test.ts @@ -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) { return (await readStore()).credentials; } +async function writeStore(credentials: Credentials): Promise { + await writeUnboundStore({ + ...credentials, + ...(credentials.oauth ? { oauth: { ...oauthIdentity(), ...credentials.oauth } } : {}), + }); +} + describe("auth/oauth", () => { let fixture: Awaited>; @@ -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(); + 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(); + 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"}', diff --git a/packages/cli/src/auth/oauth.ts b/packages/cli/src/auth/oauth.ts index 4ef646beb8..0b144c3aaa 100644 --- a/packages/cli/src/auth/oauth.ts +++ b/packages/cli/src/auth/oauth.ts @@ -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; } -/** 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 { + 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. diff --git a/packages/cli/src/auth/paths.test.ts b/packages/cli/src/auth/paths.test.ts index ec78ef4556..785543c46f 100644 --- a/packages/cli/src/auth/paths.test.ts +++ b/packages/cli/src/auth/paths.test.ts @@ -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")); }); }); diff --git a/packages/cli/src/auth/paths.ts b/packages/cli/src/auth/paths.ts index 3df82f4fc2..c909b24650 100644 --- a/packages/cli/src/auth/paths.ts +++ b/packages/cli/src/auth/paths.ts @@ -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 { diff --git a/packages/cli/src/auth/resolver.test.ts b/packages/cli/src/auth/resolver.test.ts index 6119879381..9450d74475 100644 --- a/packages/cli/src/auth/resolver.test.ts +++ b/packages/cli/src/auth/resolver.test.ts @@ -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 { + await writeUnboundStore({ + ...credentials, + ...(credentials.oauth ? { oauth: { ...oauthIdentity(), ...credentials.oauth } } : {}), + }); +} describe("auth/resolver", () => { let fixture: Awaited>; @@ -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 () => { diff --git a/packages/cli/src/auth/resolver.ts b/packages/cli/src/auth/resolver.ts index f2278ab730..f09559b28a 100644 --- a/packages/cli/src/auth/resolver.ts +++ b/packages/cli/src/auth/resolver.ts @@ -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", - * "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; } @@ -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 { 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; } diff --git a/packages/cli/src/auth/user.ts b/packages/cli/src/auth/user.ts index bb4146747a..f992a5f749 100644 --- a/packages/cli/src/auth/user.ts +++ b/packages/cli/src/auth/user.ts @@ -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 diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index 9424eb5184..0a6e7cb037 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -28,8 +28,8 @@ export const examples: Example[] = [ const HELP = ` ${c.bold("hyperframes auth")} ${c.dim(" [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). `; diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 6082c5ddc6..b685ff020d 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -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. * diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts index 0c0b3d711c..45b6041edd 100644 --- a/packages/cli/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -69,8 +69,8 @@ function warnIfEnvCredentialActive(): void { async function ensureConfirmed(yes: boolean, keepApiKey: boolean): Promise { 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); } diff --git a/packages/cli/src/commands/auth/status-guidance.ts b/packages/cli/src/commands/auth/status-guidance.ts index cb4269fe2a..efa90ae484 100644 --- a/packages/cli/src/commands/auth/status-guidance.ts +++ b/packages/cli/src/commands/auth/status-guidance.ts @@ -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):", diff --git a/packages/cli/src/commands/auth/status-user.test.ts b/packages/cli/src/commands/auth/status-user.test.ts index 5bce36e4a8..7179fc52c4 100644 --- a/packages/cli/src/commands/auth/status-user.test.ts +++ b/packages/cli/src/commands/auth/status-user.test.ts @@ -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 { + 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", diff --git a/packages/cli/src/commands/auth/status.ts b/packages/cli/src/commands/auth/status.ts index 6b262877fe..f4de6515d9 100644 --- a/packages/cli/src/commands/auth/status.ts +++ b/packages/cli/src/commands/auth/status.ts @@ -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 = { 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): 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 = { +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]; } diff --git a/packages/cli/src/commands/media-use.ts b/packages/cli/src/commands/media-use.ts index 121dcc3bb3..cbb9e4f654 100644 --- a/packages/cli/src/commands/media-use.ts +++ b/packages/cli/src/commands/media-use.ts @@ -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; -function invokeEngine(verb: MediaUseVerb): never { +async function invokeEngine(verb: MediaUseVerb): Promise { 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); diff --git a/packages/cli/src/commands/publish.test.ts b/packages/cli/src/commands/publish.test.ts index 490cd46dc4..b19f036db8 100644 --- a/packages/cli/src/commands/publish.test.ts +++ b/packages/cli/src/commands/publish.test.ts @@ -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 { + await writeUnboundStore( + { + ...credentials, + ...(credentials.oauth ? { oauth: { ...oauthIdentity(), ...credentials.oauth } } : {}), + }, + path, + ); +} + const VISIBLE_INDEX = `
Visible
`; function mockPublished(result: Record): 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 { diff --git a/packages/cli/src/media-use/lib/heygen-cli.mjs b/packages/cli/src/media-use/lib/heygen-cli.mjs index 214b93ae9e..3873b4c616 100644 --- a/packages/cli/src/media-use/lib/heygen-cli.mjs +++ b/packages/cli/src/media-use/lib/heygen-cli.mjs @@ -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"]; diff --git a/packages/cli/src/media-use/lib/telemetry.mjs b/packages/cli/src/media-use/lib/telemetry.mjs index 21a427f9c9..3bc278a6b8 100644 --- a/packages/cli/src/media-use/lib/telemetry.mjs +++ b/packages/cli/src/media-use/lib/telemetry.mjs @@ -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(); diff --git a/packages/cli/src/media-use/lib/telemetry.test.mjs b/packages/cli/src/media-use/lib/telemetry.test.mjs index df9cf206b5..fc0b021bac 100644 --- a/packages/cli/src/media-use/lib/telemetry.test.mjs +++ b/packages/cli/src/media-use/lib/telemetry.test.mjs @@ -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" } }), ); diff --git a/packages/cli/src/telemetry/signedInUser.test.ts b/packages/cli/src/telemetry/signedInUser.test.ts index 640c6e06ac..eaf5667b62 100644 --- a/packages/cli/src/telemetry/signedInUser.test.ts +++ b/packages/cli/src/telemetry/signedInUser.test.ts @@ -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(); + }); }); diff --git a/packages/cli/src/utils/publishProject.test.ts b/packages/cli/src/utils/publishProject.test.ts index a8779c29ec..a196b7992a 100644 --- a/packages/cli/src/utils/publishProject.test.ts +++ b/packages/cli/src/utils/publishProject.test.ts @@ -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( diff --git a/packages/cli/src/utils/publishProject.ts b/packages/cli/src/utils/publishProject.ts index e6b8b9cd48..8ff67d5da3 100644 --- a/packages/cli/src/utils/publishProject.ts +++ b/packages/cli/src/utils/publishProject.ts @@ -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(/\/$/, ""); } diff --git a/skills-manifest.json b/skills-manifest.json index e082ad2246..de04edc14c 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -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": { diff --git a/skills/hyperframes-cli/references/cloud.md b/skills/hyperframes-cli/references/cloud.md index c886e5cb72..13e284ae9d 100644 --- a/skills/hyperframes-cli/references/cloud.md +++ b/skills/hyperframes-cli/references/cloud.md @@ -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 diff --git a/skills/media-use/SKILL.md b/skills/media-use/SKILL.md index 53fe2edcf5..562649280a 100644 --- a/skills/media-use/SKILL.md +++ b/skills/media-use/SKILL.md @@ -31,7 +31,9 @@ It prints one line, `resolved → (, )`. 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. diff --git a/skills/media-use/audio/references/bgm.md b/skills/media-use/audio/references/bgm.md index be12c653f2..c8811ac2e8 100644 --- a/skills/media-use/audio/references/bgm.md +++ b/skills/media-use/audio/references/bgm.md @@ -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. diff --git a/skills/media-use/audio/references/requirements.md b/skills/media-use/audio/references/requirements.md index e621f77654..3244d005a6 100644 --- a/skills/media-use/audio/references/requirements.md +++ b/skills/media-use/audio/references/requirements.md @@ -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. diff --git a/skills/media-use/audio/references/tts.md b/skills/media-use/audio/references/tts.md index 2728b215ff..9a386f223e 100644 --- a/skills/media-use/audio/references/tts.md +++ b/skills/media-use/audio/references/tts.md @@ -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 diff --git a/skills/media-use/audio/scripts/gemini-pipeline.test.mjs b/skills/media-use/audio/scripts/gemini-pipeline.test.mjs index 842e5085e6..a277457d9d 100644 --- a/skills/media-use/audio/scripts/gemini-pipeline.test.mjs +++ b/skills/media-use/audio/scripts/gemini-pipeline.test.mjs @@ -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", diff --git a/skills/media-use/audio/scripts/heygen-tts.mjs b/skills/media-use/audio/scripts/heygen-tts.mjs index 3e1de356bf..c0ab58a8c2 100755 --- a/skills/media-use/audio/scripts/heygen-tts.mjs +++ b/skills/media-use/audio/scripts/heygen-tts.mjs @@ -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"; diff --git a/skills/media-use/audio/scripts/lib/audio-meta.test.mjs b/skills/media-use/audio/scripts/lib/audio-meta.test.mjs index 2fa3a68807..3eb80dfee6 100644 --- a/skills/media-use/audio/scripts/lib/audio-meta.test.mjs +++ b/skills/media-use/audio/scripts/lib/audio-meta.test.mjs @@ -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: "", }, diff --git a/skills/media-use/audio/scripts/lib/heygen.mjs b/skills/media-use/audio/scripts/lib/heygen.mjs index 24ab9ed4b6..22e3b93863 100644 --- a/skills/media-use/audio/scripts/lib/heygen.mjs +++ b/skills/media-use/audio/scripts/lib/heygen.mjs @@ -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)", ); } diff --git a/skills/media-use/audio/scripts/lib/heygen.test.mjs b/skills/media-use/audio/scripts/lib/heygen.test.mjs index 8698feb027..39f894380f 100644 --- a/skills/media-use/audio/scripts/lib/heygen.test.mjs +++ b/skills/media-use/audio/scripts/lib/heygen.test.mjs @@ -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"); diff --git a/skills/media-use/audio/scripts/lib/tts.mjs b/skills/media-use/audio/scripts/lib/tts.mjs index 879fb89f2b..842058eeaa 100644 --- a/skills/media-use/audio/scripts/lib/tts.mjs +++ b/skills/media-use/audio/scripts/lib/tts.mjs @@ -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. diff --git a/skills/media-use/audio/scripts/lib/tts.test.mjs b/skills/media-use/audio/scripts/lib/tts.test.mjs index bf59c1eac4..025eb03ddc 100644 --- a/skills/media-use/audio/scripts/lib/tts.test.mjs +++ b/skills/media-use/audio/scripts/lib/tts.test.mjs @@ -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 { diff --git a/skills/media-use/references/operations.md b/skills/media-use/references/operations.md index 8c461e898d..66d33587c2 100644 --- a/skills/media-use/references/operations.md +++ b/skills/media-use/references/operations.md @@ -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 --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 ""` 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 diff --git a/skills/media-use/references/setup-providers.md b/skills/media-use/references/setup-providers.md index 6babb88fb4..366c29a3bb 100644 --- a/skills/media-use/references/setup-providers.md +++ b/skills/media-use/references/setup-providers.md @@ -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 diff --git a/skills/media-use/scripts/lib/telemetry.mjs b/skills/media-use/scripts/lib/telemetry.mjs index 21a427f9c9..3bc278a6b8 100644 --- a/skills/media-use/scripts/lib/telemetry.mjs +++ b/skills/media-use/scripts/lib/telemetry.mjs @@ -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();