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. diff --git a/src/Cli.test.ts b/src/Cli.test.ts index 0a348e4..b96fee9 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -935,6 +935,101 @@ 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 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') + 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') @@ -2338,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({ @@ -2349,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 ea7e274..4f5c851 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,16 @@ 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) { + 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,6 +1893,21 @@ function formatHumanValidationError( return lines.join('\n') } +/** @internal Formats a field path as an option flag, env name, or positional placeholder. */ +function formatValidationTarget(command: CommandDefinition, path: string) { + const [head, ...tail] = path.split('.') + 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}` } as const + } + if (command.env?.shape[head]) { + const suffix = tail.length > 0 ? `.${tail.join('.')}` : '' + return { kind: 'environment variable', label: `${head}${suffix}` } as const + } + return { kind: 'argument', label: `<${path}>` } as const +} + /** @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,