Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -701,12 +701,13 @@ export function defineCommand<T extends z.ZodType> (config: CommandConfig<T>): O
}
if (inputValue !== undefined) {
assert(config.input instanceof z.ZodType, `command ${JSON.stringify(config.name)}: input must be a Zod schema`)
// apply strict mode to reject unknown keys, unless the author explicitly used .passthrough()
// Use passthrough so unknown fields (plugin-specific, newer ES versions) flow
// through to the server instead of being rejected client-side (#170).
let validationSchema: z.ZodType = (
config.input instanceof z.ZodObject &&
(config.input.def as unknown as { catchall?: { type: string } }).catchall?.type !== 'unknown'
)
? config.input.strict()
? config.input.passthrough()
: config.input

// Relax validation for object/array body fields. These contain user-provided
Expand Down
55 changes: 32 additions & 23 deletions test/factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1268,19 +1268,22 @@ describe('defineCommand', () => {
assert.deepEqual(received[0], { index: 'logs', size: 10 })
})

it('extra properties in JSON are rejected (strict mode)', async () => {
it('extra properties in JSON are passed through (passthrough mode)', async () => {
const schema = z.object({ index: z.string() })
const filePath = join(tmpDir, 'extra.json')
writeFileSync(filePath, JSON.stringify({ index: 'logs', unexpected: 'field' }))
const received: unknown[] = []
const cmd = defineCommand({
name: 'search',
description: 'Search',
input: schema,
handler: () => ({}),
handler: (parsed) => { received.push(parsed.input); return {} },
})
const err = await captureErrAsync(cmd, ['--input-file', filePath])
assert.match(err, /input validation failed/i)
assert.match(err, /unexpected/)
await invokeAsync(cmd, ['--input-file', filePath])
assert.equal(received.length, 1)
const input = received[0] as Record<string, unknown>
assert.equal(input.index, 'logs')
assert.equal(input.unexpected, 'field')
})

it('validation fails when schema has required fields but no input is provided', async () => {
Expand Down Expand Up @@ -2966,7 +2969,7 @@ describe('repeated flags', () => {
})
})

describe('defineCommand schema input - strict validation', () => {
describe('defineCommand schema input - passthrough validation', () => {
let tmpDir: string
let origIsTTY: boolean | undefined

Expand Down Expand Up @@ -3002,35 +3005,39 @@ describe('defineCommand schema input - strict validation', () => {
assert.deepEqual(captured, { index: 'logs', num_shards: 3 })
})

it('JSON via --input-file with an unknown key is rejected with a validation error', async () => {
it('JSON via --input-file with an unknown key passes it through', async () => {
const schema = z.object({ index: z.string().describe('Index') })
const filePath = join(tmpDir, 'unknown-key.json')
writeFileSync(filePath, JSON.stringify({ index: 'foo', bogus: 1 }))
let captured: unknown
const cmd = defineCommand({
name: 'search',
description: 'Search',
input: schema,
handler: () => ({}),
handler: (parsed) => { captured = parsed.input; return {} },
})
const err = await captureErrAsync(cmd, ['--input-file', filePath])
assert.match(err, /input validation failed/i)
assert.match(err, /bogus/)
await invokeAsync(cmd, ['--input-file', filePath])
const input = captured as Record<string, unknown>
assert.equal(input.index, 'foo')
assert.equal(input.bogus, 1)
})

it('JSON via stdin with an unknown key is rejected with a validation error', async () => {
it('JSON via stdin with an unknown key passes it through', async () => {
const schema = z.object({ index: z.string().describe('Index') })
const restore = _testSetStdinReader(() => JSON.stringify({ index: 'foo', bogus: 1 }))
Object.defineProperty(process.stdin, 'isTTY', { value: undefined, configurable: true, writable: true })
try {
let captured: unknown
const cmd = defineCommand({
name: 'search',
description: 'Search',
input: schema,
handler: () => ({}),
handler: (parsed) => { captured = parsed.input; return {} },
})
const err = await captureErrAsync(cmd, [])
assert.match(err, /input validation failed/i)
assert.match(err, /bogus/)
await invokeAsync(cmd, [])
const input = captured as Record<string, unknown>
assert.equal(input.index, 'foo')
assert.equal(input.bogus, 1)
} finally {
restore()
}
Expand Down Expand Up @@ -3124,23 +3131,25 @@ describe('defineCommand schema input - JSON + CLI merge', () => {
assert.deepEqual(captured, { index: 'my-index', num_shards: 4 })
})

it('unknown key from JSON is still rejected after merging with CLI args', async () => {
it('unknown key from JSON is passed through after merging with CLI args', async () => {
const schema = z.object({
index: z.string().describe('Index'),
num_shards: z.number().default(1).describe('Shards'),
})
const filePath = join(tmpDir, 't031.json')
writeFileSync(filePath, JSON.stringify({ index: 'logs', unknown_key: 'bad' }))
writeFileSync(filePath, JSON.stringify({ index: 'logs', unknown_key: 'extra' }))
let captured: unknown
const cmd = defineCommand({
name: 'create',
description: 'Create index',
input: schema,
handler: () => ({}),
handler: (parsed) => { captured = parsed.input; return {} },
})
// CLI provides a valid key; JSON has an unknown one, so post-merge strict check fires
const err = await captureErrAsync(cmd, ['--input-file', filePath, '--num-shards', '3'])
assert.match(err, /input validation failed/i)
assert.match(err, /unknown_key/)
await invokeAsync(cmd, ['--input-file', filePath, '--num-shards', '3'])
const input = captured as Record<string, unknown>
assert.equal(input.index, 'logs')
assert.equal(input.num_shards, 3)
assert.equal(input.unknown_key, 'extra')
})
})
describe('forward-compatibility and extensibility', () => {
Expand Down