fix(cli): address code-review findings on auth PR

This commit is contained in:
James
2026-05-28 05:39:24 +00:00
parent 9a7b3efa94
commit 8a9291c434
19 changed files with 1974 additions and 0 deletions
+53
View File
@@ -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");
});
});
+218
View File
@@ -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();
}
+74
View File
@@ -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());
}
+200
View File
@@ -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}`]);
}