Skip to content

Commit a9d274f

Browse files
authored
support arktype (generalise json-schemable validation libraries) (#59)
Be less zod-biased, so that arktype (or in theory another validation library implementing `.toJsonSchema()`) can be used instead of zod. Mostly out of my own curiosity, and zod will remain the recommended way to use this, but feels sensible to not be too coupled to zod. Notes: - arktype's `.toJsonSchema()` doesn't always produce a usable json schema, see arktypeio/arktype#1379 and arktype.test.ts - removes some overly-clever logic that attempts to smart-parse positional arguments based on zod tuple elements etc. Still support distinction between number and integer and string (so that `mycli foo 2.2` will parse as `"2.2"` for an input of type `z.union([z.string(), z.number().int()])`. But even this is arguably too smart. Maybe an argument of type `z.union([z.string(), z.number()])` should just always parse as a number Future: - valibot - typebox - effect - more?? - maybe a proposal to add `.toJsonSchema()` to the standard-schema spec? I doubt it would be accpeted, but maybe, if there were some official caveats like it being optional, and it being able to rely on external libs or something - maybe even more logic around how to pick a winner in a union - probably an env var to allow doing something like `TRPC_CLI_JSON_INPUTS=true mycli foo --input '{"xx": 1, "yy" 2}'` - idea being that if you don't like the way this library has transformed the input type into CLI arguments, you can opt out as an escape hatch --------- Co-authored-by: Misha Kaletsky <[email protected]>
1 parent 4800ca3 commit a9d274f

16 files changed

+1371
-440
lines changed

README.md

+32-2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Turn a [tRPC](https://trpc.io) router into a type-safe, fully-functional, docume
1313
- [Ignored procedures](#ignored-procedures)
1414
- [API docs](#api-docs)
1515
- [Calculator example](#calculator-example)
16+
- [arktype](#arktype)
1617
- [tRPC v10 vs v11](#trpc-v10-vs-v11)
1718
- [Output and lifecycle](#output-and-lifecycle)
1819
- [Testing your CLI](#testing-your-cli)
@@ -308,7 +309,7 @@ Note: by design, `createCli` simply collects these procedures rather than throwi
308309
### API docs
309310

310311
<!-- codegen:start {preset: markdownFromJsdoc, source: src/index.ts, export: createCli} -->
311-
#### [createCli](./src/index.ts#L64)
312+
#### [createCli](./src/index.ts#L65)
312313

313314
Run a trpc router as a CLI.
314315

@@ -443,6 +444,7 @@ Arguments:
443444
444445
Options:
445446
-h, --help display help for command
447+
446448
```
447449
<!-- codegen:end -->
448450

@@ -504,6 +506,30 @@ const appRouter = trpc.router({
504506
})
505507
```
506508

509+
## arktype
510+
511+
You can also use arktype to validate your inputs.
512+
513+
```ts
514+
import {type} from 'arktype'
515+
import {type TrpcCliMeta} from 'trpc-cli'
516+
517+
const t = initTRPC.meta<TrpcCliMeta>().create()
518+
519+
const router = t.router({
520+
add: t.procedure
521+
.input(type({left: 'number', right: 'number'}))
522+
.query(({input}) => input.left + input.right),
523+
})
524+
525+
const cli = createCli({router})
526+
527+
cli.run() // e.g. `mycli add --left 1 --right 2`
528+
```
529+
530+
Note: you will need to install `arktype` as a dependency separately
531+
Note: some arktype features result in types that can't be converted cleanly to CLI args/options, so for some procedures you may need to use the `--input` flag to pass in a JSON string. Check your CLI help text to see if this is the case.
532+
507533
## tRPC v10 vs v11
508534

509535
Both versions 10 and 11 of `@trpc/server` are both supported, but if using tRPC v11 you must pass in your `@trpc/server` module to `createCli`:
@@ -958,11 +984,15 @@ You can then use tab-completion to autocomplete commands and flags.
958984

959985
### Implementation and dependencies
960986

987+
All dependencies have zero dependencies of their own, so the dependency tree is very shallow.
988+
989+
- [@trpc/server](https://npmjs.com/package/@trpc/server) for the trpc router
961990
- [commander](https://npmjs.com/package/commander) for parsing arguments before passing to trpc
991+
- [zod](https://npmjs.com/package/zod) for input validation, included for convenience
962992
- [zod-to-json-schema](https://npmjs.com/package/zod-to-json-schema) to convert zod schemas to make them easier to recurse and format help text from
963993
- [zod-validation-error](https://npmjs.com/package/zod-validation-error) to make bad inputs have readable error messages
964994

965-
`zod` and `@tprc/server` are peer dependencies - right now only zod 3+ and @trpc/server 10+ have been tested, but it may work with most versions of zod.
995+
`zod` and `@tprc/server` are included as dependencies for convenience, but you can use your own separate installations if you prefer. Zod 3+ and @trpc/server 10 and 11, have been tested. It should work with most versions of zod.
966996

967997
### Testing
968998

eslint.config.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
1-
module.exports = require('eslint-plugin-mmkal').recommendedFlatConfigs
1+
module.exports = [
2+
...require('eslint-plugin-mmkal').recommendedFlatConfigs,
3+
{ignores: ['**/*ignoreme*']}, //
4+
]

package.json

+4-3
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,8 @@
3434
},
3535
"homepage": "https://github.com/mmkal/trpc-cli#readme",
3636
"peerDependencies": {
37-
"omelette": ">=0.4.17",
3837
"@trpc/server": ">=10",
39-
"zod": ">=3"
38+
"omelette": ">=0.4.17"
4039
},
4140
"peerDependenciesMeta": {
4241
"omelette": {
@@ -54,12 +53,14 @@
5453
"@types/omelette": "^0.4.4",
5554
"commander": "^13.1.0",
5655
"picocolors": "^1.0.1",
57-
"zod": "^3.23.8",
56+
"zod": "^3.24.2",
5857
"zod-to-json-schema": "^3.23.0",
5958
"zod-validation-error": "^3.3.0"
6059
},
6160
"devDependencies": {
61+
"@types/json-schema": "7.0.15",
6262
"@types/node": "20.16.11",
63+
"arktype": "2.1.9",
6364
"eslint": "8",
6465
"eslint-plugin-mmkal": "https://pkg.pr.new/mmkal/eslint-plugin-mmkal@899fddb",
6566
"execa": "9.3.1",

pnpm-lock.yaml

+39-13
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

readme-codegen.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,26 @@ import {createHash} from 'crypto'
22
import {execaCommandSync} from 'execa'
33
import stripAnsi from 'strip-ansi'
44

5-
export const command: import('eslint-plugin-mmkal').CodegenPreset<{command: string; reject?: false}> = ({options}) => {
5+
export const command: import('eslint-plugin-mmkal').CodegenPreset<{command: string; reject?: false}> = ({
6+
options,
7+
meta,
8+
}) => {
69
const result = execaCommandSync(options.command, {all: true, reject: options.reject})
7-
return [
10+
const output = [
811
`\`${options.command.replace(/.* test\/fixtures\//, 'node path/to/')}\` output:`,
912
'',
1013
'```',
1114
stripAnsi(result.all), // includes stderr
1215
'```',
1316
].join('\n')
17+
18+
const noWhitespace = (s: string) => s.replaceAll(/\s+/g, '')
19+
20+
if (noWhitespace(output) === noWhitespace(meta.existingContent)) {
21+
return meta.existingContent
22+
}
23+
24+
return output
1425
}
1526

1627
export const dump: import('eslint-plugin-mmkal').CodegenPreset<{file: string}> = ({dependencies, options, meta}) => {

src/index.ts

+27-5
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ import {addCompletions} from './completions'
99
import {FailedToExitError, CliValidationError} from './errors'
1010
import {flattenedProperties, incompatiblePropertyPairs, getDescription, getSchemaTypes} from './json-schema'
1111
import {lineByLineConsoleLogger} from './logging'
12+
import {parseProcedureInputs} from './parse-procedure'
1213
import {AnyProcedure, AnyRouter, CreateCallerFactoryLike, isTrpc11Procedure} from './trpc-compat'
1314
import {Logger, OmeletteInstanceLike, TrpcCliMeta, TrpcCliParams} from './types'
1415
import {looksLikeInstanceof} from './util'
15-
import {parseProcedureInputs} from './zod-procedure'
1616

1717
export * from './types'
1818

@@ -47,6 +47,7 @@ type TrpcCliRunParams = {
4747

4848
type CommanderProgramLike = {
4949
parseAsync: (args: string[], options?: {from: 'user' | 'node' | 'electron'}) => Promise<unknown>
50+
helpInformation: () => string
5051
}
5152

5253
export interface TrpcCli {
@@ -87,13 +88,12 @@ export function createCli<R extends AnyRouter>({router, ...params}: TrpcCliParam
8788
procedure,
8889
procedureInputs: {
8990
positionalParameters: [],
90-
parameters: [],
9191
optionsJsonSchema: {
9292
type: 'object',
9393
properties: {
9494
input: {
9595
type: 'json' as string as 'string',
96-
description: 'Input formatted as JSON',
96+
description: `Input formatted as JSON${procedureInputsResult.success ? '' : ` (procedure's schema couldn't be converted to CLI arguments: ${procedureInputsResult.error})`}`,
9797
},
9898
},
9999
},
@@ -149,6 +149,9 @@ export function createCli<R extends AnyRouter>({router, ...params}: TrpcCliParam
149149
throw new FailedToExitError(`Command ${command.name()} exitOverride`, {exitCode: ec.exitCode, cause: ec})
150150
})
151151
command.configureOutput({
152+
writeOut: str => {
153+
logger.info?.(str)
154+
},
152155
writeErr: str => {
153156
logger.error?.(str)
154157
},
@@ -167,7 +170,12 @@ export function createCli<R extends AnyRouter>({router, ...params}: TrpcCliParam
167170
command.description(meta?.description || '')
168171

169172
procedureInputs.positionalParameters.forEach(param => {
170-
const argument = new Argument(param.name, param.description + (param.required ? ` (required)` : ''))
173+
const descriptionParts = [
174+
// param.type, //
175+
param.description,
176+
param.required ? '(required)' : '',
177+
]
178+
const argument = new Argument(param.name, descriptionParts.join(' '))
171179
argument.required = param.required
172180
argument.variadic = param.array
173181
command.addArgument(argument)
@@ -272,6 +280,16 @@ export function createCli<R extends AnyRouter>({router, ...params}: TrpcCliParam
272280
command.addOption(option)
273281
return
274282
}
283+
if (rootTypes.length === 2 && rootTypes[0] === 'number' && rootTypes[1] === 'string') {
284+
const option = new Option(`${flags} ${bracketise('value')}`, description)
285+
option.argParser(value => {
286+
const number = numberParser(value, {fallback: null})
287+
return number ?? value
288+
})
289+
if (defaultValue.exists) option.default(defaultValue.value)
290+
command.addOption(option)
291+
return
292+
}
275293

276294
if (rootTypes.length !== 1) {
277295
const option = new Option(`${flags} ${bracketise('json')}`, `${description} (value will be parsed as JSON)`)
@@ -307,7 +325,8 @@ export function createCli<R extends AnyRouter>({router, ...params}: TrpcCliParam
307325
option.argParser(value => numberParser(value))
308326
} else if (propertyType === 'array') {
309327
option = new Option(`${flags} [values...]`, description)
310-
option.default(defaultValue.exists ? defaultValue.value : [])
328+
if (defaultValue.exists) option.default(defaultValue.value)
329+
else if (isValueRequired) option.default([])
311330
const itemTypes =
312331
'items' in propertyValue && propertyValue.items
313332
? getSchemaTypes(propertyValue.items as JsonSchema7Type)
@@ -545,6 +564,9 @@ function transformError(err: unknown, command: Command) {
545564
cause.issues = originalIssues
546565
}
547566
}
567+
if (err.code === 'BAD_REQUEST' && err.cause?.constructor?.name === 'TraversalError') {
568+
return new CliValidationError(err.cause.message + '\n\n' + command.helpInformation())
569+
}
548570
if (err.code === 'INTERNAL_SERVER_ERROR') {
549571
return cause
550572
}

src/json-schema.ts

+14-4
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export const getDescription = (v: JsonSchema7Type, depth = 0): string => {
8888
if (k === 'type' && Array.isArray(vv)) return `type: ${vv.join(' or ')}`
8989
if (k === 'description' && i === 0) return String(vv)
9090
if (k === 'properties') return `Object (json formatted)`
91+
if (typeof vv === 'object') return `${capitaliseFromCamelCase(k)}: ${JSON.stringify(vv)}`
9192
return `${capitaliseFromCamelCase(k)}: ${vv}`
9293
})
9394
.join('; ') || ''
@@ -97,15 +98,24 @@ export const getDescription = (v: JsonSchema7Type, depth = 0): string => {
9798
export const getSchemaTypes = (
9899
propertyValue: JsonSchema7Type,
99100
): Array<'string' | 'boolean' | 'number' | (string & {})> => {
101+
const array: string[] = []
100102
if ('type' in propertyValue) {
101-
return [propertyValue.type].flat()
103+
array.push(...[propertyValue.type].flat())
104+
}
105+
if ('enum' in propertyValue && Array.isArray(propertyValue.enum)) {
106+
array.push(...propertyValue.enum.flatMap(s => typeof s))
107+
}
108+
if ('const' in propertyValue && propertyValue.const === null) {
109+
array.push('null')
110+
} else if ('const' in propertyValue) {
111+
array.push(typeof propertyValue.const)
102112
}
103113
if ('oneOf' in propertyValue) {
104-
return (propertyValue.oneOf as JsonSchema7Type[]).flatMap(getSchemaTypes)
114+
array.push(...(propertyValue.oneOf as JsonSchema7Type[]).flatMap(getSchemaTypes))
105115
}
106116
if ('anyOf' in propertyValue) {
107-
return (propertyValue.anyOf as JsonSchema7Type[]).flatMap(getSchemaTypes)
117+
array.push(...(propertyValue.anyOf as JsonSchema7Type[]).flatMap(getSchemaTypes))
108118
}
109119

110-
return []
120+
return [...new Set(array)]
111121
}

0 commit comments

Comments
 (0)