refactor(cli): centralize process lifecycle

This commit is contained in:
James
2026-07-20 09:04:03 -07:00
parent 6ad738b580
commit 3aa2404747
71 changed files with 812 additions and 434 deletions
+6 -9
View File
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readStore, writeStore } from "../../auth/store.js";
import { CliRuntimeError } from "../../utils/commandResult.js";
// Mock only AuthClient — keep the real store/resolver so the test
// exercises the actual on-disk rollback / persistence behavior.
@@ -58,10 +59,6 @@ describe("auth login --api-key rollback", () => {
verifyState.reject = false;
verifyState.user = { email: "alice@example.com" };
for (const fn of Object.values(telemetry)) fn.mockClear();
// 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}`);
}) as never);
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
});
@@ -86,7 +83,7 @@ describe("auth login --api-key rollback", () => {
it("removes the rejected key on a failed FIRST login (no prior credential)", async () => {
verifyState.reject = true;
await expect(runLogin("hg_badkey123")).rejects.toThrow(/process\.exit:1/);
await expect(runLogin("hg_badkey123")).rejects.toThrow(CliRuntimeError);
// The store must NOT retain the rejected key — otherwise the next
// command would silently resolve a known-bad credential.
@@ -97,7 +94,7 @@ describe("auth login --api-key rollback", () => {
it("restores the previous credential on a failed re-login", async () => {
await writeStore({ api_key: "hg_previous_good" });
verifyState.reject = true;
await expect(runLogin("hg_newbadkey99")).rejects.toThrow(/process\.exit:1/);
await expect(runLogin("hg_newbadkey99")).rejects.toThrow(CliRuntimeError);
const { credentials } = await readStore();
expect(credentials.api_key).toBe("hg_previous_good");
@@ -144,7 +141,7 @@ describe("auth login --api-key rollback", () => {
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/);
await expect(runLogin("hg_badnewkey")).rejects.toThrow(CliRuntimeError);
const { credentials } = await readStore();
expect(credentials.api_key).toBe("hg_prev");
@@ -163,7 +160,7 @@ describe("auth login --api-key rollback", () => {
{ mode: 0o600 },
);
verifyState.reject = true;
await expect(runLogin("hg_badnewkey")).rejects.toThrow(/process\.exit:1/);
await expect(runLogin("hg_badnewkey")).rejects.toThrow(CliRuntimeError);
const onDisk = JSON.parse(await fs.readFile(join(dir, "credentials"), "utf8"));
expect(onDisk.api_key).toBeUndefined();
@@ -193,7 +190,7 @@ describe("auth login --api-key rollback", () => {
it("records a rejected key as failed and never identifies", async () => {
verifyState.reject = true;
await expect(runLogin("hg_badkey123")).rejects.toThrow(/process\.exit:1/);
await expect(runLogin("hg_badkey123")).rejects.toThrow(CliRuntimeError);
expect(telemetry.identifyUser).not.toHaveBeenCalled();
expect(telemetry.trackAuthLoginFailed).toHaveBeenCalledWith("api_key", "rejected");
});
+8 -7
View File
@@ -1,3 +1,4 @@
import { failCommand } from "../../utils/commandResult.js";
/**
* `hyperframes auth login` — sign in to HeyGen.
*
@@ -91,7 +92,7 @@ async function runOAuthLogin(): Promise<void> {
// (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);
failCommand();
}
await reportIdentity();
@@ -105,7 +106,7 @@ async function reportIdentity(): Promise<void> {
if (!credential) {
trackAuthLoginFailed("oauth", "no_credential");
console.error(c.warn("Sign-in completed but no credential was persisted."));
process.exit(1);
failCommand();
}
// Wire the refresh hook here too — a freshly-minted token shouldn't
// need it, but a fast IdP-side rotation (or a misconfigured short
@@ -211,12 +212,12 @@ async function runApiKeyLogin(inlineKey: string): Promise<void> {
} catch (err) {
trackAuthLoginFailed("api_key", "aborted");
console.error(c.error((err as Error).message || "Sign-in aborted."));
process.exit(1);
failCommand();
}
if (!key) {
trackAuthLoginFailed("api_key", "invalid_input");
console.error(c.error("No API key provided."));
process.exit(1);
failCommand();
}
if (!isHeaderSafe(key)) {
// CR/LF in the value would smuggle headers when the key is sent
@@ -224,12 +225,12 @@ async function runApiKeyLogin(inlineKey: string): Promise<void> {
// 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);
failCommand();
}
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);
failCommand();
}
const previous = await snapshotStore();
@@ -240,7 +241,7 @@ async function runApiKeyLogin(inlineKey: string): Promise<void> {
if (!user) {
trackAuthLoginFailed("api_key", "rejected");
await rollback(previous);
process.exit(1);
failCommand();
}
const id = identityKey(user);
if (id) identifyUser(id);
+2 -1
View File
@@ -1,3 +1,4 @@
import { failCommand } from "../../utils/commandResult.js";
/**
* `hyperframes auth logout` — remove the credential file. With
* `--keep-api-key`, only the OAuth block is cleared (no-op for
@@ -38,7 +39,7 @@ export default defineCommand({
if (!(await ensureConfirmed(Boolean(args.yes), keepApiKey))) {
console.log("Aborted.");
process.exit(1);
failCommand();
}
// Best-effort revoke before we wipe local state. RFC 7009 says
+3 -2
View File
@@ -1,3 +1,4 @@
import { failCommand } from "../../utils/commandResult.js";
/**
* `hyperframes auth refresh` force-refresh the OAuth access_token
* using the stored refresh_token.
@@ -26,7 +27,7 @@ export default defineCommand({
const { credentials, source } = await readStore();
if (source === "absent" || !credentials.oauth?.refresh_token) {
console.error(c.warn("No OAuth refresh token to use. Run `hyperframes auth login` first."));
process.exit(1);
failCommand();
}
try {
@@ -40,7 +41,7 @@ export default defineCommand({
if (isAuthError(err) && err.code === "REFRESH_FAILED") {
console.error(c.error(err.message));
if (err.hint) console.error(c.dim(err.hint));
process.exit(1);
failCommand();
}
throw err;
}
@@ -1,8 +1,10 @@
// fallow-ignore-file code-duplication
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";
import { consumeCommandResult } from "../../utils/commandResult.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
@@ -48,9 +50,7 @@ describe("auth status — persisted user block surface", () => {
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);
consumeCommandResult();
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
stdout.push(args.join(" "));
});
@@ -58,6 +58,7 @@ describe("auth status — persisted user block surface", () => {
});
afterEach(async () => {
consumeCommandResult();
vi.restoreAllMocks();
for (const k of ENV_KEYS) {
const v = saved[k];
@@ -69,16 +70,10 @@ describe("auth status — persisted user block surface", () => {
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;
}
await (cmd.run as (ctx: { args: Record<string, unknown> }) => Promise<void>)({
args: { json: asJson },
});
return consumeCommandResult().exitCode;
}
function lastJson(): Record<string, unknown> {
+4 -3
View File
@@ -1,3 +1,4 @@
import { failCommand, setCommandExitCode } from "../../utils/commandResult.js";
/**
* `hyperframes auth status` print the active credential's source,
* type, and identity (verified against `GET /v3/users/me`).
@@ -81,7 +82,7 @@ export default defineCommand({
const status = await verify(credential);
if (asJson) printJsonStatus(status);
else printHumanStatus(status);
process.exit(status.apiError ? 1 : 0);
setCommandExitCode(status.apiError ? 1 : 0);
},
});
@@ -123,7 +124,7 @@ function handleUnconfigured(asJson: boolean): never {
? JSON.stringify(buildUnconfiguredJson(ctx, engines))
: buildUnconfiguredLines(ctx, engines).join("\n");
console.log(output);
process.exit(1);
failCommand();
}
// fallow-ignore-next-line complexity
@@ -135,7 +136,7 @@ function handleResolveError(err: unknown, asJson: boolean): never {
console.error(c.error(err.message));
if (err.hint) console.error(c.dim(err.hint));
}
process.exit(1);
failCommand();
}
async function verify(credential: ResolvedCredential): Promise<VerifiedStatus> {