mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-06 06:37:29 +00:00
* 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>
116 lines
4.7 KiB
TypeScript
116 lines
4.7 KiB
TypeScript
/**
|
|
* Persistence + display helpers for the friendly-display `user` block in
|
|
* the shared `~/.heygen/credentials` file.
|
|
*
|
|
* The `user` block is additive METADATA captured at login time from
|
|
* `GET /v3/users/me` — NOT a credential. It lets `auth status` (and the
|
|
* post-login "Logged in as ..." line) show a recognizable identity
|
|
* without re-hitting the API on every invocation, and keeps working when
|
|
* the API is unreachable.
|
|
*
|
|
* This file is SHARED with the Go `heygen` CLI, which writes the same
|
|
* `user` block (see `heygen-cli/internal/auth/user_store.go`). Persisting
|
|
* here goes through `readStore` / `writeStore`, which round-trip every
|
|
* unrecognized key — so saving our user block never clobbers a key the
|
|
* other CLI wrote.
|
|
*/
|
|
|
|
import { credentialPath } from "./paths.js";
|
|
import {
|
|
readStore,
|
|
writeStore,
|
|
deleteStore,
|
|
hasPreservedUnknownData,
|
|
type StoredUserInfo,
|
|
} from "./store.js";
|
|
|
|
export type { StoredUserInfo } from "./store.js";
|
|
|
|
/** True when `u` carries no friendly fields worth surfacing. */
|
|
export function isUserInfoEmpty(u: StoredUserInfo): boolean {
|
|
return !u.email && !u.first_name && !u.last_name && !u.username;
|
|
}
|
|
|
|
/**
|
|
* Most friendly name available, in priority order:
|
|
* email > "first last" > first-only > last-only > username > undefined.
|
|
* The "first-only" / "last-only" intermediates come from `combineName`,
|
|
* which returns `first || last || ""` when only one of the two is present
|
|
* (so a user with just a first name still resolves to that first name,
|
|
* not straight to username). The caller falls back to its own marker
|
|
* (e.g. "(unknown user)") on `undefined`. Mirrors `UserInfo.DisplayName()`
|
|
* in heygen-cli.
|
|
*/
|
|
export function userDisplayName(u: StoredUserInfo): string | undefined {
|
|
if (u.email) return u.email;
|
|
const name = combineName(u.first_name, u.last_name);
|
|
if (name) return name;
|
|
return u.username || undefined;
|
|
}
|
|
|
|
/**
|
|
* `"first last"` when both are present; otherwise `first || last || ""`
|
|
* (one-sided fall-through), and `""` when neither is set. The empty-string
|
|
* return is treated as falsy by `userDisplayName`, so it falls through to
|
|
* `username`.
|
|
*/
|
|
function combineName(first?: string, last?: string): string {
|
|
if (first && last) return `${first} ${last}`;
|
|
return first || last || "";
|
|
}
|
|
|
|
/**
|
|
* Persist the friendly-display block, preserving any co-located api_key /
|
|
* oauth blocks and unknown/foreign keys. An all-empty `StoredUserInfo`
|
|
* is a no-op (the caller should typically gate on `isUserInfoEmpty`
|
|
* before calling, but we guard here too so a failed probe can't blank an
|
|
* existing block by accident).
|
|
*
|
|
* A broken pre-existing file surfaces as a thrown `ErrInvalidStore` from
|
|
* `readStore` — callers treat persistence as best-effort and warn rather
|
|
* than fail the login.
|
|
*/
|
|
export async function saveUserInfo(info: StoredUserInfo, path = credentialPath()): Promise<void> {
|
|
if (isUserInfoEmpty(info)) return;
|
|
// readStore preserves co-located api_key / oauth blocks and any
|
|
// unknown/foreign keys (captured on a hidden slot), so writing back
|
|
// only attaches the user block. A legacy single-line plaintext file
|
|
// parses into { api_key }, so this upgrades it to JSON in passing.
|
|
const { credentials } = await readStore(path);
|
|
credentials.user = { ...info };
|
|
await writeStore(credentials, path);
|
|
}
|
|
|
|
/**
|
|
* Read the friendly-display block. Returns `null` when the file is absent
|
|
* or carries no `user` block (a pre-this-change login). Genuine parse
|
|
* errors propagate so the caller can warn rather than silently pretend
|
|
* the file is clean.
|
|
*/
|
|
export async function loadUserInfo(path = credentialPath()): Promise<StoredUserInfo | null> {
|
|
const { credentials, source } = await readStore(path);
|
|
if (source === "absent") return null;
|
|
if (!credentials.user || isUserInfoEmpty(credentials.user)) return null;
|
|
return credentials.user;
|
|
}
|
|
|
|
/**
|
|
* Remove the friendly-display block, leaving any co-located credential
|
|
* (and unknown/foreign keys) intact. When neither a known credential NOR
|
|
* any preserved unknown/foreign data survives, the orphaned-metadata file
|
|
* is removed entirely. A surviving unknown top-level key (a future
|
|
* credential another CLI owns) keeps the file — deleting it would clobber
|
|
* exactly the cross-CLI data the store machinery exists to preserve. A
|
|
* no-op when there is nothing to clear.
|
|
*/
|
|
export async function clearUserInfo(path = credentialPath()): Promise<void> {
|
|
const { credentials, source } = await readStore(path);
|
|
if (source === "absent" || !credentials.user) return;
|
|
delete credentials.user;
|
|
if (!credentials.api_key && !credentials.oauth && !hasPreservedUnknownData(credentials)) {
|
|
await deleteStore(path);
|
|
return;
|
|
}
|
|
await writeStore(credentials, path);
|
|
}
|