From 6a3b9b4a6ba4e591cc871171fee4dd6575f838af Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Tue, 28 Jul 2026 15:19:53 +0000 Subject: [PATCH 1/3] feat(errors): migrate input parsing errors to modeled validation errors --- src/handlers/config/config.test.tsx | 7 +++---- src/handlers/config/handler.tsx | 16 +++++++--------- src/handlers/harness/create/index.tsx | 3 ++- src/handlers/harness/delete/index.tsx | 3 ++- src/handlers/harness/endpoint/create/index.tsx | 5 +++-- src/handlers/harness/endpoint/delete/index.tsx | 5 +++-- src/handlers/harness/endpoint/get/index.tsx | 5 +++-- src/handlers/harness/endpoint/list/index.tsx | 3 ++- src/handlers/harness/endpoint/update/index.tsx | 5 +++-- src/handlers/harness/exec/index.tsx | 5 +++-- src/handlers/harness/get/index.tsx | 3 ++- src/handlers/harness/invoke/index.tsx | 5 +++-- src/handlers/harness/update/index.tsx | 3 ++- src/handlers/harness/version/get/index.tsx | 5 +++-- src/handlers/harness/version/list/index.tsx | 3 ++- .../api-key-credential-provider/create/index.tsx | 5 +++-- .../api-key-credential-provider/delete/index.tsx | 3 ++- .../api-key-credential-provider/get/index.tsx | 3 ++- .../api-key-credential-provider/update/index.tsx | 5 +++-- src/handlers/runtime/endpoint/get/index.tsx | 5 +++-- src/handlers/runtime/endpoint/list/index.tsx | 3 ++- src/handlers/runtime/get/index.tsx | 3 ++- src/handlers/runtime/version/get/index.tsx | 5 +++-- src/handlers/runtime/version/list/index.tsx | 3 ++- src/handlers/utils.tsx | 4 +++- src/router/args.tsx | 4 +++- src/router/flags.tsx | 4 +++- 27 files changed, 74 insertions(+), 49 deletions(-) diff --git a/src/handlers/config/config.test.tsx b/src/handlers/config/config.test.tsx index a74da3b70..6a81b50e0 100644 --- a/src/handlers/config/config.test.tsx +++ b/src/handlers/config/config.test.tsx @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { createRootHandler } from "../index"; import { createSilentLogger, TestCoreClient, testIO } from "../../testing"; import { DefaultGlobalConfigAccessor } from "../../globalConfig"; +import { InputValidationError } from "../../errors"; import { FsReadWriteJson } from "../../io"; describe("config", () => { @@ -94,13 +95,11 @@ describe("config", () => { }); test("throws on invalid key", async () => { - // TODO: swap to validation error. - await expect(run(["nonexistent.key"])).rejects.toThrow(TypeError); + await expect(run(["nonexistent.key"])).rejects.toThrow(InputValidationError); }); test("throws on invalid value for key", async () => { - // TODO: swap to validation error - await expect(run(["telemetry.enabled", "banana"])).rejects.toThrow(TypeError); + await expect(run(["telemetry.enabled", "banana"])).rejects.toThrow(InputValidationError); }); test("coerces values based on schema", async () => { diff --git a/src/handlers/config/handler.tsx b/src/handlers/config/handler.tsx index 1dc0ca755..f610ef68b 100644 --- a/src/handlers/config/handler.tsx +++ b/src/handlers/config/handler.tsx @@ -1,6 +1,7 @@ import z from "zod"; import { createHandler, argument, GlobalConfigAccessorKey } from "../../router"; import { JsonRendererKey } from "../../tui"; +import { InputValidationError } from "../../errors"; import { DEFAULT_GLOBAL_CONFIG, type GlobalConfig } from "../../globalConfig"; /* @@ -63,16 +64,14 @@ function coerceValue(current: unknown, raw: string, path: string): unknown { const normalized = raw.trim().toLowerCase(); if (normalized === "true") return true; if (normalized === "false") return false; - // TODO: mark as validation error. - throw new TypeError(`Cannot coerce "${raw}" to boolean at "${path}"`); + throw new InputValidationError(`Cannot coerce "${raw}" to boolean at "${path}"`); } case "number": { const trimmed = raw.trim(); const n = Number(trimmed); if (trimmed === "" || Number.isNaN(n)) { - // TODO: mark as validation error. - throw new TypeError(`Cannot coerce "${raw}" to number at "${path}"`); + throw new InputValidationError(`Cannot coerce "${raw}" to number at "${path}"`); } return n; } @@ -81,15 +80,14 @@ function coerceValue(current: unknown, raw: string, path: string): unknown { try { return JSON.parse(raw); } catch (e) { - // TODO: mark as validation error. - - throw new TypeError(`Cannot coerce "${raw}" to object at "${path}"`, { cause: e }); + throw new InputValidationError(`Cannot coerce "${raw}" to object at "${path}"`, { + cause: e, + }); } } default: - // TODO: mark as validation error. - throw new TypeError(`Unsupported target type "${typeof current}" at "${path}"`); + throw new InputValidationError(`Unsupported target type "${typeof current}" at "${path}"`); } } /** Type guard that narrows `value` to a plain object record. */ diff --git a/src/handlers/harness/create/index.tsx b/src/handlers/harness/create/index.tsx index f9b6e4d0a..316d53e3a 100644 --- a/src/handlers/harness/create/index.tsx +++ b/src/handlers/harness/create/index.tsx @@ -13,6 +13,7 @@ import { createHandler, flag } from "../../../router"; import type { Core } from "../../types.tsx"; import { coreOptsFromCtx, parseJsonFlag } from "../../utils.tsx"; import { JsonRendererKey } from "../../../tui"; +import { InputValidationError } from "../../../errors"; import { parameterHelp } from "../parameterHelp.tsx"; export const createCreateHarnessHandler = (core: Core) => @@ -93,7 +94,7 @@ export const createCreateHarnessHandler = (core: Core) => // Required at runtime but declared optional so that a bare // `harness create` falls through to the TUI middleware instead. if (!flags["name"]) { - throw new TypeError("required option '--name ' not specified"); + throw new InputValidationError("required option '--name ' not specified"); } const response = await core.harness.createHarness( diff --git a/src/handlers/harness/delete/index.tsx b/src/handlers/harness/delete/index.tsx index 7e244cf24..35b00cb90 100644 --- a/src/handlers/harness/delete/index.tsx +++ b/src/handlers/harness/delete/index.tsx @@ -3,6 +3,7 @@ import { createHandler, flag } from "../../../router"; import type { Core } from "../../types.tsx"; import { coreOptsFromCtx } from "../../utils.tsx"; import { JsonRendererKey } from "../../../tui"; +import { InputValidationError } from "../../../errors"; export const createDeleteHarnessHandler = (core: Core) => createHandler({ @@ -21,7 +22,7 @@ export const createDeleteHarnessHandler = (core: Core) => // Required at runtime but declared optional so that a bare // `harness delete` falls through to the TUI middleware instead. if (!flags["id"]) { - throw new TypeError("required option '--id ' not specified"); + throw new InputValidationError("required option '--id ' not specified"); } const response = await core.harness.deleteHarness( diff --git a/src/handlers/harness/endpoint/create/index.tsx b/src/handlers/harness/endpoint/create/index.tsx index b298be28f..e4e4ba838 100644 --- a/src/handlers/harness/endpoint/create/index.tsx +++ b/src/handlers/harness/endpoint/create/index.tsx @@ -3,6 +3,7 @@ import { createHandler, flag } from "../../../../router"; import type { Core } from "../../../types.tsx"; import { coreOptsFromCtx, parseJsonFlag } from "../../../utils.tsx"; import { JsonRendererKey } from "../../../../tui"; +import { InputValidationError } from "../../../../errors"; export const createCreateEndpointHandler = (core: Core) => createHandler({ @@ -23,10 +24,10 @@ export const createCreateEndpointHandler = (core: Core) => // Required at runtime but declared optional so that a bare // `harness endpoint create` falls through to the TUI middleware instead. if (!flags["id"]) { - throw new TypeError("required option '--id ' not specified"); + throw new InputValidationError("required option '--id ' not specified"); } if (!flags["name"]) { - throw new TypeError("required option '--name ' not specified"); + throw new InputValidationError("required option '--name ' not specified"); } const response = await core.harness.createHarnessEndpoint( diff --git a/src/handlers/harness/endpoint/delete/index.tsx b/src/handlers/harness/endpoint/delete/index.tsx index 6478b59d6..989de6337 100644 --- a/src/handlers/harness/endpoint/delete/index.tsx +++ b/src/handlers/harness/endpoint/delete/index.tsx @@ -3,6 +3,7 @@ import { createHandler, flag } from "../../../../router"; import type { Core } from "../../../types.tsx"; import { coreOptsFromCtx } from "../../../utils.tsx"; import { JsonRendererKey } from "../../../../tui"; +import { InputValidationError } from "../../../../errors"; export const createDeleteEndpointHandler = (core: Core) => createHandler({ @@ -17,10 +18,10 @@ export const createDeleteEndpointHandler = (core: Core) => // Required at runtime but declared optional so that a bare // `harness endpoint delete` falls through to the TUI middleware instead. if (!flags["id"]) { - throw new TypeError("required option '--id ' not specified"); + throw new InputValidationError("required option '--id ' not specified"); } if (!flags["qualifier"]) { - throw new TypeError("required option '--qualifier ' not specified"); + throw new InputValidationError("required option '--qualifier ' not specified"); } const response = await core.harness.deleteHarnessEndpoint( diff --git a/src/handlers/harness/endpoint/get/index.tsx b/src/handlers/harness/endpoint/get/index.tsx index bfbd9a250..7df1ce552 100644 --- a/src/handlers/harness/endpoint/get/index.tsx +++ b/src/handlers/harness/endpoint/get/index.tsx @@ -3,6 +3,7 @@ import { createHandler, flag } from "../../../../router"; import type { Core } from "../../../types.tsx"; import { coreOptsFromCtx } from "../../../utils.tsx"; import { JsonRendererKey } from "../../../../tui"; +import { InputValidationError } from "../../../../errors"; export const createGetEndpointHandler = (core: Core) => createHandler({ @@ -14,10 +15,10 @@ export const createGetEndpointHandler = (core: Core) => ], handle: async (ctx, flags) => { if (!flags["id"]) { - throw new TypeError("required option '--id ' not specified"); + throw new InputValidationError("required option '--id ' not specified"); } if (!flags["qualifier"]) { - throw new TypeError("required option '--qualifier ' not specified"); + throw new InputValidationError("required option '--qualifier ' not specified"); } const endpoint = await core.harness.getHarnessEndpoint( diff --git a/src/handlers/harness/endpoint/list/index.tsx b/src/handlers/harness/endpoint/list/index.tsx index 75fae0fc9..646c23e59 100644 --- a/src/handlers/harness/endpoint/list/index.tsx +++ b/src/handlers/harness/endpoint/list/index.tsx @@ -3,6 +3,7 @@ import { createHandler, flag } from "../../../../router"; import type { Core } from "../../../types.tsx"; import { coreOptsFromCtx } from "../../../utils.tsx"; import { JsonRendererKey } from "../../../../tui"; +import { InputValidationError } from "../../../../errors"; export const createListEndpointsHandler = (core: Core) => createHandler({ @@ -15,7 +16,7 @@ export const createListEndpointsHandler = (core: Core) => ], handle: async (ctx, flags) => { if (!flags["id"]) { - throw new TypeError("required option '--id ' not specified"); + throw new InputValidationError("required option '--id ' not specified"); } const endpoints = await core.harness.listHarnessEndpoints( diff --git a/src/handlers/harness/endpoint/update/index.tsx b/src/handlers/harness/endpoint/update/index.tsx index d1781bcdf..6ea8e66e2 100644 --- a/src/handlers/harness/endpoint/update/index.tsx +++ b/src/handlers/harness/endpoint/update/index.tsx @@ -3,6 +3,7 @@ import { createHandler, flag } from "../../../../router"; import type { Core } from "../../../types.tsx"; import { coreOptsFromCtx } from "../../../utils.tsx"; import { JsonRendererKey } from "../../../../tui"; +import { InputValidationError } from "../../../../errors"; export const createUpdateEndpointHandler = (core: Core) => createHandler({ @@ -18,10 +19,10 @@ export const createUpdateEndpointHandler = (core: Core) => // Required at runtime but declared optional so that a bare // `harness endpoint update` falls through to the TUI middleware instead. if (!flags["id"]) { - throw new TypeError("required option '--id ' not specified"); + throw new InputValidationError("required option '--id ' not specified"); } if (!flags["qualifier"]) { - throw new TypeError("required option '--qualifier ' not specified"); + throw new InputValidationError("required option '--qualifier ' not specified"); } const response = await core.harness.updateHarnessEndpoint( diff --git a/src/handlers/harness/exec/index.tsx b/src/handlers/harness/exec/index.tsx index 4ef2594e8..046305811 100644 --- a/src/handlers/harness/exec/index.tsx +++ b/src/handlers/harness/exec/index.tsx @@ -5,6 +5,7 @@ import type { Core } from "../../types.tsx"; import { coreOptsFromCtx } from "../../utils.tsx"; import { JsonKey } from "../../keys.tsx"; import { JsonRendererKey, renderTuiAt } from "../../../tui"; +import { InputValidationError } from "../../../errors"; import { applyExecEvent, finishExec, newExecItem } from "../invoke/transcript.tsx"; export const createExecHarnessHandler = (core: Core, io: AppIO) => @@ -34,7 +35,7 @@ export const createExecHarnessHandler = (core: Core, io: AppIO) => // Required at runtime but declared optional so that a bare `harness exec` // falls through to the TUI middleware instead. if (!flags["id"]) { - throw new TypeError("required option '--id ' not specified"); + throw new InputValidationError("required option '--id ' not specified"); } // Without a command, open the interactive exec screen at this harness — // resuming the given session and targeting the given qualifier when @@ -42,7 +43,7 @@ export const createExecHarnessHandler = (core: Core, io: AppIO) => // shape JSON mode supports). if (!flags["command"]) { if (ctx.require(JsonKey)) { - throw new TypeError("required option '--command ' not specified"); + throw new InputValidationError("required option '--command ' not specified"); } let path = `${ctx.require(PathKey)}/${flags["id"]}`; if (flags["session-id"]) path += `/${flags["session-id"]}`; diff --git a/src/handlers/harness/get/index.tsx b/src/handlers/harness/get/index.tsx index cf04b863b..85a4fc4b5 100644 --- a/src/handlers/harness/get/index.tsx +++ b/src/handlers/harness/get/index.tsx @@ -3,6 +3,7 @@ import { createHandler, flag } from "../../../router"; import type { Core } from "../../types.tsx"; import { coreOptsFromCtx } from "../../utils.tsx"; import { JsonRendererKey } from "../../../tui"; +import { InputValidationError } from "../../../errors"; export const createGetHarnessHandler = (core: Core) => createHandler({ @@ -11,7 +12,7 @@ export const createGetHarnessHandler = (core: Core) => flags: [flag("id", "the ID of the harness", z.string().max(48).optional())], handle: async (ctx, flags) => { if (!flags["id"]) { - throw new TypeError("required option '--id ' not specified"); + throw new InputValidationError("required option '--id ' not specified"); } const harness = await core.harness.getHarness(flags["id"], coreOptsFromCtx(ctx)); diff --git a/src/handlers/harness/invoke/index.tsx b/src/handlers/harness/invoke/index.tsx index 82895bfd2..034d33107 100644 --- a/src/handlers/harness/invoke/index.tsx +++ b/src/handlers/harness/invoke/index.tsx @@ -5,6 +5,7 @@ import type { Core } from "../../types.tsx"; import { coreOptsFromCtx } from "../../utils.tsx"; import { JsonKey } from "../../keys.tsx"; import { JsonRendererKey, renderTuiAt } from "../../../tui"; +import { InputValidationError } from "../../../errors"; import { applyEvent, finishTurn, @@ -35,7 +36,7 @@ export const createInvokeHarnessHandler = (core: Core, io: AppIO) => // These are required at runtime but declared optional so that a bare // `harness invoke` falls through to the TUI middleware instead. if (!flags["id"]) { - throw new TypeError("required option '--id ' not specified"); + throw new InputValidationError("required option '--id ' not specified"); } // Without a prompt, open the interactive chat at this harness — resuming // the given session and targeting the given qualifier when passed. The @@ -43,7 +44,7 @@ export const createInvokeHarnessHandler = (core: Core, io: AppIO) => // JSON mode supports). if (!flags["prompt"]) { if (ctx.require(JsonKey)) { - throw new TypeError("required option '--prompt ' not specified"); + throw new InputValidationError("required option '--prompt ' not specified"); } let path = `${ctx.require(PathKey)}/${flags["id"]}`; if (flags["session-id"]) path += `/${flags["session-id"]}`; diff --git a/src/handlers/harness/update/index.tsx b/src/handlers/harness/update/index.tsx index 87e74c51a..800d7ef19 100644 --- a/src/handlers/harness/update/index.tsx +++ b/src/handlers/harness/update/index.tsx @@ -13,6 +13,7 @@ import { createHandler, flag } from "../../../router"; import type { Core } from "../../types.tsx"; import { coreOptsFromCtx, parseJsonFlag } from "../../utils.tsx"; import { JsonRendererKey } from "../../../tui"; +import { InputValidationError } from "../../../errors"; import { parameterHelp } from "../parameterHelp.tsx"; // updated wraps a PATCH-semantics field: `-- ` replaces the value, @@ -110,7 +111,7 @@ export const createUpdateHarnessHandler = (core: Core) => // Required at runtime but declared optional so that a bare // `harness update` falls through to the TUI middleware instead. if (!flags["id"]) { - throw new TypeError("required option '--id ' not specified"); + throw new InputValidationError("required option '--id ' not specified"); } const response = await core.harness.updateHarness( diff --git a/src/handlers/harness/version/get/index.tsx b/src/handlers/harness/version/get/index.tsx index 107649056..7bb760293 100644 --- a/src/handlers/harness/version/get/index.tsx +++ b/src/handlers/harness/version/get/index.tsx @@ -3,6 +3,7 @@ import { createHandler, flag } from "../../../../router"; import type { Core } from "../../../types.tsx"; import { coreOptsFromCtx } from "../../../utils.tsx"; import { JsonRendererKey } from "../../../../tui"; +import { InputValidationError } from "../../../../errors"; export const createGetVersionHandler = (core: Core) => createHandler({ @@ -14,10 +15,10 @@ export const createGetVersionHandler = (core: Core) => ], handle: async (ctx, flags) => { if (!flags["id"]) { - throw new TypeError("required option '--id ' not specified"); + throw new InputValidationError("required option '--id ' not specified"); } if (!flags["version"]) { - throw new TypeError("required option '--version ' not specified"); + throw new InputValidationError("required option '--version ' not specified"); } const harness = await core.harness.getHarnessVersion( diff --git a/src/handlers/harness/version/list/index.tsx b/src/handlers/harness/version/list/index.tsx index 284d42827..fbe00f5fe 100644 --- a/src/handlers/harness/version/list/index.tsx +++ b/src/handlers/harness/version/list/index.tsx @@ -3,6 +3,7 @@ import { createHandler, flag } from "../../../../router"; import type { Core } from "../../../types.tsx"; import { coreOptsFromCtx } from "../../../utils.tsx"; import { JsonRendererKey } from "../../../../tui"; +import { InputValidationError } from "../../../../errors"; export const createListVersionsHandler = (core: Core) => createHandler({ @@ -15,7 +16,7 @@ export const createListVersionsHandler = (core: Core) => ], handle: async (ctx, flags) => { if (!flags["id"]) { - throw new TypeError("required option '--id ' not specified"); + throw new InputValidationError("required option '--id ' not specified"); } const versions = await core.harness.listHarnessVersions( diff --git a/src/handlers/identity/api-key-credential-provider/create/index.tsx b/src/handlers/identity/api-key-credential-provider/create/index.tsx index 72b671a02..8c6d5378e 100644 --- a/src/handlers/identity/api-key-credential-provider/create/index.tsx +++ b/src/handlers/identity/api-key-credential-provider/create/index.tsx @@ -1,4 +1,5 @@ import z from "zod"; +import { InputValidationError } from "../../../../errors"; import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; @@ -14,10 +15,10 @@ export const createCreateApiKeyCredentialProviderHandler = (core: Core) => ], handle: async (ctx, flags) => { if (!flags.name) { - throw new TypeError("required option '--name ' not specified"); + throw new InputValidationError("required option '--name ' not specified"); } if (!flags["api-key"]) { - throw new TypeError("required option '--api-key ' not specified"); + throw new InputValidationError("required option '--api-key ' not specified"); } ctx diff --git a/src/handlers/identity/api-key-credential-provider/delete/index.tsx b/src/handlers/identity/api-key-credential-provider/delete/index.tsx index 56a637756..a9ee9b5d7 100644 --- a/src/handlers/identity/api-key-credential-provider/delete/index.tsx +++ b/src/handlers/identity/api-key-credential-provider/delete/index.tsx @@ -1,4 +1,5 @@ import z from "zod"; +import { InputValidationError } from "../../../../errors"; import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; @@ -11,7 +12,7 @@ export const createDeleteApiKeyCredentialProviderHandler = (core: Core) => flags: [flag("name", "the name of the API key credential provider", z.string().optional())], handle: async (ctx, flags) => { if (!flags.name) { - throw new TypeError("required option '--name ' not specified"); + throw new InputValidationError("required option '--name ' not specified"); } ctx diff --git a/src/handlers/identity/api-key-credential-provider/get/index.tsx b/src/handlers/identity/api-key-credential-provider/get/index.tsx index b988198a5..43eaaa4c5 100644 --- a/src/handlers/identity/api-key-credential-provider/get/index.tsx +++ b/src/handlers/identity/api-key-credential-provider/get/index.tsx @@ -1,4 +1,5 @@ import z from "zod"; +import { InputValidationError } from "../../../../errors"; import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; @@ -11,7 +12,7 @@ export const createGetApiKeyCredentialProviderHandler = (core: Core) => flags: [flag("name", "the name of the API key credential provider", z.string().optional())], handle: async (ctx, flags) => { if (!flags.name) { - throw new TypeError("required option '--name ' not specified"); + throw new InputValidationError("required option '--name ' not specified"); } ctx diff --git a/src/handlers/identity/api-key-credential-provider/update/index.tsx b/src/handlers/identity/api-key-credential-provider/update/index.tsx index b081d5198..8d8908b2f 100644 --- a/src/handlers/identity/api-key-credential-provider/update/index.tsx +++ b/src/handlers/identity/api-key-credential-provider/update/index.tsx @@ -1,4 +1,5 @@ import z from "zod"; +import { InputValidationError } from "../../../../errors"; import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; @@ -14,10 +15,10 @@ export const createUpdateApiKeyCredentialProviderHandler = (core: Core) => ], handle: async (ctx, flags) => { if (!flags.name) { - throw new TypeError("required option '--name ' not specified"); + throw new InputValidationError("required option '--name ' not specified"); } if (!flags["api-key"]) { - throw new TypeError("required option '--api-key ' not specified"); + throw new InputValidationError("required option '--api-key ' not specified"); } ctx diff --git a/src/handlers/runtime/endpoint/get/index.tsx b/src/handlers/runtime/endpoint/get/index.tsx index f4b342920..d1fd70577 100644 --- a/src/handlers/runtime/endpoint/get/index.tsx +++ b/src/handlers/runtime/endpoint/get/index.tsx @@ -1,4 +1,5 @@ import z from "zod"; +import { InputValidationError } from "../../../../errors"; import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; @@ -14,10 +15,10 @@ export const createGetRuntimeEndpointHandler = (core: Core) => ], handle: async (ctx, flags) => { if (!flags.id) { - throw new TypeError("required option '--id ' not specified"); + throw new InputValidationError("required option '--id ' not specified"); } if (!flags.qualifier) { - throw new TypeError("required option '--qualifier ' not specified"); + throw new InputValidationError("required option '--qualifier ' not specified"); } ctx diff --git a/src/handlers/runtime/endpoint/list/index.tsx b/src/handlers/runtime/endpoint/list/index.tsx index b6f492dc6..94880147f 100644 --- a/src/handlers/runtime/endpoint/list/index.tsx +++ b/src/handlers/runtime/endpoint/list/index.tsx @@ -1,4 +1,5 @@ import z from "zod"; +import { InputValidationError } from "../../../../errors"; import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; @@ -15,7 +16,7 @@ export const createListRuntimeEndpointsHandler = (core: Core) => ], handle: async (ctx, flags) => { if (!flags.id) { - throw new TypeError("required option '--id ' not specified"); + throw new InputValidationError("required option '--id ' not specified"); } ctx diff --git a/src/handlers/runtime/get/index.tsx b/src/handlers/runtime/get/index.tsx index b62f3e88d..1ba1cd93d 100644 --- a/src/handlers/runtime/get/index.tsx +++ b/src/handlers/runtime/get/index.tsx @@ -1,4 +1,5 @@ import z from "zod"; +import { InputValidationError } from "../../../errors"; import { createHandler, flag } from "../../../router"; import { JsonRendererKey } from "../../../tui"; import type { Core } from "../../types"; @@ -11,7 +12,7 @@ export const createGetRuntimeHandler = (core: Core) => flags: [flag("id", "the ID of the Runtime", z.string().optional())], handle: async (ctx, flags) => { if (!flags.id) { - throw new TypeError("required option '--id ' not specified"); + throw new InputValidationError("required option '--id ' not specified"); } ctx diff --git a/src/handlers/runtime/version/get/index.tsx b/src/handlers/runtime/version/get/index.tsx index 3d82c53b3..b64fc8770 100644 --- a/src/handlers/runtime/version/get/index.tsx +++ b/src/handlers/runtime/version/get/index.tsx @@ -1,4 +1,5 @@ import z from "zod"; +import { InputValidationError } from "../../../../errors"; import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; @@ -14,10 +15,10 @@ export const createGetRuntimeVersionHandler = (core: Core) => ], handle: async (ctx, flags) => { if (!flags.id) { - throw new TypeError("required option '--id ' not specified"); + throw new InputValidationError("required option '--id ' not specified"); } if (!flags.version) { - throw new TypeError("required option '--version ' not specified"); + throw new InputValidationError("required option '--version ' not specified"); } ctx diff --git a/src/handlers/runtime/version/list/index.tsx b/src/handlers/runtime/version/list/index.tsx index b2c20b599..d6281b696 100644 --- a/src/handlers/runtime/version/list/index.tsx +++ b/src/handlers/runtime/version/list/index.tsx @@ -1,4 +1,5 @@ import z from "zod"; +import { InputValidationError } from "../../../../errors"; import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; @@ -15,7 +16,7 @@ export const createListRuntimeVersionsHandler = (core: Core) => ], handle: async (ctx, flags) => { if (!flags.id) { - throw new TypeError("required option '--id ' not specified"); + throw new InputValidationError("required option '--id ' not specified"); } ctx diff --git a/src/handlers/utils.tsx b/src/handlers/utils.tsx index 89a79d2e7..e15a956fb 100644 --- a/src/handlers/utils.tsx +++ b/src/handlers/utils.tsx @@ -1,5 +1,6 @@ import type { Context } from "../router"; import type { CoreOptions } from "../core/types"; +import { InputValidationError } from "../errors"; import { EndpointKey, RegionKey } from "./keys"; // coreOptsFromCtx builds the standard CoreOptions handed to Core operations from @@ -23,8 +24,9 @@ export function parseJsonFlag(name: string, raw: string | undefined): T | und try { return JSON.parse(raw) as T; } catch (error) { - throw new TypeError( + throw new InputValidationError( `Invalid JSON for option '--${name}': ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, ); } } diff --git a/src/router/args.tsx b/src/router/args.tsx index dd8693eb1..b6bd9c40d 100644 --- a/src/router/args.tsx +++ b/src/router/args.tsx @@ -1,4 +1,5 @@ import { type Command, Argument as CommanderArgument } from "commander"; +import { InputValidationError } from "../errors"; import type { Argument } from "./handler"; import { coerce, formatZodError, inspect } from "./schema"; @@ -20,8 +21,9 @@ export function toCommanderArgument(arg: Argument): CommanderArgument { function validateArgument(argument: Argument, input: unknown | undefined): unknown { const result = argument.schema.safeParse(coerce(argument.schema, input)); if (!result.success) { - throw new TypeError( + throw new InputValidationError( `Invalid value for argument '${argument.name}': ${formatZodError(result.error)}`, + { cause: result.error }, ); } return result.data; diff --git a/src/router/flags.tsx b/src/router/flags.tsx index 07fb55c91..2b690d094 100644 --- a/src/router/flags.tsx +++ b/src/router/flags.tsx @@ -1,4 +1,5 @@ import { Option } from "commander"; +import { InputValidationError } from "../errors"; import type { Context } from "./context"; import type { Flag, GlobalFlag } from "./handler"; import { coerce, formatZodError, inspect } from "./schema"; @@ -62,8 +63,9 @@ function attributeName(name: string): string { function validateFlag(flag: Flag, opts: Record): unknown { const result = flag.schema.safeParse(coerce(flag.schema, opts[attributeName(flag.name)])); if (!result.success) { - throw new TypeError( + throw new InputValidationError( `Invalid value for option '--${flag.name}': ${formatZodError(result.error)}`, + { cause: result.error }, ); } return result.data; From 601bb6d318a7b9836a90613fca072b3ae5a83c7d Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Tue, 28 Jul 2026 23:17:22 +0000 Subject: [PATCH 2/3] fix(errors): swap router tests to check for error type instead of message --- src/router/router.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/router/router.test.ts b/src/router/router.test.ts index 11d798fac..5d1798b8b 100644 --- a/src/router/router.test.ts +++ b/src/router/router.test.ts @@ -15,6 +15,7 @@ import { type Handler, type Middleware, } from "./index"; +import { InputValidationError } from "../errors"; // --- helpers --------------------------------------------------------------- @@ -281,7 +282,7 @@ test("reports invalid input via command.error (throws under exitOverride)", asyn await expect( cmd.parseAsync(["node", "app", "get", "--harness-id", "toolong"]), // exceeds max(3) - ).rejects.toThrow(/Invalid value for option '--harness-id'/); + ).rejects.toThrow(InputValidationError); }); test("a required (non-optional) flag is mandatory", async () => { @@ -367,7 +368,7 @@ test("an invalid group-level flag is reported via command.error", async () => { const cmd = exitOverrideAll(compile(root, ValueContext.EmptyContext())); await expect(cmd.parseAsync(["node", "app", "get", "--level", "nope"])).rejects.toThrow( - /Invalid value for option '--level'/, + InputValidationError, ); }); From 6aa8a739db237cf5bcd6f5bcc78eb62ca02dad5c Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Tue, 28 Jul 2026 23:27:47 +0000 Subject: [PATCH 3/3] fix(errors): migrate source resolver to use input validation --- src/io/source.test.ts | 6 +++++- src/io/source.ts | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/io/source.test.ts b/src/io/source.test.ts index 55b9ed863..4092f9be3 100644 --- a/src/io/source.test.ts +++ b/src/io/source.test.ts @@ -62,8 +62,9 @@ describe("SourceResolver bytes", () => { await resolver.resolveText("payload", "-"); const resolution = resolver.resolveText("bearer-token", "-"); - await expect(resolution).rejects.toBeInstanceOf(SourceResolutionError); await expect(resolution).rejects.toThrow("'--bearer-token' conflicts with '--payload'"); + await expect(resolution).rejects.toBeInstanceOf(SourceResolutionError); + await expect(resolution).rejects.toMatchObject({ source: "user" }); }); test("claims stdin before concurrent resolutions can race", async () => { @@ -83,6 +84,7 @@ describe("SourceResolver bytes", () => { const resolution = resolver.resolveBytes("payload", `file://${missing}`); await expect(resolution).rejects.toBeInstanceOf(SourceResolutionError); + await expect(resolution).rejects.toMatchObject({ source: "user" }); await expect(resolution).rejects.toThrow(`could not read '--payload' from file '${missing}'`); }); @@ -93,6 +95,7 @@ describe("SourceResolver bytes", () => { input.destroy(new Error("stream failed")); await expect(resolution).rejects.toBeInstanceOf(SourceResolutionError); + await expect(resolution).rejects.toMatchObject({ source: "user" }); await expect(resolution).rejects.toThrow("could not read '--payload' from stdin"); }); }); @@ -126,6 +129,7 @@ describe("SourceResolver text", () => { const resolution = resolver.resolveText("api-key", `file://${path}`); await expect(resolution).rejects.toBeInstanceOf(SourceResolutionError); + await expect(resolution).rejects.toMatchObject({ source: "user" }); await expect(resolution).rejects.toThrow("'--api-key' must contain valid UTF-8"); }); }); diff --git a/src/io/source.ts b/src/io/source.ts index 2c3e8e7ee..eb0d332bd 100644 --- a/src/io/source.ts +++ b/src/io/source.ts @@ -1,6 +1,7 @@ import { readFile } from "node:fs/promises"; import { addAbortSignal } from "node:stream"; import { buffer } from "node:stream/consumers"; +import { InputValidationError } from "../errors"; const FILE_PREFIX = "file://"; const STDIN = "-"; @@ -10,7 +11,7 @@ export type SourceResolverConfig = { signal?: AbortSignal; }; -export class SourceResolutionError extends TypeError { +export class SourceResolutionError extends InputValidationError { constructor(message: string, options?: ErrorOptions) { super(message, options); this.name = "SourceResolutionError";