Skip to content

Commit 4484fed

Browse files
committed
Merge remote-tracking branch 'origin/main' into deps
2 parents 7ebcca9 + 62965c7 commit 4484fed

File tree

8 files changed

+365
-39
lines changed

8 files changed

+365
-39
lines changed

README.md

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,31 @@ const router = t.router({
295295

296296
#### Both
297297

298-
To use positional arguments _and_ options, use a tuple with an object at the end:
298+
To use positional arguments _and_ options, the preferred way is to use input schema metadata (e.g. `z.string().meta({positional: true})`. This is available on zod v4, and arktype via `type('string').configure({positional: true})`):
299+
300+
```ts
301+
t.router({
302+
copy: t.procedure
303+
.input(
304+
z.object({
305+
source: z.string().meta({positional: true}),
306+
target: z.string().meta({positional: true}),
307+
mkdirp: z
308+
.boolean()
309+
.optional()
310+
.describe("Ensure target's parent directory exists before copying"),
311+
}),
312+
)
313+
.mutation(async ({input: [source, target, opts]}) => {
314+
if (opts.mkdirp) {
315+
await fs.mkdir(path.dirname(target, {recursive: true}))
316+
}
317+
await fs.copyFile(source, target)
318+
}),
319+
})
320+
```
321+
322+
2) use a tuple with an object at the end (use this if you're on an old version of zod, or using a library which doesn't support `meta`):
299323

300324
```ts
301325
t.router({
@@ -327,7 +351,7 @@ You might use the above with a command like:
327351
path/to/cli copy a.txt b.txt --mkdirp
328352
```
329353

330-
>Note: object types for options must appear _last_ in the `.input(...)` tuple, when being used with positional arguments. So `z.tuple([z.string(), z.object({mkdirp: z.boolean()}), z.string()])` would not be allowed.
354+
>Note: when using a tuple, object types for options must appear _last_ in the `.input(...)` tuple, when being used with positional arguments. So `z.tuple([z.string(), z.object({mkdirp: z.boolean()}), z.string()])` would not be allowed (inputs would have to be passed as JSON).
331355
332356
>You can pass an existing tRPC router that's primarily designed to be deployed as a server, in order to invoke your procedures directly in development.
333357
@@ -427,7 +451,7 @@ const router = t.router({
427451
### API docs
428452

429453
<!-- codegen:start {preset: markdownFromJsdoc, source: src/index.ts, export: createCli} -->
430-
#### [createCli](./src/index.ts#L168)
454+
#### [createCli](./src/index.ts#L186)
431455

432456
Run a trpc router as a CLI.
433457

src/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,24 @@ export * as zod from 'zod'
3838

3939
export * as trpcServer from '@trpc/server'
4040

41+
declare module 'zod/v4' {
42+
interface GlobalMeta {
43+
/**
44+
* If true, this property will be mapped to a positional CLI argument by trpc-cli. Only valid for string, number, or boolean types (or arrays of these types).
45+
* Note: the order of positional arguments is determined by the order of properties in the schema.
46+
* For example, the following are different:
47+
* - `z.object({abc: z.string().meta({positional: true}), xyz: z.string().meta({positional: true})})`
48+
* - `z.object({xyz: z.string().meta({positional: true}), abc: z.string().meta({positional: true})})`
49+
*/
50+
positional?: boolean
51+
/**
52+
* If set, this value will be used an alias for the option.
53+
* Note: this is only valid for options, not positional arguments.
54+
*/
55+
alias?: string
56+
}
57+
}
58+
4159
export class Command extends BaseCommand {
4260
/** @internal track the commands that have been run, so that we can find the `__result` of the last command */
4361
__ran: Command[] = []

src/parse-procedure.ts

Lines changed: 87 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,60 @@ function looksLikeJsonSchema(value: unknown): value is JSONSchema7 & {type: stri
4242
}
4343

4444
export function parseProcedureInputs(inputs: unknown[], dependencies: Dependencies): Result<ParsedProcedure> {
45+
const inner = parseProcedureInputsInner(inputs, dependencies)
46+
if (inner.success && inner.value.positionalParameters.some((param, i, {length}) => param.array && i < length - 1)) {
47+
return {success: false, error: `Array positional parameters must be at the end of the input.`}
48+
}
49+
50+
if (inner.success) {
51+
const optionsProps = schemaDefPropValue(inner.value.optionsJsonSchema as JSONSchema7, 'properties')
52+
if (optionsProps) {
53+
const optionishPositionals = Object.entries(optionsProps).flatMap(([key, schema]) => {
54+
if (typeof schema === 'object' && 'positional' in schema && schema.positional === true) {
55+
return [{key, schema}]
56+
}
57+
return []
58+
})
59+
60+
if (optionishPositionals.length > 0) {
61+
return {
62+
success: true,
63+
value: {
64+
positionalParameters: [
65+
...inner.value.positionalParameters,
66+
...optionishPositionals.map(({key, schema}): (typeof inner.value.positionalParameters)[number] => ({
67+
name: key,
68+
array: looksLikeArray(schema),
69+
description: schema.description ?? '',
70+
required: !isOptional(schema),
71+
type: getSchemaTypes(schema).join(' | '),
72+
})),
73+
],
74+
optionsJsonSchema: {
75+
...inner.value.optionsJsonSchema,
76+
properties: Object.fromEntries(
77+
Object.entries(optionsProps).filter(([key]) => !optionishPositionals.some(x => x.key === key)),
78+
),
79+
} as JSONSchema7,
80+
getPojoInput: params => {
81+
const positionalValues = [...params.positionalValues]
82+
const options = {...params.options}
83+
for (const {key, schema} of optionishPositionals) {
84+
options[key] = convertPositional(schema, positionalValues.shift() as string)
85+
}
86+
87+
return inner.value.getPojoInput({positionalValues, options})
88+
},
89+
},
90+
}
91+
}
92+
}
93+
}
94+
95+
return inner
96+
}
97+
98+
function parseProcedureInputsInner(inputs: unknown[], dependencies: Dependencies): Result<ParsedProcedure> {
4599
if (inputs.length === 0) {
46100
return {
47101
success: true,
@@ -83,25 +137,6 @@ function handleMergedSchema(mergedSchema: JSONSchema7): Result<ParsedProcedure>
83137
return {success: false, error: `Inputs with additional properties are not currently supported`}
84138
}
85139

86-
if (mergedSchema.type === 'string') {
87-
return {
88-
success: true,
89-
value: {
90-
positionalParameters: [
91-
{
92-
type: 'string',
93-
array: false,
94-
description: mergedSchema.description || '',
95-
name: mergedSchema.title || 'string',
96-
required: !isOptional(mergedSchema),
97-
},
98-
],
99-
optionsJsonSchema: {},
100-
getPojoInput: argv => argv.positionalValues[0] as string,
101-
},
102-
}
103-
}
104-
105140
if (acceptedPrimitiveTypes(mergedSchema).length > 0) {
106141
return parsePrimitiveInput(mergedSchema)
107142
}
@@ -202,8 +237,26 @@ function acceptedPrimitiveTypes(schema: JSONSchema7Definition): Array<(typeof pr
202237
return primitiveCandidateTypes.filter(c => acceptedJsonSchemaTypes.has(c))
203238
}
204239

240+
/**
241+
* From a list of schemas, if they are all record-style schemas, return a single schema with all properties (an intersection).
242+
* Returns `null` if the schemas are not all record-style schemas.
243+
*/
244+
function maybeMergeObjectSchemas(schemas: JSONSchema7[]): JSONSchema7 | null {
245+
const required: string[] = []
246+
const properties: Record<string, JSONSchema7> = {}
247+
for (const schema of schemas) {
248+
if (!schema) return null
249+
const {required: schemaRequired, properties: schemaProperties, type, $schema, ...rest} = schema
250+
if (type && type !== 'object') return null
251+
if (Object.keys(rest).length) return null
252+
if (schemaRequired) required.push(...schemaRequired)
253+
if (schemaProperties) Object.assign(properties, schemaProperties)
254+
}
255+
return {type: 'object', required, properties}
256+
}
257+
205258
function parseMultiInputs(inputs: unknown[], dependencies: Dependencies): Result<ParsedProcedure> {
206-
const parsedIndividually = inputs.map(input => parseProcedureInputs([input], dependencies))
259+
const parsedIndividually = inputs.map(input => parseProcedureInputsInner([input], dependencies))
207260

208261
const failures = parsedIndividually.flatMap(p => (p.success ? [] : [p.error]))
209262
if (failures.length > 0) {
@@ -218,6 +271,20 @@ function parseMultiInputs(inputs: unknown[], dependencies: Dependencies): Result
218271
}
219272
}
220273

274+
const merged = maybeMergeObjectSchemas(
275+
parsedIndividually.map(p => (p.success ? (p.value.optionsJsonSchema as JSONSchema7) : {})),
276+
)
277+
if (merged) {
278+
return {
279+
success: true,
280+
value: {
281+
positionalParameters: [],
282+
optionsJsonSchema: merged,
283+
getPojoInput: argv => argv.options,
284+
},
285+
}
286+
}
287+
221288
return {
222289
success: true,
223290
value: {

test/arktype.test.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@ test('merging input types', async () => {
2222
.input(type({bar: 'string'}))
2323
.input(type({baz: 'number'}))
2424
.input(type({qux: 'boolean'}))
25-
.query(({input}) => Object.entries(input).join(', ')),
25+
.query(({input}) => JSON.stringify({bar: input.bar, baz: input.baz, qux: input.qux})),
2626
})
2727

2828
expect(await run(router, ['foo', '--bar', 'hello', '--baz', '42', '--qux'])).toMatchInlineSnapshot(
29-
`"bar,hello, baz,42, qux,true"`,
29+
`"{"bar":"hello","baz":42,"qux":true}"`,
3030
)
3131
})
3232

@@ -390,6 +390,38 @@ test('mixed array input', async () => {
390390
expect(result).toMatchInlineSnapshot(`"list: [12,true,3.14,"null","undefined","hello"]"`)
391391
})
392392

393+
test('number then string array input', async () => {
394+
const router = t.router({
395+
test: t.procedure
396+
.input(type(['number', 'string[]'])) //
397+
.query(({input}) => `list: ${JSON.stringify(input)}`),
398+
})
399+
400+
expect(await run(router, ['test', '123', 'hello', 'world'])).toMatchInlineSnapshot(`"list: [123,["hello","world"]]"`)
401+
})
402+
403+
test('string array then number input (downgrades to json input)', async () => {
404+
const router = t.router({
405+
test: t.procedure
406+
.input(type(['string[]', 'number'])) //
407+
.query(({input}) => `list: ${JSON.stringify(input)}`),
408+
})
409+
410+
expect(await run(router, ['test', '--help'], {expectJsonInput: true})).toMatchInlineSnapshot(`
411+
"Usage: program test [options]
412+
413+
Options:
414+
--input [json] Input formatted as JSON (procedure's schema couldn't be
415+
converted to CLI arguments: Array positional parameters must
416+
be at the end of the input.)
417+
-h, --help display help for command
418+
"
419+
`)
420+
expect(
421+
await run(router, ['test', '--input', '[["hello","world"], 123]'], {expectJsonInput: true}),
422+
).toMatchInlineSnapshot(`"list: [["hello","world"],123]"`)
423+
})
424+
393425
test('record input', async () => {
394426
const router = t.router({
395427
test: t.procedure

test/json.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {expect, test} from 'vitest'
22

33
import {createCli, trpcServer, z} from '../src'
4-
import {router} from './fixtures/migrations'
4+
import {router as migrationsRouter} from './fixtures/migrations'
55

66
expect.addSnapshotSerializer({
77
test: () => true,
@@ -83,7 +83,7 @@ test('simple toJSON', async () => {
8383
})
8484

8585
test('migrations toJSON', async () => {
86-
const json = createCli({router}).toJSON()
86+
const json = createCli({router: migrationsRouter}).toJSON()
8787
expect(json).toMatchInlineSnapshot(`
8888
{
8989
"description": "Available subcommands: up, create, list, search",
@@ -201,11 +201,11 @@ test('migrations toJSON', async () => {
201201
},
202202
{
203203
"name": "name",
204-
"required": false,
205-
"optional": true,
204+
"required": true,
205+
"optional": false,
206206
"negate": false,
207207
"variadic": false,
208-
"flags": "--name [string]",
208+
"flags": "--name <string>",
209209
"attributeName": "name"
210210
}
211211
],
@@ -233,11 +233,11 @@ test('migrations toJSON', async () => {
233233
},
234234
{
235235
"name": "search-term",
236-
"required": false,
237-
"optional": true,
236+
"required": true,
237+
"optional": false,
238238
"negate": false,
239239
"variadic": false,
240-
"flags": "-q, --search-term [string]",
240+
"flags": "-q, --search-term <string>",
241241
"short": "-q",
242242
"description": "Only show migrations whose \`content\` value contains this string",
243243
"attributeName": "searchTerm"

test/valibot.test.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ test('merging input types', async () => {
1919
.input(v.object({bar: v.string()}))
2020
.input(v.object({baz: v.number()}))
2121
.input(v.object({qux: v.boolean()}))
22-
.query(({input}) => Object.entries(input).join(', ')),
22+
.query(({input}) => JSON.stringify({bar: input.bar, baz: input.baz, qux: input.qux})),
2323
})
2424

2525
expect(await run(router, ['foo', '--bar', 'hello', '--baz', '42', '--qux'])).toMatchInlineSnapshot(
26-
`"bar,hello, baz,42, qux,true"`,
26+
`"{"bar":"hello","baz":42,"qux":true}"`,
2727
)
2828
})
2929

@@ -398,6 +398,38 @@ test('mixed array input', async () => {
398398
expect(result).toMatchInlineSnapshot(`"list: [12,true,3.14,"null","undefined","hello"]"`)
399399
})
400400

401+
test('number then string array input', async () => {
402+
const router = t.router({
403+
test: t.procedure
404+
.input(v.tuple([v.number(), v.array(v.string())])) //
405+
.query(({input}) => `list: ${JSON.stringify(input)}`),
406+
})
407+
408+
expect(await run(router, ['test', '123', 'hello', 'world'])).toMatchInlineSnapshot(`"list: [123,["hello","world"]]"`)
409+
})
410+
411+
test('string array then number input (downgrades to json input)', async () => {
412+
const router = t.router({
413+
test: t.procedure
414+
.input(v.tuple([v.array(v.string()), v.number()])) //
415+
.query(({input}) => `list: ${JSON.stringify(input)}`),
416+
})
417+
418+
expect(await run(router, ['test', '--help'], {expectJsonInput: true})).toMatchInlineSnapshot(`
419+
"Usage: program test [options]
420+
421+
Options:
422+
--input [json] Input formatted as JSON (procedure's schema couldn't be
423+
converted to CLI arguments: Array positional parameters must
424+
be at the end of the input.)
425+
-h, --help display help for command
426+
"
427+
`)
428+
expect(
429+
await run(router, ['test', '--input', '[["hello","world"], 123]'], {expectJsonInput: true}),
430+
).toMatchInlineSnapshot(`"list: [["hello","world"],123]"`)
431+
})
432+
401433
test('record input', async () => {
402434
const router = t.router({
403435
test: t.procedure

0 commit comments

Comments
 (0)