diff --git a/packages/axi-sdk-js/src/cli.ts b/packages/axi-sdk-js/src/cli.ts index 76f61f37..fcce6b6a 100644 --- a/packages/axi-sdk-js/src/cli.ts +++ b/packages/axi-sdk-js/src/cli.ts @@ -40,7 +40,7 @@ export interface AxiCliOptions { commands: Record>; home: AxiCliCommand; getCommandHelp?: (command: string) => string | null | undefined; - initialize?: () => void; + initialize?: () => MaybePromise; resolveContext?: (input: AxiResolveContextInput) => MaybePromise; stdout?: { write: (chunk: string) => unknown }; renderUnknownCommand?: (command: string) => string; @@ -74,9 +74,15 @@ function defaultUnknownCommand(command: string): string { export async function runAxiCli( options: AxiCliOptions, ): Promise { - options.initialize?.(); - const stdout = options.stdout ?? process.stdout; + + try { + await options.initialize?.(); + } catch (error) { + writeFormattedError(error, stdout, options); + return; + } + const argv = options.argv ?? process.argv.slice(2); if (argv.length === 1 && argv[0] === "--help") { @@ -108,11 +114,14 @@ export async function runAxiCli( const command = argv[0]; if (!command) { - const context = await options.resolveContext?.({ - command: undefined, - args: [], - }); - await runHandler(options.home, [], context, stdout, options, true); + await runHandler( + options.home, + [], + { command: undefined, args: [] }, + stdout, + options, + true, + ); return; } @@ -148,25 +157,26 @@ export async function runAxiCli( return; } - const context = await options.resolveContext?.({ command, args }); - await runHandler(handler, args, context, stdout, options, false); + await runHandler(handler, args, { command, args }, stdout, options, false); } async function runHandler( handler: AxiCliCommand, args: string[], - context: TContext | undefined, + contextInput: AxiResolveContextInput, stdout: { write: (chunk: string) => unknown }, options: AxiCliOptions, isHomeView: boolean, ): Promise { try { + // Context resolution stays inside this boundary so a failing `resolveContext` + // reports through the same structured-error contract as the handler itself, + // and still only runs for views that actually need a context. + const context = await options.resolveContext?.(contextInput); const output = await handler(args, context); stdout.write(`${renderCommandOutput(output, options, isHomeView)}\n`); } catch (error) { - const formatted = (options.formatError ?? defaultFormatError)(error); - stdout.write(formatted.output); - process.exitCode = formatted.exitCode; + writeFormattedError(error, stdout, options); } } @@ -189,12 +199,20 @@ async function runBuiltinUpdate( }); stdout.write(`${renderOutput(output)}\n`); } catch (error) { - const formatted = (options.formatError ?? defaultFormatError)(error); - stdout.write(formatted.output); - process.exitCode = formatted.exitCode; + writeFormattedError(error, stdout, options); } } +function writeFormattedError( + error: unknown, + stdout: { write: (chunk: string) => unknown }, + options: AxiCliOptions, +): void { + const formatted = (options.formatError ?? defaultFormatError)(error); + stdout.write(formatted.output); + process.exitCode = formatted.exitCode; +} + function resolveBinName(): string { return basename(process.argv[1] ?? "tool") || "tool"; } diff --git a/packages/axi-sdk-js/test/cli.test.ts b/packages/axi-sdk-js/test/cli.test.ts index f889a8d3..c1ea6b88 100644 --- a/packages/axi-sdk-js/test/cli.test.ts +++ b/packages/axi-sdk-js/test/cli.test.ts @@ -37,6 +37,39 @@ import { AxiError } from "../src/errors.js"; const execFileAsync = promisify(execFile); +async function runErrorBoundaryFixture( + phase: "initialize" | "initialize-async" | "resolveContext", + errorKind: "axi" | "generic", + view: "home" | "command", +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + const fixturePath = fileURLToPath( + new URL("./fixtures/error-boundary-bin.mjs", import.meta.url), + ); + const viteNodePath = fileURLToPath( + new URL("../node_modules/.bin/vite-node", import.meta.url), + ); + + try { + const { stdout, stderr } = await execFileAsync( + viteNodePath, + [fixturePath, phase, errorKind, view], + { cwd: new URL("..", import.meta.url) }, + ); + return { exitCode: 0, stdout, stderr }; + } catch (error) { + const result = error as Error & { + code: number; + stdout: string; + stderr: string; + }; + return { + exitCode: result.code, + stdout: result.stdout, + stderr: result.stderr, + }; + } +} + describe("runAxiCli", () => { const originalArgv = [...process.argv]; const stdout = { write: vi.fn(() => true) }; @@ -440,6 +473,72 @@ describe("runAxiCli", () => { }); describe("runAxiCli subprocess integration", () => { + const failureCases = [ + { + errorKind: "axi" as const, + exitCode: 2, + stdout: + "error: Invalid fixture request\ncode: VALIDATION_ERROR\nhelp[1]: Run `fixture --help`\n", + }, + { + errorKind: "generic" as const, + exitCode: 1, + stdout: "error: Dependency exploded\ncode: UNKNOWN\n", + }, + ]; + + it.each(failureCases)( + "formats $errorKind initialize failures on stdout", + async ({ errorKind, exitCode, stdout }) => { + const result = await runErrorBoundaryFixture( + "initialize", + errorKind, + "home", + ); + + expect(result).toEqual({ exitCode, stdout, stderr: "" }); + }, + ); + + it.each(failureCases)( + "formats $errorKind asynchronous initialize rejections on stdout", + async ({ errorKind, exitCode, stdout }) => { + const result = await runErrorBoundaryFixture( + "initialize-async", + errorKind, + "home", + ); + + expect(result).toEqual({ exitCode, stdout, stderr: "" }); + }, + ); + + it.each(failureCases)( + "formats $errorKind resolveContext failures for home on stdout", + async ({ errorKind, exitCode, stdout }) => { + const result = await runErrorBoundaryFixture( + "resolveContext", + errorKind, + "home", + ); + + expect(result).toEqual({ exitCode, stdout, stderr: "" }); + }, + ); + + it.each(failureCases)( + "formats $errorKind resolveContext failures for commands on stdout", + async ({ errorKind, exitCode, stdout }) => { + const result = await runErrorBoundaryFixture( + "resolveContext", + errorKind, + "command", + ); + + expect(result).toEqual({ exitCode, stdout, stderr: "" }); + }, + ); + it.each(["--version", "-v", "-V"])( "prints version from a real entrypoint for bare %s", async (flag) => { diff --git a/packages/axi-sdk-js/test/fixtures/error-boundary-bin.mjs b/packages/axi-sdk-js/test/fixtures/error-boundary-bin.mjs new file mode 100644 index 00000000..011c5284 --- /dev/null +++ b/packages/axi-sdk-js/test/fixtures/error-boundary-bin.mjs @@ -0,0 +1,39 @@ +import { AxiError, runAxiCli } from "../../src/index.ts"; + +const [phase, errorKind, view] = process.argv.slice(2); + +function fixtureError() { + if (errorKind === "axi") { + return new AxiError("Invalid fixture request", "VALIDATION_ERROR", [ + "Run `fixture --help`", + ]); + } + + return new Error("Dependency exploded"); +} + +await runAxiCli({ + description: "Fixture CLI", + argv: view === "command" ? ["issue", "list"] : [], + topLevelHelp: "fixture help", + initialize: + phase === "initialize" + ? () => { + throw fixtureError(); + } + : phase === "initialize-async" + ? async () => { + throw fixtureError(); + } + : undefined, + resolveContext: + phase === "resolveContext" + ? async () => { + throw fixtureError(); + } + : undefined, + home: async () => ({ home: "ok" }), + commands: { + issue: async () => ({ issues: [] }), + }, +});