From 45fa7cd60a2473ad5cf2233af459c6e5ac934823 Mon Sep 17 00:00:00 2001 From: Long Zhang Date: Mon, 13 Apr 2026 14:38:16 +0200 Subject: [PATCH 1/4] fix: preserve validation messages in tty output Signed-off-by: Long Zhang --- src/Cli.test.ts | 79 ++++++++++++++++++++++++++++++++++++++++++++++ src/Cli.ts | 30 ++++++++++++++++-- src/Errors.test.ts | 4 +++ src/Errors.ts | 4 +++ src/Parser.test.ts | 34 ++++++++++++++++++++ src/Parser.ts | 16 ++++++++++ 6 files changed, 165 insertions(+), 2 deletions(-) diff --git a/src/Cli.test.ts b/src/Cli.test.ts index 0a348e4..c05b1d1 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -935,6 +935,85 @@ describe('serve', () => { expect(output).toContain('Error: missing required argument ') }) + test('ValidationError preserves Zod messages in machine output', async () => { + const cli = Cli.create('test') + cli.command('send', { + options: z.object({ address: z.string().min(32) }), + run() { + return {} + }, + }) + + const { output, exitCode } = await serve(cli, ['send', '--address', 'abc', '--format', 'json']) + expect(exitCode).toBe(1) + expect(JSON.parse(output)).toMatchObject({ + code: 'VALIDATION_ERROR', + fieldErrors: [ + { + code: 'too_small', + message: 'Too small: expected string to have >=32 characters', + missing: false, + path: 'address', + }, + ], + }) + }) + + test('ValidationError shows invalid option messages in TTY', async () => { + ;(process.stdout as any).isTTY = true + const cli = Cli.create('test') + cli.command('send', { + options: z.object({ address: z.string().min(32) }), + run() { + return {} + }, + }) + + const { output, exitCode } = await serve(cli, ['send', '--address', 'abc']) + ;(process.stdout as any).isTTY = false + expect(exitCode).toBe(1) + expect(output).toContain( + 'Error: invalid value for --address: Too small: expected string to have >=32 characters', + ) + expect(output).not.toContain('Error: missing required argument
') + }) + + test('ValidationError shows invalid enum messages in TTY', async () => { + ;(process.stdout as any).isTTY = true + const cli = Cli.create('test') + cli.command('list', { + options: z.object({ state: z.enum(['open', 'closed']) }), + run() { + return {} + }, + }) + + const { output, exitCode } = await serve(cli, ['list', '--state', 'invalid']) + ;(process.stdout as any).isTTY = false + expect(exitCode).toBe(1) + expect(output).toContain( + 'Error: invalid value for --state: Invalid option: expected one of "open"|"closed"', + ) + }) + + test('ValidationError shows positional refinement messages in TTY', async () => { + ;(process.stdout as any).isTTY = true + const cli = Cli.create('test') + cli.command('get', { + args: z.object({ + id: z.string().refine((value) => value.startsWith('x'), { message: 'must start with x' }), + }), + run() { + return {} + }, + }) + + const { output, exitCode } = await serve(cli, ['get', 'abc']) + ;(process.stdout as any).isTTY = false + expect(exitCode).toBe(1) + expect(output).toContain('Error: invalid value for : must start with x') + }) + test('agent is true when not TTY', async () => { let agent: boolean | undefined const cli = Cli.create('test') diff --git a/src/Cli.ts b/src/Cli.ts index ea7e274..42eb9cf 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -21,7 +21,7 @@ import { shells, } from './internal/command.js' import * as Command from './internal/command.js' -import { isRecord, suggest } from './internal/helpers.js' +import { isRecord, suggest, toKebab } from './internal/helpers.js' import { detectRunner } from './internal/pm.js' import type { OneOf } from './internal/types.js' import * as Mcp from './Mcp.js' @@ -1864,7 +1864,7 @@ function formatHumanValidationError( configFlag?: string, ): string { const lines: string[] = [] - for (const fe of error.fieldErrors) lines.push(`Error: missing required argument <${fe.path}>`) + for (const fe of error.fieldErrors) lines.push(formatHumanValidationLine(command, fe)) lines.push('See below for usage.') lines.push('') lines.push( @@ -1884,6 +1884,32 @@ function formatHumanValidationError( return lines.join('\n') } +/** @internal Formats a single human-readable validation issue. */ +function formatHumanValidationLine( + command: CommandDefinition, + error: FieldError, +): string { + const target = formatValidationTarget(command, error.path) + if (error.missing) return `Error: missing required argument ${target}` + return `Error: invalid value for ${target}: ${error.message}` +} + +/** @internal Formats a field path as an option flag or positional placeholder. */ +function formatValidationTarget( + command: CommandDefinition, + path: string, +): string { + const [head, ...tail] = path.split('.') + if (!head) return 'input' + + if (command.options?.shape[head]) { + const suffix = tail.length > 0 ? `.${tail.join('.')}` : '' + return `--${toKebab(head)}${suffix}` + } + + return `<${path}>` +} + /** @internal Resolves a command from the tree by walking tokens until a leaf is found. */ function resolveCommand( commands: Map, diff --git a/src/Errors.test.ts b/src/Errors.test.ts index 331d353..6e40782 100644 --- a/src/Errors.test.ts +++ b/src/Errors.test.ts @@ -66,6 +66,8 @@ describe('ValidationError', () => { message: 'Invalid arguments', fieldErrors: [ { + code: 'invalid_value', + missing: false, path: 'state', expected: 'open | closed', received: 'invalid', @@ -76,6 +78,8 @@ describe('ValidationError', () => { expect(error.name).toBe('Incur.ValidationError') expect(error.fieldErrors).toEqual([ { + code: 'invalid_value', + missing: false, path: 'state', expected: 'open | closed', received: 'invalid', diff --git a/src/Errors.ts b/src/Errors.ts index 660b498..84b0b99 100644 --- a/src/Errors.ts +++ b/src/Errors.ts @@ -73,6 +73,10 @@ export declare namespace IncurError { /** A field-level validation error detail. */ export type FieldError = { + /** The Zod issue code. */ + code?: string | undefined + /** Whether the input was missing entirely. */ + missing?: boolean | undefined /** The field path that failed validation. */ path: string /** The expected value or type. */ diff --git a/src/Parser.test.ts b/src/Parser.test.ts index 5061ce8..db4aa52 100644 --- a/src/Parser.test.ts +++ b/src/Parser.test.ts @@ -107,6 +107,40 @@ describe('parse', () => { ).toThrow(expect.objectContaining({ name: 'Incur.ValidationError' })) }) + test('captures missing metadata for missing positional args', () => { + try { + Parser.parse([], { + args: z.object({ name: z.string() }), + }) + expect.unreachable() + } catch (error: any) { + expect(error.fieldErrors).toEqual([ + expect.objectContaining({ + code: 'invalid_type', + missing: true, + path: 'name', + }), + ]) + } + }) + + test('captures metadata for invalid option values', () => { + try { + Parser.parse(['--state', 'invalid'], { + options: z.object({ state: z.enum(['open', 'closed']) }), + }) + expect.unreachable() + } catch (error: any) { + expect(error.fieldErrors).toEqual([ + expect.objectContaining({ + code: 'invalid_value', + missing: false, + path: 'state', + }), + ]) + } + }) + test('stacks boolean short aliases (-vD)', () => { const result = Parser.parse(['-vD'], { options: z.object({ diff --git a/src/Parser.ts b/src/Parser.ts index d00c99b..ea21a75 100644 --- a/src/Parser.ts +++ b/src/Parser.ts @@ -263,6 +263,8 @@ function zodParse(schema: z.ZodObject, data: Record) { } catch (err: any) { const issues: any[] = err?.issues ?? err?.error?.issues ?? [] const fieldErrors: FieldError[] = issues.map((issue: any) => ({ + code: issue.code, + missing: !hasPath(data, issue.path ?? []), path: (issue.path ?? []).join('.'), expected: issue.expected ?? '', received: issue.received ?? '', @@ -276,6 +278,20 @@ function zodParse(schema: z.ZodObject, data: Record) { } } +/** Checks whether the raw input contains the full issue path. */ +function hasPath(data: Record, path: PropertyKey[]): boolean { + if (path.length === 0) return true + + let current: unknown = data + for (const part of path) { + if (!isRecord(current) && !Array.isArray(current)) return false + if (!(part in current)) return false + current = (current as any)[part] + } + + return true +} + /** Parses environment variables against a Zod schema. Falls back to `process.env` → `Deno.env` when no source is provided. */ export function parseEnv>( schema: env, From 0bfb2639d70e48b2cbcb59c8c9afcc5039c56dd5 Mon Sep 17 00:00:00 2001 From: tmm Date: Mon, 27 Apr 2026 14:42:20 -0400 Subject: [PATCH 2/4] fix: clarify tty validation targets --- src/Cli.test.ts | 40 +++++++++++++++++++++++++++++++++++++++- src/Cli.ts | 26 +++++++++++++++++++------- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/src/Cli.test.ts b/src/Cli.test.ts index c05b1d1..b96fee9 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -978,6 +978,22 @@ describe('serve', () => { expect(output).not.toContain('Error: missing required argument
') }) + test('ValidationError shows missing required options in TTY', async () => { + ;(process.stdout as any).isTTY = true + const cli = Cli.create('test') + cli.command('send', { + options: z.object({ address: z.string() }), + run() { + return {} + }, + }) + + const { output, exitCode } = await serve(cli, ['send']) + ;(process.stdout as any).isTTY = false + expect(exitCode).toBe(1) + expect(output).toContain('Error: missing required option --address') + }) + test('ValidationError shows invalid enum messages in TTY', async () => { ;(process.stdout as any).isTTY = true const cli = Cli.create('test') @@ -2417,6 +2433,7 @@ describe('env', () => { }) test('env validation error for missing required var', async () => { + ;(process.stdout as any).isTTY = true const cli = Cli.create('test') cli.command('deploy', { env: z.object({ @@ -2428,8 +2445,29 @@ describe('env', () => { }) const { output, exitCode } = await serve(cli, ['deploy'], { env: {} }) + ;(process.stdout as any).isTTY = false expect(exitCode).toBe(1) - expect(output).toContain('Error') + expect(output).toContain('Error: missing required environment variable API_TOKEN') + }) + + test('env validation error for invalid var shows human message in TTY', async () => { + ;(process.stdout as any).isTTY = true + const cli = Cli.create('test') + cli.command('deploy', { + env: z.object({ + API_TOKEN: z.string().min(8).describe('Auth token'), + }), + run() { + return {} + }, + }) + + const { output, exitCode } = await serve(cli, ['deploy'], { env: { API_TOKEN: 'short' } }) + ;(process.stdout as any).isTTY = false + expect(exitCode).toBe(1) + expect(output).toContain( + 'Error: invalid value for environment variable API_TOKEN: Too small: expected string to have >=8 characters', + ) }) test('env with defaults works when var is unset', async () => { diff --git a/src/Cli.ts b/src/Cli.ts index 42eb9cf..eeb87dc 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -1890,24 +1890,36 @@ function formatHumanValidationLine( error: FieldError, ): string { const target = formatValidationTarget(command, error.path) - if (error.missing) return `Error: missing required argument ${target}` - return `Error: invalid value for ${target}: ${error.message}` + if (error.missing) return `Error: missing required ${target.kind} ${target.label}` + if (target.kind === 'environment variable') + return `Error: invalid value for environment variable ${target.label}: ${error.message}` + return `Error: invalid value for ${target.label}: ${error.message}` } -/** @internal Formats a field path as an option flag or positional placeholder. */ +type ValidationTarget = { + kind: 'argument' | 'environment variable' | 'option' + label: string +} + +/** @internal Formats a field path as an option flag, env name, or positional placeholder. */ function formatValidationTarget( command: CommandDefinition, path: string, -): string { +): ValidationTarget { const [head, ...tail] = path.split('.') - if (!head) return 'input' + if (!head) return { kind: 'argument', label: 'input' } if (command.options?.shape[head]) { const suffix = tail.length > 0 ? `.${tail.join('.')}` : '' - return `--${toKebab(head)}${suffix}` + return { kind: 'option', label: `--${toKebab(head)}${suffix}` } + } + + if (command.env?.shape[head]) { + const suffix = tail.length > 0 ? `.${tail.join('.')}` : '' + return { kind: 'environment variable', label: `${head}${suffix}` } } - return `<${path}>` + return { kind: 'argument', label: `<${path}>` } } /** @internal Resolves a command from the tree by walking tokens until a leaf is found. */ From dad74aac7061a4171f9a591cb44f5fba43281392 Mon Sep 17 00:00:00 2001 From: tmm Date: Mon, 27 Apr 2026 14:53:36 -0400 Subject: [PATCH 3/4] chore: add changeset for tty validation wording --- .changeset/tame-tips-drop.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tame-tips-drop.md diff --git a/.changeset/tame-tips-drop.md b/.changeset/tame-tips-drop.md new file mode 100644 index 0000000..8e85c04 --- /dev/null +++ b/.changeset/tame-tips-drop.md @@ -0,0 +1,5 @@ +--- +'incur': patch +--- + +Clarified TTY validation output for missing options and environment variables. From 8a1095ee78e23f340f7631248b860cf83e000238 Mon Sep 17 00:00:00 2001 From: tmm Date: Mon, 27 Apr 2026 15:39:50 -0400 Subject: [PATCH 4/4] chore: up --- src/Cli.ts | 44 +++++++++++++++----------------------------- 1 file changed, 15 insertions(+), 29 deletions(-) diff --git a/src/Cli.ts b/src/Cli.ts index eeb87dc..4f5c851 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -1864,7 +1864,16 @@ function formatHumanValidationError( configFlag?: string, ): string { const lines: string[] = [] - for (const fe of error.fieldErrors) lines.push(formatHumanValidationLine(command, fe)) + for (const fe of error.fieldErrors) { + const line = (() => { + const target = formatValidationTarget(command, fe.path) + if (fe.missing) return `Error: missing required ${target.kind} ${target.label}` + if (target.kind === 'environment variable') + return `Error: invalid value for environment variable ${target.label}: ${fe.message}` + return `Error: invalid value for ${target.label}: ${fe.message}` + })() + lines.push(line) + } lines.push('See below for usage.') lines.push('') lines.push( @@ -1884,42 +1893,19 @@ function formatHumanValidationError( return lines.join('\n') } -/** @internal Formats a single human-readable validation issue. */ -function formatHumanValidationLine( - command: CommandDefinition, - error: FieldError, -): string { - const target = formatValidationTarget(command, error.path) - if (error.missing) return `Error: missing required ${target.kind} ${target.label}` - if (target.kind === 'environment variable') - return `Error: invalid value for environment variable ${target.label}: ${error.message}` - return `Error: invalid value for ${target.label}: ${error.message}` -} - -type ValidationTarget = { - kind: 'argument' | 'environment variable' | 'option' - label: string -} - /** @internal Formats a field path as an option flag, env name, or positional placeholder. */ -function formatValidationTarget( - command: CommandDefinition, - path: string, -): ValidationTarget { +function formatValidationTarget(command: CommandDefinition, path: string) { const [head, ...tail] = path.split('.') - if (!head) return { kind: 'argument', label: 'input' } - + if (!head) return { kind: 'argument', label: 'input' } as const if (command.options?.shape[head]) { const suffix = tail.length > 0 ? `.${tail.join('.')}` : '' - return { kind: 'option', label: `--${toKebab(head)}${suffix}` } + return { kind: 'option', label: `--${toKebab(head)}${suffix}` } as const } - if (command.env?.shape[head]) { const suffix = tail.length > 0 ? `.${tail.join('.')}` : '' - return { kind: 'environment variable', label: `${head}${suffix}` } + return { kind: 'environment variable', label: `${head}${suffix}` } as const } - - return { kind: 'argument', label: `<${path}>` } + return { kind: 'argument', label: `<${path}>` } as const } /** @internal Resolves a command from the tree by walking tokens until a leaf is found. */