Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 35 additions & 17 deletions packages/axi-sdk-js/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export interface AxiCliOptions<TContext = undefined> {
commands: Record<string, AxiCliCommand<TContext>>;
home: AxiCliCommand<TContext>;
getCommandHelp?: (command: string) => string | null | undefined;
initialize?: () => void;
initialize?: () => MaybePromise<void>;
resolveContext?: (input: AxiResolveContextInput) => MaybePromise<TContext>;
stdout?: { write: (chunk: string) => unknown };
renderUnknownCommand?: (command: string) => string;
Expand Down Expand Up @@ -74,9 +74,15 @@ function defaultUnknownCommand(command: string): string {
export async function runAxiCli<TContext = undefined>(
options: AxiCliOptions<TContext>,
): Promise<void> {
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") {
Expand Down Expand Up @@ -108,11 +114,14 @@ export async function runAxiCli<TContext = undefined>(

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;
}

Expand Down Expand Up @@ -148,25 +157,26 @@ export async function runAxiCli<TContext = undefined>(
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<TContext>(
handler: AxiCliCommand<TContext>,
args: string[],
context: TContext | undefined,
contextInput: AxiResolveContextInput,
stdout: { write: (chunk: string) => unknown },
options: AxiCliOptions<TContext>,
isHomeView: boolean,
): Promise<void> {
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);
}
}

Expand All @@ -189,12 +199,20 @@ async function runBuiltinUpdate<TContext>(
});
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<TContext>(
error: unknown,
stdout: { write: (chunk: string) => unknown },
options: AxiCliOptions<TContext>,
): 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";
}
Expand Down
99 changes: 99 additions & 0 deletions packages/axi-sdk-js/test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) };
Expand Down Expand Up @@ -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) => {
Expand Down
39 changes: 39 additions & 0 deletions packages/axi-sdk-js/test/fixtures/error-boundary-bin.mjs
Original file line number Diff line number Diff line change
@@ -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: [] }),
},
});
Loading