mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(cli): add hyperframes auth login --api-key, status, logout (#1081)
## What
Introduces the `hyperframes auth` command group + a shared credential
store library that hyperframes-CLI and heygen-cli will both read from.
- `hyperframes auth login --api-key` saves a HeyGen API key to
`~/.heygen/credentials.json` (stdin pipe or hidden-input prompt).
- `hyperframes auth status` resolves the active credential (env vars
→ file) and verifies it against `GET /v3/users/me`, printing
identity + billing.
- `hyperframes auth logout` removes the credential (`--keep-api-key`
drops only the OAuth block).
Internals (`packages/cli/src/auth/`):
- `paths.ts` — `~/.heygen` layout, `HEYGEN_CONFIG_DIR` override.
- `store.ts` — read/write `credentials.json` (file 0600, dir 0700)
with legacy single-line plaintext fallback so existing heygen-cli
users don't lose their session.
- `resolver.ts` — chain: `HEYGEN_API_KEY` → `HYPERFRAMES_API_KEY` →
file (unexpired OAuth wins over api_key).
- `client.ts` — hand-written typed wrapper for `GET /v3/users/me`
(intentionally not OpenAPI codegen — single endpoint).
- `errors.ts` — typed `AuthError` with discriminating `code`.
## Why
This is the foundation for `hyperframes cloud render`. Splitting it
out keeps the cloud-render PR small and lets users sign in today.
The plan originally called for a library-only PR followed by a
commands PR. The `fallow` dead-code gate flagged the library-only
shape as unused exports, so I bundled them — the library and its
first consumers ship together. PR 3 (OAuth PKCE) and PR 4
(heygen-cli read-side JSON support) follow.
## How
- Credential file format: JSON with optional `api_key` + `oauth`
blocks. Both CLIs read it; the resolver picks the freshest valid
credential.
- Auth header selection happens in the HTTP client: OAuth →
`Authorization: Bearer ...`, API key → `x-api-key: ...`.
- `HEYGEN_API_URL` lets dev testing target `api.dev.heygen.com`
without rebuilding.
- The new `auth` command lazy-loads its subverbs (same pattern as
`lambda`).
## Test plan
- [x] Unit tests added (`vitest`) for paths, store, resolver,
client, and errors — 45 tests, all green.
- [x] `bunx tsc --noEmit -p packages/cli/tsconfig.json` clean.
- [x] `bunx oxlint` + `bunx oxfmt --check` clean.
- [x] `bunx fallow audit --base origin/main --fail-on-issues` —
zero new findings.
- [ ] Smoke test against dev API:
`HEYGEN_API_URL=https://api.dev.heygen.com hyperframes auth login --api-key`
then `hyperframes auth status`.
This commit is contained in:
@@ -860,6 +860,63 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## hyperframes auth
|
||||
|
||||
Sign in to HeyGen and manage credentials. Credentials are stored in
|
||||
`~/.heygen/credentials` (mode `0600`) and are **shared with the
|
||||
`heygen` CLI** — sign in with one and the other picks up the session.
|
||||
|
||||
Resolution order (first match wins):
|
||||
|
||||
1. `HEYGEN_API_KEY` environment variable
|
||||
2. `HYPERFRAMES_API_KEY` environment variable (hyperframes alias)
|
||||
3. `~/.heygen/credentials`
|
||||
|
||||
### Subcommands
|
||||
|
||||
#### `auth login --api-key`
|
||||
|
||||
Save a HeyGen API key. The key is verified against `GET /v3/users/me`
|
||||
before the command reports success; a rejected key is not left on disk.
|
||||
|
||||
```bash
|
||||
# Interactive hidden-input prompt
|
||||
hyperframes auth login --api-key
|
||||
|
||||
# Pipe a key from stdin (CI-friendly)
|
||||
echo "$HEYGEN_API_KEY" | hyperframes auth login --api-key
|
||||
```
|
||||
|
||||
#### `auth status`
|
||||
|
||||
Show the active credential's source, type, and verified identity
|
||||
(account + billing snapshot). Exits non-zero when nothing is configured
|
||||
or the API rejects the credential, so scripts can check sign-in state.
|
||||
|
||||
```bash
|
||||
hyperframes auth status
|
||||
hyperframes auth status --json # machine-readable
|
||||
```
|
||||
|
||||
#### `auth logout`
|
||||
|
||||
Remove the stored credential. Prompts for confirmation on a TTY.
|
||||
|
||||
```bash
|
||||
hyperframes auth logout
|
||||
hyperframes auth logout --keep-api-key # clear only an OAuth session
|
||||
hyperframes auth logout --yes # skip the confirmation prompt
|
||||
```
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `HEYGEN_API_KEY` | Override the stored credential. |
|
||||
| `HYPERFRAMES_API_KEY` | Alias for `HEYGEN_API_KEY`. |
|
||||
| `HEYGEN_API_URL` | API base URL (default `https://api.heygen.com`). |
|
||||
| `HEYGEN_CONFIG_DIR` | Credentials directory (default `~/.heygen`). |
|
||||
|
||||
## hyperframes lambda
|
||||
|
||||
Deploy HyperFrames distributed rendering to AWS Lambda and drive renders from your laptop or CI.
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AuthClient, apiBaseUrl, buildAuthHeaders } from "./client.js";
|
||||
import { isAuthError } from "./errors.js";
|
||||
import type { ResolvedCredential } from "./resolver.js";
|
||||
|
||||
function jsonFetch(body: unknown, status = 200): typeof fetch {
|
||||
return (async () =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function textFetch(body: string, status: number): typeof fetch {
|
||||
return (async () => new Response(body, { status })) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function apiKeyCred(): ResolvedCredential {
|
||||
return { type: "api_key", key: "hg_x", source: "env" };
|
||||
}
|
||||
|
||||
function makeClient(fetchImpl: typeof fetch): AuthClient {
|
||||
return new AuthClient({ baseUrl: "https://api.test.example", fetchImpl });
|
||||
}
|
||||
|
||||
describe("auth/client", () => {
|
||||
const original = process.env["HEYGEN_API_URL"];
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env["HEYGEN_API_URL"];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (original !== undefined) process.env["HEYGEN_API_URL"] = original;
|
||||
else delete process.env["HEYGEN_API_URL"];
|
||||
});
|
||||
|
||||
it("apiBaseUrl defaults to https://api.heygen.com", () => {
|
||||
expect(apiBaseUrl()).toBe("https://api.heygen.com");
|
||||
});
|
||||
|
||||
it("apiBaseUrl honors HEYGEN_API_URL and strips trailing slash", () => {
|
||||
process.env["HEYGEN_API_URL"] = "https://api.dev.heygen.com/";
|
||||
expect(apiBaseUrl()).toBe("https://api.dev.heygen.com");
|
||||
});
|
||||
|
||||
it("buildAuthHeaders uses Bearer for oauth", () => {
|
||||
const cred: ResolvedCredential = {
|
||||
type: "oauth",
|
||||
access_token: "at_123",
|
||||
source: "file_json",
|
||||
refreshable: false,
|
||||
};
|
||||
expect(buildAuthHeaders(cred)).toEqual({ authorization: "Bearer at_123" });
|
||||
});
|
||||
|
||||
it("buildAuthHeaders uses x-api-key for api_key", () => {
|
||||
expect(buildAuthHeaders(apiKeyCred())).toEqual({ "x-api-key": "hg_x" });
|
||||
});
|
||||
|
||||
it("getCurrentUser parses a wrapped {data: {...}} payload", async () => {
|
||||
const client = makeClient(
|
||||
jsonFetch({
|
||||
code: 100,
|
||||
message: "ok",
|
||||
data: {
|
||||
username: "alice",
|
||||
email: "alice@example.com",
|
||||
billing_type: "subscription",
|
||||
subscription: {
|
||||
plan: "team",
|
||||
credits: {
|
||||
premium_credits: { remaining: 4200, resets_at: "2026-12-01T00:00:00Z" },
|
||||
add_on_credits: { remaining: 9 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const user = await client.getCurrentUser(apiKeyCred());
|
||||
expect(user.username).toBe("alice");
|
||||
expect(user.email).toBe("alice@example.com");
|
||||
expect(user.subscription?.plan).toBe("team");
|
||||
expect(user.subscription?.credits?.premium_credits?.remaining).toBe(4200);
|
||||
expect(user.subscription?.credits?.premium_credits?.resets_at).toBe("2026-12-01T00:00:00Z");
|
||||
expect(user.subscription?.credits?.add_on_credits?.remaining).toBe(9);
|
||||
});
|
||||
|
||||
it("getCurrentUser parses an unwrapped payload", async () => {
|
||||
const client = makeClient(jsonFetch({ email: "bob@example.com" }));
|
||||
const user = await client.getCurrentUser(apiKeyCred());
|
||||
expect(user.email).toBe("bob@example.com");
|
||||
});
|
||||
|
||||
it("getCurrentUser throws ErrUnauthenticated on 401", async () => {
|
||||
const client = makeClient(textFetch("invalid token", 401));
|
||||
await expect(client.getCurrentUser(apiKeyCred())).rejects.toSatisfy((err) => {
|
||||
return isAuthError(err) && (err as { code: string }).code === "UNAUTHENTICATED";
|
||||
});
|
||||
});
|
||||
|
||||
it("getCurrentUser throws ErrApi on 5xx", async () => {
|
||||
const client = makeClient(textFetch("upstream", 503));
|
||||
await expect(client.getCurrentUser(apiKeyCred())).rejects.toSatisfy((err) => {
|
||||
return isAuthError(err) && (err as { code: string }).code === "API_ERROR";
|
||||
});
|
||||
});
|
||||
|
||||
it("getCurrentUser throws ErrApi when 2xx body is not valid JSON", async () => {
|
||||
const fetchImpl = (async () =>
|
||||
new Response("<html>not json</html>", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html" },
|
||||
})) as unknown as typeof fetch;
|
||||
const client = makeClient(fetchImpl);
|
||||
await expect(client.getCurrentUser(apiKeyCred())).rejects.toSatisfy((err) => {
|
||||
return isAuthError(err) && (err as { code: string }).code === "API_ERROR";
|
||||
});
|
||||
});
|
||||
|
||||
it("getCurrentUser returns empty UserInfo when payload.data is an array", async () => {
|
||||
const client = makeClient(jsonFetch({ code: 0, data: [{ email: "x@y" }] }));
|
||||
const user = await client.getCurrentUser(apiKeyCred());
|
||||
expect(user).toEqual({});
|
||||
});
|
||||
|
||||
it("getCurrentUser scrubs hg_ keys and JWTs from 401 detail", async () => {
|
||||
const fetchImpl = textFetch(
|
||||
'invalid request — got header "x-api-key: hg_supersecret_abc123"',
|
||||
401,
|
||||
);
|
||||
const client = makeClient(fetchImpl);
|
||||
try {
|
||||
await client.getCurrentUser(apiKeyCred());
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
expect(msg).not.toContain("hg_supersecret_abc123");
|
||||
expect(msg).toContain("<redacted>");
|
||||
return;
|
||||
}
|
||||
throw new Error("expected rejection");
|
||||
});
|
||||
|
||||
it("getCurrentUser redacts the full Authorization: Bearer value (not just the scheme)", async () => {
|
||||
const fetchImpl = textFetch(
|
||||
"rejected — echoed Authorization: Bearer at_opaque_secret_999",
|
||||
401,
|
||||
);
|
||||
const client = makeClient(fetchImpl);
|
||||
try {
|
||||
await client.getCurrentUser(apiKeyCred());
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
expect(msg).not.toContain("at_opaque_secret_999");
|
||||
expect(msg).not.toContain("Bearer at_opaque_secret_999");
|
||||
expect(msg).toContain("<redacted>");
|
||||
return;
|
||||
}
|
||||
throw new Error("expected rejection");
|
||||
});
|
||||
|
||||
it("getCurrentUser sends the right header for oauth credentials", async () => {
|
||||
let captured: Record<string, string> = {};
|
||||
const fetchImpl = (async (_url: string, init?: RequestInit) => {
|
||||
captured = (init?.headers as Record<string, string>) ?? {};
|
||||
return new Response(JSON.stringify({ email: "alice@example.com" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const client = makeClient(fetchImpl);
|
||||
await client.getCurrentUser({
|
||||
type: "oauth",
|
||||
access_token: "at_xyz",
|
||||
source: "file_json",
|
||||
refreshable: false,
|
||||
});
|
||||
expect(captured["authorization"]).toBe("Bearer at_xyz");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Minimal typed HTTP client for HeyGen endpoints needed by the auth
|
||||
* commands. Hand-written rather than codegen'd because the surface is
|
||||
* one endpoint (`/v3/users/me`) and pulling in an OpenAPI pipeline is
|
||||
* disproportionate.
|
||||
*
|
||||
* Reads `HEYGEN_API_URL` (default `https://api.heygen.com`) so dev
|
||||
* testing is one env var away.
|
||||
*
|
||||
* Auth header selection:
|
||||
* - OAuth → `Authorization: Bearer <token>`
|
||||
* - API key → `x-api-key: <key>`
|
||||
*
|
||||
* The backend `/v3/users/me` accepts both. See
|
||||
* `movio/api_service/app/controller/user_v3.py`.
|
||||
*/
|
||||
|
||||
import { ErrApi, ErrUnauthenticated } from "./errors.js";
|
||||
import type { ResolvedCredential } from "./resolver.js";
|
||||
|
||||
const DEFAULT_BASE_URL = "https://api.heygen.com";
|
||||
|
||||
export function apiBaseUrl(): string {
|
||||
const override = process.env["HEYGEN_API_URL"];
|
||||
return override && override.length > 0 ? override.replace(/\/+$/, "") : DEFAULT_BASE_URL;
|
||||
}
|
||||
|
||||
export type BillingType = "wallet" | "subscription" | "usage_based" | string;
|
||||
|
||||
export interface WalletInfo {
|
||||
currency?: string;
|
||||
remaining_balance?: number;
|
||||
auto_reload?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The API returns `credits.{premium,add_on}_credits` as nested objects
|
||||
* (`{ remaining, resets_at? }`), not bare numbers — discovered live
|
||||
* against api.heygen.com. Modelling them as nested objects so the row
|
||||
* formatter can render them properly instead of `[object Object]`.
|
||||
*/
|
||||
export interface CreditBalance {
|
||||
remaining?: number;
|
||||
resets_at?: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionInfo {
|
||||
plan?: string;
|
||||
credits?: {
|
||||
premium_credits?: CreditBalance;
|
||||
add_on_credits?: CreditBalance;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UsageBasedInfo {
|
||||
spending_current_usd?: number;
|
||||
spending_cap_usd?: number;
|
||||
}
|
||||
|
||||
/** Subset of the backend response we surface to users today. */
|
||||
export interface UserInfo {
|
||||
username?: string;
|
||||
email?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
billing_type?: BillingType;
|
||||
wallet?: WalletInfo;
|
||||
subscription?: SubscriptionInfo;
|
||||
usage_based?: UsageBasedInfo;
|
||||
}
|
||||
|
||||
export interface AuthClientOptions {
|
||||
/** Override base URL (otherwise `HEYGEN_API_URL` / default). */
|
||||
baseUrl?: string;
|
||||
/** Inject a custom fetch (used by tests). */
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export class AuthClient {
|
||||
private readonly base: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
|
||||
constructor(opts: AuthClientOptions = {}) {
|
||||
this.base = (opts.baseUrl ?? apiBaseUrl()).replace(/\/+$/, "");
|
||||
this.fetchImpl = opts.fetchImpl ?? fetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /v3/users/me`. Throws `ErrUnauthenticated` on 401, `ErrApi`
|
||||
* on any other non-2xx or non-JSON body.
|
||||
*/
|
||||
async getCurrentUser(credential: ResolvedCredential): Promise<UserInfo> {
|
||||
const url = `${this.base}/v3/users/me`;
|
||||
const headers = buildAuthHeaders(credential);
|
||||
const res = await this.fetchImpl(url, { method: "GET", headers });
|
||||
|
||||
if (res.status === 401) {
|
||||
const detail = await safeText(res);
|
||||
throw ErrUnauthenticated(detail || `${res.status} ${res.statusText}`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw ErrApi(res.status, (await safeText(res)) || res.statusText);
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await res.json();
|
||||
} catch (err) {
|
||||
throw ErrApi(res.status, `non-JSON body: ${(err as Error).message}`);
|
||||
}
|
||||
return extractUserInfo(payload);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAuthHeaders(credential: ResolvedCredential): Record<string, string> {
|
||||
if (credential.type === "oauth") {
|
||||
return { authorization: `Bearer ${credential.access_token}` };
|
||||
}
|
||||
return { "x-api-key": credential.key };
|
||||
}
|
||||
|
||||
async function safeText(res: Response): Promise<string> {
|
||||
try {
|
||||
const body = (await res.text()).slice(0, 500);
|
||||
return scrubCredentials(body);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip credential-shaped substrings from error bodies before they
|
||||
* surface in user-facing messages or `--json` output. Some proxies
|
||||
* echo request headers in their error pages and we never want a
|
||||
* HeyGen API key, OAuth bearer, or JWT to land in scrollback / CI
|
||||
* logs because of one of those echoes.
|
||||
*/
|
||||
function scrubCredentials(s: string): string {
|
||||
return (
|
||||
s
|
||||
.replace(/hg_[A-Za-z0-9_-]{4,}/g, "hg_<redacted>")
|
||||
// Redact the ENTIRE header value to end-of-line — `Bearer <token>`
|
||||
// is two whitespace-separated words, so a `\S+` would leave the
|
||||
// opaque token exposed after the scheme.
|
||||
.replace(/(authorization|x-api-key)[ \t]*[:=][ \t]*[^\r\n]+/gi, "$1: <redacted>")
|
||||
.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "<jwt-redacted>")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The backend wraps responses in `{code, message, data: {...}}` for some
|
||||
* endpoints and returns raw fields directly for others. Handle both.
|
||||
*/
|
||||
function extractUserInfo(payload: unknown): UserInfo {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return {};
|
||||
const obj = payload as Record<string, unknown>;
|
||||
const wrapped = obj["data"];
|
||||
const data =
|
||||
wrapped && typeof wrapped === "object" && !Array.isArray(wrapped)
|
||||
? (wrapped as Record<string, unknown>)
|
||||
: obj;
|
||||
return {
|
||||
username: pickString(data, "username"),
|
||||
email: pickString(data, "email"),
|
||||
first_name: pickString(data, "first_name"),
|
||||
last_name: pickString(data, "last_name"),
|
||||
billing_type: pickString(data, "billing_type"),
|
||||
wallet: pickObject(data, "wallet") as WalletInfo | undefined,
|
||||
subscription: pickObject(data, "subscription") as SubscriptionInfo | undefined,
|
||||
usage_based: pickObject(data, "usage_based") as UsageBasedInfo | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function pickString(obj: Record<string, unknown>, key: string): string | undefined {
|
||||
const v = obj[key];
|
||||
return typeof v === "string" ? v : undefined;
|
||||
}
|
||||
|
||||
function pickObject(
|
||||
obj: Record<string, unknown>,
|
||||
key: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
const v = obj[key];
|
||||
return v && typeof v === "object" && !Array.isArray(v)
|
||||
? (v as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AuthError,
|
||||
ErrApi,
|
||||
ErrInvalidStore,
|
||||
ErrNotConfigured,
|
||||
ErrUnauthenticated,
|
||||
isAuthError,
|
||||
} from "./errors.js";
|
||||
|
||||
describe("auth/errors", () => {
|
||||
it("ErrNotConfigured carries the right code + hint", () => {
|
||||
const err = ErrNotConfigured();
|
||||
expect(err).toBeInstanceOf(AuthError);
|
||||
expect(err.code).toBe("NOT_CONFIGURED");
|
||||
expect(err.hint).toContain("hyperframes auth login");
|
||||
});
|
||||
|
||||
it("ErrInvalidStore wraps the detail", () => {
|
||||
const err = ErrInvalidStore("malformed at line 3");
|
||||
expect(err.code).toBe("INVALID_STORE");
|
||||
expect(err.message).toContain("malformed at line 3");
|
||||
});
|
||||
|
||||
it("ErrUnauthenticated includes detail when provided", () => {
|
||||
expect(ErrUnauthenticated().code).toBe("UNAUTHENTICATED");
|
||||
expect(ErrUnauthenticated("invalid token").message).toContain("invalid token");
|
||||
});
|
||||
|
||||
it("ErrApi captures status + detail", () => {
|
||||
const err = ErrApi(503, "upstream timeout");
|
||||
expect(err.code).toBe("API_ERROR");
|
||||
expect(err.message).toContain("503");
|
||||
expect(err.message).toContain("upstream timeout");
|
||||
});
|
||||
|
||||
it("isAuthError narrows properly", () => {
|
||||
expect(isAuthError(ErrNotConfigured())).toBe(true);
|
||||
expect(isAuthError(new Error("plain"))).toBe(false);
|
||||
expect(isAuthError(null)).toBe(false);
|
||||
expect(isAuthError("string")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Typed errors for the auth layer. Callers branch on `code` so commands
|
||||
* can map specific failures to friendly UX without parsing messages.
|
||||
*/
|
||||
|
||||
export type AuthErrorCode = "NOT_CONFIGURED" | "INVALID_STORE" | "API_ERROR" | "UNAUTHENTICATED";
|
||||
|
||||
export class AuthError extends Error {
|
||||
readonly code: AuthErrorCode;
|
||||
readonly hint?: string;
|
||||
|
||||
constructor(code: AuthErrorCode, message: string, hint?: string) {
|
||||
super(message);
|
||||
this.name = "AuthError";
|
||||
this.code = code;
|
||||
this.hint = hint;
|
||||
}
|
||||
}
|
||||
|
||||
export const ErrNotConfigured = () =>
|
||||
new AuthError(
|
||||
"NOT_CONFIGURED",
|
||||
"No HeyGen credentials found",
|
||||
"Run `hyperframes auth login` to sign in.",
|
||||
);
|
||||
|
||||
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.",
|
||||
);
|
||||
|
||||
export const ErrUnauthenticated = (detail?: string) =>
|
||||
new AuthError(
|
||||
"UNAUTHENTICATED",
|
||||
detail ? `HeyGen rejected the credential: ${detail}` : "HeyGen rejected the credential",
|
||||
"Run `hyperframes auth login` to re-authenticate.",
|
||||
);
|
||||
|
||||
export const ErrApi = (status: number, detail: string) =>
|
||||
new AuthError("API_ERROR", `HeyGen API error (${status}): ${detail}`);
|
||||
|
||||
export function isAuthError(err: unknown): err is AuthError {
|
||||
return err instanceof AuthError;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Public surface of the auth library — only the symbols the auth
|
||||
* commands consume today. Internal types stay in their source files.
|
||||
*/
|
||||
|
||||
export { isAuthError } from "./errors.js";
|
||||
|
||||
export { clearOAuth, deleteStore, isHeaderSafe, readStore, writeStore } from "./store.js";
|
||||
export type { Credentials } from "./store.js";
|
||||
|
||||
export { configDir, credentialPath } from "./paths.js";
|
||||
|
||||
export { tryResolveCredential } from "./resolver.js";
|
||||
export type { ResolvedCredential } from "./resolver.js";
|
||||
|
||||
export { AuthClient } from "./client.js";
|
||||
export type { UserInfo } from "./client.js";
|
||||
@@ -0,0 +1,32 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { CREDENTIAL_FILENAME, configDir, credentialPath } from "./paths.js";
|
||||
|
||||
describe("auth/paths", () => {
|
||||
const original = process.env["HEYGEN_CONFIG_DIR"];
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env["HEYGEN_CONFIG_DIR"];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (original !== undefined) process.env["HEYGEN_CONFIG_DIR"] = original;
|
||||
else delete process.env["HEYGEN_CONFIG_DIR"];
|
||||
});
|
||||
|
||||
it("defaults to ~/.heygen", () => {
|
||||
expect(configDir()).toBe(join(homedir(), ".heygen"));
|
||||
});
|
||||
|
||||
it("honors HEYGEN_CONFIG_DIR override", () => {
|
||||
process.env["HEYGEN_CONFIG_DIR"] = "/tmp/some-test-dir";
|
||||
expect(configDir()).toBe("/tmp/some-test-dir");
|
||||
expect(credentialPath()).toBe(join("/tmp/some-test-dir", CREDENTIAL_FILENAME));
|
||||
});
|
||||
|
||||
it("treats empty HEYGEN_CONFIG_DIR as unset", () => {
|
||||
process.env["HEYGEN_CONFIG_DIR"] = "";
|
||||
expect(configDir()).toBe(join(homedir(), ".heygen"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Filesystem layout for the shared HeyGen credential store. Mirrors
|
||||
* `heygen-cli/internal/paths/paths.go` so both CLIs read the same file.
|
||||
* `HEYGEN_CONFIG_DIR` overrides the directory.
|
||||
*/
|
||||
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* Filename for the credential store. Matches heygen-cli (no `.json`
|
||||
* suffix) so a `~/.heygen/credentials` written by either CLI is
|
||||
* readable by the other — see `heygen-cli/internal/auth/file_resolver.go`.
|
||||
*/
|
||||
export const CREDENTIAL_FILENAME = "credentials";
|
||||
|
||||
export function configDir(): string {
|
||||
const override = process.env["HEYGEN_CONFIG_DIR"];
|
||||
if (override && override.length > 0) return override;
|
||||
return join(homedir(), ".heygen");
|
||||
}
|
||||
|
||||
export function credentialPath(): string {
|
||||
return join(configDir(), CREDENTIAL_FILENAME);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { isAuthError } from "./errors.js";
|
||||
import { resolveCredential, tryResolveCredential } from "./resolver.js";
|
||||
import { writeStore } from "./store.js";
|
||||
|
||||
async function makeTmpDir(): Promise<string> {
|
||||
return fs.mkdtemp(join(tmpdir(), "hf-auth-resolve-"));
|
||||
}
|
||||
|
||||
const ENV_KEYS = ["HEYGEN_API_KEY", "HYPERFRAMES_API_KEY", "HEYGEN_CONFIG_DIR"] as const;
|
||||
|
||||
describe("auth/resolver", () => {
|
||||
let dir: string;
|
||||
const saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>> = {};
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await makeTmpDir();
|
||||
for (const k of ENV_KEYS) {
|
||||
saved[k] = process.env[k];
|
||||
delete process.env[k];
|
||||
}
|
||||
process.env["HEYGEN_CONFIG_DIR"] = dir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const k of ENV_KEYS) {
|
||||
const v = saved[k];
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("prefers HEYGEN_API_KEY over everything else", async () => {
|
||||
process.env["HEYGEN_API_KEY"] = "env-key";
|
||||
process.env["HYPERFRAMES_API_KEY"] = "alias-key";
|
||||
await writeStore({ api_key: "file-key" });
|
||||
const r = await resolveCredential();
|
||||
expect(r).toEqual({ type: "api_key", key: "env-key", source: "env" });
|
||||
});
|
||||
|
||||
it("falls through to HYPERFRAMES_API_KEY", async () => {
|
||||
process.env["HYPERFRAMES_API_KEY"] = "alias-key";
|
||||
await writeStore({ api_key: "file-key" });
|
||||
const r = await resolveCredential();
|
||||
expect(r).toEqual({ type: "api_key", key: "alias-key", source: "env_alias" });
|
||||
});
|
||||
|
||||
it("returns file api_key when no env is set", async () => {
|
||||
await writeStore({ api_key: "file-key" });
|
||||
const r = await resolveCredential();
|
||||
expect(r.type).toBe("api_key");
|
||||
if (r.type === "api_key") {
|
||||
expect(r.key).toBe("file-key");
|
||||
expect(r.source).toBe("file_json");
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers fresh oauth over api_key", async () => {
|
||||
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
await writeStore({
|
||||
api_key: "file-key",
|
||||
oauth: { access_token: "fresh-at", refresh_token: "rt", expires_at: future },
|
||||
});
|
||||
const r = await resolveCredential();
|
||||
expect(r.type).toBe("oauth");
|
||||
if (r.type === "oauth") {
|
||||
expect(r.access_token).toBe("fresh-at");
|
||||
// Fresh access_token does NOT need refresh, even with refresh_token present.
|
||||
expect(r.refreshable).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("oauth without expires_at is treated as fresh (refreshable=false)", async () => {
|
||||
await writeStore({
|
||||
oauth: { access_token: "at", refresh_token: "rt" },
|
||||
});
|
||||
const r = await resolveCredential();
|
||||
expect(r.type).toBe("oauth");
|
||||
if (r.type === "oauth") expect(r.refreshable).toBe(false);
|
||||
});
|
||||
|
||||
it("marks expired-but-refreshable oauth as refreshable", async () => {
|
||||
const past = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
await writeStore({
|
||||
oauth: { access_token: "stale-at", refresh_token: "rt", expires_at: past },
|
||||
});
|
||||
const r = await resolveCredential();
|
||||
expect(r.type).toBe("oauth");
|
||||
if (r.type === "oauth") expect(r.refreshable).toBe(true);
|
||||
});
|
||||
|
||||
it("skips expired non-refreshable oauth and falls through to api_key", async () => {
|
||||
const past = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
await writeStore({
|
||||
api_key: "fallback",
|
||||
oauth: { access_token: "stale-at", expires_at: past },
|
||||
});
|
||||
const r = await resolveCredential();
|
||||
expect(r.type).toBe("api_key");
|
||||
if (r.type === "api_key") expect(r.key).toBe("fallback");
|
||||
});
|
||||
|
||||
it("rejects HEYGEN_API_KEY containing CRLF (header-injection guard)", async () => {
|
||||
process.env["HEYGEN_API_KEY"] = "hg_x\r\nX-Evil: 1";
|
||||
await expect(resolveCredential()).rejects.toSatisfy((err) => {
|
||||
return isAuthError(err) && (err as { code: string }).code === "INVALID_STORE";
|
||||
});
|
||||
});
|
||||
|
||||
it("throws ErrNotConfigured when nothing is configured", async () => {
|
||||
await expect(resolveCredential()).rejects.toSatisfy((err) => {
|
||||
return isAuthError(err) && (err as { code: string }).code === "NOT_CONFIGURED";
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies legacy plaintext file source", async () => {
|
||||
const path = join(dir, "credentials");
|
||||
await fs.writeFile(path, "hg_legacy_key", { mode: 0o600 });
|
||||
const r = await resolveCredential();
|
||||
expect(r.type).toBe("api_key");
|
||||
if (r.type === "api_key") {
|
||||
expect(r.key).toBe("hg_legacy_key");
|
||||
expect(r.source).toBe("file_legacy");
|
||||
}
|
||||
});
|
||||
|
||||
it("tryResolveCredential returns null when not configured", async () => {
|
||||
expect(await tryResolveCredential()).toBeNull();
|
||||
});
|
||||
|
||||
it("tryResolveCredential surfaces broken-file errors", async () => {
|
||||
const path = join(dir, "credentials");
|
||||
await fs.writeFile(path, "{not valid", { mode: 0o600 });
|
||||
await expect(tryResolveCredential()).rejects.toSatisfy((err) => isAuthError(err));
|
||||
});
|
||||
|
||||
it("uses injected now() for expiry decisions", async () => {
|
||||
// expires_at is one hour ago in real time. Injecting `now` two
|
||||
// hours in the past makes the token appear fresh (still valid for
|
||||
// another hour), so the resolver should NOT mark it refreshable.
|
||||
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
await writeStore({
|
||||
oauth: { access_token: "at", refresh_token: "rt", expires_at: oneHourAgo },
|
||||
});
|
||||
const r = await resolveCredential({
|
||||
now: () => new Date(Date.now() - 2 * 60 * 60 * 1000),
|
||||
});
|
||||
expect(r.type).toBe("oauth");
|
||||
if (r.type === "oauth") {
|
||||
expect(r.access_token).toBe("at");
|
||||
expect(r.refreshable).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Chain resolver for HeyGen 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/credentials` (JSON) — unexpired OAuth, else api_key
|
||||
*
|
||||
* Absent sources fall through. A broken file (parse error, bad shape)
|
||||
* surfaces immediately as `ErrInvalidStore` — silently falling back
|
||||
* would mask user config bugs.
|
||||
*
|
||||
* Expiry policy: an OAuth access_token whose `expires_at` is in the
|
||||
* past (60s skew) is considered expired. If a `refresh_token` is also
|
||||
* present, callers can still use it via `refreshable: true`. Otherwise
|
||||
* the api_key (if any) wins.
|
||||
*/
|
||||
|
||||
import { isHeaderSafe, readStore } from "./store.js";
|
||||
import { ErrInvalidStore, ErrNotConfigured, isAuthError } from "./errors.js";
|
||||
|
||||
type CredentialSource = "env" | "env_alias" | "file_json" | "file_legacy";
|
||||
|
||||
interface ApiKeyCredential {
|
||||
type: "api_key";
|
||||
key: string;
|
||||
source: CredentialSource;
|
||||
}
|
||||
|
||||
interface OAuthCredential {
|
||||
type: "oauth";
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_at?: Date;
|
||||
scope?: string;
|
||||
source: CredentialSource;
|
||||
/** True when the access_token is expired but a refresh_token exists. */
|
||||
refreshable: boolean;
|
||||
}
|
||||
|
||||
export type ResolvedCredential = ApiKeyCredential | OAuthCredential;
|
||||
|
||||
const EXPIRY_SKEW_MS = 60 * 1000;
|
||||
|
||||
export interface ResolveOptions {
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
export async function resolveCredential(opts: ResolveOptions = {}): Promise<ResolvedCredential> {
|
||||
const now = (opts.now ?? (() => new Date()))();
|
||||
|
||||
const heygenEnv = process.env["HEYGEN_API_KEY"];
|
||||
if (heygenEnv && heygenEnv.length > 0) {
|
||||
if (!isHeaderSafe(heygenEnv)) {
|
||||
throw ErrInvalidStore("HEYGEN_API_KEY contains control characters");
|
||||
}
|
||||
return { type: "api_key", key: heygenEnv, source: "env" };
|
||||
}
|
||||
|
||||
const hfEnv = process.env["HYPERFRAMES_API_KEY"];
|
||||
if (hfEnv && hfEnv.length > 0) {
|
||||
if (!isHeaderSafe(hfEnv)) {
|
||||
throw ErrInvalidStore("HYPERFRAMES_API_KEY contains control characters");
|
||||
}
|
||||
return { type: "api_key", key: hfEnv, source: "env_alias" };
|
||||
}
|
||||
|
||||
const { credentials, source } = await readStore();
|
||||
if (source === "absent") throw ErrNotConfigured();
|
||||
|
||||
const fileSource: CredentialSource = source === "file_legacy" ? "file_legacy" : "file_json";
|
||||
|
||||
if (credentials.oauth) {
|
||||
const oauth = pickOAuth(credentials.oauth, now, fileSource);
|
||||
if (oauth) return oauth;
|
||||
}
|
||||
if (credentials.api_key) {
|
||||
return { type: "api_key", key: credentials.api_key, source: fileSource };
|
||||
}
|
||||
throw ErrNotConfigured();
|
||||
}
|
||||
|
||||
/** Like `resolveCredential` but returns `null` instead of throwing `NOT_CONFIGURED`. */
|
||||
export async function tryResolveCredential(
|
||||
opts: ResolveOptions = {},
|
||||
): Promise<ResolvedCredential | null> {
|
||||
try {
|
||||
return await resolveCredential(opts);
|
||||
} catch (err) {
|
||||
if (isAuthError(err) && err.code === "NOT_CONFIGURED") {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function pickOAuth(
|
||||
tokens: NonNullable<Awaited<ReturnType<typeof readStore>>["credentials"]["oauth"]>,
|
||||
now: Date,
|
||||
source: CredentialSource,
|
||||
): OAuthCredential | null {
|
||||
const expiresAt = parseDate(tokens.expires_at);
|
||||
const expired = expiresAt !== undefined && expiresAt.getTime() - EXPIRY_SKEW_MS < now.getTime();
|
||||
|
||||
if (expired && !tokens.refresh_token) return null;
|
||||
|
||||
const out: OAuthCredential = {
|
||||
type: "oauth",
|
||||
access_token: tokens.access_token,
|
||||
source,
|
||||
refreshable: expired && tokens.refresh_token !== undefined,
|
||||
};
|
||||
if (tokens.refresh_token) out.refresh_token = tokens.refresh_token;
|
||||
if (expiresAt) out.expires_at = expiresAt;
|
||||
if (tokens.scope) out.scope = tokens.scope;
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseDate(s: string | undefined): Date | undefined {
|
||||
if (!s) return undefined;
|
||||
const d = new Date(s);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { isAuthError } from "./errors.js";
|
||||
import { clearOAuth, deleteStore, readStore, writeStore, type Credentials } from "./store.js";
|
||||
|
||||
async function makeTmpDir(): Promise<string> {
|
||||
return fs.mkdtemp(join(tmpdir(), "hf-auth-store-"));
|
||||
}
|
||||
|
||||
// POSIX file modes don't apply on Windows — `fs.chmod` only toggles the
|
||||
// read-only bit there, so `stat.mode & 0o777` reports 0o666/0o444
|
||||
// regardless of what we requested. Skip the mode assertions on win32;
|
||||
// the 0600/0700 hardening is a Unix concern.
|
||||
const IS_POSIX = process.platform !== "win32";
|
||||
|
||||
describe("auth/store", () => {
|
||||
let dir: string;
|
||||
let path: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await makeTmpDir();
|
||||
path = join(dir, "credentials");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns absent when the file does not exist", async () => {
|
||||
const result = await readStore(path);
|
||||
expect(result).toEqual({ credentials: {}, source: "absent" });
|
||||
});
|
||||
|
||||
it("round-trips api_key only", async () => {
|
||||
const creds: Credentials = { api_key: "hg_test_abc" };
|
||||
await writeStore(creds, path);
|
||||
const result = await readStore(path);
|
||||
expect(result.source).toBe("file_json");
|
||||
expect(result.credentials).toEqual(creds);
|
||||
});
|
||||
|
||||
it("round-trips oauth tokens", async () => {
|
||||
const creds: Credentials = {
|
||||
oauth: {
|
||||
access_token: "at_123",
|
||||
refresh_token: "rt_456",
|
||||
expires_at: "2026-06-25T12:00:00.000Z",
|
||||
scope: "openid profile",
|
||||
token_type: "Bearer",
|
||||
},
|
||||
};
|
||||
await writeStore(creds, path);
|
||||
const result = await readStore(path);
|
||||
expect(result.credentials).toEqual(creds);
|
||||
});
|
||||
|
||||
it("round-trips both api_key and oauth", async () => {
|
||||
const creds: Credentials = {
|
||||
api_key: "hg_test_abc",
|
||||
oauth: { access_token: "at_123" },
|
||||
};
|
||||
await writeStore(creds, path);
|
||||
const result = await readStore(path);
|
||||
expect(result.credentials.api_key).toBe("hg_test_abc");
|
||||
expect(result.credentials.oauth?.access_token).toBe("at_123");
|
||||
});
|
||||
|
||||
it("reads legacy one-line plaintext format", async () => {
|
||||
await fs.writeFile(path, "hg_legacy_key\n", { mode: 0o600 });
|
||||
const result = await readStore(path);
|
||||
expect(result.source).toBe("file_legacy");
|
||||
expect(result.credentials.api_key).toBe("hg_legacy_key");
|
||||
});
|
||||
|
||||
it("treats empty file as absent", async () => {
|
||||
await fs.writeFile(path, "", { mode: 0o600 });
|
||||
const result = await readStore(path);
|
||||
expect(result.source).toBe("absent");
|
||||
});
|
||||
|
||||
it("throws ErrInvalidStore on garbage JSON", async () => {
|
||||
await fs.writeFile(path, "{not valid json", { mode: 0o600 });
|
||||
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
|
||||
});
|
||||
|
||||
it("throws ErrInvalidStore on multi-line non-JSON content", async () => {
|
||||
await fs.writeFile(path, "not\na\nkey", { mode: 0o600 });
|
||||
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
|
||||
});
|
||||
|
||||
it.skipIf(!IS_POSIX)("writes file 0600 and dir 0700", async () => {
|
||||
const nested = join(dir, "sub", "deeper");
|
||||
const p = join(nested, "credentials");
|
||||
await writeStore({ api_key: "hg_x" }, p);
|
||||
expect((await fs.stat(p)).mode & 0o777).toBe(0o600);
|
||||
expect((await fs.stat(nested)).mode & 0o777).toBe(0o700);
|
||||
});
|
||||
|
||||
it("preserves content across overwrites", async () => {
|
||||
await writeStore({ api_key: "first" }, path);
|
||||
await writeStore({ api_key: "second" }, path);
|
||||
if (IS_POSIX) {
|
||||
expect((await fs.stat(path)).mode & 0o777).toBe(0o600);
|
||||
}
|
||||
const result = await readStore(path);
|
||||
expect(result.credentials.api_key).toBe("second");
|
||||
});
|
||||
|
||||
it("rejects empty-string api_key", async () => {
|
||||
await fs.writeFile(path, JSON.stringify({ api_key: "" }), { mode: 0o600 });
|
||||
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
|
||||
});
|
||||
|
||||
it("rejects api_key with CR/LF (header-injection guard)", async () => {
|
||||
await fs.writeFile(path, JSON.stringify({ api_key: "hg_x\r\nX-Evil: foo" }), { mode: 0o600 });
|
||||
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
|
||||
});
|
||||
|
||||
it("strips oauth fields containing CR/LF rather than crashing later", async () => {
|
||||
await fs.writeFile(
|
||||
path,
|
||||
JSON.stringify({
|
||||
oauth: {
|
||||
access_token: "good_at",
|
||||
refresh_token: "bad_rt\r\nX-Smuggle: 1",
|
||||
},
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const result = await readStore(path);
|
||||
expect(result.credentials.oauth?.access_token).toBe("good_at");
|
||||
expect(result.credentials.oauth?.refresh_token).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects access_token containing CR/LF (header-injection guard)", async () => {
|
||||
await fs.writeFile(path, JSON.stringify({ oauth: { access_token: "at\r\nX-Evil: 1" } }), {
|
||||
mode: 0o600,
|
||||
});
|
||||
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
|
||||
});
|
||||
|
||||
it("accepts a legacy plaintext key of any HeyGen key format", async () => {
|
||||
// Real HeyGen keys come in multiple formats (`sk_V2_…`, `hg_…`,
|
||||
// partner keys, etc.). The CLI doesn't shape-check — the backend
|
||||
// does. Any single-line printable non-empty value is accepted as
|
||||
// a legacy key here; the next /v3/users/me call decides validity.
|
||||
await fs.writeFile(path, "sk_V2_hgu_kVzzCxfI3cT_Yi96MxT2Ki6UamtWxyP7oOIPqsxaFHqN", {
|
||||
mode: 0o600,
|
||||
});
|
||||
const result = await readStore(path);
|
||||
expect(result.source).toBe("file_legacy");
|
||||
expect(result.credentials.api_key).toBe(
|
||||
"sk_V2_hgu_kVzzCxfI3cT_Yi96MxT2Ki6UamtWxyP7oOIPqsxaFHqN",
|
||||
);
|
||||
});
|
||||
|
||||
it("still rejects plaintext that contains a space (not a credential shape)", async () => {
|
||||
await fs.writeFile(path, "hello world this is not a key", { mode: 0o600 });
|
||||
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
|
||||
});
|
||||
|
||||
it("still rejects too-short plaintext", async () => {
|
||||
await fs.writeFile(path, "tiny", { mode: 0o600 });
|
||||
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
|
||||
});
|
||||
|
||||
it("rejects oauth without access_token", async () => {
|
||||
await fs.writeFile(path, JSON.stringify({ oauth: { refresh_token: "rt" } }), {
|
||||
mode: 0o600,
|
||||
});
|
||||
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
|
||||
});
|
||||
|
||||
it("drops unknown top-level keys", async () => {
|
||||
await fs.writeFile(path, JSON.stringify({ api_key: "hg_x", future_field: { stuff: 1 } }), {
|
||||
mode: 0o600,
|
||||
});
|
||||
const result = await readStore(path);
|
||||
expect(result.credentials).toEqual({ api_key: "hg_x" });
|
||||
});
|
||||
|
||||
it("deleteStore is idempotent", async () => {
|
||||
await writeStore({ api_key: "hg_x" }, path);
|
||||
await deleteStore(path);
|
||||
await deleteStore(path);
|
||||
await expect(fs.access(path)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("clearOAuth removes only the oauth field", async () => {
|
||||
await writeStore({ api_key: "hg_keep", oauth: { access_token: "drop_me" } }, path);
|
||||
await clearOAuth(path);
|
||||
const result = await readStore(path);
|
||||
expect(result.credentials.oauth).toBeUndefined();
|
||||
expect(result.credentials.api_key).toBe("hg_keep");
|
||||
});
|
||||
|
||||
it("clearOAuth removes the whole file when no api_key remains", async () => {
|
||||
await writeStore({ oauth: { access_token: "only" } }, path);
|
||||
await clearOAuth(path);
|
||||
await expect(fs.access(path)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("clearOAuth is a no-op when file is absent", async () => {
|
||||
await clearOAuth(path);
|
||||
await expect(fs.access(path)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Read/write the shared `~/.heygen/credentials` file (JSON contents,
|
||||
* no `.json` extension — the path matches heygen-cli).
|
||||
*
|
||||
* Current format:
|
||||
* {
|
||||
* "api_key": "hg_...",
|
||||
* "oauth": {
|
||||
* "access_token": "...",
|
||||
* "refresh_token": "...",
|
||||
* "expires_at": "2026-06-25T12:00:00Z",
|
||||
* "scope": "openid profile",
|
||||
* "token_type": "Bearer"
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { promises as fs } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { credentialPath } from "./paths.js";
|
||||
import { ErrInvalidStore } from "./errors.js";
|
||||
|
||||
const FILE_MODE = 0o600;
|
||||
const DIR_MODE = 0o700;
|
||||
|
||||
export interface OAuthTokens {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
/** ISO-8601 UTC. */
|
||||
expires_at?: string;
|
||||
scope?: string;
|
||||
token_type?: string;
|
||||
}
|
||||
|
||||
export interface Credentials {
|
||||
api_key?: string;
|
||||
oauth?: OAuthTokens;
|
||||
}
|
||||
|
||||
export type StoreSource = "file_json" | "file_legacy" | "absent";
|
||||
|
||||
export interface ReadResult {
|
||||
credentials: Credentials;
|
||||
source: StoreSource;
|
||||
}
|
||||
|
||||
export async function readStore(path = credentialPath()): Promise<ReadResult> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(path, "utf8");
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return { credentials: {}, source: "absent" };
|
||||
}
|
||||
throw ErrInvalidStore(`unable to read ${path}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length === 0) return { credentials: {}, source: "absent" };
|
||||
|
||||
if (trimmed.startsWith("{")) {
|
||||
return { credentials: parseJsonStore(trimmed), source: "file_json" };
|
||||
}
|
||||
|
||||
if (looksLikeApiKey(trimmed)) {
|
||||
return { credentials: { api_key: trimmed }, source: "file_legacy" };
|
||||
}
|
||||
|
||||
throw ErrInvalidStore("file is not JSON and does not look like a plain API key");
|
||||
}
|
||||
|
||||
export async function writeStore(credentials: Credentials, path = credentialPath()): Promise<void> {
|
||||
await ensureDir(dirname(path));
|
||||
const body = JSON.stringify(serializeCredentials(credentials), null, 2);
|
||||
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
||||
await fs.writeFile(tmp, `${body}\n`, { mode: FILE_MODE, encoding: "utf8" });
|
||||
// `mode` on `writeFile` is masked by umask and only applies on file
|
||||
// creation — explicit chmod is the only reliable way to land on 0600.
|
||||
// `rename` moves the (already-0600) tmp inode over the destination,
|
||||
// so the final file carries the tmp's mode; no post-rename chmod
|
||||
// needed even when overwriting a looser-permissioned file.
|
||||
await fs.chmod(tmp, FILE_MODE);
|
||||
await fs.rename(tmp, path);
|
||||
}
|
||||
|
||||
export async function deleteStore(path = credentialPath()): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(path);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove only the `oauth` block. Used by `auth logout --keep-api-key`. */
|
||||
export async function clearOAuth(path = credentialPath()): Promise<void> {
|
||||
const { credentials, source } = await readStore(path);
|
||||
if (source === "absent" || !credentials.oauth) return;
|
||||
if (!credentials.api_key) {
|
||||
await deleteStore(path);
|
||||
return;
|
||||
}
|
||||
await writeStore({ api_key: credentials.api_key }, path);
|
||||
}
|
||||
|
||||
async function ensureDir(dir: string): Promise<void> {
|
||||
try {
|
||||
const stat = await fs.stat(dir);
|
||||
if (!stat.isDirectory()) {
|
||||
throw ErrInvalidStore(`${dir} exists and is not a directory`);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
||||
await fs.mkdir(dir, { recursive: true, mode: DIR_MODE });
|
||||
}
|
||||
try {
|
||||
await fs.chmod(dir, DIR_MODE);
|
||||
} catch {
|
||||
/* perm-less filesystems are fine */
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonStore(text: string): Credentials {
|
||||
const obj = parseJsonObject(text, "credential file root");
|
||||
const out: Credentials = {};
|
||||
const apiKey = pickRequiredStringOrAbsent(obj, "api_key", "api_key");
|
||||
if (apiKey !== undefined) {
|
||||
if (!isHeaderSafe(apiKey)) {
|
||||
throw ErrInvalidStore("api_key must not contain control characters");
|
||||
}
|
||||
out.api_key = apiKey;
|
||||
}
|
||||
if (obj["oauth"] !== undefined && obj["oauth"] !== null) {
|
||||
out.oauth = parseOAuth(obj["oauth"]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseOAuth(raw: unknown): OAuthTokens {
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
throw ErrInvalidStore("oauth must be a JSON object");
|
||||
}
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const accessToken = pickHeaderSafeString(obj, "access_token");
|
||||
if (!accessToken) {
|
||||
throw ErrInvalidStore("oauth.access_token must be a non-empty string with no control chars");
|
||||
}
|
||||
const out: OAuthTokens = { access_token: accessToken };
|
||||
const refresh = pickHeaderSafeString(obj, "refresh_token");
|
||||
if (refresh) out.refresh_token = refresh;
|
||||
const exp = pickNonEmptyString(obj, "expires_at");
|
||||
if (exp) out.expires_at = exp;
|
||||
const scope = pickNonEmptyString(obj, "scope");
|
||||
if (scope) out.scope = scope;
|
||||
const tokenType = pickNonEmptyString(obj, "token_type");
|
||||
if (tokenType) out.token_type = tokenType;
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseJsonObject(text: string, label: string): Record<string, unknown> {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(text);
|
||||
} catch (err) {
|
||||
throw ErrInvalidStore(`invalid JSON: ${(err as Error).message}`);
|
||||
}
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
throw ErrInvalidStore(`${label} must be a JSON object`);
|
||||
}
|
||||
return raw as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function pickNonEmptyString(obj: Record<string, unknown>, key: string): string | undefined {
|
||||
const v = obj[key];
|
||||
return typeof v === "string" && v.length > 0 ? v : undefined;
|
||||
}
|
||||
|
||||
/** Like `pickNonEmptyString` but rejects values containing control chars. */
|
||||
function pickHeaderSafeString(obj: Record<string, unknown>, key: string): string | undefined {
|
||||
const v = pickNonEmptyString(obj, key);
|
||||
return v !== undefined && isHeaderSafe(v) ? v : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header-safety check for credential strings: reject any string with
|
||||
* CR, LF, NUL, or other C0 control characters. Without this, a
|
||||
* malicious credentials.json could smuggle extra request headers via
|
||||
* `Authorization` / `x-api-key` (RFC 7230 header injection).
|
||||
*/
|
||||
export function isHeaderSafe(s: string): boolean {
|
||||
// Reject U+0000-U+001F (C0 controls) and U+007F (DEL) — bytes that
|
||||
// aren't allowed in HTTP header values. Using charCodeAt avoids
|
||||
// embedding control characters in regex source (lint requirement).
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s.charCodeAt(i);
|
||||
if (c < 0x20 || c === 0x7f) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict variant: returns the string when present and non-empty,
|
||||
* `undefined` when the key is absent or null, and throws when the
|
||||
* field is present-but-invalid (wrong type or empty string).
|
||||
*/
|
||||
function pickRequiredStringOrAbsent(
|
||||
obj: Record<string, unknown>,
|
||||
key: string,
|
||||
errorLabel: string,
|
||||
): string | undefined {
|
||||
const v = obj[key];
|
||||
if (v === undefined || v === null) return undefined;
|
||||
if (typeof v !== "string" || v.length === 0) {
|
||||
throw ErrInvalidStore(`${errorLabel} must be a non-empty string`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function serializeCredentials(c: Credentials): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
if (c.api_key) out["api_key"] = c.api_key;
|
||||
if (c.oauth) {
|
||||
const oauth: Record<string, unknown> = { access_token: c.oauth.access_token };
|
||||
if (c.oauth.refresh_token) oauth["refresh_token"] = c.oauth.refresh_token;
|
||||
if (c.oauth.expires_at) oauth["expires_at"] = c.oauth.expires_at;
|
||||
if (c.oauth.scope) oauth["scope"] = c.oauth.scope;
|
||||
if (c.oauth.token_type) oauth["token_type"] = c.oauth.token_type;
|
||||
out["oauth"] = oauth;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy-plaintext heuristic. HeyGen API keys come in multiple formats
|
||||
* (`sk_V2_…`, historic `hg_…`, partner keys, etc.) and the CLI should
|
||||
* NOT shape-check them — the backend's `/v3/users/me` is the source of
|
||||
* truth and the existing `auth login` rollback handles bad keys cleanly.
|
||||
* We only require: a single line, printable, of reasonable length, and
|
||||
* header-safe (no CR/LF). JSON files are detected separately by the
|
||||
* leading `{`, so this path can't swallow a JSON fragment.
|
||||
*/
|
||||
function looksLikeApiKey(s: string): boolean {
|
||||
if (s.length < 8) return false;
|
||||
if (!isHeaderSafe(s)) return false;
|
||||
// Single line of printable ASCII (excluding space, since real keys
|
||||
// don't contain spaces — a space-bearing blob is almost certainly
|
||||
// not a credential).
|
||||
return /^[!-~]+$/.test(s);
|
||||
}
|
||||
@@ -124,6 +124,7 @@ const subCommands = {
|
||||
snapshot: () => import("./commands/snapshot.js").then((m) => m.default),
|
||||
capture: () => import("./commands/capture.js").then((m) => m.default),
|
||||
lambda: () => import("./commands/lambda.js").then((m) => m.default),
|
||||
auth: () => import("./commands/auth.js").then((m) => m.default),
|
||||
};
|
||||
|
||||
const main = defineCommand({
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* `hyperframes auth` — credential management for HeyGen.
|
||||
*
|
||||
* Subverbs:
|
||||
* - `login` sign in via API key (OAuth coming next)
|
||||
* - `status` show the active credential + identity
|
||||
* - `logout` remove the stored credential
|
||||
*
|
||||
* Each subverb lives in `./auth/<name>.ts` and is dynamic-imported on
|
||||
* demand. Keeps cold-start fast and lets the auth library load only
|
||||
* when the user is doing auth work.
|
||||
*/
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
|
||||
export const examples: Example[] = [
|
||||
["Save an API key (interactive)", "hyperframes auth login --api-key"],
|
||||
["Save an API key from stdin", "echo $HEYGEN_API_KEY | hyperframes auth login --api-key"],
|
||||
["Check who you're signed in as", "hyperframes auth status"],
|
||||
["Sign out", "hyperframes auth logout"],
|
||||
];
|
||||
|
||||
const HELP = `
|
||||
${c.bold("hyperframes auth")} ${c.dim("<subcommand> [args]")}
|
||||
|
||||
Manage HeyGen credentials. Credentials live in
|
||||
${c.accent("~/.heygen/credentials")} and are shared with heygen-cli.
|
||||
|
||||
${c.bold("SUBCOMMANDS:")}
|
||||
${c.accent("login")} ${c.dim("Save a HeyGen API key (--api-key). OAuth login lands in a follow-up.")}
|
||||
${c.accent("status")} ${c.dim("Show the active credential's source, type, and identity.")}
|
||||
${c.accent("logout")} ${c.dim("Remove the stored credential (--keep-api-key for OAuth-only).")}
|
||||
|
||||
${c.bold("ENV VARS:")}
|
||||
${c.accent("HEYGEN_API_KEY")} Override the stored credential.
|
||||
${c.accent("HYPERFRAMES_API_KEY")} Alias for HEYGEN_API_KEY.
|
||||
${c.accent("HEYGEN_API_URL")} Override the API base URL (default https://api.heygen.com).
|
||||
${c.accent("HEYGEN_CONFIG_DIR")} Override the credentials directory (default ~/.heygen).
|
||||
`;
|
||||
|
||||
export default defineCommand({
|
||||
meta: { name: "auth", description: "Sign in to HeyGen and manage credentials" },
|
||||
subCommands: {
|
||||
login: () => import("./auth/login.js").then((m) => m.default),
|
||||
status: () => import("./auth/status.js").then((m) => m.default),
|
||||
logout: () => import("./auth/logout.js").then((m) => m.default),
|
||||
},
|
||||
async run({ args }) {
|
||||
if (!args._?.[0]) console.log(HELP);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { readStore, writeStore } from "../../auth/store.js";
|
||||
|
||||
// Mock only AuthClient — keep the real store/resolver so the test
|
||||
// exercises the actual on-disk rollback behavior. `verifyResult`
|
||||
// controls what `getCurrentUser` does per test.
|
||||
const verifyState = vi.hoisted(() => ({ reject: false }));
|
||||
|
||||
vi.mock("../../auth/index.js", async (orig) => {
|
||||
const actual = await orig<typeof import("../../auth/index.js")>();
|
||||
class MockAuthClient {
|
||||
async getCurrentUser(): Promise<{ email: string }> {
|
||||
if (verifyState.reject) {
|
||||
const { ErrUnauthenticated: rej } = await import("../../auth/errors.js");
|
||||
throw rej("invalid key");
|
||||
}
|
||||
return { email: "alice@example.com" };
|
||||
}
|
||||
}
|
||||
return { ...actual, AuthClient: MockAuthClient };
|
||||
});
|
||||
|
||||
const ENV_KEYS = ["HEYGEN_API_KEY", "HYPERFRAMES_API_KEY", "HEYGEN_CONFIG_DIR"] as const;
|
||||
|
||||
describe("auth login --api-key rollback", () => {
|
||||
let dir: string;
|
||||
const saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>> = {};
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await fs.mkdtemp(join(tmpdir(), "hf-login-"));
|
||||
for (const k of ENV_KEYS) {
|
||||
saved[k] = process.env[k];
|
||||
delete process.env[k];
|
||||
}
|
||||
process.env["HEYGEN_CONFIG_DIR"] = dir;
|
||||
verifyState.reject = false;
|
||||
// process.exit throws so we can assert the post-rollback state.
|
||||
vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null) => {
|
||||
throw new Error(`process.exit:${code ?? 0}`);
|
||||
}) as never);
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
for (const k of ENV_KEYS) {
|
||||
const v = saved[k];
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function runLogin(apiKey: string): Promise<void> {
|
||||
const cmd = (await import("./login.js")).default;
|
||||
// citty command run only reads `args` here.
|
||||
await (cmd.run as (ctx: { args: Record<string, unknown> }) => Promise<void>)({
|
||||
args: { "api-key": apiKey },
|
||||
});
|
||||
}
|
||||
|
||||
it("removes the rejected key on a failed FIRST login (no prior credential)", async () => {
|
||||
verifyState.reject = true;
|
||||
await expect(runLogin("hg_badkey123")).rejects.toThrow(/process\.exit:1/);
|
||||
|
||||
// The store must NOT retain the rejected key — otherwise the next
|
||||
// command would silently resolve a known-bad credential.
|
||||
const { source } = await readStore();
|
||||
expect(source).toBe("absent");
|
||||
});
|
||||
|
||||
it("restores the previous credential on a failed re-login", async () => {
|
||||
await writeStore({ api_key: "hg_previous_good" });
|
||||
verifyState.reject = true;
|
||||
await expect(runLogin("hg_newbadkey99")).rejects.toThrow(/process\.exit:1/);
|
||||
|
||||
const { credentials } = await readStore();
|
||||
expect(credentials.api_key).toBe("hg_previous_good");
|
||||
});
|
||||
|
||||
it("persists the key on a successful login", async () => {
|
||||
verifyState.reject = false;
|
||||
await runLogin("hg_goodkey456");
|
||||
const { credentials } = await readStore();
|
||||
expect(credentials.api_key).toBe("hg_goodkey456");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* `hyperframes auth login` — write a HeyGen credential to
|
||||
* `~/.heygen/credentials`.
|
||||
*
|
||||
* This first cut ships the `--api-key` path only. Running `auth login`
|
||||
* without `--api-key` prints a pointer at the OAuth PKCE work that
|
||||
* lands in a follow-up.
|
||||
*
|
||||
* Inputs:
|
||||
* - `--api-key=<value>` — take the value inline (note: may leak into
|
||||
* shell history).
|
||||
* - `--api-key` with stdin piped — read one line from stdin.
|
||||
* - `--api-key` interactive — `@clack/prompts` password input.
|
||||
*
|
||||
* Write semantics:
|
||||
* - Read the existing credential file first; preserve any `oauth`
|
||||
* block so saving a new API key doesn't wipe an OAuth session.
|
||||
* - Sanity-check that the input is non-empty and header-safe (no
|
||||
* CR/LF) before touching disk. The backend's `/v3/users/me` is
|
||||
* the source of truth for whether the key is actually valid —
|
||||
* we do NOT shape-check the prefix (real keys come in multiple
|
||||
* formats: `sk_V2_…`, `hg_…`, partner keys, etc.).
|
||||
* - Verify via `GET /v3/users/me`. On 401, roll back to the previous
|
||||
* state — leaving a confirmed-invalid key on disk would silently
|
||||
* break subsequent commands. On other errors (network blip, 5xx)
|
||||
* keep the new key so retries don't require re-typing.
|
||||
*/
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
import { stdin as input } from "node:process";
|
||||
import {
|
||||
AuthClient,
|
||||
deleteStore,
|
||||
isAuthError,
|
||||
isHeaderSafe,
|
||||
readStore,
|
||||
writeStore,
|
||||
type Credentials,
|
||||
} from "../../auth/index.js";
|
||||
import { c } from "../../ui/colors.js";
|
||||
|
||||
const STDIN_TIMEOUT_MS = 30_000;
|
||||
// Smallest plausible length for a real API key. We don't validate the
|
||||
// prefix or character set — the backend's /v3/users/me is the source
|
||||
// of truth and rolls back on rejection. The only must-check is
|
||||
// header-safety (CR/LF), which `isHeaderSafe` covers.
|
||||
const MIN_KEY_LENGTH = 8;
|
||||
|
||||
export default defineCommand({
|
||||
meta: {
|
||||
name: "login",
|
||||
description: "Sign in to HeyGen by saving an API key (OAuth coming soon)",
|
||||
},
|
||||
args: {
|
||||
"api-key": {
|
||||
type: "string",
|
||||
description:
|
||||
"API key value. Pass `--api-key` with no value to read from stdin or interactively.",
|
||||
},
|
||||
},
|
||||
// fallow-ignore-next-line complexity
|
||||
async run({ args }) {
|
||||
const inlineKey = args["api-key"];
|
||||
if (inlineKey === undefined) {
|
||||
printOAuthPlaceholder();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const key = await collectApiKey(inlineKey);
|
||||
if (!key) {
|
||||
console.error(c.error("No API key provided."));
|
||||
process.exit(1);
|
||||
}
|
||||
if (!isHeaderSafe(key)) {
|
||||
// CR/LF in the value would smuggle headers when the key is sent
|
||||
// via `x-api-key`. The backend handles "wrong key" itself, but
|
||||
// header-injection has to be caught here.
|
||||
console.error(c.error("API key must not contain newline or control characters."));
|
||||
process.exit(1);
|
||||
}
|
||||
if (key.length < MIN_KEY_LENGTH) {
|
||||
console.error(c.error(`API key looks too short (got ${key.length} chars).`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const previous = await snapshotStore();
|
||||
const next: Credentials = { ...previous, api_key: key };
|
||||
await writeStore(next);
|
||||
|
||||
const verifyOk = await verifyAndReport(key);
|
||||
if (!verifyOk) {
|
||||
await rollback(previous);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function printOAuthPlaceholder(): void {
|
||||
console.error(
|
||||
`${c.warn("Browser-based login isn't ready yet.")} ` +
|
||||
`Re-run with ${c.accent("--api-key")} to save an API key, ` +
|
||||
`or pipe one in:\n` +
|
||||
` ${c.accent("echo $HEYGEN_API_KEY | hyperframes auth login --api-key")}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function snapshotStore(): Promise<Credentials> {
|
||||
try {
|
||||
const { credentials } = await readStore();
|
||||
return { ...credentials };
|
||||
} catch {
|
||||
// Existing file is unreadable; treat as empty so the new key still
|
||||
// lands cleanly. The previous bytes are lost either way.
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function rollback(previous: Credentials): Promise<void> {
|
||||
try {
|
||||
if (previous.api_key || previous.oauth) {
|
||||
await writeStore(previous);
|
||||
console.error(c.dim("Rolled back to the previous credential."));
|
||||
} else {
|
||||
// No prior credential — restore true absence. Leaving the
|
||||
// rejected key on disk would make the next `auth status` /
|
||||
// command silently resolve a known-bad key.
|
||||
await deleteStore();
|
||||
console.error(c.dim("Removed the rejected credential."));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(c.error(`Failed to roll back: ${(err as Error).message}`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` on successful verify, `false` on a 401. Other errors
|
||||
* (network blip, 5xx) bubble out — the caller leaves the new key in
|
||||
* place since the issue is transient.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
async function verifyAndReport(key: string): Promise<boolean> {
|
||||
const client = new AuthClient();
|
||||
try {
|
||||
const user = await client.getCurrentUser({ type: "api_key", key, source: "file_json" });
|
||||
const identity = user.email ?? user.username ?? "(unknown user)";
|
||||
console.log(c.success(`✓ API key saved. Authenticated as ${identity}.`));
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (isAuthError(err) && err.code === "UNAUTHENTICATED") {
|
||||
console.error(
|
||||
`${c.warn("HeyGen rejected the API key.")}\n` +
|
||||
` ${c.dim(err.message)}\n` +
|
||||
`Run ${c.accent("hyperframes auth login --api-key")} again with a valid key.`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Citty's arg type for `--api-key` is `string`, so:
|
||||
* - `--api-key=hg_x` → `"hg_x"`
|
||||
* - `--api-key ""` / `--api-key` with no value → `""` → fall through
|
||||
* to stdin/prompt.
|
||||
*/
|
||||
async function collectApiKey(inline: string): Promise<string> {
|
||||
if (inline.length > 0) return inline.trim();
|
||||
if (!input.isTTY) {
|
||||
return (await readAllWithTimeout(input, STDIN_TIMEOUT_MS)).trim();
|
||||
}
|
||||
return await promptForKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all of stdin, or bail with an empty string after `timeoutMs`.
|
||||
* Hanging forever when stdin is non-TTY but unattached (Docker `-d`,
|
||||
* some CI shells) is worse than a clear timeout.
|
||||
*/
|
||||
async function readAllWithTimeout(
|
||||
stream: NodeJS.ReadableStream,
|
||||
timeoutMs: number,
|
||||
): Promise<string> {
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error(`Timed out waiting for stdin (${timeoutMs}ms). Pipe the key explicitly.`));
|
||||
}, timeoutMs);
|
||||
stream.on("data", (chunk: Buffer | string) => {
|
||||
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
||||
});
|
||||
stream.on("end", () => {
|
||||
clearTimeout(timer);
|
||||
resolve(Buffer.concat(chunks).toString("utf8"));
|
||||
});
|
||||
stream.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function promptForKey(): Promise<string> {
|
||||
const clack = await import("@clack/prompts");
|
||||
const value = await clack.password({
|
||||
message: "Enter HeyGen API key",
|
||||
validate: (v) => {
|
||||
if (!v || v.length < MIN_KEY_LENGTH) return "API key looks too short";
|
||||
if (!isHeaderSafe(v)) return "API key must not contain newline or control characters";
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
if (clack.isCancel(value)) {
|
||||
console.error("Aborted.");
|
||||
process.exit(1);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* `hyperframes auth logout` — remove the credential file. With
|
||||
* `--keep-api-key`, only the OAuth block is cleared (no-op for
|
||||
* API-key-only stores).
|
||||
*
|
||||
* Env-only credentials (`HEYGEN_API_KEY`, `HYPERFRAMES_API_KEY`) can't
|
||||
* be cleared by this command — we tell the user to unset them.
|
||||
*/
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
import { clearOAuth, configDir, credentialPath, deleteStore } from "../../auth/index.js";
|
||||
import { c } from "../../ui/colors.js";
|
||||
|
||||
export default defineCommand({
|
||||
meta: { name: "logout", description: "Remove the stored HeyGen credential" },
|
||||
args: {
|
||||
"keep-api-key": {
|
||||
type: "boolean",
|
||||
description: "Only clear the OAuth session; preserve the API key.",
|
||||
default: false,
|
||||
},
|
||||
yes: {
|
||||
type: "boolean",
|
||||
description: "Skip the confirmation prompt.",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
warnIfEnvCredentialActive();
|
||||
const keepApiKey = Boolean(args["keep-api-key"]);
|
||||
|
||||
if (!(await ensureConfirmed(Boolean(args.yes), keepApiKey))) {
|
||||
console.log("Aborted.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (keepApiKey) {
|
||||
await clearOAuth();
|
||||
console.log(c.success("✓ OAuth session removed. API key retained."));
|
||||
return;
|
||||
}
|
||||
await deleteStore();
|
||||
console.log(c.success(`✓ Signed out. Removed ${credentialPath()}.`));
|
||||
},
|
||||
});
|
||||
|
||||
function warnIfEnvCredentialActive(): void {
|
||||
if (process.env["HEYGEN_API_KEY"] || process.env["HYPERFRAMES_API_KEY"]) {
|
||||
console.log(
|
||||
c.warn(
|
||||
"An env-var credential is active. Unset HEYGEN_API_KEY / HYPERFRAMES_API_KEY to remove it.",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureConfirmed(yes: boolean, keepApiKey: boolean): Promise<boolean> {
|
||||
if (yes) return true;
|
||||
const prompt = keepApiKey
|
||||
? `This will sign out of any active OAuth session on this machine (~/.heygen lives at ${configDir()}). Continue? [y/N] `
|
||||
: `This will sign out of HeyGen on this machine (~/.heygen lives at ${configDir()}). Continue? [y/N] `;
|
||||
return confirmInteractive(prompt);
|
||||
}
|
||||
|
||||
async function confirmInteractive(prompt: string): Promise<boolean> {
|
||||
if (!process.stdin.isTTY) return false;
|
||||
const { createInterface } = await import("node:readline");
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise<string>((resolve) => {
|
||||
rl.question(prompt, (line) => resolve(line));
|
||||
});
|
||||
rl.close();
|
||||
return /^y(es)?$/i.test(answer.trim());
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* `hyperframes auth status` — print the active credential's source,
|
||||
* type, and identity (verified against `GET /v3/users/me`).
|
||||
*
|
||||
* Exits non-zero when nothing is configured or the API rejects the
|
||||
* credential, so scripts can check "am I logged in?" with `$?`.
|
||||
*/
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
import {
|
||||
AuthClient,
|
||||
isAuthError,
|
||||
tryResolveCredential,
|
||||
type ResolvedCredential,
|
||||
type UserInfo,
|
||||
} from "../../auth/index.js";
|
||||
import { c } from "../../ui/colors.js";
|
||||
|
||||
interface VerifiedStatus {
|
||||
credential: ResolvedCredential;
|
||||
user: UserInfo | null;
|
||||
apiError: string | null;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
meta: { name: "status", description: "Show the active HeyGen credential" },
|
||||
args: {
|
||||
json: {
|
||||
type: "boolean",
|
||||
description: "Emit machine-readable JSON",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
// fallow-ignore-next-line complexity
|
||||
async run({ args }) {
|
||||
const asJson = Boolean(args.json);
|
||||
let credential;
|
||||
try {
|
||||
credential = await tryResolveCredential();
|
||||
} catch (err) {
|
||||
handleResolveError(err, asJson);
|
||||
return;
|
||||
}
|
||||
if (!credential) {
|
||||
handleUnconfigured(asJson);
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await verify(credential);
|
||||
if (asJson) printJsonStatus(status);
|
||||
else printHumanStatus(status);
|
||||
process.exit(status.apiError ? 1 : 0);
|
||||
},
|
||||
});
|
||||
|
||||
function handleUnconfigured(asJson: boolean): never {
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify({ configured: false }));
|
||||
} else {
|
||||
console.log(c.warn("Not signed in to HeyGen."));
|
||||
console.log(`Run ${c.accent("hyperframes auth login --api-key")} to sign in.`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function handleResolveError(err: unknown, asJson: boolean): never {
|
||||
if (!isAuthError(err)) throw err;
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify({ configured: false, error: err.message, hint: err.hint ?? null }));
|
||||
} else {
|
||||
console.error(c.error(err.message));
|
||||
if (err.hint) console.error(c.dim(err.hint));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function verify(credential: ResolvedCredential): Promise<VerifiedStatus> {
|
||||
const client = new AuthClient();
|
||||
try {
|
||||
const user = await client.getCurrentUser(credential);
|
||||
return { credential, user, apiError: null };
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) throw err;
|
||||
return {
|
||||
credential,
|
||||
user: null,
|
||||
apiError: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function printJsonStatus(s: VerifiedStatus): void {
|
||||
const payload: Record<string, unknown> = {
|
||||
configured: true,
|
||||
source: s.credential.source,
|
||||
type: s.credential.type,
|
||||
user: s.user,
|
||||
api_error: s.apiError,
|
||||
};
|
||||
if (s.credential.type === "oauth") {
|
||||
payload["expires_at"] = s.credential.expires_at?.toISOString() ?? null;
|
||||
payload["refreshable"] = s.credential.refreshable;
|
||||
payload["scope"] = s.credential.scope ?? null;
|
||||
}
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
||||
function printHumanStatus(s: VerifiedStatus): void {
|
||||
const rows = collectStatusRows(s);
|
||||
for (const [label, value] of rows) console.log(`${c.bold(label)} ${value}`);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function collectStatusRows(s: VerifiedStatus): [string, string][] {
|
||||
const rows: [string, string][] = [
|
||||
["Source:", describeSource(s.credential.source)],
|
||||
["Type: ", s.credential.type === "oauth" ? "oauth" : "api_key"],
|
||||
];
|
||||
if (s.credential.type === "oauth") rows.push(...oauthRows(s.credential));
|
||||
if (s.apiError) {
|
||||
rows.push([c.error("API check failed:"), s.apiError]);
|
||||
return rows;
|
||||
}
|
||||
if (s.user) rows.push(...identityRows(s.user));
|
||||
return rows;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function oauthRows(credential: Extract<ResolvedCredential, { type: "oauth" }>): [string, string][] {
|
||||
const rows: [string, string][] = [];
|
||||
if (credential.expires_at) {
|
||||
const fresh = credential.expires_at.getTime() > Date.now();
|
||||
const tag = fresh ? c.success("(valid)") : c.warn("(expired)");
|
||||
const refresh = credential.refreshable ? c.dim(" · refreshable") : "";
|
||||
rows.push(["Expires:", `${credential.expires_at.toISOString()} ${tag}${refresh}`]);
|
||||
}
|
||||
if (credential.scope) rows.push(["Scope: ", credential.scope]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function identityRows(user: UserInfo): [string, string][] {
|
||||
const identity = user.email ?? user.username ?? "(unknown user)";
|
||||
return [["Account:", identity], ...billingRows(user)];
|
||||
}
|
||||
|
||||
const SOURCE_LABELS: Record<ResolvedCredential["source"], string> = {
|
||||
env: "env (HEYGEN_API_KEY)",
|
||||
env_alias: "env (HYPERFRAMES_API_KEY)",
|
||||
file_legacy: "file (~/.heygen/credentials — legacy plaintext)",
|
||||
file_json: "file (~/.heygen/credentials)",
|
||||
};
|
||||
|
||||
function describeSource(source: ResolvedCredential["source"]): string {
|
||||
return SOURCE_LABELS[source];
|
||||
}
|
||||
|
||||
function billingRows(user: UserInfo): [string, string][] {
|
||||
const rows: [string, string][] = [];
|
||||
if (user.billing_type) rows.push(["Billing:", user.billing_type]);
|
||||
pushWalletRow(rows, user);
|
||||
pushSubscriptionRows(rows, user);
|
||||
pushUsageRow(rows, user);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function pushWalletRow(rows: [string, string][], user: UserInfo): void {
|
||||
const balance = user.wallet?.remaining_balance;
|
||||
if (balance === undefined) return;
|
||||
const currency = user.wallet?.currency ? ` ${user.wallet.currency}` : "";
|
||||
rows.push(["Wallet: ", `${balance}${currency}`]);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function pushSubscriptionRows(rows: [string, string][], user: UserInfo): void {
|
||||
if (user.subscription?.plan) rows.push(["Plan: ", user.subscription.plan]);
|
||||
pushCreditRow(rows, "Premium credits:", user.subscription?.credits?.premium_credits);
|
||||
pushCreditRow(rows, "Add-on credits: ", user.subscription?.credits?.add_on_credits);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function pushCreditRow(
|
||||
rows: [string, string][],
|
||||
label: string,
|
||||
credit: { remaining?: number; resets_at?: string } | undefined,
|
||||
): void {
|
||||
if (!credit || credit.remaining === undefined) return;
|
||||
const resets = credit.resets_at ? ` (resets ${credit.resets_at.slice(0, 10)})` : "";
|
||||
rows.push([label, `${credit.remaining}${resets}`]);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function pushUsageRow(rows: [string, string][], user: UserInfo): void {
|
||||
const current = user.usage_based?.spending_current_usd;
|
||||
if (current === undefined) return;
|
||||
const cap = user.usage_based?.spending_cap_usd;
|
||||
const capPart = cap !== undefined ? ` / $${cap}` : "";
|
||||
rows.push(["Usage: ", `$${current}${capPart}`]);
|
||||
}
|
||||
@@ -67,6 +67,10 @@ const GROUPS: Group[] = [
|
||||
["remove-background", "Remove background from a video or image to produce transparent media"],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Account",
|
||||
commands: [["auth", "Sign in to HeyGen and manage credentials"]],
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
commands: [["telemetry", "Manage anonymous usage telemetry"]],
|
||||
|
||||
Reference in New Issue
Block a user