Skip to content
Merged
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
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"@aws-sdk/client-bedrock-agentcore-control": "^3.1079.0",
"@aws-sdk/client-iam": "^3.1080.0",
"@inkui-cli/data-table": "^0.2.0",
"@smithy/core": "3.29.3",
"@tanstack/react-query": "^5.101.2",
"commander": "^15.0.0",
"ink": "^7.1.0",
Expand Down
66 changes: 66 additions & 0 deletions src/errors/errors.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, test, expect } from "bun:test";
import {
AccessDeniedException,
ValidationException,
InternalServerException,
} from "@aws-sdk/client-bedrock-agentcore-control";
import { AgentCoreCLIError, InputValidationError } from "./errors";

describe("AgentCoreCLIError", () => {
test("fromError preserves existing AgentCoreCLIError instances", () => {
const err = new AgentCoreCLIError("cli error", { source: "internal" });
expect(AgentCoreCLIError.fromError(err)).toBe(err);
});

test("fromError preserves AgentCoreCLIError subclasses", () => {
const err = new InputValidationError("bad input");
expect(AgentCoreCLIError.fromError(err)).toBe(err);
});

test.each([
[
"AccessDeniedException (403)",
new AccessDeniedException({ $metadata: { httpStatusCode: 403 }, message: "" }),
"user",
],
[
"ValidationException (400)",
new ValidationException({
$metadata: { httpStatusCode: 400 },
message: "",
reason: "FieldValidationFailed",
}),
"user",
],
[
"InternalServerException (500)",
new InternalServerException({ $metadata: { httpStatusCode: 500 }, message: "" }),
"service",
],
[
"InternalServerException (no status)",
new InternalServerException({ $metadata: {}, message: "" }),
"service",
],
])("fromError SDK %s → expected source", (_label, err, expectedSource) => {
const result = AgentCoreCLIError.fromError(err as Error);
expect(result.json()).toMatchObject({
name: "AgentCoreCLIError",
source: expectedSource,
});
});

test.each([
["Error", new Error("plain error"), "plain error"],
["string", "string error", "string error"],
["null", null, "null"],
["undefined", undefined, "undefined"],
])("fromError non-SDK %s → internal source", (_label, input, expectedMessage) => {
const result = AgentCoreCLIError.fromError(input);
expect(result.json()).toMatchObject({
name: "AgentCoreCLIError",
source: "internal",
message: expectedMessage,
});
});
});
66 changes: 66 additions & 0 deletions src/errors/errors.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { ServiceException } from "@smithy/core/client";
import { ERROR_SOURCE, type ErrorSource } from "./types";

export interface AgentCoreCLIErrorOptions extends ErrorOptions {
/** The source fo the error. See {@link ErrorSource} for more information */
source?: ErrorSource;
/** Arbitrary metdata to attach to errors for logging */
meta?: Record<string, unknown>;
/** Describes the exitCode for the CLI when this error hits the root handler */
exitCode?: number;
}

/** Base error for CLI failures, including their source, metadata, and process exit code. */
export class AgentCoreCLIError extends Error {
readonly source: ErrorSource;
readonly meta: Record<string, unknown>;
readonly exitCode: number;

constructor(message?: string, options?: AgentCoreCLIErrorOptions) {
super(message, options);
this.name = new.target.name;
this.source = options?.source ?? ERROR_SOURCE.INTERNAL;
this.meta = options?.meta ?? {};
this.exitCode = options?.exitCode ?? 1;
}
/** Convert the error into an object with its attributes enumerated as keys **/
json(): Record<string, unknown> {
return {
name: this.name,
message: this.message,
stack: this.stack,
exitCode: this.exitCode,
meta: this.meta,
source: this.source,
};
}

static fromError(error: unknown): AgentCoreCLIError {
if (error instanceof AgentCoreCLIError) return error;

if (ServiceException.isInstance(error)) {
const httpStatusCode = error.$metadata.httpStatusCode;
const source =
httpStatusCode !== undefined && httpStatusCode >= 400 && httpStatusCode < 500
? ERROR_SOURCE.USER
: ERROR_SOURCE.SERVICE;

return new AgentCoreCLIError(error.message, {
cause: error,
source,
meta: { ...error.$metadata },
});
}

if (error instanceof Error) return new AgentCoreCLIError(error.message, { cause: error });

return new AgentCoreCLIError(String(error), { cause: error });
}
}

/** Error raised for invalid user input. */
export class InputValidationError extends AgentCoreCLIError {
constructor(message?: string, options?: Omit<AgentCoreCLIErrorOptions, "source">) {
super(message, { ...options, source: ERROR_SOURCE.USER });
}
}
1 change: 1 addition & 0 deletions src/errors/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AgentCoreCLIError, InputValidationError } from "./errors";
9 changes: 9 additions & 0 deletions src/errors/types.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const ERROR_SOURCE = {
// note: this maps to the `client` error source in telemetry.
INTERNAL: "internal",
USER: "user",
SERVICE: "service",
} as const;

/** Describes the source of the error, whether it was the user, internal to the CLI, a service, or unknown. */
export type ErrorSource = (typeof ERROR_SOURCE)[keyof typeof ERROR_SOURCE];
9 changes: 4 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { FsReadWriteJson } from "./io";
import { createFileLogger, LOG_LEVEL } from "./logging";
import { runWithExitCode } from "./runnable";
import { DefaultGlobalConfigAccessor } from "./globalConfig";
import { AgentCoreCLIError } from "./errors";

process.exit(
await runWithExitCode(async (argv: string[]) => {
Expand Down Expand Up @@ -65,11 +66,9 @@ process.exit(
// Handle the request
await rootHandler.route(argv);
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e));
rootLogger
.child({ errorName: error.name, errorMessage: error.message, stack: error.stack ?? "" })
.error();
throw e;
const error = AgentCoreCLIError.fromError(e);
rootLogger.child({ error: error.json() }).error();
throw error;
} finally {
await rootLogger.end();
}
Expand Down
26 changes: 3 additions & 23 deletions src/middleware/withLogging.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ describe("withLogging", () => {
]);
});

test("logs success and error with correct command path bindings", async () => {
test("logs success with the correct command path binding", async () => {
const app = new Router("myapp", "test app");
app.use(withLogging({ logger }));
app.handler(
Expand All @@ -72,33 +72,13 @@ describe("withLogging", () => {
handle: async () => {},
}),
);
app.handler(
createHandler({
name: "boom",
description: "throws",
handle: async () => {
throw new Error("connection timeout");
},
}),
);

await app.route(["node", "myapp", "happy"]);
await app.route(["node", "myapp", "boom"]).catch(() => {});
await app.route(["node", "myapp", "happy"]);

await assertLogsMatch(tempDir, [
{
filter: (l: any) =>
l.msg === "command executed successfully" && l.commandPath === "/myapp/happy",
expectedCount: 2,
},
{
filter: (l: any) =>
l.level === "error" &&
l.msg === "command failed" &&
l.errorName === "Error" &&
l.errorMessage === "connection timeout" &&
l.commandPath === "/myapp/boom",
filter: (log: any) =>
log.msg === "command executed successfully" && log.commandPath === "/myapp/happy",
expectedCount: 1,
},
]);
Expand Down
19 changes: 6 additions & 13 deletions src/middleware/withLogging.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function redactSensitiveFlags(

/**
* Middleware that creates a child logger bound to the current command path
* and logs execution start, success, and failure.
* and logs execution start and success.
*
* @param config - Contains the root {@link Logger} to derive children from.
*/
Expand All @@ -39,18 +39,11 @@ export function withLogging(config: WithLoggingConfig): Middleware {
handle: async (ctx, flags, args) => {
const commandPath = ctx.require(PathKey);
const logger = config.logger.child({ commandPath });
try {
const safeFlags = redactSensitiveFlags(flags, h.flags());
logger.child({ flags: safeFlags, args }).debug("executing command");
await h.handle(ctx.withValue<Logger>(LoggerKey, logger), flags, args);
logger.debug("command executed successfully");
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
logger
.child({ errorName: error.name, errorMessage: error.message, stack: error.stack ?? "" })
.error("command failed");
throw err;
}
const safeFlags = redactSensitiveFlags(flags, h.flags());

logger.child({ flags: safeFlags, args }).debug("executing command");
await h.handle(ctx.withValue<Logger>(LoggerKey, logger), flags, args);
logger.debug("command executed successfully");
},
});
}
30 changes: 19 additions & 11 deletions src/runnable/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { test, expect } from "bun:test";
import { expect, test } from "bun:test";

import { runRunnable, runWithExitCode, ExitCode, type Runnable } from "./index.tsx";
import { AgentCoreCLIError } from "../errors";
import { runRunnable, type Runnable } from "./index.tsx";

test("returns SUCCESS and forwards argv when run completes", async () => {
test("returns zero and forwards argv when run completes", async () => {
let receivedArgv: string[] | undefined;
const runnable: Runnable = {
run: async (argv: string[]) => {
Expand All @@ -13,11 +14,11 @@ test("returns SUCCESS and forwards argv when run completes", async () => {
const argv = ["node", "script", "--flag"];
const code = await runRunnable(() => runnable, argv);

expect(code).toBe(ExitCode.SUCCESS);
expect(code).toBe(0);
expect(receivedArgv).toEqual(argv);
});

test("returns FAILURE when run rejects with an Error", async () => {
test("returns the default failure code when run rejects with an Error", async () => {
const runnable: Runnable = {
run: async () => {
throw new Error("boom");
Expand All @@ -26,18 +27,25 @@ test("returns FAILURE when run rejects with an Error", async () => {

const code = await runRunnable(() => runnable, []);

expect(code).toBe(ExitCode.FAILURE);
expect(code).toBe(1);
});

test("returns FAILURE when the factory throws a non-Error value", async () => {
test("returns the default failure code when the factory throws a non-Error value", async () => {
const code = await runRunnable(() => {
throw "kaboom";
}, []);

expect(code).toBe(ExitCode.FAILURE);
expect(code).toBe(1);
});

test("runWithExitCode returns SUCCESS for a resolving function", async () => {
const code = await runWithExitCode(async () => {});
expect(code).toBe(ExitCode.SUCCESS);
test("respects custom errors codes from known errors", async () => {
const runnable: Runnable = {
run: async () => {
throw new AgentCoreCLIError("custom failure", { exitCode: 42 });
},
};

const code = await runRunnable(() => runnable, []);

expect(code).toBe(42);
});
17 changes: 7 additions & 10 deletions src/runnable/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
// ExitCode provides names for default Unix exit codes.
export enum ExitCode {
SUCCESS = 0,
FAILURE = 1,
}
import { AgentCoreCLIError } from "../errors";

// Runnable can be implemented by any application's main entrypoint.
export interface Runnable {
Expand All @@ -13,7 +9,7 @@ export interface Runnable {
export function runRunnable(
createRunnable: () => Runnable,
argv: string[] = process.argv,
): Promise<ExitCode> {
): Promise<number> {
return runWithExitCode(async () => {
await createRunnable().run(argv);
});
Expand All @@ -23,13 +19,14 @@ export function runRunnable(
export async function runWithExitCode(
fn: (argv: string[]) => Promise<void>,
argv: string[] = process.argv,
): Promise<ExitCode> {
): Promise<number> {
try {
await fn(argv);
return ExitCode.SUCCESS;
return 0;
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e));
console.error(`${error.name}: ${error.message}`);
return ExitCode.FAILURE;
console.error(`Error: ${error.message}`);

return error instanceof AgentCoreCLIError ? error.exitCode : 1;
}
}
Loading