mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
fix(cli): address code-review findings on auth PR
This commit is contained in:
@@ -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}`]);
|
||||
}
|
||||
Reference in New Issue
Block a user