From bf9d2f6a2c74cde6e83dd6e4adb2cf03fc2ae079 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Thu, 10 Sep 2026 20:39:13 +0000 Subject: [PATCH 1/7] fix(router): allow required flags --- src/handlers/harness/get/index.tsx | 7 +------ src/handlers/runtime/logs/logs.test.tsx | 2 +- src/router/flags.tsx | 3 --- src/router/router.test.ts | 5 ++++- src/router/router.tsx | 14 +++++++++++--- 5 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/handlers/harness/get/index.tsx b/src/handlers/harness/get/index.tsx index 85a4fc4b5..ced2c4f4f 100644 --- a/src/handlers/harness/get/index.tsx +++ b/src/handlers/harness/get/index.tsx @@ -3,18 +3,13 @@ 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({ name: "get", description: "get a harness", - flags: [flag("id", "the ID of the harness", z.string().max(48).optional())], + flags: [flag("id", "the ID of the harness", z.string().min(1).max(48))], handle: async (ctx, flags) => { - if (!flags["id"]) { - throw new InputValidationError("required option '--id ' not specified"); - } - const harness = await core.harness.getHarness(flags["id"], coreOptsFromCtx(ctx)); ctx.require(JsonRendererKey).renderJson(harness); }, diff --git a/src/handlers/runtime/logs/logs.test.tsx b/src/handlers/runtime/logs/logs.test.tsx index 51769edb8..4e714faab 100644 --- a/src/handlers/runtime/logs/logs.test.tsx +++ b/src/handlers/runtime/logs/logs.test.tsx @@ -107,7 +107,7 @@ describe("runtime logs", () => { process.chdir(root); try { await expect(route(["runtime", "logs", "--since", `${SINCE_MS}`])).rejects.toThrow( - "required option '--id ' not specified", + "Invalid value for option '--id': Invalid input: expected string, received undefined", ); } finally { process.chdir(previousCwd); diff --git a/src/router/flags.tsx b/src/router/flags.tsx index 6314cf223..758c84a91 100644 --- a/src/router/flags.tsx +++ b/src/router/flags.tsx @@ -29,9 +29,6 @@ export function toOption(flag: Flag): Option { } else if (info.boolean) { option.default(false); } - if (info.required && !info.boolean) { - option.makeOptionMandatory(true); - } return option; } diff --git a/src/router/router.test.ts b/src/router/router.test.ts index ecdc19726..0a9b35d54 100644 --- a/src/router/router.test.ts +++ b/src/router/router.test.ts @@ -367,6 +367,7 @@ test("a required (non-optional) flag is mandatory", async () => { const root = new Router("app"); root.handler(get); + root.supportedTuiCommands(); const cmd = exitOverrideAll(compile(root, ValueContext.EmptyContext())); @@ -502,6 +503,7 @@ test("validates, coerces, and passes typed positional arguments to handle", asyn const root = new Router("app"); root.handler(serve); + root.supportedTuiCommands(); await root.route(["node", "app", "serve", "api", "8080", "true"]); @@ -528,7 +530,7 @@ test("optional arguments resolve to undefined when omitted", async () => { expect(seen).toEqual({ key: undefined }); }); -test("arguments with schema defaults use the default when omitted", async () => { +test("arguments with schema defaults use the default value when omitted", async () => { let seen: { env: string } | undefined; const deploy = createHandler({ @@ -542,6 +544,7 @@ test("arguments with schema defaults use the default when omitted", async () => const root = new Router("app"); root.handler(deploy); + root.supportedTuiCommands(); await root.route(["node", "app", "deploy"]); diff --git a/src/router/router.tsx b/src/router/router.tsx index 7fdbf1da5..3185dc8e1 100644 --- a/src/router/router.tsx +++ b/src/router/router.tsx @@ -118,7 +118,7 @@ function attachAction( // of where they appear on the command line. c.action(async (...actionArgs: unknown[]) => { const command = actionArgs[actionArgs.length - 1] as Command; - const merged = command.optsWithGlobals(); + const allOptions = command.optsWithGlobals(); recordCommandPath(ctx); @@ -133,10 +133,18 @@ function attachAction( // Inherited group/global flags -> context (typed, read via ctx.value(key)). let leafCtx = ctx.withValue(CommandKey, command); - leafCtx = applyGlobalFlags(globals, merged, leafCtx); + leafCtx = applyGlobalFlags(globals, allOptions, leafCtx); + if ( + node.doesSupportTui() && + Object.keys(command.opts()).length === 0 && + node.arguments().length == 0 + ) { + await wrapped.handle(leafCtx, {}, {}); + return; + } // Own flags -> the statically-typed object passed to handle. - const parsedFlags = parseFlags(ownFlags, merged); + const parsedFlags = parseFlags(ownFlags, allOptions); const parsedArguments = parseArguments(node.arguments(), command); await wrapped.handle(leafCtx, parsedFlags, parsedArguments); From 9ac08a1f90f980f7518ce4562c90f50df9fc7314 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 11 Sep 2026 13:47:35 +0000 Subject: [PATCH 2/7] fix(router): move parsing into middleware to allow required flags --- src/middleware/withTuiOnEmptyFlagsAndArgs.tsx | 44 ++++------------ src/router/flags.tsx | 5 +- src/router/router.test.ts | 5 +- src/router/router.tsx | 51 ++++++++++++------- 4 files changed, 50 insertions(+), 55 deletions(-) diff --git a/src/middleware/withTuiOnEmptyFlagsAndArgs.tsx b/src/middleware/withTuiOnEmptyFlagsAndArgs.tsx index 4574b0aa6..1d0e72b66 100644 --- a/src/middleware/withTuiOnEmptyFlagsAndArgs.tsx +++ b/src/middleware/withTuiOnEmptyFlagsAndArgs.tsx @@ -1,33 +1,11 @@ -import { Option, type Command } from "commander"; import { renderTui } from "../tui"; -import { JsonKey } from "../handlers/keys"; import type { AppIO } from "../io"; import type { Core } from "../handlers/types"; -import { CommandKey, type Handler, type Middleware } from "../router"; - -// countPassedValues counts how many entries of an object hold a defined value. -const countPassedValues = (obj: object) => - Object.entries(obj).reduce((acc, [_key, val]) => { - if (val !== undefined) { - acc += 1; - } - - return acc; - }, 0); - -// countPassedFlags counts the leaf's own flags the user actually supplied on -// the command line. The parsed flags object can't be used for this: schema -// (and Commander boolean) defaults arrive there as defined values, which would -// make a leaf with defaulted flags look non-empty on a bare invocation. -const countPassedFlags = (h: Handler, command: Command) => - h.flags().filter((f) => { - const attribute = new Option(`--${f.name}`).attributeName(); - return command.getOptionValueSource(attribute) === "cli"; - }).length; +import { type Middleware } from "../router"; +import { CommandKey } from "../router/router"; +import { attributeName } from "../router/flags"; +import { JsonKey } from "../handlers/keys"; -// withTuiOnEmptyFlagsAndArgs opens the interactive TUI when a leaf command is -// invoked with no flags or arguments (and not in JSON mode); otherwise it -// delegates to the wrapped handler. export function withTuiOnEmptyFlagsAndArgs(core: Core, io: AppIO): Middleware { const boundRenderTui = renderTui(core, io); @@ -40,17 +18,15 @@ export function withTuiOnEmptyFlagsAndArgs(core: Core, io: AppIO): Middleware { children: () => h.children(), handle: async (ctx, flags, args) => { const command = ctx.require(CommandKey); - if ( - h.doesSupportTui() && - !ctx.require(JsonKey) && - countPassedFlags(h, command) === 0 && - countPassedValues(args) === 0 - ) { + const noFlagsPassed = h + .flags() + .every((f) => command.getOptionValueSource(attributeName(f.name)) !== "cli"); + + if (h.doesSupportTui() && !ctx.value(JsonKey) && noFlagsPassed && command.args.length === 0) { await boundRenderTui(ctx, flags, args); return; - } else { - await h.handle(ctx, flags, args); } + await h.handle(ctx, flags, args); }, }); } diff --git a/src/router/flags.tsx b/src/router/flags.tsx index 758c84a91..6300cf08f 100644 --- a/src/router/flags.tsx +++ b/src/router/flags.tsx @@ -8,8 +8,7 @@ import { coerce, formatZodError, inspect } from "./schema"; // to true is exposed as `--no-`: the behavior is already on, so the only useful // action is turning it off, which Commander stores under the positive name (e.g. // `--no-traces` sets `traces=false`). A boolean that defaults off stays `--`. -// Everything else takes a value (`` / variadic ``); a required -// non-boolean flag is made mandatory; defaults are forwarded. +// Everything else takes a value (`` / variadic ``); defaults are forwarded. export function toOption(flag: Flag): Option { const info = inspect(flag.schema); const long = `--${flag.name}`; @@ -52,7 +51,7 @@ export function formatParameterDetails(flags: Flag[]): string | undefined { // attributeName mirrors how Commander camelCases an option name into the key it // stores on the parsed options object (e.g. "harness-id" -> "harnessId"). -function attributeName(name: string): string { +export function attributeName(name: string): string { return new Option(`--${name}`).attributeName(); } diff --git a/src/router/router.test.ts b/src/router/router.test.ts index 0a9b35d54..976ed6cc6 100644 --- a/src/router/router.test.ts +++ b/src/router/router.test.ts @@ -289,6 +289,7 @@ test("boolean flags default to false when omitted", async () => { }); const root = new Router("app"); + root.supportedTuiCommands(); root.handler(run); await root.route(["node", "app", "run"]); @@ -309,6 +310,7 @@ test("a boolean flag defaulting to true is declared as its --no- negation", asyn }); const root = new Router("app"); + root.supportedTuiCommands(); root.handler(run); await root.route(["node", "app", "run"]); @@ -331,6 +333,7 @@ test("applies a schema default for an omitted flag", async () => { const root = new Router("app"); root.handler(opt); + root.supportedTuiCommands(); await root.route(["node", "app", "opt"]); @@ -567,7 +570,6 @@ test("variadic argument collects multiple values into an array", async () => { root.handler(lint); await root.route(["node", "app", "lint", "a.ts", "b.ts", "c.ts"]); - expect(seen).toEqual({ files: ["a.ts", "b.ts", "c.ts"] }); }); @@ -599,6 +601,7 @@ test("rejects an argument that fails schema validation", async () => { const root = new Router("app"); root.handler(config); + root.supportedTuiCommands(); const cmd = exitOverrideAll(compile(root, ValueContext.EmptyContext())); diff --git a/src/router/router.tsx b/src/router/router.tsx index 3185dc8e1..a7a2e051d 100644 --- a/src/router/router.tsx +++ b/src/router/router.tsx @@ -1,9 +1,14 @@ import type { Argument, Flag, GlobalFlag, Handler } from "./handler"; import { type Middleware, type MiddlewareProvider, isMiddlewareProvider } from "./middleware"; import { type Context, type ContextKey, ValueContext, contextKey } from "./context"; -import { applyGlobalFlags, formatParameterDetails, parseFlags, toOption } from "./flags"; +import { + applyGlobalFlags, + attributeName, + formatParameterDetails, + parseFlags, + toOption, +} from "./flags"; import { parseArguments, toCommanderArgument } from "./args"; - import { Command, CommanderError } from "commander"; import { InputValidationError } from "../errors"; import type { Logger } from "../logging"; @@ -99,6 +104,23 @@ function declareArguments(c: Command, args: Argument[]): void { } } +function withValidation(ownFlags: Flag[]): Middleware { + return (node: Handler) => ({ + name: () => node.name(), + description: () => node.description(), + flags: () => node.flags(), + arguments: () => node.arguments(), + doesSupportTui: () => node.doesSupportTui(), + children: () => node.children(), + handle: async (ctx) => { + const command = ctx.require(CommandKey); + const flags = parseFlags(ownFlags, command.optsWithGlobals()); + const args = parseArguments(node.arguments(), command); + await node.handle(ctx, flags, args); + }, + }); +} + // attachAction wires `node` as the executing handler for command `c`. The // accumulated middleware `stack` wraps the node (ancestor-first, via reduceRight), // `globals` are validated and injected into the context under their keys, and the @@ -112,7 +134,7 @@ function attachAction( globals: GlobalFlag[], ownFlags: Flag[], ): void { - const wrapped = stack.reduceRight((h, mw) => mw(h), node); + const wrapped = stack.reduceRight((h, mw) => mw(h), withValidation(ownFlags)(node)); // `optsWithGlobals()` merges this command's options with all ancestors', so // group-level flags declared higher in the tree are visible here regardless // of where they appear on the command line. @@ -134,20 +156,15 @@ function attachAction( // Inherited group/global flags -> context (typed, read via ctx.value(key)). let leafCtx = ctx.withValue(CommandKey, command); leafCtx = applyGlobalFlags(globals, allOptions, leafCtx); - if ( - node.doesSupportTui() && - Object.keys(command.opts()).length === 0 && - node.arguments().length == 0 - ) { - await wrapped.handle(leafCtx, {}, {}); - return; - } - // Own flags -> the statically-typed object passed to handle. - const parsedFlags = parseFlags(ownFlags, allOptions); - const parsedArguments = parseArguments(node.arguments(), command); + const named = Object.fromEntries( + ownFlags.map((f) => [f.name, allOptions[attributeName(f.name)]]), + ); + const namedArgs = Object.fromEntries( + node.arguments().map((a, i) => [a.name, command.processedArgs[i]]), + ); - await wrapped.handle(leafCtx, parsedFlags, parsedArguments); + await wrapped.handle(leafCtx, named, namedArgs); }); } @@ -266,7 +283,7 @@ export class Router implements Handler, MiddlewareProvider, DefaultHandlerProvid constructor( private readonly cmdName: string, private readonly cmdDescription: string = "", - ) {} + ) { } // --- Router authoring API --- @@ -341,7 +358,7 @@ export class Router implements Handler, MiddlewareProvider, DefaultHandlerProvid } // A group/branch never executes directly; it just hosts subcommands. - async handle(_ctx: Context, _flags: any, _args: any): Promise {} + async handle(_ctx: Context, _flags: any, _args: any): Promise { } children(): Handler[] { return this.handlers; From 55ba59ea989392d133bd124ff32aa475b04db417 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 11 Sep 2026 15:00:22 +0000 Subject: [PATCH 3/7] fix(handler): handle edge case where we need middleware in the leaf node only --- src/handlers/project/index.ts | 31 +++++++++++++------------------ src/router/router.tsx | 8 ++++---- 2 files changed, 17 insertions(+), 22 deletions(-) diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index e50a4f040..f69385baf 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -1,4 +1,4 @@ -import { Router, type Handler } from "../../router"; +import { Router, type Handler, type MiddlewareProvider } from "../../router"; import { checkPort, openBrowser, startHttpServer, watchFile, type AppIO } from "../../io"; import { CodeZipDevRunner } from "../../core/dev/codezip"; import { ContainerDevRunner } from "../../core/dev/container"; @@ -47,25 +47,19 @@ export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router // The TTY gate wraps the middleware (rather than living inside it) so a // piped/CI invocation also stays headless and reports the missing --name as // a usage error instead of renderTui's "interactive mode requires a TTY". - const createProject = createCreateProjectHandler({ - projectManager, - io, - }); - const createProjectWithWizard = withTuiOnEmptyFlagsAndArgs(core, io)(createProject); + const createProject = createCreateProjectHandler({ projectManager, io }); const isInteractive = () => io.stdin.isTTY === true && io.stdout.isTTY === true; - const createProjectDispatch: Handler = { + const createProjectWithTui: Handler & MiddlewareProvider = { name: () => createProject.name(), description: () => createProject.description(), flags: () => createProject.flags(), arguments: () => createProject.arguments(), doesSupportTui: () => createProject.doesSupportTui(), children: () => createProject.children(), - handle: (ctx, flags, args) => - isInteractive() - ? createProjectWithWizard.handle(ctx, flags, args) - : createProject.handle(ctx, flags, args), + handle: (ctx, flags, args) => createProject.handle(ctx, flags, args), + middlewares: () => (isInteractive() ? [withTuiOnEmptyFlagsAndArgs(core, io)] : []), }; - project.handler(createProjectDispatch); + project.handler(createProjectWithTui); project.handler(createAddProjectResourceHandler(config)); project.handler(createExportProjectResourceHandler({ projectManager, core, io })); project.handler( @@ -105,20 +99,21 @@ export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router // withProject stays outermost so the not-found guidance outside a project is // the CLI's own, and the resolved project seeds the screen via ProjectKey. const statusProject = createStatusProjectHandler({ projectManager: config.projectManager }); - const statusProjectWithTui = withTuiOnEmptyFlagsAndArgs(core, io)(statusProject); - const statusProjectDispatch: Handler = { + const withStatusProject = withProject({ projectManager: config.projectManager }); + const statusProjectWithTui: Handler & MiddlewareProvider = { name: () => statusProject.name(), description: () => statusProject.description(), flags: () => statusProject.flags(), arguments: () => statusProject.arguments(), doesSupportTui: () => statusProject.doesSupportTui(), children: () => statusProject.children(), - handle: (ctx, flags, args) => + handle: (ctx, flags, args) => statusProject.handle(ctx, flags, args), + middlewares: () => isInteractive() - ? statusProjectWithTui.handle(ctx, flags, args) - : statusProject.handle(ctx, flags, args), + ? [withStatusProject, withTuiOnEmptyFlagsAndArgs(core, io)] + : [withStatusProject], }; - project.handler(withProject({ projectManager: config.projectManager })(statusProjectDispatch)); + project.handler(statusProjectWithTui); // withProject wraps only the commands that require an existing project, so // `create` (which refuses to nest inside one) stays unaffected. project.handler( diff --git a/src/router/router.tsx b/src/router/router.tsx index a7a2e051d..74c5ca8f4 100644 --- a/src/router/router.tsx +++ b/src/router/router.tsx @@ -157,14 +157,14 @@ function attachAction( let leafCtx = ctx.withValue(CommandKey, command); leafCtx = applyGlobalFlags(globals, allOptions, leafCtx); - const named = Object.fromEntries( + const namedFlags = Object.fromEntries( ownFlags.map((f) => [f.name, allOptions[attributeName(f.name)]]), ); const namedArgs = Object.fromEntries( node.arguments().map((a, i) => [a.name, command.processedArgs[i]]), ); - await wrapped.handle(leafCtx, named, namedArgs); + await wrapped.handle(leafCtx, namedFlags, namedArgs); }); } @@ -283,7 +283,7 @@ export class Router implements Handler, MiddlewareProvider, DefaultHandlerProvid constructor( private readonly cmdName: string, private readonly cmdDescription: string = "", - ) { } + ) {} // --- Router authoring API --- @@ -358,7 +358,7 @@ export class Router implements Handler, MiddlewareProvider, DefaultHandlerProvid } // A group/branch never executes directly; it just hosts subcommands. - async handle(_ctx: Context, _flags: any, _args: any): Promise { } + async handle(_ctx: Context, _flags: any, _args: any): Promise {} children(): Handler[] { return this.handlers; From 39c5c45168f6e1fdb99dc649fa654d3984975b48 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 11 Sep 2026 15:50:58 +0000 Subject: [PATCH 4/7] feat(middleware): allow handlers to define their own middleware to simplify wrapping handlers --- src/handlers/project/create/index.ts | 4 ++- src/handlers/project/index.ts | 40 ++++++++++------------------ src/handlers/project/status/index.ts | 4 ++- src/router/handler.tsx | 8 ++++++ src/router/middleware.tsx | 4 +-- 5 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/handlers/project/create/index.ts b/src/handlers/project/create/index.ts index f0fd26c62..b5b5b9d7d 100644 --- a/src/handlers/project/create/index.ts +++ b/src/handlers/project/create/index.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import z from "zod"; -import { createHandler, flag, PlatformKey } from "../../../router"; +import { createHandler, flag, PlatformKey, type Middleware } from "../../../router"; import { assertProjectPathFits } from "./pathLimit"; import { SourceResolver, type AppIO } from "../../../io"; import { runWithProgress } from "../../../tui/progress"; @@ -31,6 +31,7 @@ import { projectReference, type ProjectMutationResult } from "../output"; type CreateProjectHandlerConfig = { projectManager: ProjectManager; io: AppIO; + middlewares?: Middleware[]; }; const ModelProviderFlagSchema = z.enum([...HarnessModelProviderSchema.options, "anthropic"]); @@ -47,6 +48,7 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) = createHandler({ name: "create", description: "create a new AgentCore project", + middlewares: config.middlewares, flags: [ // Optional at the flag layer (and enforced in handle) so a bare // interactive `project create` reaches the TUI wizard middleware instead diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index f69385baf..0a28b6d76 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -1,4 +1,4 @@ -import { Router, type Handler, type MiddlewareProvider } from "../../router"; +import { Router } from "../../router"; import { checkPort, openBrowser, startHttpServer, watchFile, type AppIO } from "../../io"; import { CodeZipDevRunner } from "../../core/dev/codezip"; import { ContainerDevRunner } from "../../core/dev/container"; @@ -47,19 +47,14 @@ export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router // The TTY gate wraps the middleware (rather than living inside it) so a // piped/CI invocation also stays headless and reports the missing --name as // a usage error instead of renderTui's "interactive mode requires a TTY". - const createProject = createCreateProjectHandler({ projectManager, io }); const isInteractive = () => io.stdin.isTTY === true && io.stdout.isTTY === true; - const createProjectWithTui: Handler & MiddlewareProvider = { - name: () => createProject.name(), - description: () => createProject.description(), - flags: () => createProject.flags(), - arguments: () => createProject.arguments(), - doesSupportTui: () => createProject.doesSupportTui(), - children: () => createProject.children(), - handle: (ctx, flags, args) => createProject.handle(ctx, flags, args), - middlewares: () => (isInteractive() ? [withTuiOnEmptyFlagsAndArgs(core, io)] : []), - }; - project.handler(createProjectWithTui); + project.handler( + createCreateProjectHandler({ + projectManager, + io, + middlewares: isInteractive() ? [withTuiOnEmptyFlagsAndArgs(core, io)] : [], + }), + ); project.handler(createAddProjectResourceHandler(config)); project.handler(createExportProjectResourceHandler({ projectManager, core, io })); project.handler( @@ -98,22 +93,15 @@ export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router // invocation keeps the headless JSON report (same dispatch shape as create). // withProject stays outermost so the not-found guidance outside a project is // the CLI's own, and the resolved project seeds the screen via ProjectKey. - const statusProject = createStatusProjectHandler({ projectManager: config.projectManager }); const withStatusProject = withProject({ projectManager: config.projectManager }); - const statusProjectWithTui: Handler & MiddlewareProvider = { - name: () => statusProject.name(), - description: () => statusProject.description(), - flags: () => statusProject.flags(), - arguments: () => statusProject.arguments(), - doesSupportTui: () => statusProject.doesSupportTui(), - children: () => statusProject.children(), - handle: (ctx, flags, args) => statusProject.handle(ctx, flags, args), - middlewares: () => - isInteractive() + project.handler( + createStatusProjectHandler({ + projectManager: config.projectManager, + middlewares: isInteractive() ? [withStatusProject, withTuiOnEmptyFlagsAndArgs(core, io)] : [withStatusProject], - }; - project.handler(statusProjectWithTui); + }), + ); // withProject wraps only the commands that require an existing project, so // `create` (which refuses to nest inside one) stays unaffected. project.handler( diff --git a/src/handlers/project/status/index.ts b/src/handlers/project/status/index.ts index 8ad24d1eb..27978a150 100644 --- a/src/handlers/project/status/index.ts +++ b/src/handlers/project/status/index.ts @@ -1,6 +1,6 @@ import z from "zod"; import { DEFAULT_TARGET_NAME } from "../../../projectSchemas/aws-targets"; -import { createHandler, flag, ProjectKey } from "../../../router"; +import { createHandler, flag, ProjectKey, type Middleware } from "../../../router"; import { JsonRendererKey } from "../../../tui"; import type { ProjectManager, ResolvedProjectResource } from "../types"; import { RegionKey } from "../../keys"; @@ -8,6 +8,7 @@ import { ProjectStateError } from "../../../errors"; type StatusProjectHandlerConfig = { projectManager: ProjectManager; + middlewares?: Middleware[]; }; type ProjectStatus = { @@ -21,6 +22,7 @@ export const createStatusProjectHandler = (config: StatusProjectHandlerConfig) = createHandler({ name: "status", description: "show the status of the project's deployed resources", + middlewares: config.middlewares, flags: [ flag( "target", diff --git a/src/router/handler.tsx b/src/router/handler.tsx index 146c6dff2..51d22abd7 100644 --- a/src/router/handler.tsx +++ b/src/router/handler.tsx @@ -1,5 +1,6 @@ import type z from "zod"; import type { Context, ContextKey } from "./context"; +import type { Middleware } from "./middleware"; // Flag is generic over its literal name `N` and its inferred value type `T`, so a // tuple of flags can be mapped to a typed object at the authoring boundary (see @@ -102,6 +103,7 @@ type CreateHandlerInput< arguments?: A; handle?: HandleFn; children?: Handler[]; + middlewares?: Middleware[]; }; const noOpHandler = async (_ctx: Context, _flags: any, _args: any): Promise => {}; @@ -113,6 +115,7 @@ class BaseHandler implements Handler { _arguments: Argument[]; _handle: HandleFn; _children: Handler[]; + _middlewares: Middleware[]; constructor( input: CreateHandlerInput[], readonly Argument[]>, @@ -123,6 +126,7 @@ class BaseHandler implements Handler { this._arguments = (input.arguments ?? []) as Argument[]; this._handle = (input.handle ?? noOpHandler) as HandleFn; this._children = input.children ?? []; + this._middlewares = input.middlewares ?? []; } name(): string { @@ -152,6 +156,10 @@ class BaseHandler implements Handler { children(): Handler[] { return this._children; } + + middlewares(): Middleware[] { + return this._middlewares; + } } // createHandler infers the flags tuple from `flags` (the `const` type parameter diff --git a/src/router/middleware.tsx b/src/router/middleware.tsx index b6cb1e0b6..0bb307127 100644 --- a/src/router/middleware.tsx +++ b/src/router/middleware.tsx @@ -2,8 +2,8 @@ import type { Handler } from "./handler"; export type Middleware = (handler: Handler) => Handler; -// A node carries its own middleware when it can contribute to its subtree. -// Routers implement this; plain leaf handlers don't need to. +// A node may carry its own middleware, applied to it and its subtree. Routers +// implement this via `use()`; leaf handlers via createHandler's `middlewares`. export interface MiddlewareProvider { middlewares(): Middleware[]; } From 75039deef4aeaadaac376b6a66d4ed8723037c3f Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 11 Sep 2026 17:04:34 +0000 Subject: [PATCH 5/7] docs(router): remove stale comment --- src/router/router.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/router/router.test.ts b/src/router/router.test.ts index 976ed6cc6..b7f62c65a 100644 --- a/src/router/router.test.ts +++ b/src/router/router.test.ts @@ -374,7 +374,6 @@ test("a required (non-optional) flag is mandatory", async () => { const cmd = exitOverrideAll(compile(root, ValueContext.EmptyContext())); - // Omitting the mandatory option makes Commander reject before the handler runs. await expect(cmd.parseAsync(["node", "app", "get"])).rejects.toThrow(); }); From a01961a8cc01d30822752a6439771d343364e2e6 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 11 Sep 2026 19:02:22 +0000 Subject: [PATCH 6/7] fix(help): mark required fields as required in the help text --- src/router/flags.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/router/flags.tsx b/src/router/flags.tsx index 6300cf08f..021b60577 100644 --- a/src/router/flags.tsx +++ b/src/router/flags.tsx @@ -22,7 +22,9 @@ export function toOption(flag: Flag): Option { token = `${long} <${flag.name}>`; } - const option = new Option(token, flag.description); + const description = + info.required && !info.boolean ? `${flag.description} (required)` : flag.description; + const option = new Option(token, description); if (info.hasDefault) { option.default(info.defaultValue); } else if (info.boolean) { From c29f33b32a38c73e7506329ff86a11875f63ca9e Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 11 Sep 2026 20:34:14 +0000 Subject: [PATCH 7/7] refactor(error): clean up error message --- src/handlers/runtime/logs/logs.test.tsx | 2 +- src/router/args.tsx | 3 +++ src/router/flags.tsx | 8 +++++++- src/router/router.test.ts | 8 ++++++-- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/handlers/runtime/logs/logs.test.tsx b/src/handlers/runtime/logs/logs.test.tsx index 4e714faab..51769edb8 100644 --- a/src/handlers/runtime/logs/logs.test.tsx +++ b/src/handlers/runtime/logs/logs.test.tsx @@ -107,7 +107,7 @@ describe("runtime logs", () => { process.chdir(root); try { await expect(route(["runtime", "logs", "--since", `${SINCE_MS}`])).rejects.toThrow( - "Invalid value for option '--id': Invalid input: expected string, received undefined", + "required option '--id ' not specified", ); } finally { process.chdir(previousCwd); diff --git a/src/router/args.tsx b/src/router/args.tsx index b6bd9c40d..fdde3af80 100644 --- a/src/router/args.tsx +++ b/src/router/args.tsx @@ -21,6 +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) { + if (input === undefined) { + throw new InputValidationError(`missing required argument '${argument.name}'`); + } throw new InputValidationError( `Invalid value for argument '${argument.name}': ${formatZodError(result.error)}`, { cause: result.error }, diff --git a/src/router/flags.tsx b/src/router/flags.tsx index 021b60577..b247e1f27 100644 --- a/src/router/flags.tsx +++ b/src/router/flags.tsx @@ -62,8 +62,14 @@ export function attributeName(name: string): string { // `command.error`, which prints a message and exits (or, with exitOverride, // throws) — so this returns only on success. function validateFlag(flag: Flag, opts: Record): unknown { - const result = flag.schema.safeParse(coerce(flag.schema, opts[attributeName(flag.name)])); + const raw = opts[attributeName(flag.name)]; + const result = flag.schema.safeParse(coerce(flag.schema, raw)); if (!result.success) { + if (raw === undefined) { + throw new InputValidationError( + `required option '--${flag.name} <${flag.name}>' not specified`, + ); + } throw new InputValidationError( `Invalid value for option '--${flag.name}': ${formatZodError(result.error)}`, { cause: result.error }, diff --git a/src/router/router.test.ts b/src/router/router.test.ts index b7f62c65a..4b47e90f1 100644 --- a/src/router/router.test.ts +++ b/src/router/router.test.ts @@ -374,7 +374,9 @@ test("a required (non-optional) flag is mandatory", async () => { const cmd = exitOverrideAll(compile(root, ValueContext.EmptyContext())); - await expect(cmd.parseAsync(["node", "app", "get"])).rejects.toThrow(); + await expect(cmd.parseAsync(["node", "app", "get"])).rejects.toThrow( + "required option '--harness-id ' not specified", + ); }); // --- flag inheritance (group-level / global flags) ------------------------- @@ -585,7 +587,9 @@ test("a required positional argument is mandatory", async () => { const cmd = exitOverrideAll(compile(root, ValueContext.EmptyContext())); - await expect(cmd.parseAsync(["node", "app", "get"])).rejects.toThrow(); + await expect(cmd.parseAsync(["node", "app", "get"])).rejects.toThrow( + "missing required argument 'id'", + ); }); test("rejects an argument that fails schema validation", async () => {