mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 12:28:12 +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
@@ -0,0 +1,167 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { readStore, writeStore } from "./store.js";
|
||||
import {
|
||||
clearUserInfo,
|
||||
isUserInfoEmpty,
|
||||
loadUserInfo,
|
||||
saveUserInfo,
|
||||
userDisplayName,
|
||||
type StoredUserInfo,
|
||||
} from "./user.js";
|
||||
|
||||
async function makeTmpDir(): Promise<string> {
|
||||
return fs.mkdtemp(join(tmpdir(), "hf-auth-user-"));
|
||||
}
|
||||
|
||||
describe("auth/user — userDisplayName priority", () => {
|
||||
const cases: { name: string; ui: StoredUserInfo; want: string | undefined }[] = [
|
||||
{
|
||||
name: "email wins over everything",
|
||||
ui: { email: "u@example.com", first_name: "Jane", last_name: "Doe", username: "jdoe" },
|
||||
want: "u@example.com",
|
||||
},
|
||||
{
|
||||
name: "no email → first last",
|
||||
ui: { first_name: "Jane", last_name: "Doe", username: "jdoe" },
|
||||
want: "Jane Doe",
|
||||
},
|
||||
{ name: "only first name", ui: { first_name: "Jane", username: "jdoe" }, want: "Jane" },
|
||||
{ name: "only last name", ui: { last_name: "Doe", username: "jdoe" }, want: "Doe" },
|
||||
{ name: "only username", ui: { username: "jdoe" }, want: "jdoe" },
|
||||
{ name: "all empty → undefined", ui: {}, want: undefined },
|
||||
];
|
||||
for (const tc of cases) {
|
||||
it(tc.name, () => {
|
||||
expect(userDisplayName(tc.ui)).toBe(tc.want);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("auth/user — isUserInfoEmpty", () => {
|
||||
it("true for an all-empty block", () => {
|
||||
expect(isUserInfoEmpty({})).toBe(true);
|
||||
});
|
||||
it("false when any field is set", () => {
|
||||
expect(isUserInfoEmpty({ email: "u@example.com" })).toBe(false);
|
||||
expect(isUserInfoEmpty({ username: "u" })).toBe(false);
|
||||
expect(isUserInfoEmpty({ first_name: "J" })).toBe(false);
|
||||
expect(isUserInfoEmpty({ last_name: "D" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth/user — save / load / clear", () => {
|
||||
let dir: string;
|
||||
let path: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await makeTmpDir();
|
||||
path = join(dir, "credentials");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("round-trips a user block through the credentials file", async () => {
|
||||
await writeStore({ api_key: "hg_x" }, path);
|
||||
const ui: StoredUserInfo = {
|
||||
email: "u@example.com",
|
||||
first_name: "Jane",
|
||||
last_name: "Doe",
|
||||
username: "jdoe",
|
||||
};
|
||||
await saveUserInfo(ui, path);
|
||||
expect(await loadUserInfo(path)).toEqual(ui);
|
||||
});
|
||||
|
||||
it("saveUserInfo preserves a co-located api_key", async () => {
|
||||
await writeStore({ api_key: "hg_keep" }, path);
|
||||
await saveUserInfo({ email: "u@example.com" }, path);
|
||||
const { credentials } = await readStore(path);
|
||||
expect(credentials.api_key).toBe("hg_keep");
|
||||
expect(credentials.user).toEqual({ email: "u@example.com" });
|
||||
});
|
||||
|
||||
it("saveUserInfo upgrades a legacy plaintext file to JSON with the user block", async () => {
|
||||
await fs.writeFile(path, "hg_legacy_key\n", { mode: 0o600 });
|
||||
await saveUserInfo({ email: "u@example.com" }, path);
|
||||
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
|
||||
expect(onDisk.api_key).toBe("hg_legacy_key");
|
||||
expect(onDisk.user).toEqual({ email: "u@example.com" });
|
||||
});
|
||||
|
||||
it("saveUserInfo with an empty block is a no-op (does not blank an existing block)", async () => {
|
||||
await writeStore({ api_key: "hg_x", user: { email: "keep@example.com" } }, path);
|
||||
const before = await fs.readFile(path, "utf8");
|
||||
await saveUserInfo({}, path);
|
||||
const after = await fs.readFile(path, "utf8");
|
||||
expect(after).toBe(before);
|
||||
expect(await loadUserInfo(path)).toEqual({ email: "keep@example.com" });
|
||||
});
|
||||
|
||||
it("loadUserInfo returns null for an absent file", async () => {
|
||||
expect(await loadUserInfo(path)).toBeNull();
|
||||
});
|
||||
|
||||
it("loadUserInfo returns null for a legacy file without a user block (backwards-compat)", async () => {
|
||||
await fs.writeFile(path, JSON.stringify({ api_key: "hg_legacy" }), { mode: 0o600 });
|
||||
expect(await loadUserInfo(path)).toBeNull();
|
||||
});
|
||||
|
||||
it("clearUserInfo removes only the user block, keeping the credential", async () => {
|
||||
await writeStore({ api_key: "hg_keep", user: { email: "u@example.com" } }, path);
|
||||
await clearUserInfo(path);
|
||||
const { credentials } = await readStore(path);
|
||||
expect(credentials.api_key).toBe("hg_keep");
|
||||
expect(credentials.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clearUserInfo removes the whole file when no credential survives", async () => {
|
||||
// A file holding ONLY a user block (no credential) — clearing leaves
|
||||
// nothing, so the file should be removed entirely.
|
||||
await fs.writeFile(path, JSON.stringify({ user: { email: "u@example.com" } }), { mode: 0o600 });
|
||||
await clearUserInfo(path);
|
||||
await expect(fs.access(path)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("clearUserInfo is a no-op when there is no user block", async () => {
|
||||
await writeStore({ api_key: "hg_only" }, path);
|
||||
const before = await fs.readFile(path, "utf8");
|
||||
await clearUserInfo(path);
|
||||
const after = await fs.readFile(path, "utf8");
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it("clearUserInfo keeps the file (preserving a foreign top-level key) when no credential survives", async () => {
|
||||
// The file holds a user block plus a future/foreign top-level key but
|
||||
// NO known credential. Clearing the user block must NOT delete the
|
||||
// file — the foreign key may be a credential another CLI owns, and
|
||||
// dropping it would clobber the cross-CLI data the store preserves.
|
||||
await fs.writeFile(
|
||||
path,
|
||||
JSON.stringify({
|
||||
user: { email: "u@example.com" },
|
||||
future_credential: { token: "owned_by_other_cli" },
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
await clearUserInfo(path);
|
||||
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
|
||||
expect(onDisk.user).toBeUndefined();
|
||||
expect(onDisk.future_credential).toEqual({ token: "owned_by_other_cli" });
|
||||
});
|
||||
|
||||
it("saveUserInfo does not clobber an unknown/foreign top-level key", async () => {
|
||||
// The cross-CLI invariant exercised through the persistence helper.
|
||||
await fs.writeFile(path, JSON.stringify({ api_key: "hg_x", future_field: 42 }), {
|
||||
mode: 0o600,
|
||||
});
|
||||
await saveUserInfo({ email: "u@example.com" }, path);
|
||||
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
|
||||
expect(onDisk.future_field).toBe(42);
|
||||
expect(onDisk.user).toEqual({ email: "u@example.com" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user