mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
feat(cli): persist + show friendly user identity; preserve unknown credential fields (#1741)
* feat(cli): persist + show friendly user identity; preserve unknown credential fields The `~/.heygen/credentials` file is SHARED with the Go `heygen` CLI. This is the hyperframes-side mirror of heygen-cli#197, which adds an optional `user` block to that file. Two CLIs writing one file must round-trip each other's data without loss. Load-bearing change: the credentials reader/writer now PRESERVES unknown fields on round-trip. Previously readStore/writeStore stripped any key this CLI didn't model, so writing the file back would silently drop the `user` block heygen-cli wrote (and any future key). Unrecognized top-level keys, and unknown keys inside `oauth` / `user`, are captured on a hidden symbol slot and re-emitted verbatim. Known fields stay strictly validated. Also mirrors heygen-cli#197's friendly-display feature: - New optional `user` block schema (email/first_name/last_name/username), all omitempty; legacy files without it parse fine. - After login (OAuth + api-key paths) probe /v3/users/me, persist the block, and show a friendly name (email > "first last" > username). Probe failure is non-fatal (login still succeeds); a stale block is cleared on probe failure so a wrong account can't surface. - `auth status` surfaces the persisted block (persisted_user in JSON, a cached Account row in human output) for file-sourced credentials; env-sourced credentials skip it (the on-disk block may belong to a different key). - Fixed the OAuth write path to carry the user block + unknown keys across a fresh login / refresh (it previously rebuilt a minimal record). Tests: preserve-unknown-fields round-trip (top-level, oauth, user), the exact cross-CLI `user`-block scenario, schema round-trip + omitempty, backwards-compat with legacy files, login persistence + graceful probe failure + stale-clear, and the `auth status` surface. Full CLI suite (1009 tests) green; oxlint + oxfmt + tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): preserve unknown credential data in cleanup/rollback paths Addresses Magi's REQUEST_CHANGES on #1741. The credentials reader/writer already round-trips unknown/foreign keys (the cross-CLI forward-compat contract), but three destructive paths still deleted the whole file when no known api_key/oauth survived — even when the hidden Symbol-keyed unknown-field bag held a future credential another CLI owns. That clobbers exactly the data this PR preserves. - Add `hasPreservedUnknownData(record)` to store.ts (checks the top-level unknown bag + the oauth/user sub-object bags) and export it via the barrel. - `clearOAuth`, `clearUserInfo`, and the failed `auth login --api-key` rollback now write the credential-less remnant (carrying the unknown bag) instead of deleting the file when unknown/foreign data survives. They still delete when nothing worth preserving remains. - Regression tests: rollback path + both cleanup paths (clearOAuth, clearUserInfo) preserve a foreign top-level key; `hasPreservedUnknownData` unit tests at all three levels. Also addresses the review's minor items: - Add a refresh-path round-trip test (`refreshTokens`) proving an unknown key inside the oauth sub-object survives a no-rotation refresh — the most-frequent write path, previously only implicitly covered. - Clarify the `userDisplayName` / `combineName` docstrings: precedence is `email > "first last" > first-only > last-only > username`. - Replace the stale `expires_at` example date in store.ts with `<ISO-8601 UTC>`. Full CLI suite green (1020 tests); tsc, oxlint, oxfmt --check all clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c9e8dd3862
commit
b9b5780396
@@ -5,19 +5,27 @@ 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 }));
|
||||
// exercises the actual on-disk rollback / persistence behavior.
|
||||
// `verifyState` controls what `getCurrentUser` returns per test:
|
||||
// - reject: throw ErrUnauthenticated (invalid key path)
|
||||
// - user: the /v3/users/me identity returned on success
|
||||
const verifyState = vi.hoisted(
|
||||
() =>
|
||||
({ reject: false, user: { email: "alice@example.com" } }) as {
|
||||
reject: boolean;
|
||||
user: Record<string, unknown>;
|
||||
},
|
||||
);
|
||||
|
||||
vi.mock("../../auth/index.js", async (orig) => {
|
||||
const actual = await orig<typeof import("../../auth/index.js")>();
|
||||
class MockAuthClient {
|
||||
async getCurrentUser(): Promise<{ email: string }> {
|
||||
async getCurrentUser(): Promise<Record<string, unknown>> {
|
||||
if (verifyState.reject) {
|
||||
const { ErrUnauthenticated: rej } = await import("../../auth/errors.js");
|
||||
throw rej("invalid key");
|
||||
}
|
||||
return { email: "alice@example.com" };
|
||||
return verifyState.user;
|
||||
}
|
||||
}
|
||||
return { ...actual, AuthClient: MockAuthClient };
|
||||
@@ -37,6 +45,7 @@ describe("auth login --api-key rollback", () => {
|
||||
}
|
||||
process.env["HEYGEN_CONFIG_DIR"] = dir;
|
||||
verifyState.reject = false;
|
||||
verifyState.user = { email: "alice@example.com" };
|
||||
// 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}`);
|
||||
@@ -88,4 +97,79 @@ describe("auth login --api-key rollback", () => {
|
||||
const { credentials } = await readStore();
|
||||
expect(credentials.api_key).toBe("hg_goodkey456");
|
||||
});
|
||||
|
||||
it("persists the friendly user block from /v3/users/me on a successful login", async () => {
|
||||
verifyState.user = {
|
||||
email: "jane@example.com",
|
||||
first_name: "Jane",
|
||||
last_name: "Doe",
|
||||
username: "jdoe",
|
||||
};
|
||||
await runLogin("hg_goodkey456");
|
||||
const { credentials } = await readStore();
|
||||
expect(credentials.api_key).toBe("hg_goodkey456");
|
||||
expect(credentials.user).toEqual({
|
||||
email: "jane@example.com",
|
||||
first_name: "Jane",
|
||||
last_name: "Doe",
|
||||
username: "jdoe",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears a stale user block when the new key's identity probe returns nothing", async () => {
|
||||
// Prior login left a user block on disk. The new key is valid but
|
||||
// /v3/users/me returns no identity fields — the stale block must be
|
||||
// cleared so `auth status` can't surface the previous account.
|
||||
await writeStore({ api_key: "hg_old", user: { email: "old@example.com" } });
|
||||
verifyState.user = {}; // verified, but no identity returned
|
||||
await runLogin("hg_newgoodkey");
|
||||
|
||||
const { credentials } = await readStore();
|
||||
expect(credentials.api_key).toBe("hg_newgoodkey");
|
||||
expect(credentials.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rollback on a rejected key restores the previous user block too", async () => {
|
||||
await writeStore({ api_key: "hg_prev", user: { email: "prev@example.com" } });
|
||||
verifyState.reject = true;
|
||||
await expect(runLogin("hg_badnewkey")).rejects.toThrow(/process\.exit:1/);
|
||||
|
||||
const { credentials } = await readStore();
|
||||
expect(credentials.api_key).toBe("hg_prev");
|
||||
expect(credentials.user).toEqual({ email: "prev@example.com" });
|
||||
});
|
||||
|
||||
it("rollback on a rejected key preserves a prior foreign top-level key (no known credential)", async () => {
|
||||
// The prior file held ONLY a future/foreign top-level key — no
|
||||
// api_key, no oauth. A rejected new key must roll back WITHOUT
|
||||
// deleting the file, or the foreign credential another CLI owns is
|
||||
// clobbered. (Before the fix, rollback deleted the file because
|
||||
// neither api_key nor oauth was present.)
|
||||
await fs.writeFile(
|
||||
join(dir, "credentials"),
|
||||
JSON.stringify({ future_credential: { token: "owned_by_other_cli" } }),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
verifyState.reject = true;
|
||||
await expect(runLogin("hg_badnewkey")).rejects.toThrow(/process\.exit:1/);
|
||||
|
||||
const onDisk = JSON.parse(await fs.readFile(join(dir, "credentials"), "utf8"));
|
||||
expect(onDisk.api_key).toBeUndefined();
|
||||
expect(onDisk.future_credential).toEqual({ token: "owned_by_other_cli" });
|
||||
});
|
||||
|
||||
it("preserves an unknown/foreign top-level key across a successful re-login", async () => {
|
||||
// Cross-CLI invariant end-to-end: a key heygen-cli (or a future
|
||||
// version) wrote must survive a hyperframes-cli login round-trip.
|
||||
await fs.writeFile(join(dir, "credentials"), JSON.stringify({ future_field: { x: 1 } }), {
|
||||
mode: 0o600,
|
||||
});
|
||||
verifyState.user = { email: "jane@example.com" };
|
||||
await runLogin("hg_goodkey456");
|
||||
|
||||
const onDisk = JSON.parse(await fs.readFile(join(dir, "credentials"), "utf8"));
|
||||
expect(onDisk.api_key).toBe("hg_goodkey456");
|
||||
expect(onDisk.user).toEqual({ email: "jane@example.com" });
|
||||
expect(onDisk.future_field).toEqual({ x: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,15 +26,22 @@ import { stdin as input } from "node:process";
|
||||
import {
|
||||
AuthClient,
|
||||
assertOAuthConfiguredOrExit,
|
||||
clearUserInfo,
|
||||
deleteStore,
|
||||
hasPreservedUnknownData,
|
||||
isAuthError,
|
||||
isHeaderSafe,
|
||||
isUserInfoEmpty,
|
||||
readStore,
|
||||
refreshTokens,
|
||||
saveUserInfo,
|
||||
startAuthorizationCodeFlow,
|
||||
tryResolveCredential,
|
||||
userDisplayName,
|
||||
writeStore,
|
||||
type Credentials,
|
||||
type StoredUserInfo,
|
||||
type UserInfo,
|
||||
} from "../../auth/index.js";
|
||||
import { c } from "../../ui/colors.js";
|
||||
|
||||
@@ -97,18 +104,63 @@ async function reportIdentity(): Promise<void> {
|
||||
});
|
||||
try {
|
||||
const user = await client.getCurrentUser(credential);
|
||||
const identity = user.email ?? user.username ?? "(unknown user)";
|
||||
// Persist the friendly-display block alongside the OAuth tokens so
|
||||
// `auth status` can show "Logged in as ..." without re-hitting
|
||||
// /v3/users/me. Best-effort — a persist failure never fails the login.
|
||||
await persistUserInfo(user);
|
||||
const identity = userDisplayName(toStoredUserInfo(user)) ?? "(unknown user)";
|
||||
console.log(c.success(`✓ Signed in as ${identity}.`));
|
||||
} catch (err) {
|
||||
// Don't roll back — the OAuth tokens are valid on disk; this is a
|
||||
// transient verify-side issue. Surface as a warning so the user
|
||||
// can re-check with `auth status` rather than re-running login.
|
||||
// transient verify-side issue. The identity probe failed, so any
|
||||
// stale user block from a prior login (possibly a DIFFERENT account)
|
||||
// is cleared so `auth status` can't surface the wrong identity.
|
||||
await clearUserInfoBestEffort();
|
||||
console.error(
|
||||
c.warn(`Signed in. Identity check failed (transient): ${(err as Error).message}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Project the API `/v3/users/me` view onto the on-disk identity block. */
|
||||
function toStoredUserInfo(user: UserInfo): StoredUserInfo {
|
||||
const out: StoredUserInfo = {};
|
||||
if (user.email) out.email = user.email;
|
||||
if (user.first_name) out.first_name = user.first_name;
|
||||
if (user.last_name) out.last_name = user.last_name;
|
||||
if (user.username) out.username = user.username;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the friendly-display block (best-effort). A non-empty block is
|
||||
* saved; an empty one (the API returned no identity fields) clears any
|
||||
* stale block so a wrong account can't surface in `auth status`. A
|
||||
* persist/clear failure is warned, never fatal — the credential is valid
|
||||
* on disk and that's what matters.
|
||||
*/
|
||||
async function persistUserInfo(user: UserInfo): Promise<void> {
|
||||
const stored = toStoredUserInfo(user);
|
||||
try {
|
||||
if (isUserInfoEmpty(stored)) {
|
||||
await clearUserInfo();
|
||||
} else {
|
||||
await saveUserInfo(stored);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(c.dim(`(warning: could not persist user info: ${(err as Error).message})`));
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop any stale user block; best-effort, never fatal. */
|
||||
async function clearUserInfoBestEffort(): Promise<void> {
|
||||
try {
|
||||
await clearUserInfo();
|
||||
} catch (err) {
|
||||
console.error(c.dim(`(warning: could not clear stale user info: ${(err as Error).message})`));
|
||||
}
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
async function runApiKeyLogin(inlineKey: string): Promise<void> {
|
||||
const key = await collectApiKey(inlineKey);
|
||||
@@ -150,13 +202,18 @@ async function snapshotStore(): Promise<Credentials> {
|
||||
|
||||
async function rollback(previous: Credentials): Promise<void> {
|
||||
try {
|
||||
if (previous.api_key || previous.oauth) {
|
||||
if (previous.api_key || previous.oauth || hasPreservedUnknownData(previous)) {
|
||||
// Restore the prior state. This branch also covers the case where
|
||||
// the only prior content was an unknown/foreign top-level key (a
|
||||
// future credential another CLI owns): writing `previous` back
|
||||
// re-emits that key, so the rollback doesn't clobber cross-CLI data
|
||||
// the file had before this login attempt.
|
||||
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.
|
||||
// No prior credential and nothing worth preserving — 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."));
|
||||
}
|
||||
@@ -170,7 +227,10 @@ 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)";
|
||||
// Persist the friendly-display block next to the now-verified api_key
|
||||
// so `auth status` can show a recognizable identity. Best-effort.
|
||||
await persistUserInfo(user);
|
||||
const identity = userDisplayName(toStoredUserInfo(user)) ?? "(unknown user)";
|
||||
console.log(c.success(`✓ API key saved. Authenticated as ${identity}.`));
|
||||
return true;
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
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 { writeStore } from "../../auth/store.js";
|
||||
|
||||
// Mock only AuthClient so the live /v3/users/me probe is controllable;
|
||||
// keep the real store/resolver/user helpers so the test exercises the
|
||||
// actual on-disk persisted-user-block surfacing.
|
||||
// - apiReject: throw ErrApi on getCurrentUser (simulates an API blip)
|
||||
// - user: the live identity returned on success
|
||||
const probeState = vi.hoisted(
|
||||
() =>
|
||||
({ apiReject: false, user: { email: "live@example.com" } }) as {
|
||||
apiReject: boolean;
|
||||
user: Record<string, unknown>;
|
||||
},
|
||||
);
|
||||
|
||||
vi.mock("../../auth/index.js", async (orig) => {
|
||||
const actual = await orig<typeof import("../../auth/index.js")>();
|
||||
class MockAuthClient {
|
||||
async getCurrentUser(): Promise<Record<string, unknown>> {
|
||||
if (probeState.apiReject) {
|
||||
const { ErrApi } = await import("../../auth/errors.js");
|
||||
throw ErrApi(503, "service unavailable");
|
||||
}
|
||||
return probeState.user;
|
||||
}
|
||||
}
|
||||
return { ...actual, AuthClient: MockAuthClient };
|
||||
});
|
||||
|
||||
const ENV_KEYS = ["HEYGEN_API_KEY", "HYPERFRAMES_API_KEY", "HEYGEN_CONFIG_DIR"] as const;
|
||||
|
||||
describe("auth status — persisted user block surface", () => {
|
||||
let dir: string;
|
||||
const saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>> = {};
|
||||
let stdout: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await fs.mkdtemp(join(tmpdir(), "hf-status-"));
|
||||
for (const k of ENV_KEYS) {
|
||||
saved[k] = process.env[k];
|
||||
delete process.env[k];
|
||||
}
|
||||
process.env["HEYGEN_CONFIG_DIR"] = dir;
|
||||
probeState.apiReject = false;
|
||||
probeState.user = { email: "live@example.com" };
|
||||
stdout = [];
|
||||
vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null) => {
|
||||
throw new Error(`process.exit:${code ?? 0}`);
|
||||
}) as never);
|
||||
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
|
||||
stdout.push(args.join(" "));
|
||||
});
|
||||
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 runStatus(asJson: boolean): Promise<number> {
|
||||
const cmd = (await import("./status.js")).default;
|
||||
try {
|
||||
await (cmd.run as (ctx: { args: Record<string, unknown> }) => Promise<void>)({
|
||||
args: { json: asJson },
|
||||
});
|
||||
return 0;
|
||||
} catch (err) {
|
||||
const m = /process\.exit:(\d+)/.exec((err as Error).message);
|
||||
if (m) return Number(m[1]);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function lastJson(): Record<string, unknown> {
|
||||
return JSON.parse(stdout[stdout.length - 1] ?? "{}");
|
||||
}
|
||||
|
||||
it("surfaces the persisted user block (with resolved display_name) for a file credential", async () => {
|
||||
await writeStore({
|
||||
api_key: "hg_x",
|
||||
user: { email: "jane@example.com", first_name: "Jane", last_name: "Doe", username: "jdoe" },
|
||||
});
|
||||
|
||||
const code = await runStatus(true);
|
||||
expect(code).toBe(0);
|
||||
const payload = lastJson();
|
||||
expect(payload["source"]).toBe("file_json");
|
||||
expect(payload["persisted_user"]).toEqual({
|
||||
email: "jane@example.com",
|
||||
first_name: "Jane",
|
||||
last_name: "Doe",
|
||||
username: "jdoe",
|
||||
display_name: "jane@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("backwards-compat: a file credential with no user block reports persisted_user: null", async () => {
|
||||
await writeStore({ api_key: "hg_legacy" });
|
||||
|
||||
const code = await runStatus(true);
|
||||
expect(code).toBe(0);
|
||||
const payload = lastJson();
|
||||
expect(payload["persisted_user"]).toBeNull();
|
||||
// The live `user` field (from the API probe) is unchanged / additive.
|
||||
expect(payload["user"]).toEqual({ email: "live@example.com" });
|
||||
});
|
||||
|
||||
it("skips the persisted block for an env-sourced credential (could be a different key)", async () => {
|
||||
// Seed a file-side user block, then resolve via the env key — the
|
||||
// active credential is env, so the file block must NOT be surfaced.
|
||||
await writeStore({ api_key: "hg_file", user: { email: "file-user@example.com" } });
|
||||
process.env["HEYGEN_API_KEY"] = "hg_env_key";
|
||||
|
||||
const code = await runStatus(true);
|
||||
expect(code).toBe(0);
|
||||
const payload = lastJson();
|
||||
expect(payload["source"]).toBe("env");
|
||||
expect(payload["persisted_user"]).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the cached identity in human output when the live probe fails", async () => {
|
||||
await writeStore({ api_key: "hg_x", user: { email: "cached@example.com" } });
|
||||
probeState.apiReject = true;
|
||||
|
||||
const code = await runStatus(false);
|
||||
expect(code).toBe(1); // API failure → non-zero exit
|
||||
const text = stdout.join("\n");
|
||||
expect(text).toContain("API check failed");
|
||||
expect(text).toContain("cached@example.com");
|
||||
expect(text).toContain("cached");
|
||||
});
|
||||
});
|
||||
@@ -18,9 +18,12 @@ import { defineCommand } from "citty";
|
||||
import {
|
||||
AuthClient,
|
||||
isAuthError,
|
||||
loadUserInfo,
|
||||
refreshTokens,
|
||||
tryResolveCredential,
|
||||
userDisplayName,
|
||||
type ResolvedCredential,
|
||||
type StoredUserInfo,
|
||||
type UserInfo,
|
||||
} from "../../auth/index.js";
|
||||
import { getSystemMeta } from "../../telemetry/system.js";
|
||||
@@ -36,9 +39,21 @@ import {
|
||||
interface VerifiedStatus {
|
||||
credential: ResolvedCredential;
|
||||
user: UserInfo | null;
|
||||
/**
|
||||
* The friendly-display block persisted at login time, when the active
|
||||
* credential is file-sourced and a block is on disk. `null` for
|
||||
* env-sourced credentials (the on-disk block could belong to a
|
||||
* different key) and for pre-this-change credentials files.
|
||||
*/
|
||||
persistedUser: StoredUserInfo | null;
|
||||
apiError: string | null;
|
||||
}
|
||||
|
||||
/** True for credentials resolved from the shared file (not env). */
|
||||
function isFileSource(source: ResolvedCredential["source"]): boolean {
|
||||
return source === "file_json" || source === "file_legacy";
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
meta: { name: "status", description: "Show the active HeyGen credential" },
|
||||
args: {
|
||||
@@ -130,25 +145,47 @@ async function verify(credential: ResolvedCredential): Promise<VerifiedStatus> {
|
||||
// invalidate the old RT on every refresh).
|
||||
onUnauthenticatedRefresh: async (rt) => await refreshTokens(rt),
|
||||
});
|
||||
const persistedUser = await loadPersistedUser(credential);
|
||||
try {
|
||||
const user = await client.getCurrentUser(credential);
|
||||
return { credential, user, apiError: null };
|
||||
return { credential, user, persistedUser, apiError: null };
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) throw err;
|
||||
return {
|
||||
credential,
|
||||
user: null,
|
||||
persistedUser,
|
||||
apiError: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the persisted friendly-display block, but only for file-sourced
|
||||
* credentials. An env credential (`HEYGEN_API_KEY` / `HYPERFRAMES_API_KEY`)
|
||||
* could belong to a different key than the on-disk block, so surfacing
|
||||
* that block would mislabel the active account. A read error is swallowed
|
||||
* — the block is purely cosmetic and must never break `auth status`.
|
||||
*/
|
||||
async function loadPersistedUser(credential: ResolvedCredential): Promise<StoredUserInfo | null> {
|
||||
if (!isFileSource(credential.source)) return null;
|
||||
try {
|
||||
return await loadUserInfo();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function printJsonStatus(s: VerifiedStatus): void {
|
||||
const payload: Record<string, unknown> = {
|
||||
configured: true,
|
||||
source: s.credential.source,
|
||||
type: s.credential.type,
|
||||
user: s.user,
|
||||
// The friendly-display block persisted at login (file-sourced creds
|
||||
// only). Strictly additive — the live `user` field above is
|
||||
// unchanged. Lets callers read identity offline / on an API blip.
|
||||
persisted_user: persistedUserJson(s.persistedUser),
|
||||
api_error: s.apiError,
|
||||
};
|
||||
if (s.credential.type === "oauth") {
|
||||
@@ -159,6 +196,24 @@ function printJsonStatus(s: VerifiedStatus): void {
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape the persisted block for JSON: the four optional fields plus the
|
||||
* resolved `display_name` (email > "first last" > username). Returns
|
||||
* `null` when nothing is persisted so the field is an explicit null
|
||||
* rather than an empty object.
|
||||
*/
|
||||
function persistedUserJson(u: StoredUserInfo | null): Record<string, unknown> | null {
|
||||
if (!u) return null;
|
||||
const display = userDisplayName(u);
|
||||
return {
|
||||
email: u.email ?? null,
|
||||
first_name: u.first_name ?? null,
|
||||
last_name: u.last_name ?? null,
|
||||
username: u.username ?? null,
|
||||
display_name: display ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function printHumanStatus(s: VerifiedStatus): void {
|
||||
const rows = collectStatusRows(s);
|
||||
for (const [label, value] of rows) console.log(`${c.bold(label)} ${value}`);
|
||||
@@ -173,6 +228,10 @@ function collectStatusRows(s: VerifiedStatus): [string, string][] {
|
||||
if (s.credential.type === "oauth") rows.push(...oauthRows(s.credential));
|
||||
if (s.apiError) {
|
||||
rows.push([c.error("API check failed:"), s.apiError]);
|
||||
// Fall back to the persisted identity so the user still sees who
|
||||
// they're logged in as when the live probe is unreachable.
|
||||
const cached = s.persistedUser && userDisplayName(s.persistedUser);
|
||||
if (cached) rows.push(["Account:", `${cached} ${c.dim("(cached)")}`]);
|
||||
return rows;
|
||||
}
|
||||
if (s.user) rows.push(...identityRows(s.user));
|
||||
|
||||
Reference in New Issue
Block a user