Skip to content

Commit 130bc18

Browse files
committed
Merge remote-tracking branch 'origin/main' into deps
2 parents 1a70cff + 41fbb3e commit 130bc18

File tree

13 files changed

+262
-113
lines changed

13 files changed

+262
-113
lines changed

.github/workflows/autofix.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ jobs:
1616
- run: corepack enable
1717
- run: pnpm install --no-frozen-lockfile
1818
- run: pnpm run lint --fix
19-
- uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27
19+
- run: git diff
20+
- uses: autofix-ci/action@551dded8c6cc8a1054039c8bc0b8b48c51dfc6ef

README.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -224,14 +224,24 @@ Booleans:
224224
- no option supplied to `{foo: false}`
225225
- `--foo` `{foo: true}`
226226

227+
227228
- `z.object({foo: z.boolean().default(true)})` will map:
228229
- no option supplied to `{foo: true}`
229-
- `--no-foo` `{foo: false}`
230+
- `--foo false` or `--foo=false` to `{foo: false}`
230231

231232
- `z.object({foo: z.boolean().optional()})` will map:
232233
- no option supplied to `{}` (foo is undefined)
233234
- `--foo` to `{foo: true}`
234-
- `--foo false` to `{foo: false}` (note: `--no-foo` doesn't work here, because its existence prevents `{}` from being the default value)
235+
- `--foo false` to `{foo: false}`
236+
237+
Negated options can be useful for default-true booleans:
238+
239+
- `z.object({foo: z.boolean().default(true).meta({negatable: true})})` will map:
240+
- no option supplied to `{foo: true}`
241+
- `--no-foo` to `{foo: false}`
242+
- `--foo false` to `{foo: false}`
243+
244+
(Note: you can set booleans to negatable-by-default by setting `negateBooleans: true` on the procedure's `meta`)
235245

236246
Numbers:
237247

@@ -333,7 +343,7 @@ You can also explicitly opt into this behavior for any procedure by setting `jso
333343
### API docs
334344

335345
<!-- codegen:start {preset: markdownFromJsdoc, source: src/index.ts, export: createCli} -->
336-
#### [createCli](./src/index.ts#L167)
346+
#### [createCli](./src/index.ts#L168)
337347

338348
Run a trpc router as a CLI.
339349

@@ -489,7 +499,7 @@ When passing a command along with its options, the return value will be logged t
489499

490500
Invalid inputs are helpfully displayed, along with help text for the associated command:
491501

492-
<!-- codegen:start {preset: custom, require: tsx/cjs, source: ./readme-codegen.ts, export: command, command: './node_modules/.bin/tsx test/fixtures/calculator add 2 notanumber', reject: false} -->
502+
<!-- TODO:reenable codegen:start {preset: custom, require: tsx/cjs, source: ./readme-codegen.ts, export: command, command: './node_modules/.bin/tsx test/fixtures/calculator add 2 notanumber', reject: false} -->
493503
`node path/to/calculator add 2 notanumber` output:
494504

495505
```

readme-codegen.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
import {createHash} from 'crypto'
2+
import {execaCommandSync} from 'execa'
23
import stripAnsi from 'strip-ansi'
34

45
export const command: import('eslint-plugin-mmkal').CodegenPreset<{command: string; reject?: false}> = ({
56
options,
67
meta,
7-
dependencies: {zx},
88
}) => {
9-
const $ = zx.$({nothrow: !options.reject, sync: true})
10-
const result = $`sh -c ${options.command}`
9+
const result = execaCommandSync(options.command, {all: true, reject: options.reject})
10+
if (!stripAnsi(result.all)) throw new Error(`Command ${options.command} had no output`)
1111
const output = [
1212
`\`${options.command.replace(/.* test\/fixtures\//, 'node path/to/')}\` output:`,
1313
'',
1414
'```',
15-
stripAnsi(result.stdout), // includes stderr
15+
stripAnsi(result.all), // includes stderr
1616
'```',
1717
].join('\n')
1818

src/index.ts

Lines changed: 81 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
getDescription,
1313
getSchemaTypes,
1414
getEnumChoices,
15+
getAllowedSchemas,
1516
} from './json-schema'
1617
import {lineByLineConsoleLogger} from './logging'
1718
import {parseProcedureInputs} from './parse-procedure'
@@ -258,109 +259,53 @@ export function createCli<R extends AnyRouter>({router, ...params}: TrpcCliParam
258259
delete unusedOptionAliases[propertyKey]
259260
}
260261

261-
const defaultValue =
262-
'default' in propertyValue
263-
? ({exists: true, value: propertyValue.default} as const)
264-
: ({exists: false} as const)
262+
const allowedSchemas = getAllowedSchemas(propertyValue)
263+
const firstSchemaWithDefault = allowedSchemas.find(subSchema => 'default' in subSchema)
264+
const defaultValue = firstSchemaWithDefault
265+
? ({exists: true, value: firstSchemaWithDefault.default} as const)
266+
: ({exists: false} as const)
265267

266268
const rootTypes = getSchemaTypes(propertyValue).sort()
267269

268-
const parseJson = (value: string, ErrorClass: new (message: string) => Error = InvalidArgumentError) => {
269-
try {
270-
return JSON.parse(value) as {}
271-
} catch {
272-
throw new ErrorClass(`Malformed JSON.`)
273-
}
274-
}
275-
/** try to get a parser that can confidently parse a string into the correct type. Returns null if it can't confidently parse */
276-
const getValueParser = (types: ReturnType<typeof getSchemaTypes>) => {
277-
types = types.map(t => (t === 'integer' ? 'number' : t))
278-
if (types.length === 2 && types[0] === 'boolean' && types[1] === 'number') {
279-
return {
280-
type: 'boolean|number',
281-
parser: (value: string) => booleanParser(value, {fallback: null}) ?? numberParser(value),
282-
} as const
283-
}
284-
if (types.length === 1 && types[0] === 'boolean') {
285-
return {type: 'boolean', parser: (value: string) => booleanParser(value)} as const
286-
}
287-
if (types.length === 1 && types[0] === 'number') {
288-
return {type: 'number', parser: (value: string) => numberParser(value)} as const
289-
}
290-
if (types.length === 1 && types[0] === 'string') {
291-
return {type: 'string', parser: null} as const
292-
}
293-
return {
294-
type: 'object',
295-
parser: (value: string) => {
296-
const parsed = parseJson(value)
297-
if (!types.length) return parsed // if types is empty, it means any type is allowed - e.g. for json input
298-
const jsonSchemaType = Array.isArray(parsed) ? 'array' : parsed === null ? 'null' : typeof parsed
299-
if (!types.includes(jsonSchemaType)) {
300-
throw new InvalidArgumentError(`Got ${jsonSchemaType} but expected ${types.join(' or ')}`)
301-
}
302-
return parsed
303-
},
304-
} as const
305-
}
306-
307270
const propertyType = rootTypes[0]
308271
const isValueRequired =
309272
'required' in parsedProcedure.optionsJsonSchema &&
310273
parsedProcedure.optionsJsonSchema.required?.includes(propertyKey)
311274
const isCliOptionRequired = isValueRequired && propertyType !== 'boolean' && !defaultValue.exists
312275

313276
function negate() {
314-
const negation = new Option(longOption.replace('--', '--no-'), `Negate \`${longOption}\` option.`.trim())
315-
command.addOption(negation)
277+
const shouldNegate = 'negatable' in propertyValue ? propertyValue.negatable : meta.negateBooleans
278+
if (shouldNegate) {
279+
const negation = new Option(longOption.replace('--', '--no-'), `Negate \`${longOption}\` option.`.trim())
280+
command.addOption(negation)
281+
}
316282
}
317283

318284
const bracketise = (name: string) => (isCliOptionRequired ? `<${name}>` : `[${name}]`)
319285

320-
if (rootTypes.length === 2 && rootTypes[0] === 'boolean' && rootTypes[1] === 'string') {
321-
const option = new Option(`${flags} [value]`, description)
322-
option.default(defaultValue.exists ? defaultValue.value : false)
323-
command.addOption(option)
324-
negate()
325-
return
326-
}
327-
if (rootTypes.length === 2 && rootTypes[0] === 'boolean' && rootTypes[1] === 'number') {
286+
if (allowedSchemas.length > 1) {
328287
const option = new Option(`${flags} [value]`, description)
329-
option.argParser(getValueParser(rootTypes).parser!)
330-
option.default(defaultValue.exists ? defaultValue.value : false)
331-
command.addOption(option)
332-
negate()
333-
return
334-
}
335-
if (rootTypes.length === 2 && rootTypes[0] === 'number' && rootTypes[1] === 'string') {
336-
const option = new Option(`${flags} ${bracketise('value')}`, description)
337-
option.argParser(value => {
338-
const number = numberParser(value, {fallback: null})
339-
return number ?? value
340-
})
341288
if (defaultValue.exists) option.default(defaultValue.value)
289+
else if (rootTypes.includes('boolean')) option.default(false)
290+
option.argParser(getOptionValueParser(propertyValue))
342291
command.addOption(option)
292+
if (rootTypes.includes('boolean')) negate()
343293
return
344294
}
345295

346296
if (rootTypes.length !== 1) {
347297
const option = new Option(`${flags} ${bracketise('json')}`, description)
348-
option.argParser(getValueParser(rootTypes).parser!)
298+
option.argParser(getOptionValueParser(propertyValue))
349299
command.addOption(option)
350300
return
351301
}
352302

353-
if (propertyType === 'boolean' && isValueRequired) {
354-
const option = new Option(flags, description)
355-
option.default(defaultValue.exists ? defaultValue.value : false)
356-
command.addOption(option)
357-
negate()
358-
return
359-
} else if (propertyType === 'boolean') {
303+
if (propertyType === 'boolean') {
360304
const option = new Option(`${flags} [boolean]`, description)
361305
option.argParser(value => booleanParser(value))
362306
// don't set a default value of `false`, because `undefined` is accepted by the procedure
363-
if (defaultValue.exists) option.default(defaultValue.value)
307+
if (isValueRequired) option.default(false)
308+
else if (defaultValue.exists) option.default(defaultValue.value)
364309
command.addOption(option)
365310
negate()
366311
return
@@ -369,10 +314,7 @@ export function createCli<R extends AnyRouter>({router, ...params}: TrpcCliParam
369314
let option: Option | null = null
370315

371316
// eslint-disable-next-line unicorn/prefer-switch
372-
if (propertyType === 'string' && 'format' in propertyValue && (propertyValue.format as string) === 'json') {
373-
// option = new Option(`${flags} ${bracketise('json')}`, description)
374-
// option.argParser(value => parseJson(value, InvalidOptionArgumentError))
375-
} else if (propertyType === 'string') {
317+
if (propertyType === 'string') {
376318
option = new Option(`${flags} ${bracketise('string')}`, description)
377319
} else if (propertyType === 'boolean') {
378320
option = new Option(flags, description)
@@ -383,20 +325,18 @@ export function createCli<R extends AnyRouter>({router, ...params}: TrpcCliParam
383325
option = new Option(`${flags} [values...]`, description)
384326
if (defaultValue.exists) option.default(defaultValue.value)
385327
else if (isValueRequired) option.default([])
386-
const itemsProp = 'items' in propertyValue ? (propertyValue.items as JsonSchema7Type) : null
387-
const itemTypes = itemsProp ? getSchemaTypes(itemsProp) : []
328+
const itemsSchema = 'items' in propertyValue ? (propertyValue.items as JsonSchema7Type) : {}
388329

389-
const itemEnumTypes = itemsProp && getEnumChoices(itemsProp)
330+
const itemEnumTypes = getEnumChoices(itemsSchema)
390331
if (itemEnumTypes?.type === 'string_enum') {
391332
option.choices(itemEnumTypes.choices)
392333
}
393334

394-
const itemParser = getValueParser(itemTypes)
395-
if (itemParser.parser) {
396-
option.argParser((value, previous) => {
397-
const parsed = itemParser.parser(value)
398-
if (Array.isArray(previous)) return [...previous, parsed] as unknown[]
399-
return [parsed] as unknown[]
335+
const itemParser = getOptionValueParser(itemsSchema)
336+
if (itemParser) {
337+
option.argParser((value, previous): unknown[] => {
338+
const parsed = itemParser(value)
339+
return Array.isArray(previous) ? [...previous, parsed] : [parsed]
400340
})
401341
}
402342
}
@@ -441,7 +381,6 @@ export function createCli<R extends AnyRouter>({router, ...params}: TrpcCliParam
441381
program.__ran ||= []
442382
program.__ran.push(command)
443383
const options = command.opts()
444-
// console.dir({options, args}, {depth: null})
445384

446385
if (args.at(-2) !== options) {
447386
// This is a code bug and not recoverable. Will hopefully never happen but if commander totally changes their API this will break
@@ -672,3 +611,57 @@ const booleanParser = (val: string, {fallback = val as unknown} = {}) => {
672611
if (val === 'false') return false
673612
return fallback
674613
}
614+
615+
const getOptionValueParser = (schema: JsonSchema7Type) => {
616+
const allowedSchemas = getAllowedSchemas(schema)
617+
.slice()
618+
.sort((a, b) => String(getSchemaTypes(a)[0]).localeCompare(String(getSchemaTypes(b)[0])))
619+
620+
const typesArray = allowedSchemas.flatMap(getSchemaTypes)
621+
const types = new Set(typesArray)
622+
623+
return (value: string) => {
624+
const definitelyPrimitive = typesArray.every(
625+
t => t === 'boolean' || t === 'number' || t === 'integer' || t === 'string',
626+
)
627+
if (types.size === 0 || !definitelyPrimitive) {
628+
// parse this as JSON - too risky to fall back to a string because that will probably do the wrong thing if someone passes malformed JSON like `{"foo": 1,}` (trailing comma)
629+
const hint = `Malformed JSON. If passing a string, pass it as a valid JSON string with quotes (${JSON.stringify(value)})`
630+
const parsed = parseJson(value, InvalidOptionArgumentError, hint)
631+
if (!types.size) return parsed // if types is empty, it means any type is allowed - e.g. for json input
632+
const jsonSchemaType = Array.isArray(parsed) ? 'array' : parsed === null ? 'null' : typeof parsed
633+
if (!types.has(jsonSchemaType)) {
634+
throw new InvalidOptionArgumentError(`Got ${jsonSchemaType} but expected ${[...types].join(' or ')}`)
635+
}
636+
return parsed
637+
}
638+
if (types.has('boolean')) {
639+
const parsed = booleanParser(value, {fallback: null})
640+
if (typeof parsed === 'boolean') return parsed
641+
}
642+
if (types.has('number')) {
643+
const parsed = numberParser(value, {fallback: null})
644+
if (typeof parsed === 'number') return parsed
645+
}
646+
if (types.has('integer')) {
647+
const parsed = numberParser(value, {fallback: null})
648+
if (typeof parsed === 'number' && Number.isInteger(parsed)) return parsed
649+
}
650+
if (types.has('string')) {
651+
return value
652+
}
653+
throw new InvalidOptionArgumentError(`Got ${JSON.stringify(value)} but expected ${[...types].join(' or ')}`)
654+
}
655+
}
656+
657+
const parseJson = (
658+
value: string,
659+
ErrorClass: new (message: string) => Error = InvalidArgumentError,
660+
hint = `Malformed JSON.`,
661+
) => {
662+
try {
663+
return JSON.parse(value) as {}
664+
} catch {
665+
throw new ErrorClass(hint)
666+
}
667+
}

src/json-schema.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export const getDescription = (v: JsonSchema7Type, depth = 0): string => {
100100

101101
export const getSchemaTypes = (
102102
propertyValue: JsonSchema7Type,
103-
): Array<'string' | 'boolean' | 'number' | (string & {})> => {
103+
): Array<'string' | 'boolean' | 'number' | 'integer' | (string & {})> => {
104104
const array: string[] = []
105105
if ('type' in propertyValue) {
106106
array.push(...[propertyValue.type].flat())
@@ -123,6 +123,18 @@ export const getSchemaTypes = (
123123
return [...new Set(array)]
124124
}
125125

126+
/** Returns a list of all allowed subschemas. If the schema is not a union, returns a list with a single item. */
127+
export const getAllowedSchemas = (schema: JsonSchema7Type): JsonSchema7Type[] => {
128+
if (!schema) return []
129+
if ('anyOf' in schema && Array.isArray(schema.anyOf))
130+
return (schema.anyOf as JsonSchema7Type[]).flatMap(getAllowedSchemas)
131+
if ('oneOf' in schema && Array.isArray(schema.oneOf))
132+
return (schema.oneOf as JsonSchema7Type[]).flatMap(getAllowedSchemas)
133+
const types = getSchemaTypes(schema)
134+
if (types.length === 1) return [schema]
135+
return types.map(type => ({...schema, type}))
136+
}
137+
126138
export const getEnumChoices = (propertyValue: JsonSchema7Type) => {
127139
if (!propertyValue) return null
128140
if (!('enum' in propertyValue && Array.isArray(propertyValue.enum))) {

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ export interface TrpcCliMeta {
5353
jsonInput?: boolean
5454
/** Sub-property for the CLI meta. If present, will take precedence over the top-level meta, to avoid conflicts with other tools. */
5555
cliMeta?: TrpcCliMeta
56+
/** If set to true, add a "--no-*" option to negate each boolean option by default. Can still be overriden by doing `z.boolean().meta({negatable: ...})` or equivalent. */
57+
negateBooleans?: boolean
5658
}
5759

5860
export interface ParsedProcedure {

test/arktype.test.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -517,12 +517,10 @@ test('defaults and negations', async () => {
517517

518518
expect(await run(router, ['optional-boolean'])).toMatchInlineSnapshot(`"{}"`)
519519
expect(await run(router, ['optional-boolean', '--foo'])).toMatchInlineSnapshot(`"{ foo: true }"`)
520-
expect(await run(router, ['optional-boolean', '--no-foo'])).toMatchInlineSnapshot(`"{ foo: false }"`)
521520
expect(await run(router, ['optional-boolean', '--foo', 'true'])).toMatchInlineSnapshot(`"{ foo: true }"`)
522521
expect(await run(router, ['optional-boolean', '--foo', 'false'])).toMatchInlineSnapshot(`"{ foo: false }"`)
523522

524523
expect(await run(router, ['default-true-boolean'])).toMatchInlineSnapshot(`"{ foo: true }"`)
525-
expect(await run(router, ['default-true-boolean', '--no-foo'])).toMatchInlineSnapshot(`"{ foo: false }"`)
526524

527525
expect(await run(router, ['default-false-boolean'])).toMatchInlineSnapshot(`"{ foo: false }"`)
528526
expect(await run(router, ['default-false-boolean', '--foo'])).toMatchInlineSnapshot(`"{ foo: true }"`)

test/e2e.test.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,6 @@ test('fs copy help', async () => {
418418
419419
Options:
420420
--force [boolean] Overwrite destination if it exists (default: false)
421-
--no-force Negate \`--force\` option.
422421
-h, --help display help for command
423422
"
424423
`)
@@ -480,9 +479,7 @@ test('fs copy', async () => {
480479
481480
Options:
482481
--ignore-whitespace [boolean] Ignore whitespace changes (default: false)
483-
--no-ignore-whitespace Negate \`--ignore-whitespace\` option.
484482
--trim [boolean] Trim start/end whitespace (default: false)
485-
--no-trim Negate \`--trim\` option.
486483
-h, --help display help for command
487484
"
488485
`)
@@ -498,9 +495,7 @@ test('fs diff', async () => {
498495
499496
Options:
500497
--ignore-whitespace [boolean] Ignore whitespace changes (default: false)
501-
--no-ignore-whitespace Negate \`--ignore-whitespace\` option.
502498
--trim [boolean] Trim start/end whitespace (default: false)
503-
--no-trim Negate \`--trim\` option.
504499
-h, --help display help for command
505500
"
506501
`)

0 commit comments

Comments
 (0)