Files
hyperframes/packages/cli/src/auth/user.ts
James RussoandClaude Opus 4.8 b9b5780396 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>
2026-06-26 10:16:23 -07:00

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);
}