diff --git a/src/commands/current.ts b/src/commands/current.ts index 862f67f..656d39f 100644 --- a/src/commands/current.ts +++ b/src/commands/current.ts @@ -3,10 +3,19 @@ import { BaseCommand } from "../lib/base-command"; export default class CurrentCommand extends BaseCommand { static description = "Show the currently active account name"; + static flags = { + ...BaseCommand.jsonFlag, + } as const; + async run(): Promise { + const { flags } = await this.parse(CurrentCommand); + this.setJsonMode(flags); + await this.runSafe(async () => { const name = await this.accounts.getCurrentAccountName(); - this.log(name ?? "No Codex account is active yet."); + this.emit({ active: name ?? null }, (data) => { + this.log(data.active ?? "No Codex account is active yet."); + }); }); } } diff --git a/src/commands/list.ts b/src/commands/list.ts index 9b291c7..7d62b67 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -21,46 +21,45 @@ export default class ListCommand extends BaseCommand { description: "Show per-account mapping metadata (email/account/user/type/usage)", default: false, }), + ...BaseCommand.jsonFlag, } as const; async run(): Promise { + const { flags } = await this.parse(ListCommand); + this.setJsonMode(flags); + const detailed = Boolean(flags.details); + await this.runSafe(async () => { - const { flags } = await this.parse(ListCommand); - const detailed = Boolean(flags.details); - await this.maybeOfferGlobalUpdate(); + // Interactive update prompt would corrupt JSON stdout; skip it. + if (!this.jsonMode) { + await this.maybeOfferGlobalUpdate(); + } + + const accounts = await this.accounts.listAccountMappings({ refreshUsage: "missing" }); - if (!detailed) { - const accounts = await this.accounts.listAccountMappings({ refreshUsage: "missing" }); - if (!accounts.length) { + this.emit({ accounts, detailed }, (payload) => { + if (!payload.accounts.length) { this.log("No saved Codex accounts yet. Run `authmux save `."); return; } - for (const account of accounts) { + for (const account of payload.accounts) { const mark = account.active ? "*" : " "; + if (!payload.detailed) { + this.log( + `${mark} ${account.name} type=${formatAccountType(account.planType)} 5h=${this.formatRemaining(account.remaining5hPercent)} weekly=${this.formatRemaining(account.remainingWeeklyPercent)}`, + ); + continue; + } + this.log(`${mark} ${account.name}`); + this.log( + ` email=${account.email ?? "-"} account=${account.accountId ?? "-"} user=${account.userId ?? "-"}`, + ); this.log( - `${mark} ${account.name} type=${formatAccountType(account.planType)} 5h=${this.formatRemaining(account.remaining5hPercent)} weekly=${this.formatRemaining(account.remainingWeeklyPercent)}`, + ` type=${formatAccountType(account.planType)} plan=${account.planType ?? "-"} usage=${account.usageSource ?? "-"} 5h=${this.formatRemaining(account.remaining5hPercent)} weekly=${this.formatRemaining(account.remainingWeeklyPercent)} lastUsageAt=${account.lastUsageAt ?? "-"}`, ); } - return; - } - - const accounts = await this.accounts.listAccountMappings({ refreshUsage: "missing" }); - if (!accounts.length) { - this.log("No saved Codex accounts yet. Run `authmux save `."); - return; - } - - for (const account of accounts) { - const mark = account.active ? "*" : " "; - this.log(`${mark} ${account.name}`); - this.log( - ` email=${account.email ?? "-"} account=${account.accountId ?? "-"} user=${account.userId ?? "-"}`, - ); - this.log( - ` type=${formatAccountType(account.planType)} plan=${account.planType ?? "-"} usage=${account.usageSource ?? "-"} 5h=${this.formatRemaining(account.remaining5hPercent)} weekly=${this.formatRemaining(account.remainingWeeklyPercent)} lastUsageAt=${account.lastUsageAt ?? "-"}`, - ); - } + }); }); } diff --git a/src/commands/save.ts b/src/commands/save.ts index 507471a..d7ce430 100644 --- a/src/commands/save.ts +++ b/src/commands/save.ts @@ -20,11 +20,14 @@ export default class SaveCommand extends BaseCommand { "Force overwrite when the existing snapshot name belongs to a different email account", default: false, }), + ...BaseCommand.jsonFlag, } as const; async run(): Promise { + const { args, flags } = await this.parse(SaveCommand); + this.setJsonMode(flags); + await this.runSafe(async () => { - const { args, flags } = await this.parse(SaveCommand); const providedName = args.name as string | undefined; const resolvedName = providedName ? { name: providedName, source: "explicit" as const, forceOverwrite: false } @@ -32,15 +35,25 @@ export default class SaveCommand extends BaseCommand { const savedName = await this.accounts.saveAccount(resolvedName.name, { force: Boolean(flags.force || resolvedName.forceOverwrite), }); - const suffix = - resolvedName.source === "explicit" - ? "" - : resolvedName.source === "active" - ? " (reused active account name)" - : resolvedName.source === "existing" - ? " (reused saved account name)" - : " (inferred from auth email)"; - this.log(`Saved current Codex auth tokens as "${savedName}"${suffix}.`); + + this.emit( + { + saved: savedName, + source: resolvedName.source, + forced: Boolean(flags.force || resolvedName.forceOverwrite), + }, + (data) => { + const suffix = + data.source === "explicit" + ? "" + : data.source === "active" + ? " (reused active account name)" + : data.source === "existing" + ? " (reused saved account name)" + : " (inferred from auth email)"; + this.log(`Saved current Codex auth tokens as "${data.saved}"${suffix}.`); + }, + ); }); } } diff --git a/src/commands/status.ts b/src/commands/status.ts index acfec33..692f1d4 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -3,13 +3,22 @@ import { BaseCommand } from "../lib/base-command"; export default class StatusCommand extends BaseCommand { static description = "Show auto-switch, service, and usage status"; + static flags = { + ...BaseCommand.jsonFlag, + } as const; + async run(): Promise { + const { flags } = await this.parse(StatusCommand); + this.setJsonMode(flags); + await this.runSafe(async () => { const status = await this.accounts.getStatus(); - this.log(`auto-switch: ${status.autoSwitchEnabled ? "ON" : "OFF"}`); - this.log(`service: ${status.serviceState}`); - this.log(`thresholds: 5h<${status.threshold5hPercent}%, weekly<${status.thresholdWeeklyPercent}%`); - this.log(`usage: ${status.usageMode}`); + this.emit(status, (data) => { + this.log(`auto-switch: ${data.autoSwitchEnabled ? "ON" : "OFF"}`); + this.log(`service: ${data.serviceState}`); + this.log(`thresholds: 5h<${data.threshold5hPercent}%, weekly<${data.thresholdWeeklyPercent}%`); + this.log(`usage: ${data.usageMode}`); + }); }); } } diff --git a/src/commands/use.ts b/src/commands/use.ts index 25f773d..eabfd1a 100644 --- a/src/commands/use.ts +++ b/src/commands/use.ts @@ -21,14 +21,22 @@ export default class UseCommand extends BaseCommand { static flags = { "no-kiro": Flags.boolean({ description: "Skip Kiro CLI mirror even if a matching snapshot exists" }), + ...BaseCommand.jsonFlag, } as const; async run(): Promise { + const { args, flags } = await this.parse(UseCommand); + this.setJsonMode(flags); + await this.runSafe(async () => { - const { args, flags } = await this.parse(UseCommand); let account = args.account as string | undefined; if (!account) { + if (this.jsonMode) { + // No interactive prompt allowed under --json: stdout would + // be corrupted by the prompt UI. + throw new PromptCancelledError(); + } account = await this.promptForAccount(); } @@ -37,20 +45,38 @@ export default class UseCommand extends BaseCommand { activated = await this.accounts.useAccount(account); recordSuccess(activated); recordSwitch(); - this.log(`Switched Codex auth to "${activated}".`); } catch (err) { recordFailure(account); throw err; } - if (flags["no-kiro"]) return; - - const mirror = switchKiroSnapshot(activated); - if (mirror.switched) { - this.log(`Mirrored Kiro CLI to "${mirror.active}".`); - } else if (mirror.attempted) { - this.warn(`Kiro mirror skipped: ${mirror.reason}`); + let mirror: { switched: boolean; attempted: boolean; active?: string; reason?: string } = { + switched: false, + attempted: false, + }; + if (!flags["no-kiro"]) { + mirror = switchKiroSnapshot(activated); } + + this.emit( + { + activated, + kiro: { + attempted: mirror.attempted, + switched: mirror.switched, + active: mirror.active ?? null, + reason: mirror.reason ?? null, + }, + }, + (data) => { + this.log(`Switched Codex auth to "${data.activated}".`); + if (data.kiro.switched && data.kiro.active) { + this.log(`Mirrored Kiro CLI to "${data.kiro.active}".`); + } else if (data.kiro.attempted && data.kiro.reason) { + this.warn(`Kiro mirror skipped: ${data.kiro.reason}`); + } + }, + ); }); } diff --git a/src/lib/accounts/errors.ts b/src/lib/accounts/errors.ts index 0a42d1d..db8383a 100644 --- a/src/lib/accounts/errors.ts +++ b/src/lib/accounts/errors.ts @@ -1,7 +1,89 @@ -export class CodexAuthError extends Error { - constructor(message: string) { +// AuthmuxError taxonomy (see docs/future/01-ARCHITECTURE.md §6.2). +// Stable machine codes survive across releases; human-readable `message` +// strings are kept identical to pre-N3 wording so existing scripts that +// grep stdout continue to work. New code should consume `code`/`severity`/ +// `details` rather than parsing `message`. + +export type ErrorCode = + | "E_AUTH_MISSING" + | "E_AUTH_INVALID" + | "E_ACCOUNT_NOT_FOUND" + | "E_NO_ACCOUNTS" + | "E_NAME_INVALID" + | "E_NAME_INFERENCE_FAILED" + | "E_SNAPSHOT_EMAIL_MISMATCH" + | "E_PROMPT_CANCELLED" + | "E_REMOVE_EMPTY_SELECTION" + | "E_QUERY_AMBIGUOUS" + | "E_AUTOSWITCH_CONFIG" + | "E_REGISTRY_LOCKED" + | "E_REGISTRY_CORRUPT" + | "E_SNAPSHOT_CLOBBERED" + | "E_DAEMON_UNSUPPORTED_OS" + | "E_PROVIDER_NOT_INSTALLED" + | "E_USAGE_FETCH_FAILED"; + +export type ErrorSeverity = "fatal" | "warn" | "info"; + +export interface AuthmuxErrorJSON { + ok: false; + error: { + code: ErrorCode; + severity: ErrorSeverity; + message: string; + hint?: string; + details?: Record; + }; +} + +export class AuthmuxError extends Error { + public readonly code: ErrorCode; + public readonly severity: ErrorSeverity; + public readonly hint?: string; + public readonly details?: Record; + + constructor( + code: ErrorCode, + severity: ErrorSeverity, + message: string, + hint?: string, + details?: Record, + ) { super(message); this.name = new.target.name; + this.code = code; + this.severity = severity; + this.hint = hint; + this.details = details; + } + + toJSON(): AuthmuxErrorJSON { + return { + ok: false, + error: { + code: this.code, + severity: this.severity, + message: this.message, + ...(this.hint !== undefined ? { hint: this.hint } : {}), + ...(this.details !== undefined ? { details: this.details } : {}), + }, + }; + } +} + +// Back-compat alias. Pre-N3 code (and external consumers) imported +// `CodexAuthError` as the catch-all base. Keeping it as a subclass of +// `AuthmuxError` lets `instanceof CodexAuthError` checks keep working. +export class CodexAuthError extends AuthmuxError { + constructor( + message: string, + code: ErrorCode = "E_AUTH_INVALID", + severity: ErrorSeverity = "fatal", + hint?: string, + details?: Record, + ) { + super(code, severity, message, hint, details); + this.name = new.target.name; } } @@ -10,19 +92,34 @@ export class AuthFileMissingError extends CodexAuthError { super( `No Codex auth file found at ${targetPath}. ` + `Log into Codex first so ~/.codex/auth.json exists.`, + "E_AUTH_MISSING", + "fatal", + `Run \`codex login\` (or the matching provider login) so ${targetPath} exists.`, + { path: targetPath }, ); } } export class AccountNotFoundError extends CodexAuthError { constructor(accountName: string) { - super(`No saved Codex account named "${accountName}" was found.`); + super( + `No saved Codex account named "${accountName}" was found.`, + "E_ACCOUNT_NOT_FOUND", + "fatal", + `Run "authmux list" to see available names.`, + { name: accountName }, + ); } } export class NoAccountsSavedError extends CodexAuthError { constructor() { - super(`No saved Codex accounts yet. Run "authmux save " first.`); + super( + `No saved Codex accounts yet. Run "authmux save " first.`, + "E_NO_ACCOUNTS", + "fatal", + `Run "authmux save " after logging into a provider.`, + ); } } @@ -31,13 +128,21 @@ export class InvalidAccountNameError extends CodexAuthError { super( "Account names must include at least one non-space character and " + "may contain letters, numbers, dashes, underscores, dots, and @.", + "E_NAME_INVALID", + "fatal", + `Pick a name matching /^[A-Za-z0-9._@-]+$/.`, ); } } export class AccountNameInferenceError extends CodexAuthError { constructor() { - super("Could not infer account name from auth email. Pass one explicitly: authmux save ."); + super( + "Could not infer account name from auth email. Pass one explicitly: authmux save .", + "E_NAME_INFERENCE_FAILED", + "fatal", + `Pass an explicit name: "authmux save ".`, + ); } } @@ -47,30 +152,50 @@ export class SnapshotEmailMismatchError extends CodexAuthError { `Refusing to overwrite snapshot "${accountName}" because it belongs to ` + `${existingEmail}, but current auth is ${incomingEmail}. ` + `Use a different name, run "authmux remove ${accountName}" first, or re-run with --force.`, + "E_SNAPSHOT_EMAIL_MISMATCH", + "fatal", + `Use a different name, "authmux remove ${accountName}", or pass --force.`, + { accountName, existingEmail, incomingEmail }, ); } } export class PromptCancelledError extends CodexAuthError { constructor() { - super("No account selected. The operation was cancelled."); + super( + "No account selected. The operation was cancelled.", + "E_PROMPT_CANCELLED", + "info", + `Re-run and pick an account, or pass it explicitly as an argument.`, + ); } } export class InvalidRemoveSelectionError extends CodexAuthError { constructor() { - super("No accounts were selected for removal."); + super( + "No accounts were selected for removal.", + "E_REMOVE_EMPTY_SELECTION", + "warn", + `Pick at least one account, or pass names as arguments.`, + ); } } export class AmbiguousAccountQueryError extends CodexAuthError { constructor(query: string) { - super(`Query "${query}" matched multiple accounts. Refine the query or use interactive mode.`); + super( + `Query "${query}" matched multiple accounts. Refine the query or use interactive mode.`, + "E_QUERY_AMBIGUOUS", + "fatal", + `Refine the query or omit it to pick interactively.`, + { query }, + ); } } export class AutoSwitchConfigError extends CodexAuthError { constructor(message: string) { - super(message); + super(message, "E_AUTOSWITCH_CONFIG", "fatal"); } } diff --git a/src/lib/accounts/index.ts b/src/lib/accounts/index.ts index 75de14c..a5dc816 100644 --- a/src/lib/accounts/index.ts +++ b/src/lib/accounts/index.ts @@ -3,9 +3,11 @@ import { AccountService } from "./account-service"; export { AccountService } from "./account-service"; export type { AccountChoice, RemoveResult } from "./account-service"; export { + AccountNameInferenceError, AccountNotFoundError, AmbiguousAccountQueryError, AuthFileMissingError, + AuthmuxError, AutoSwitchConfigError, CodexAuthError, InvalidAccountNameError, @@ -14,6 +16,7 @@ export { PromptCancelledError, SnapshotEmailMismatchError, } from "./errors"; +export type { AuthmuxErrorJSON, ErrorCode, ErrorSeverity } from "./errors"; export type { AutoSwitchRunResult, StatusReport } from "./types"; export const accountService = new AccountService(); diff --git a/src/lib/base-command.ts b/src/lib/base-command.ts index 58d947a..606fa68 100644 --- a/src/lib/base-command.ts +++ b/src/lib/base-command.ts @@ -1,10 +1,27 @@ -import { Command } from "@oclif/core"; -import { accountService, CodexAuthError } from "./accounts"; +import { Command, Flags } from "@oclif/core"; +import { accountService, AuthmuxError, CodexAuthError } from "./accounts"; +import { + exitCodeForErrorCode, + jsonSuccess, + writeJsonEnvelope, +} from "./cli/json-envelope"; export abstract class BaseCommand extends Command { protected readonly accounts = accountService; protected readonly syncExternalAuthBeforeRun: boolean = true; + // Per-command JSON support. Commands that opt in must include + // `...BaseCommand.jsonFlag` in their static flags. + static jsonFlag = { + json: Flags.boolean({ + description: "Emit a single JSON envelope to stdout (Theme N3).", + default: false, + }), + } as const; + + // Set by the per-command parse to route output/error formatting. + protected jsonMode = false; + protected async runSafe(action: () => Promise): Promise { try { if (this.syncExternalAuthBeforeRun) { @@ -16,9 +33,41 @@ export abstract class BaseCommand extends Command { } } + // For commands that produce a structured payload. When `--json` is on, + // emits `{ ok: true, data }` and suppresses any human text the caller + // would have written. Otherwise calls `humanPrinter(data)`. + protected emit(data: T, humanPrinter: (data: T) => void): void { + if (this.jsonMode) { + writeJsonEnvelope(jsonSuccess(data)); + return; + } + humanPrinter(data); + } + + // Read the parsed --json flag and remember it for the rest of the run. + // Call this once from inside `run()` after `await this.parse(...)`. + protected setJsonMode(flags: { json?: boolean }): void { + this.jsonMode = Boolean(flags.json); + } + private handleError(error: unknown): never { + if (error instanceof AuthmuxError) { + if (this.jsonMode) { + writeJsonEnvelope(error.toJSON()); + return this.exit(exitCodeForErrorCode(error.code)) as never; + } + // Human path: keep historical wording exactly. oclif `this.error` + // sets exit=2 by default, which is "usage error". Use a structured + // exit code from the §6.3 table instead. + process.stderr.write(`Error: ${error.message}\n`); + return this.exit(exitCodeForErrorCode(error.code)) as never; + } + + // Legacy: pre-N3 subclasses that didn't migrate. Preserved for safety + // — `CodexAuthError` now extends `AuthmuxError`, so this branch is + // effectively dead, but kept to surface non-Authmux errors. if (error instanceof CodexAuthError) { - this.error(error.message); + this.error((error as Error).message); } throw error; diff --git a/src/lib/cli/json-envelope.ts b/src/lib/cli/json-envelope.ts new file mode 100644 index 0000000..59982de --- /dev/null +++ b/src/lib/cli/json-envelope.ts @@ -0,0 +1,60 @@ +// JSON envelope shape for --json output (Theme N3). +// Success: { "ok": true, "data": } +// Error: { "ok": false, "error": { code, severity, message, hint?, details? } } +// One object per invocation, written to stdout via console.log. Human prose +// goes to stderr (or is suppressed) so stdout stays parseable. + +import type { AuthmuxErrorJSON, ErrorCode } from "../accounts/errors"; +import { AuthmuxError } from "../accounts/errors"; + +export interface JsonSuccess { + ok: true; + data: T; +} + +export type JsonEnvelope = JsonSuccess | AuthmuxErrorJSON; + +export function jsonSuccess(data: T): JsonSuccess { + return { ok: true, data }; +} + +export function jsonError(err: AuthmuxError): AuthmuxErrorJSON { + return err.toJSON(); +} + +// Exit-code table per docs/future/01-ARCHITECTURE.md §6.3. +// 0 Success +// 1 Generic failure +// 2 Usage error (oclif default) +// 3 E_AUTH_MISSING +// 4 E_ACCOUNT_NOT_FOUND +// 5 E_SNAPSHOT_EMAIL_MISMATCH +// 6 E_REGISTRY_LOCKED +// 7 E_REGISTRY_CORRUPT +// 8 E_PROVIDER_NOT_INSTALLED +// 64 E_PROMPT_CANCELLED +export function exitCodeForErrorCode(code: ErrorCode): number { + switch (code) { + case "E_AUTH_MISSING": + return 3; + case "E_ACCOUNT_NOT_FOUND": + return 4; + case "E_SNAPSHOT_EMAIL_MISMATCH": + return 5; + case "E_REGISTRY_LOCKED": + return 6; + case "E_REGISTRY_CORRUPT": + return 7; + case "E_PROVIDER_NOT_INSTALLED": + return 8; + case "E_PROMPT_CANCELLED": + return 64; + default: + return 1; + } +} + +export function writeJsonEnvelope(envelope: JsonEnvelope): void { + // Single-line JSON on stdout; nothing else. + process.stdout.write(`${JSON.stringify(envelope)}\n`); +} diff --git a/src/tests/error-taxonomy.test.ts b/src/tests/error-taxonomy.test.ts new file mode 100644 index 0000000..3b195a1 --- /dev/null +++ b/src/tests/error-taxonomy.test.ts @@ -0,0 +1,167 @@ +// Enforces the §6.2 error-code allowlist and the §6.3 severity table. +// If you add a new AuthmuxError subclass, append it here too. + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + AccountNameInferenceError, + AccountNotFoundError, + AmbiguousAccountQueryError, + AuthFileMissingError, + AuthmuxError, + AutoSwitchConfigError, + CodexAuthError, + InvalidAccountNameError, + InvalidRemoveSelectionError, + NoAccountsSavedError, + PromptCancelledError, + SnapshotEmailMismatchError, +} from "../lib/accounts"; +import type { ErrorCode, ErrorSeverity } from "../lib/accounts"; +import { exitCodeForErrorCode } from "../lib/cli/json-envelope"; + +const ALLOWED_CODES: ReadonlySet = new Set([ + "E_AUTH_MISSING", + "E_AUTH_INVALID", + "E_ACCOUNT_NOT_FOUND", + "E_NO_ACCOUNTS", + "E_NAME_INVALID", + "E_NAME_INFERENCE_FAILED", + "E_SNAPSHOT_EMAIL_MISMATCH", + "E_PROMPT_CANCELLED", + "E_REMOVE_EMPTY_SELECTION", + "E_QUERY_AMBIGUOUS", + "E_AUTOSWITCH_CONFIG", + "E_REGISTRY_LOCKED", + "E_REGISTRY_CORRUPT", + "E_SNAPSHOT_CLOBBERED", + "E_DAEMON_UNSUPPORTED_OS", + "E_PROVIDER_NOT_INSTALLED", + "E_USAGE_FETCH_FAILED", +]); + +interface Expectation { + className: string; + instance: AuthmuxError; + expectedCode: ErrorCode; + expectedSeverity: ErrorSeverity; +} + +const expectations: Expectation[] = [ + { + className: "AuthFileMissingError", + instance: new AuthFileMissingError("/tmp/auth.json"), + expectedCode: "E_AUTH_MISSING", + expectedSeverity: "fatal", + }, + { + className: "AccountNotFoundError", + instance: new AccountNotFoundError("alice"), + expectedCode: "E_ACCOUNT_NOT_FOUND", + expectedSeverity: "fatal", + }, + { + className: "NoAccountsSavedError", + instance: new NoAccountsSavedError(), + expectedCode: "E_NO_ACCOUNTS", + expectedSeverity: "fatal", + }, + { + className: "InvalidAccountNameError", + instance: new InvalidAccountNameError(), + expectedCode: "E_NAME_INVALID", + expectedSeverity: "fatal", + }, + { + className: "AccountNameInferenceError", + instance: new AccountNameInferenceError(), + expectedCode: "E_NAME_INFERENCE_FAILED", + expectedSeverity: "fatal", + }, + { + className: "SnapshotEmailMismatchError", + instance: new SnapshotEmailMismatchError("alice", "a@x.com", "b@x.com"), + expectedCode: "E_SNAPSHOT_EMAIL_MISMATCH", + expectedSeverity: "fatal", + }, + { + className: "PromptCancelledError", + instance: new PromptCancelledError(), + expectedCode: "E_PROMPT_CANCELLED", + expectedSeverity: "info", + }, + { + className: "InvalidRemoveSelectionError", + instance: new InvalidRemoveSelectionError(), + expectedCode: "E_REMOVE_EMPTY_SELECTION", + expectedSeverity: "warn", + }, + { + className: "AmbiguousAccountQueryError", + instance: new AmbiguousAccountQueryError("ali"), + expectedCode: "E_QUERY_AMBIGUOUS", + expectedSeverity: "fatal", + }, + { + className: "AutoSwitchConfigError", + instance: new AutoSwitchConfigError("bad config"), + expectedCode: "E_AUTOSWITCH_CONFIG", + expectedSeverity: "fatal", + }, +]; + +test("every error class extends AuthmuxError and has an allowlisted code", () => { + for (const exp of expectations) { + assert.ok( + exp.instance instanceof AuthmuxError, + `${exp.className} must extend AuthmuxError`, + ); + assert.equal(exp.instance.code, exp.expectedCode, `${exp.className} code`); + assert.equal( + exp.instance.severity, + exp.expectedSeverity, + `${exp.className} severity`, + ); + assert.ok( + ALLOWED_CODES.has(exp.instance.code), + `${exp.className} code ${exp.instance.code} is not in §6.2 allowlist`, + ); + } +}); + +test("CodexAuthError remains a back-compat subclass of AuthmuxError", () => { + const err = new CodexAuthError("legacy"); + assert.ok(err instanceof AuthmuxError); + assert.ok(err instanceof CodexAuthError); + // Every concrete subclass must also satisfy `instanceof CodexAuthError` + // because legacy code (and BaseCommand pre-N3) caught that class. + for (const exp of expectations) { + assert.ok( + exp.instance instanceof CodexAuthError, + `${exp.className} should still be a CodexAuthError for back-compat`, + ); + } +}); + +test("toJSON envelope has the §6.3 shape", () => { + const err = new AccountNotFoundError("alice"); + const env = err.toJSON(); + assert.equal(env.ok, false); + assert.equal(env.error.code, "E_ACCOUNT_NOT_FOUND"); + assert.equal(env.error.severity, "fatal"); + assert.equal(typeof env.error.message, "string"); + assert.deepEqual(env.error.details, { name: "alice" }); +}); + +test("exit-code table matches §6.3", () => { + assert.equal(exitCodeForErrorCode("E_AUTH_MISSING"), 3); + assert.equal(exitCodeForErrorCode("E_ACCOUNT_NOT_FOUND"), 4); + assert.equal(exitCodeForErrorCode("E_SNAPSHOT_EMAIL_MISMATCH"), 5); + assert.equal(exitCodeForErrorCode("E_REGISTRY_LOCKED"), 6); + assert.equal(exitCodeForErrorCode("E_REGISTRY_CORRUPT"), 7); + assert.equal(exitCodeForErrorCode("E_PROVIDER_NOT_INSTALLED"), 8); + assert.equal(exitCodeForErrorCode("E_PROMPT_CANCELLED"), 64); + // Generic fallback for unmapped codes. + assert.equal(exitCodeForErrorCode("E_USAGE_FETCH_FAILED"), 1); +});