Files
hyperframes/packages/cli/src/commands/auth/login.ts
T
Miguel Ángel 769dc702c1 feat(cli): emit sign-in lifecycle telemetry (#2000)
* feat(cli): emit sign-in lifecycle telemetry

The CLI tracks command and render lifecycles but emits nothing for
`auth login`, so sign-in outcomes are invisible on the observability
dashboards — a completed sign-in, an abandoned browser flow, and a
rejected key all look identical (absent). This leaves a blind spot in
the same funnel the render events already cover.

Add three events mirroring the existing `trackX` pattern:
  - auth_login_started    (method: oauth | api_key)
  - auth_login_completed  (method)
  - auth_login_failed     (method, reason)

`reason` is a fixed low-cardinality enum (flow_error / no_credential /
rejected / invalid_input). No token, key, identity, email, or free text
is ever attached — consistent with the existing anonymous telemetry and
the `telemetry disable` opt-out. Wired into both the OAuth and
--api-key paths in `auth login`, with unit coverage for the new events.

* fix(cli): close sign-in telemetry funnel dropout gaps

Follow-up so `started` reconciles to `completed + failed` on the common
abandonment paths, which the first cut missed:

- Interactive prompt cancel (Ctrl-C) now surfaces as a throw that the
  single catch in the api-key path records as `aborted`, instead of a
  bare exit with no event.
- A stdin read that times out in non-TTY `--api-key` mode now records
  `aborted` before the error propagates, rather than exiting silently.
- OAuth split: a timed-out browser callback (user closed the tab) is
  tagged `flow_timeout`, separated from real `flow_error` (IdP/network),
  since the walk-away timeout is the dominant non-error dropout.

Also pre-plumb an optional `distinctId` on the three trackers, mirroring
trackRenderComplete/trackRenderError. Unused today; it lets a later
identity-level attribution be a one-line callsite change rather than a
signature sweep. Coverage added for the new reasons and forwarding.
2026-07-06 16:59:47 -04:00

333 lines
12 KiB
TypeScript

/**
* `hyperframes auth login` — sign in to HeyGen.
*
* Default: OAuth 2.0 + PKCE via a loopback callback. The CLI opens
* the user's browser, captures the authorization code on an
* ephemeral 127.0.0.1 port, exchanges it for tokens, and persists
* them to `~/.heygen/credentials`.
*
* `--api-key`: opts into the legacy long-lived API-key path.
*
* Write semantics:
* - Snapshot existing credentials first; merge so a new OAuth session
* preserves an existing API key (and vice versa).
* - 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. Network/5xx errors keep the new credential in place per
* the transient-blip rationale.
*/
import { defineCommand } from "citty";
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";
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 (OAuth by default; --api-key for long-lived keys)",
},
args: {
"api-key": {
type: "string",
description: "API key value, or pass `--api-key` with no value to read from stdin / prompt.",
},
},
// fallow-ignore-next-line complexity
async run({ args }) {
const inlineKey = args["api-key"];
if (inlineKey !== undefined) {
await runApiKeyLogin(inlineKey);
return;
}
await runOAuthLogin();
},
});
// fallow-ignore-next-line complexity
async function runOAuthLogin(): Promise<void> {
assertOAuthConfiguredOrExit();
const { trackAuthLoginStarted, trackAuthLoginFailed } = await import("../../telemetry/index.js");
trackAuthLoginStarted("oauth");
try {
await startAuthorizationCodeFlow();
} catch (err) {
const message = (err as Error).message ?? "";
// The loopback server rejects with "OAuth callback timed out after …" when
// the user never completes the browser step (closed the tab / walked away).
// That is the dominant non-error dropout, so split it from real failures
// (IdP misconfig, network) instead of lumping everything as flow_error.
trackAuthLoginFailed("oauth", /timed out/i.test(message) ? "flow_timeout" : "flow_error");
console.error(c.error(`Sign-in failed: ${message}`));
process.exit(1);
}
await reportIdentity();
}
// fallow-ignore-next-line complexity
async function reportIdentity(): Promise<void> {
const { trackAuthLoginCompleted, trackAuthLoginFailed } =
await import("../../telemetry/index.js");
const credential = await tryResolveCredential();
if (!credential) {
trackAuthLoginFailed("oauth", "no_credential");
console.error(c.warn("Sign-in completed but no credential was persisted."));
process.exit(1);
}
// A resolvable credential IS the success signal: the tokens are on disk and
// usable. The `/v3/users/me` probe below only fetches a display name, so its
// outcome is cosmetic and does not gate completion.
trackAuthLoginCompleted("oauth");
// Wire the refresh hook here too — a freshly-minted token shouldn't
// need it, but a fast IdP-side rotation (or a misconfigured short
// TTL) shouldn't punish the user with a hard failure when the
// refresh_token would have transparently fixed it.
const client = new AuthClient({
onUnauthenticatedRefresh: async (rt) => await refreshTokens(rt),
});
try {
const user = await client.getCurrentUser(credential);
// 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. 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 { trackAuthLoginStarted, trackAuthLoginCompleted, trackAuthLoginFailed } =
await import("../../telemetry/index.js");
trackAuthLoginStarted("api_key");
// collectApiKey throws when the user cancels the interactive prompt (Ctrl-C)
// or when no key arrives on stdin before the timeout — both are "user walked
// away", the abandonment signal we most want. Record it before the error
// propagates so `started` still reconciles to `completed + failed`.
let key: string;
try {
key = await collectApiKey(inlineKey);
} catch (err) {
trackAuthLoginFailed("api_key", "aborted");
console.error(c.error((err as Error).message || "Sign-in aborted."));
process.exit(1);
}
if (!key) {
trackAuthLoginFailed("api_key", "invalid_input");
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.
trackAuthLoginFailed("api_key", "invalid_input");
console.error(c.error("API key must not contain newline or control characters."));
process.exit(1);
}
if (key.length < MIN_KEY_LENGTH) {
trackAuthLoginFailed("api_key", "invalid_input");
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) {
trackAuthLoginFailed("api_key", "rejected");
await rollback(previous);
process.exit(1);
}
trackAuthLoginCompleted("api_key");
}
async function snapshotStore(): Promise<Credentials> {
try {
const { credentials } = await readStore();
return { ...credentials };
} catch {
return {};
}
}
async function rollback(previous: Credentials): Promise<void> {
try {
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 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."));
}
} catch (err) {
console.error(c.error(`Failed to roll back: ${(err as Error).message}`));
}
}
// 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" });
// 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) {
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;
}
}
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();
}
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)) {
// Throw rather than exit here so the single catch in runApiKeyLogin records
// the abandonment (auth_login_failed: aborted) and then exits.
throw new Error("Aborted.");
}
return value.trim();
}