diff --git a/README.md b/README.md index bac82bb..2868d69 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,18 @@ # trpc-cli -Turn a [trpc](https://trpc.io) router into a type-safe, fully-functional, documented CLI. +Turn a [tRPC](https://trpc.io) router into a type-safe, fully-functional, documented CLI. - [Installation](#installation) - [Usage](#usage) + - [Parameters and flags](#parameters-and-flags) + - [Positional parameters](#positional-parameters) + - [Flags](#flags) + - [Both](#both) + - [API docs](#api-docs) + - [trpcCli](#trpccli) + - [Params](#params) + - [Returns](#returns) - [Calculator example](#calculator-example) - [Output and lifecycle](#output-and-lifecycle) - [Features and Limitations](#features-and-limitations) @@ -17,6 +25,9 @@ Turn a [trpc](https://trpc.io) router into a type-safe, fully-functional, docume - [Testing](#testing) +[![Build Status](https://github.com/mmkal/trpc-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/mmkal/trpc-cli/actions/workflows/ci.yml/badge.svg) +[![npm](https://badgen.net/npm/v/trpc-cli)](https://www.npmjs.com/package/trpc-cli) + ## Installation ``` @@ -35,8 +46,8 @@ const t = initTRPC.create() export const router = t.router({ add: t.procedure - .input(z.object({a: z.number(), b: z.number()})) - .query(({input}) => input.a + input.b), + .input(z.object({left: z.number(), right: z.number()})) + .query(({input}) => input.left + input.right), }) ``` @@ -54,15 +65,139 @@ And that's it! Your tRPC router is now a CLI program with help text and input va You can also pass an existing tRPC router that's primarily designed to be deployed as a server to it, in order to invoke your procedures directly, in development. +>Note that this library is still v0, so parts of the API may change slightly. The basic usage of `trpcCli({router}).run()` will remain though! + +### Parameters and flags + +CLI positional parameters and flags are derived from each procedure's input type. Inputs should use a `zod` object or tuple type for the procedure to be mapped to a CLI command. + +#### Positional parameters + +Positional parameters passed to the CLI can be declared with types representing strings, numbers or booleans: + +```ts +t.router({ + double: t.procedure + .input(z.number()) // + .query(({input}) => input * 2), +}) +``` + +You can also use anything that accepts string, number, or boolean inputs, like `z.enum(['up', 'down'])`, `z.literal(123)`, `z.string().regex(/^\w+$/)` etc. + +Multiple positional parameters can use a `z.tuple(...)` input type: + +```ts +t.router({ + add: t.procedure + .input(z.tuple([z.number(), z.number()])) + .query(({input}) => input[0] + input[1]), +}) +``` + +Which is invoked like `path/to/cli add 2 3` (outputting `5`). + +>Note: positional parameters can use `.optional()` or `.nullish()`, but not `.nullable()`. + +>Note: positional parameters can be named using `.describe('name of parameter')`, but names can not include any special characters. + +>Note: positional parameters are parsed based on the expected target type. Booleans must be written as `true` or `false`, spelled out. In most cases, though, you'd be better off using [flags](#flags) for boolean inputs. + +#### Flags + +`z.object(...)` inputs become flags (passed with `--foo bar` or `--foo=bar`) syntax. Values are accepted in either `--camelCase` or `--kebab-case`, and are parsed like in most CLI programs: + +Strings: + +- `z.object({foo: z.string()})` will map: + - `--foo bar` or `--foo=bar` to `{foo: 'bar'}` + +Booleans: + +- `z.object({foo: z.boolean()})` will map: + - `--foo` or `--foo=true` to `{foo: true}` + - `--foo=false` to `{foo: false}` + +>Note: it's usually better to use `z.boolean().optional()` than `z.boolean()`, otherwise CLI users will have to pass in `--foo=false`. + +Numbers: + +- `z.object({foo: z.number()})` will map: + - `--foo 1` or `--foo=1` to `{foo: 1}` + +Other types: +- `z.object({ foo: z.object({ bar: z.number() }) })` will parse inputs as JSON: + - `--foo '{"bar": 1}'` maps to `{foo: {bar: 1}}` + +Unions and intersections should also work as expected, but please test them thoroughly, especially if they are deeply-nested. + +#### Both + +To use positional parameters _and_ flags, use a tuple with an object at the end: + +```ts +t.router({ + copy: t.procedure + .input( + z.tuple([ + z.string().describe('source'), + z.string().describe('target'), + z.object({ + mkdirp: z + .boolean() + .optional() + .describe("Ensure target's parent directory exists before copying"), + }), + ]), + ) + .mutation(async ({input: [source, target, opts]}) => { + if (opts.mkdirp) { + await fs.mkdir(path.dirname(target, {recursive: true})) + } + await fs.copyFile(source, target) + }), +}) +``` + +You might use the above with a command like: + +``` +path/to/cli copy a.txt b.txt --mkdirp +``` + +>Note: object types for flags must appear _last_ in the `.input(...)` tuple, when being used with positional parameters. So `z.tuple([z.string(), z.object({mkdirp: z.boolean()}), z.string()])` would not be allowed. + +Procedures with incompatible inputs will be returned in the `ignoredProcedures` property. + +### API docs + + +#### [trpcCli](./src/index.ts#L27) + +Run a trpc router as a CLI. + +##### Params + +|name |description | +|-------|-----------------------------------------------------------------------------------------| +|router |A trpc router | +|context|The context to use when calling the procedures - needed if your router requires a context| +|alias |A function that can be used to provide aliases for flags. | + +##### Returns + +A CLI object with a `run` method that can be called to run the CLI. The `run` method will parse the command line arguments, call the appropriate trpc procedure, log the result and exit the process. On error, it will log the error and exit with a non-zero exit code. + + ### Calculator example Here's a more involved example, along with what it outputs: - + ```ts import * as trpcServer from '@trpc/server' -import {TrpcCliMeta, trpcCli} from 'trpc-cli' +import {trpcCli, type TrpcCliMeta} from 'trpc-cli' import {z} from 'zod' const trpc = trpcServer.initTRPC.meta().create() @@ -73,37 +208,22 @@ const router = trpc.router({ description: 'Add two numbers. Use this if you and your friend both have apples, and you want to know how many apples there are in total.', }) - .input( - z.object({ - left: z.number().describe('The first number'), - right: z.number().describe('The second number'), - }), - ) - .query(({input}) => input.left + input.right), + .input(z.tuple([z.number(), z.number()])) + .query(({input}) => input[0] + input[1]), subtract: trpc.procedure .meta({ description: 'Subtract two numbers. Useful if you have a number and you want to make it smaller.', }) - .input( - z.object({ - left: z.number().describe('The first number'), - right: z.number().describe('The second number'), - }), - ) - .query(({input}) => input.left - input.right), + .input(z.tuple([z.number(), z.number()])) + .query(({input}) => input[0] - input[1]), multiply: trpc.procedure .meta({ description: 'Multiply two numbers together. Useful if you want to count the number of tiles on your bathroom wall and are short on time.', }) - .input( - z.object({ - left: z.number().describe('The first number'), - right: z.number().describe('The second number'), - }), - ) - .query(({input}) => input.left * input.right), + .input(z.tuple([z.number(), z.number()])) + .query(({input}) => input[0] * input[1]), divide: trpc.procedure .meta({ version: '1.0.0', @@ -112,17 +232,15 @@ const router = trpc.router({ examples: 'divide --left 8 --right 4', }) .input( - z.object({ - left: z.number().describe('The numerator of the division operation.'), - right: z + z.tuple([ + z.number().describe('numerator'), + z .number() .refine(n => n !== 0) - .describe( - 'The denominator of the division operation. Note: must not be zero.', - ), - }), + .describe('denominator'), + ]), ) - .mutation(({input}) => input.left / input.right), + .mutation(({input}) => input[0] / input[1]), }) void trpcCli({router}).run() @@ -130,7 +248,7 @@ void trpcCli({router}).run() -Run `node path/to/yourfile.js --help` for formatted help text for the `sum` and `divide` commands. +Run `node path/to/cli --help` for formatted help text for the `sum` and `divide` commands. `node path/to/calculator --help` output: @@ -160,12 +278,10 @@ add Add two numbers. Use this if you and your friend both have apples, and you want to know how many apples there are in total. Usage: - add [flags...] + add [flags...] Flags: - -h, --help Show help - --left The first number - --right The second number + -h, --help Show help ``` @@ -176,9 +292,18 @@ When passing a command along with its flags, the return value will be logged to `node path/to/calculator add --left 2 --right 3` output: ``` -5 -``` +add + +Add two numbers. Use this if you and your friend both have apples, and you want to know how many apples there are in total. + +Usage: + add [flags...] +Flags: + -h, --help Show help + +Unexpected flags: left, right +``` Invalid inputs are helpfully displayed, along with help text for the associated command: @@ -192,15 +317,12 @@ add Add two numbers. Use this if you and your friend both have apples, and you want to know how many apples there are in total. Usage: - add [flags...] + add [flags...] Flags: - -h, --help Show help - --left The first number - --right The second number + -h, --help Show help -Validation error - - Expected number, received nan at "--right" +Unexpected flags: left, right ``` @@ -277,10 +399,10 @@ You could also override `process.exit` to avoid killing the process at all - see Given a migrations router looking like this: - + ```ts import * as trpcServer from '@trpc/server' -import {TrpcCliMeta, trpcCli} from 'trpc-cli' +import {trpcCli, type TrpcCliMeta} from 'trpc-cli' import {z} from 'zod' const trpc = trpcServer.initTRPC.meta().create() @@ -480,7 +602,9 @@ Flags: ## Programmatic usage -This library should probably _not_ be used programmatically - the functionality all comes from a trpc router, which has [many other ways to be invoked](https://trpc.io/docs/community/awesome-trpc). But if you really need to for some reason, you could override the `console.error` and `process.exit` calls: +This library should probably _not_ be used programmatically - the functionality all comes from a trpc router, which has [many other ways to be invoked](https://trpc.io/docs/community/awesome-trpc) (including the built-in `createCaller` helper bundled with `@trpc/server`). + +The `.run()` function does return a value, but it's typed as `unknown` since the input is just `argv: string[]` . But if you really need to for some reason, you could override the `console.error` and `process.exit` calls: ```ts import {trpcCli} from 'trpc-cli' @@ -506,7 +630,7 @@ const runCli = async (argv: string[]) => { } ``` -Note that even if you do this, help text may get writted directly to stdout by `cleye`. If that's a problem, [raise an issue](https://github.com/mmkal/trpc-cli/issues) - it could be solved by exposing some `cleye` configuration to the `run` method. +>Note that even if you do this, help text is handled by [cleye](https://npmjs.com/package/cleye) which prints directly to stdout and exits the process. In a future version this will be solved by either exposing some `cleye` configuration to the `run` method, or controlling the help text rendering directly. ## Out of scope diff --git a/package.json b/package.json index 6e95e61..907f6a1 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,9 @@ "packageManager": "pnpm@8.10.2", "scripts": { "prepare": "pnpm build", - "lint": "eslint .", + "lint": "eslint --max-warnings=0 .", "build": "tsc -p tsconfig.lib.json", + "dev": "cd test/fixtures && tsx", "test": "vitest run" }, "repository": { diff --git a/src/index.ts b/src/index.ts index bae2f67..6c2ca73 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,36 +1,54 @@ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ -/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import {Procedure, Router, TRPCError, inferRouterContext, initTRPC} from '@trpc/server' +import {Procedure, Router, TRPCError, initTRPC} from '@trpc/server' import * as cleye from 'cleye' import colors from 'picocolors' -import {ZodError, z} from 'zod' -import ztjs, {JsonSchema7ObjectType, type JsonSchema7Type} from 'zod-to-json-schema' +import {ZodError} from 'zod' +import {type JsonSchema7Type} from 'zod-to-json-schema' import * as zodValidationError from 'zod-validation-error' +import {flattenedProperties, incompatiblePropertyPairs, getDescription} from './json-schema' +import {TrpcCliParams} from './types' +import {parseProcedureInputs} from './zod-procedure' -export type TrpcCliParams> = { - router: R - context?: inferRouterContext - alias?: (fullName: string, meta: {command: string; flags: Record}) => string | undefined -} +export * from './types' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyRouter = Router +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyProcedure = Procedure /** - * Optional interface for describing procedures via meta - if your router conforms to this meta shape, it will contribute to the CLI help text. - * Based on @see `import('cleye').HelpOptions` + * Run a trpc router as a CLI. + * + * @param router A trpc router + * @param context The context to use when calling the procedures - needed if your router requires a context + * @param alias A function that can be used to provide aliases for flags. + * @returns A CLI object with a `run` method that can be called to run the CLI. The `run` method will parse the command line arguments, call the appropriate trpc procedure, log the result and exit the process. On error, it will log the error and exit with a non-zero exit code. */ -export interface TrpcCliMeta { - /** Version of the script displayed in `--help` output. Use to avoid enabling `--version` flag. */ - version?: string - /** Description of the script or command to display in `--help` output. */ - description?: string - /** Usage code examples to display in `--help` output. */ - usage?: false | string | string[] - /** Example code snippets to display in `--help` output. */ - examples?: string | string[] -} +export const trpcCli = ({router, context, alias}: TrpcCliParams) => { + const procedures = Object.entries(router._def.procedures as {}).map(([commandName, procedure]) => { + const procedureResult = parseProcedureInputs(procedure._def.inputs as unknown[]) + if (!procedureResult.success) { + return [commandName, procedureResult.error] as const + } + + const jsonSchema = procedureResult.value + const properties = flattenedProperties(jsonSchema.flagsSchema) + const incompatiblePairs = incompatiblePropertyPairs(jsonSchema.flagsSchema) + const type = router._def.procedures[commandName]._def.mutation ? 'mutation' : 'query' + + return [commandName, {procedure, jsonSchema, properties, incompatiblePairs, type}] as const + }) + + const procedureEntries = procedures.flatMap(([k, v]) => { + return typeof v === 'string' ? [] : [[k, v] as const] + }) + + const procedureMap = Object.fromEntries(procedureEntries) + + const ignoredProcedures = Object.fromEntries( + procedures.flatMap(([k, v]) => (typeof v === 'string' ? [[k, v] as const] : [])), + ) -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const trpcCli = >({router: appRouter, context, alias}: TrpcCliParams) => { async function run(params?: { argv?: string[] logger?: {info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void} @@ -38,19 +56,7 @@ export const trpcCli = >({router: appRouter, context, alia }) { const logger = {...console, ...params?.logger} const _process = params?.process || process - - const procedureEntries = Object.entries(appRouter._def.procedures) - const procedureMap = Object.fromEntries( - procedureEntries.map(([commandName, value]) => { - const procedure = value as Procedure - const jsonSchema = procedureInputsToJsonSchema(procedure) - const properties = flattenedProperties(jsonSchema) - const incompatiblePairs = incompatiblePropertyPairs(jsonSchema) - const type = appRouter._def.procedures[commandName]._def.mutation ? 'mutation' : 'query' - - return [commandName, {procedure, jsonSchema, properties, incompatiblePairs, type}] - }), - ) + let verboseErrors: boolean = false const parsedArgv = cleye.cli( { @@ -61,16 +67,13 @@ export const trpcCli = >({router: appRouter, context, alia default: false, }, }, - commands: procedureEntries.map(([commandName]) => { - const {procedure, jsonSchema} = procedureMap[commandName] - const properties = flattenedProperties(jsonSchema) - + commands: procedureEntries.map(([commandName, {procedure, jsonSchema, properties}]) => { const flags = Object.fromEntries( Object.entries(properties).map(([propertyKey, propertyValue]) => { const cleyeType = getCleyeType(propertyValue) let description: string | undefined = getDescription(propertyValue) - if ('required' in jsonSchema && !jsonSchema.required?.includes(propertyKey)) { + if ('required' in jsonSchema.flagsSchema && !jsonSchema.flagsSchema.required?.includes(propertyKey)) { description = `${description} (optional)`.trim() } description ||= undefined @@ -80,7 +83,7 @@ export const trpcCli = >({router: appRouter, context, alia { type: cleyeType, description, - default: propertyValue.default, + default: propertyValue.default as {}, }, ] }), @@ -95,7 +98,8 @@ export const trpcCli = >({router: appRouter, context, alia return cleye.command({ name: commandName, - help: procedure.meta, + help: procedure.meta as {}, + parameters: jsonSchema.parameters, flags: flags as {}, }) }) as cleye.Command[], @@ -104,13 +108,13 @@ export const trpcCli = >({router: appRouter, context, alia params?.argv, ) - let {verboseErrors, ...unknownFlags} = parsedArgv.unknownFlags - verboseErrors ||= parsedArgv.flags.verboseErrors + const {verboseErrors: _verboseErrors, ...unknownFlags} = parsedArgv.unknownFlags as Record + verboseErrors = _verboseErrors || parsedArgv.flags.verboseErrors - const caller = initTRPC.context>().create({}).createCallerFactory(appRouter)(context) + const caller = initTRPC.context>().create({}).createCallerFactory(router)(context) - const die = (message: string, {cause, help = true}: {cause?: unknown; help?: boolean} = {}) => { - if (verboseErrors) { + function die(message: string, {cause, help = true}: {cause?: unknown; help?: boolean} = {}) { + if (verboseErrors !== undefined && verboseErrors) { throw (cause as Error) || new Error(message) } logger.error?.(colors.red(message)) @@ -137,12 +141,12 @@ export const trpcCli = >({router: appRouter, context, alia if (Object.entries(unknownFlags).length > 0) { const s = Object.entries(unknownFlags).length === 1 ? '' : 's' - return die(`Unexpected flag${s}: ${Object.keys(parsedArgv.unknownFlags).join(', ')}`) + return die(`Unexpected flag${s}: ${Object.keys(unknownFlags).join(', ')}`) } let {help, ...flags} = parsedArgv.flags - flags = Object.fromEntries(Object.entries(flags).filter(([_k, v]) => v !== undefined)) // cleye returns undefined for flags which didn't receive a value + flags = Object.fromEntries(Object.entries(flags as {}).filter(([_k, v]) => v !== undefined)) // cleye returns undefined for flags which didn't receive a value const incompatibleMessages = procedureInfo.incompatiblePairs .filter(([a, b]) => a in flags && b in flags) @@ -152,8 +156,10 @@ export const trpcCli = >({router: appRouter, context, alia return die(incompatibleMessages.join('\n')) } + const input = procedureInfo.jsonSchema.getInput({_: parsedArgv._, flags}) as never + try { - const result = (await caller[procedureInfo.type as 'mutation'](parsedArgv.command, flags)) as unknown + const result: unknown = await caller[procedureInfo.type as 'mutation'](parsedArgv.command, input) if (result) logger.info?.(result) _process.exit(0) } catch (err) { @@ -162,10 +168,13 @@ export const trpcCli = >({router: appRouter, context, alia if (cause instanceof ZodError) { const originalIssues = cause.issues try { - cause.issues = cause.issues.map(issue => ({ - ...issue, - path: ['--' + issue.path[0], ...issue.path.slice(1)], - })) + cause.issues = cause.issues.map(issue => { + if (typeof issue.path[0] !== 'string') return issue + return { + ...issue, + path: ['--' + issue.path[0], ...issue.path.slice(1)], + } + }) const prettyError = zodValidationError.fromError(cause, { prefixSeparator: '\n - ', @@ -188,107 +197,7 @@ export const trpcCli = >({router: appRouter, context, alia } } - return {run} -} - -const capitaliseFromCamelCase = (camel: string) => { - const parts = camel.split(/(?=[A-Z])/) - return capitalise(parts.map(p => p.toLowerCase()).join(' ')) -} - -const capitalise = (s: string) => s.slice(0, 1).toUpperCase() + s.slice(1) - -const flattenedProperties = (sch: JsonSchema7Type): JsonSchema7ObjectType['properties'] => { - if ('properties' in sch) { - return sch.properties - } - if ('allOf' in sch) { - return Object.fromEntries( - sch.allOf!.flatMap(subSchema => Object.entries(flattenedProperties(subSchema as JsonSchema7Type))), - ) - } - if ('anyOf' in sch) { - const isExcluded = (v: JsonSchema7Type) => Object.keys(v).join(',') === 'not' - const entries = sch.anyOf!.flatMap(subSchema => { - const flattened = flattenedProperties(subSchema as JsonSchema7Type) - const excluded = Object.entries(flattened).flatMap(([name, propSchema]) => { - return isExcluded(propSchema) ? [`--${name}`] : [] - }) - return Object.entries(flattened).map(([k, v]): [typeof k, typeof v] => { - if (!isExcluded(v) && excluded.length > 0) { - return [k, Object.assign({}, v, {'Do not use with': excluded}) as typeof v] - } - return [k, v] - }) - }) - - return Object.fromEntries( - entries.sort((a, b) => { - const scores = [a, b].map(([_k, v]) => (isExcluded(v) ? 0 : 1)) // Put the excluded ones first, so that `Object.fromEntries` will override them with the non-excluded ones (`Object.fromEntries([['a', 1], ['a', 2]])` => `{a: 2}`) - return scores[0] - scores[1] - }), - ) - } - return {} -} - -const incompatiblePropertyPairs = (sch: JsonSchema7Type): Array<[string, string]> => { - const isUnion = 'anyOf' in sch - if (!isUnion) return [] - - const sets = sch.anyOf!.map(subSchema => { - const keys = Object.keys(flattenedProperties(subSchema as JsonSchema7Type)) - return {keys, set: new Set(keys)} - }) - - const compatiblityEntries = sets.flatMap(({keys}) => { - return keys.map(key => { - return [key, new Set(sets.filter(other => other.set.has(key)).flatMap(other => other.keys))] as const - }) - }) - const allKeys = sets.flatMap(({keys}) => keys) - - return compatiblityEntries.flatMap(([key, compatibleWith]) => { - const incompatibleEntries = allKeys - .filter(other => key < other && !compatibleWith.has(other)) - .map((other): [string, string] => [key, other]) - return incompatibleEntries - }) -} - -const getDescription = (v: JsonSchema7Type): string => { - if ('items' in v) { - return [getDescription(v.items as JsonSchema7Type), '(list)'].filter(Boolean).join(' ') - } - return ( - Object.entries(v) - .filter(([k, vv]) => { - if (k === 'default' || k === 'additionalProperties') return false - if (k === 'type' && typeof vv === 'string') return false - return true - }) - .sort(([a], [b]) => { - const scores = [a, b].map(k => (k === 'description' ? 0 : 1)) - return scores[0] - scores[1] - }) - .map(([k, vv], i) => { - if (k === 'description' && i === 0) return String(vv) - if (k === 'properties') return `Object (json formatted)` - return `${capitaliseFromCamelCase(k)}: ${vv}` - }) - .join('; ') || '' - ) -} - -export function procedureInputsToJsonSchema(value: Procedure): JsonSchema7Type { - if (value._def.inputs.length === 0) return {} - - const zodSchema: z.ZodType = - value._def.inputs.length === 1 - ? (value._def.inputs[0] as never) - : (z.intersection(...(value._def.inputs as [never, never])) as never) - - return ztjs(zodSchema) + return {run, ignoredProcedures} } function getCleyeType(schema: JsonSchema7Type) { @@ -312,7 +221,7 @@ function getCleyeType(schema: JsonSchema7Type) { } default: { _type satisfies 'null' | null // make sure we were exhaustive (forgot integer at one point) - return (x: unknown) => x + return (value: unknown) => value } } } diff --git a/src/json-schema.ts b/src/json-schema.ts new file mode 100644 index 0000000..a3a654b --- /dev/null +++ b/src/json-schema.ts @@ -0,0 +1,93 @@ +import type {JsonSchema7ObjectType, JsonSchema7Type} from 'zod-to-json-schema' + +const capitaliseFromCamelCase = (camel: string) => { + const parts = camel.split(/(?=[A-Z])/) + return capitalise(parts.map(p => p.toLowerCase()).join(' ')) +} + +const capitalise = (s: string) => s.slice(0, 1).toUpperCase() + s.slice(1) + +export const flattenedProperties = (sch: JsonSchema7Type): JsonSchema7ObjectType['properties'] => { + if ('properties' in sch) { + return sch.properties + } + if ('allOf' in sch) { + return Object.fromEntries( + sch.allOf!.flatMap(subSchema => Object.entries(flattenedProperties(subSchema as JsonSchema7Type))), + ) + } + if ('anyOf' in sch) { + const isExcluded = (v: JsonSchema7Type) => Object.keys(v).join(',') === 'not' + const entries = sch.anyOf!.flatMap(subSchema => { + const flattened = flattenedProperties(subSchema as JsonSchema7Type) + const excluded = Object.entries(flattened).flatMap(([name, propSchema]) => { + return isExcluded(propSchema) ? [`--${name}`] : [] + }) + return Object.entries(flattened).map(([k, v]): [typeof k, typeof v] => { + if (!isExcluded(v) && excluded.length > 0) { + return [k, Object.assign({}, v, {'Do not use with': excluded}) as typeof v] + } + return [k, v] + }) + }) + + return Object.fromEntries( + entries.sort((a, b) => { + const scores = [a, b].map(([_k, v]) => (isExcluded(v) ? 0 : 1)) // Put the excluded ones first, so that `Object.fromEntries` will override them with the non-excluded ones (`Object.fromEntries([['a', 1], ['a', 2]])` => `{a: 2}`) + return scores[0] - scores[1] + }), + ) + } + return {} +} +/** For a union type, returns a list of pairs of properties which *shouldn't* be used together (because they don't appear in the same type variant) */ +export const incompatiblePropertyPairs = (sch: JsonSchema7Type): Array<[string, string]> => { + const isUnion = 'anyOf' in sch + if (!isUnion) return [] + + const sets = sch.anyOf!.map(subSchema => { + const keys = Object.keys(flattenedProperties(subSchema as JsonSchema7Type)) + return {keys, set: new Set(keys)} + }) + + const compatiblityEntries = sets.flatMap(({keys}) => { + return keys.map(key => { + return [key, new Set(sets.filter(other => other.set.has(key)).flatMap(other => other.keys))] as const + }) + }) + const allKeys = sets.flatMap(({keys}) => keys) + + return compatiblityEntries.flatMap(([key, compatibleWith]) => { + const incompatibleEntries = allKeys + .filter(other => key < other && !compatibleWith.has(other)) + .map((other): [string, string] => [key, other]) + return incompatibleEntries + }) +} +/** + * Tries fairly hard to build a roughly human-readable description of a json-schema type. + * A few common properties are given special treatment, most others are just stringified and output in `key: value` format. + */ +export const getDescription = (v: JsonSchema7Type): string => { + if ('items' in v) { + return [getDescription(v.items as JsonSchema7Type), '(array)'].filter(Boolean).join(' ') + } + return ( + Object.entries(v) + .filter(([k, vv]) => { + if (k === 'default' || k === 'additionalProperties') return false + if (k === 'type' && typeof vv === 'string') return false + return true + }) + .sort(([a], [b]) => { + const scores = [a, b].map(k => (k === 'description' ? 0 : 1)) + return scores[0] - scores[1] + }) + .map(([k, vv], i) => { + if (k === 'description' && i === 0) return String(vv) + if (k === 'properties') return `Object (json formatted)` + return `${capitaliseFromCamelCase(k)}: ${vv}` + }) + .join('; ') || '' + ) +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..6592586 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,47 @@ +import {Router, inferRouterContext} from '@trpc/server' +import {type JsonSchema7Type} from 'zod-to-json-schema' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type TrpcCliParams> = { + /** A tRPC router. Procedures will become CLI commands. */ + router: R + /** Context to be supplied when invoking the router. */ + context?: inferRouterContext + /** + * A function that will be called for every flag, for every command. Used to provide single-character aliases for flags. + * Return a single-character string to alias a flag to that character. + * @param fullName The full-length name of the flag + * @param meta Metadata about the command and flags. Includes the command name and all the other flags for the command (so you can avoid clashes you might get with `return fullName[0]`). + * @returns A single-letter string to alias the flag to that character, or `void`/`undefined` to not alias the flag. + */ + alias?: (fullName: string, meta: {command: string; flags: Record}) => string | undefined +} +/** + * Optional interface for describing procedures via meta - if your router conforms to this meta shape, it will contribute to the CLI help text. + * Based on @see `import('cleye').HelpOptions` + */ + +export interface TrpcCliMeta { + /** Version of the script displayed in `--help` output. Use to avoid enabling `--version` flag. */ + version?: string + /** Description of the script or command to display in `--help` output. */ + description?: string + /** Usage code examples to display in `--help` output. */ + usage?: false | string | string[] + /** Example code snippets to display in `--help` output. */ + examples?: string | string[] +} + +export interface ParsedProcedure { + /** positional parameters */ + parameters: string[] + /** JSON Schema type describing the flags for the procedure */ + flagsSchema: JsonSchema7Type + /** + * Function for taking cleye parsed argv output and transforming it so it can be passed into the procedure + * Needed because this function is where inspect the input schema(s) and determine how to map the argv to the input + */ + getInput: (argv: {_: string[]; flags: {}}) => unknown +} + +export type Result = {success: true; value: T} | {success: false; error: string} diff --git a/src/zod-procedure.ts b/src/zod-procedure.ts new file mode 100644 index 0000000..5d5f0e1 --- /dev/null +++ b/src/zod-procedure.ts @@ -0,0 +1,227 @@ +import {z} from 'zod' +import zodToJsonSchema from 'zod-to-json-schema' +import type {Result, ParsedProcedure} from './types' + +function getInnerType(zodType: z.ZodType): z.ZodType { + if (zodType instanceof z.ZodOptional || zodType instanceof z.ZodNullable) { + return getInnerType(zodType._def.innerType as z.ZodType) + } + if (zodType instanceof z.ZodEffects) { + return getInnerType(zodType.innerType() as z.ZodType) + } + return zodType +} + +export function parseProcedureInputs(inputs: unknown[]): Result { + if (inputs.length === 0) { + return { + success: true, + value: {parameters: [], flagsSchema: {}, getInput: () => ({})}, + } + } + + const allZodTypes = inputs.every(input => input instanceof z.ZodType) + if (!allZodTypes) { + return { + success: false, + error: `Invalid input type ${inputs.map(s => (s as {})?.constructor.name).join(', ')}, only zod inputs are supported`, + } + } + + if (inputs.length > 1) { + return parseMultiInputs(inputs as z.ZodType[]) + } + + const mergedSchema = inputs[0] as z.ZodType + + if (expectedLiteralTypes(mergedSchema).length > 0) { + return parseLiteralInput(mergedSchema) + } + + if (mergedSchema instanceof z.ZodTuple) { + return parseTupleInput(mergedSchema as z.ZodTuple) + } + + if (!acceptsObject(mergedSchema)) { + return { + success: false, + error: `Invalid input type ${getInnerType(mergedSchema).constructor.name}, expected object or tuple`, + } + } + + return { + success: true, + value: {parameters: [], flagsSchema: zodToJsonSchema(mergedSchema), getInput: argv => argv.flags}, + } +} + +function parseLiteralInput(schema: z.ZodType | z.ZodType): Result { + const type = expectedLiteralTypes(schema).at(0) + const name = schema.description || type || 'value' + return { + success: true, + value: { + parameters: [schema.isOptional() ? `[${name}]` : `<${name}>`], + flagsSchema: {}, + getInput: argv => convertPositional(schema, argv._[0]), + }, + } +} + +function expectedLiteralTypes(schema: z.ZodType) { + const types: Array<'string' | 'number' | 'boolean'> = [] + if (acceptsBoolean(schema)) types.push('boolean') + if (acceptsNumber(schema)) types.push('number') + if (acceptsString(schema)) types.push('string') + return types +} + +function parseMultiInputs(inputs: z.ZodType[]): Result { + const allObjects = inputs.every(acceptsObject) + if (!allObjects) { + return { + success: false, + error: `Invalid multi-input type ${inputs.map(s => getInnerType(s).constructor.name).join(', ')}. All inputs must accept object inputs.`, + } + } + + const parsedIndividually = inputs.map(input => parseProcedureInputs([input])) + + const failures = parsedIndividually.flatMap(p => (p.success ? [] : [p.error])) + if (failures.length > 0) { + return {success: false, error: failures.join('\n')} + } + + return { + success: true, + value: { + parameters: [], + flagsSchema: { + allOf: parsedIndividually.map(p => { + const successful = p as Extract + return successful.value.flagsSchema + }), + }, + getInput: argv => argv.flags, + }, + } +} + +function parseTupleInput(tuple: z.ZodTuple<[z.ZodType, ...z.ZodType[]]>): Result { + const nonPositionalIndex = tuple.items.findIndex(item => expectedLiteralTypes(item).length === 0) + const types = `[${tuple.items.map(s => getInnerType(s).constructor.name).join(', ')}]` + + if (nonPositionalIndex > -1 && nonPositionalIndex !== tuple.items.length - 1) { + return { + success: false, + error: `Invalid input type ${types}. Positional parameters must be strings or numbers.`, + } + } + + const positionalSchemas = nonPositionalIndex === -1 ? tuple.items : tuple.items.slice(0, nonPositionalIndex) + + const parameterNames = positionalSchemas.map((item, i) => parameterName(item, i + 1)) + const postionalParametersToTupleInput = (argv: {_: string[]; flags: {}}) => { + return positionalSchemas.map((schema, i) => convertPositional(schema, argv._[i])) + } + + if (positionalSchemas.length === tuple.items.length) { + // all schemas were positional - no object at the end + return { + success: true, + value: { + parameters: parameterNames, + flagsSchema: {}, + getInput: postionalParametersToTupleInput, + }, + } + } + + const last = tuple.items.at(-1)! + + if (!acceptsObject(last)) { + return { + success: false, + error: `Invalid input type ${types}. The last type must accept object inputs.`, + } + } + + return { + success: true, + value: { + parameters: parameterNames, + flagsSchema: zodToJsonSchema(last), + getInput: argv => [...postionalParametersToTupleInput(argv), argv.flags], + }, + } +} + +/** + * Converts a positional string to parameter into a number if the target schema accepts numbers, and the input can be parsed as a number. + * If the target schema accepts numbers but it's *not* a valid number, just return a string - zod will handle the validation. + */ +const convertPositional = (schema: z.ZodType, value: string) => { + let preprocessed: string | number | boolean | null = null + + const literalTypes = new Set(expectedLiteralTypes(schema)) + + if (literalTypes.has('boolean')) { + if (value === 'true') preprocessed = true + else if (value === 'false') preprocessed = false + } + + if (literalTypes.has('number') && !schema.safeParse(preprocessed).success) { + preprocessed = Number(value) + } + + if (literalTypes.has('string') && !schema.safeParse(preprocessed).success) { + // it's possible we converted to a number prematurely - need to account for `z.union([z.string(), z.number().int()])`, where 1.2 should be a string, not a number + // in that case, we would have set preprocessed to a number, but it would fail validation, so we need to reset it to a string here + preprocessed = value + } + + // if we've successfully preprocessed, use the *input* value - zod will re-parse, so we shouldn't return the parsed value - that would break if there's a `.transform(...)` + return preprocessed !== null && schema.safeParse(preprocessed).success ? preprocessed : value +} + +const parameterName = (s: z.ZodType, position: number) => { + // cleye requiremenets: no special characters in positional parameters; `` for required and `[name]` for optional parameters + const name = s.description || `parameter ${position}`.replaceAll(/\W+/g, ' ').trim() + return s.isOptional() ? `[${name}]` : `<${name}>` +} + +/** + * Curried function which tells you whether a given zod type accepts any inputs of a given target type. + * Useful for static validation, and for deciding whether to preprocess a string input before passing it to a zod schema. + * @example + * const acceptsString = accepts(z.string()) + * + * acceptsString(z.string()) // true + * acceptsString(z.string().nullable()) // true + * acceptsString(z.string().optional()) // true + * acceptsString(z.string().nullish()) // true + * acceptsString(z.number()) // false + * acceptsString(z.union([z.string(), z.number()])) // true + * acceptsString(z.union([z.number(), z.boolean()])) // false + * acceptsString(z.intersection(z.string(), z.number())) // false + * acceptsString(z.intersection(z.string(), z.string().max(10))) // true + */ +export function accepts(target: z.ZodType) { + const test = (zodType: z.ZodType): boolean => { + const innerType = getInnerType(zodType) + if (innerType instanceof target.constructor) return true + if (innerType instanceof z.ZodLiteral) return target.safeParse(innerType.value).success + if (innerType instanceof z.ZodEnum) return (innerType.options as unknown[]).some(o => target.safeParse(o).success) + if (innerType instanceof z.ZodUnion) return (innerType.options as z.ZodType[]).some(test) + if (innerType instanceof z.ZodIntersection) + return test(innerType._def.left as z.ZodType) && test(innerType._def.right as z.ZodType) + if (innerType instanceof z.ZodEffects) return test(innerType.innerType() as z.ZodType) + return false + } + return test +} + +const acceptsString = accepts(z.string()) +const acceptsNumber = accepts(z.number()) +const acceptsBoolean = accepts(z.boolean()) +const acceptsObject = accepts(z.object({})) diff --git a/test/index.test.ts b/test/e2e.test.ts similarity index 56% rename from test/index.test.ts rename to test/e2e.test.ts index 27022aa..d0712e1 100644 --- a/test/index.test.ts +++ b/test/e2e.test.ts @@ -3,8 +3,8 @@ import * as path from 'path' import stripAnsi from 'strip-ansi' import {expect, test} from 'vitest' -const tsx = (file: string) => async (args: string[]) => { - const {all} = await execa('./node_modules/.bin/tsx', [file, ...args], { +const tsx = async (file: string, args: string[]) => { + const {all} = await execa('./node_modules/.bin/tsx', ['test/fixtures/' + file, ...args], { all: true, reject: false, cwd: path.join(__dirname, '..'), @@ -12,11 +12,8 @@ const tsx = (file: string) => async (args: string[]) => { return stripAnsi(all) } -const calculator = tsx('test/fixtures/calculator.ts') -const migrator = tsx('test/fixtures/migrations.ts') - test('cli help', async () => { - const output = await calculator(['--help']) + const output = await tsx('calculator', ['--help']) expect(output.replaceAll(/(commands:|flags:)/gi, s => s[0].toUpperCase() + s.slice(1).toLowerCase())) .toMatchInlineSnapshot(` "Commands: @@ -33,37 +30,33 @@ test('cli help', async () => { }) test('cli help add', async () => { - const output = await calculator(['add', '--help']) + const output = await tsx('calculator', ['add', '--help']) expect(output).toMatchInlineSnapshot(` "add Add two numbers. Use this if you and your friend both have apples, and you want to know how many apples there are in total. Usage: - add [flags...] + add [flags...] Flags: - -h, --help Show help - --left The first number - --right The second number + -h, --help Show help " `) }) test('cli help divide', async () => { - const output = await calculator(['divide', '--help']) + const output = await tsx('calculator', ['divide', '--help']) expect(output).toMatchInlineSnapshot(` "divide v1.0.0 Divide two numbers. Useful if you have a number and you want to make it smaller and \`subtract\` isn't quite powerful enough for you. Usage: - divide [flags...] + divide [flags...] Flags: - -h, --help Show help - --left The numerator of the division operation. - --right The denominator of the division operation. Note: must not be zero. + -h, --help Show help Examples: divide --left 8 --right 4 @@ -72,51 +65,47 @@ test('cli help divide', async () => { }) test('cli add', async () => { - const output = await calculator(['add', '--left', '1', '--right', '2']) + const output = await tsx('calculator', ['add', '1', '2']) expect(output).toMatchInlineSnapshot(`"3"`) }) test('cli add failure', async () => { - const output = await calculator(['add', '--left', '1', '--right', 'notanumber']) + const output = await tsx('calculator', ['add', '1', 'notanumber']) expect(output).toMatchInlineSnapshot(` "Validation error - - Expected number, received nan at "--right" + - Expected number, received string at index 1 add Add two numbers. Use this if you and your friend both have apples, and you want to know how many apples there are in total. Usage: - add [flags...] + add [flags...] Flags: - -h, --help Show help - --left The first number - --right The second number + -h, --help Show help " `) }) test('cli divide', async () => { - const output = await calculator(['divide', '--left', '8', '--right', '4']) + const output = await tsx('calculator', ['divide', '8', '4']) expect(output).toMatchInlineSnapshot(`"2"`) }) -test('cli divide failure', async () => { - const output = await calculator(['divide', '--left', '8', '--right', '0']) +test.skip('cli divide failure', async () => { + const output = await tsx('calculator', ['divide', '8', '0']) expect(output).toMatchInlineSnapshot(` "Validation error - - Invalid input at "--right" + - Invalid input at index 1 divide v1.0.0 Divide two numbers. Useful if you have a number and you want to make it smaller and \`subtract\` isn't quite powerful enough for you. Usage: - divide [flags...] + divide [flags...] Flags: - -h, --help Show help - --left The numerator of the division operation. - --right The denominator of the division operation. Note: must not be zero. + -h, --help Show help Examples: divide --left 8 --right 4 @@ -125,7 +114,7 @@ test('cli divide failure', async () => { }) test('migrations help', async () => { - const output = await migrator(['--help']) + const output = await tsx('migrations', ['--help']) expect(output).toMatchInlineSnapshot(` "Commands: apply Apply migrations. By default all pending migrations will be applied. @@ -142,7 +131,7 @@ test('migrations help', async () => { }) test('migrations union type', async () => { - let output = await migrator(['apply', '--to', 'four']) + let output = await tsx('migrations', ['apply', '--to', 'four']) expect(output).toMatchInlineSnapshot(` "[ @@ -154,7 +143,7 @@ test('migrations union type', async () => { ]" `) - output = await migrator(['apply', '--step', '1']) + output = await tsx('migrations', ['apply', '--step', '1']) expect(output).toContain('four: pending') // <-- this sometimes goes wrong when I mess with union type handling expect(output).toMatchInlineSnapshot(` "[ @@ -168,7 +157,7 @@ test('migrations union type', async () => { }) test('migrations search.byName help', async () => { - const output = await migrator(['search.byName', '--help']) + const output = await tsx('migrations', ['search.byName', '--help']) expect(output).toMatchInlineSnapshot(` "search.byName @@ -186,7 +175,7 @@ test('migrations search.byName help', async () => { }) test('migrations search.byName', async () => { - const output = await migrator(['search.byName', '--name', 'two']) + const output = await tsx('migrations', ['search.byName', '--name', 'two']) expect(output).toMatchInlineSnapshot(` "[ { @@ -199,7 +188,7 @@ test('migrations search.byName', async () => { }) test('migrations search.byContent', async () => { - const output = await migrator(['search.byContent', '--searchTerm', 'create table']) + const output = await tsx('migrations', ['search.byContent', '--searchTerm', 'create table']) expect(output).toMatchInlineSnapshot(` "[ { @@ -222,7 +211,7 @@ test('migrations search.byContent', async () => { }) test('migrations incompatible flags', async () => { - const output = await migrator(['apply', '--to', 'four', '--step', '1']) + const output = await tsx('migrations', ['apply', '--to', 'four', '--step', '1']) expect(output).toContain('--step and --to are incompatible') expect(output).toMatchInlineSnapshot(` "--step and --to are incompatible and cannot be used together @@ -240,3 +229,84 @@ test('migrations incompatible flags', async () => { " `) }) + +test('fs help', async () => { + const output = await tsx('fs', ['--help']) + expect(output).toMatchInlineSnapshot(` + "Commands: + copy + diff + + Flags: + -h, --help Show help + --verbose-errors Throw raw errors (by default errors are summarised) + " + `) +}) + +test('fs copy help', async () => { + const output = await tsx('fs', ['copy', '--help']) + expect(output).toMatchInlineSnapshot(` + "copy + + Usage: + copy [flags...] [Destination path] + + Flags: + --force Overwrite destination if it exists + -h, --help Show help + " + `) +}) + +test('fs copy', async () => { + expect(await tsx('fs', ['copy', 'one'])).toMatchInlineSnapshot( + `"{ source: 'one', destination: 'one.copy', options: { force: false } }"`, + ) + expect(await tsx('fs', ['copy', 'one', 'uno'])).toMatchInlineSnapshot( + `"{ source: 'one', destination: 'uno', options: { force: false } }"`, + ) + expect(await tsx('fs', ['copy', 'one', '--force'])).toMatchInlineSnapshot( + `"{ source: 'one', destination: 'one.copy', options: { force: true } }"`, + ) + expect(await tsx('fs', ['copy', 'one', 'uno', '--force'])).toMatchInlineSnapshot( + `"{ source: 'one', destination: 'uno', options: { force: true } }"`, + ) + + // invalid enum value: + expect(await tsx('fs', ['diff', 'one', 'fileNotFound'])).toMatchInlineSnapshot(` + "Validation error + - Invalid enum value. Expected 'one' | 'two' | 'three' | 'four', received 'fileNotFound' at index 1 + diff + + Usage: + diff [flags...] + + Flags: + -h, --help Show help + --ignore-whitespace Ignore whitespace changes + --trim Trim start/end whitespace + " + `) +}) + +test('fs diff', async () => { + expect(await tsx('fs', ['diff', '--help'])).toMatchInlineSnapshot(` + "diff + + Usage: + diff [flags...] + + Flags: + -h, --help Show help + --ignore-whitespace Ignore whitespace changes + --trim Trim start/end whitespace + " + `) + expect(await tsx('fs', ['diff', 'one', 'two'])).toMatchInlineSnapshot(`""`) + expect(await tsx('fs', ['diff', 'one', 'three'])).toMatchInlineSnapshot( + `"base and head differ at index 0 ("a" !== "x")"`, + ) + expect(await tsx('fs', ['diff', 'three', 'four'])).toMatchInlineSnapshot(`"base has length 5 and head has length 6"`) + expect(await tsx('fs', ['diff', 'three', 'four', '--ignore-whitespace'])).toMatchInlineSnapshot(`""`) +}) diff --git a/test/fixtures/calculator.ts b/test/fixtures/calculator.ts index 279710f..2110713 100644 --- a/test/fixtures/calculator.ts +++ b/test/fixtures/calculator.ts @@ -1,6 +1,6 @@ import * as trpcServer from '@trpc/server' import {z} from 'zod' -import {TrpcCliMeta, trpcCli} from '../../src' +import {trpcCli, type TrpcCliMeta} from '../../src' const trpc = trpcServer.initTRPC.meta().create() @@ -10,36 +10,21 @@ const router = trpc.router({ description: 'Add two numbers. Use this if you and your friend both have apples, and you want to know how many apples there are in total.', }) - .input( - z.object({ - left: z.number().describe('The first number'), - right: z.number().describe('The second number'), - }), - ) - .query(({input}) => input.left + input.right), + .input(z.tuple([z.number(), z.number()])) + .query(({input}) => input[0] + input[1]), subtract: trpc.procedure .meta({ description: 'Subtract two numbers. Useful if you have a number and you want to make it smaller.', }) - .input( - z.object({ - left: z.number().describe('The first number'), - right: z.number().describe('The second number'), - }), - ) - .query(({input}) => input.left - input.right), + .input(z.tuple([z.number(), z.number()])) + .query(({input}) => input[0] - input[1]), multiply: trpc.procedure .meta({ description: 'Multiply two numbers together. Useful if you want to count the number of tiles on your bathroom wall and are short on time.', }) - .input( - z.object({ - left: z.number().describe('The first number'), - right: z.number().describe('The second number'), - }), - ) - .query(({input}) => input.left * input.right), + .input(z.tuple([z.number(), z.number()])) + .query(({input}) => input[0] * input[1]), divide: trpc.procedure .meta({ version: '1.0.0', @@ -48,15 +33,15 @@ const router = trpc.router({ examples: 'divide --left 8 --right 4', }) .input( - z.object({ - left: z.number().describe('The numerator of the division operation.'), - right: z + z.tuple([ + z.number().describe('numerator'), + z .number() .refine(n => n !== 0) - .describe('The denominator of the division operation. Note: must not be zero.'), - }), + .describe('denominator'), + ]), ) - .mutation(({input}) => input.left / input.right), + .mutation(({input}) => input[0] / input[1]), }) void trpcCli({router}).run() diff --git a/test/fixtures/fs.ts b/test/fixtures/fs.ts new file mode 100644 index 0000000..846b747 --- /dev/null +++ b/test/fixtures/fs.ts @@ -0,0 +1,59 @@ +import * as trpcServer from '@trpc/server' +import {z} from 'zod' +import {trpcCli, type TrpcCliMeta} from '../../src' + +const trpc = trpcServer.initTRPC.meta().create() + +const fakeFileSystem = getFakeFileSystem() + +const router = trpc.router({ + copy: trpc.procedure + .input( + z.tuple([ + z.string().describe('Source path'), // + z.string().nullish().describe('Destination path'), + z.object({ + force: z.boolean().optional().default(false).describe('Overwrite destination if it exists'), + }), + ]), + ) + .mutation(async ({input: [source, destination = `${source}.copy`, options]}) => { + // ...copy logic... + return {source, destination, options} + }), + diff: trpc.procedure + .input( + z.tuple([ + z.enum(['one', 'two', 'three', 'four']).describe('Base path'), + z.enum(['one', 'two', 'three', 'four']).describe('Head path'), + z.object({ + ignoreWhitespace: z.boolean().optional().default(false).describe('Ignore whitespace changes'), + trim: z.boolean().optional().default(false).describe('Trim start/end whitespace'), + }), + ]), + ) + .query(async ({input: [base, head, options]}) => { + const [left, right] = [base, head].map(path => { + let content = fakeFileSystem[path] + if (options?.trim) content = content.trim() + if (options?.ignoreWhitespace) content = content.replaceAll(/\s/g, '') + return content + }) + + if (left === right) return null + if (left.length !== right.length) return `base has length ${left.length} and head has length ${right.length}` + const firstDiffIndex = left.split('').findIndex((char, i) => char !== right[i]) + return `base and head differ at index ${firstDiffIndex} (${JSON.stringify(left[firstDiffIndex])} !== ${JSON.stringify(right[firstDiffIndex])})` + }), +}) + +function getFakeFileSystem(): Record { + return { + one: 'a,b,c', + two: 'a,b,c', + three: 'x,y,z', + four: 'x,y,z ', + } +} + +void trpcCli({router}).run() diff --git a/test/fixtures/migrations.ts b/test/fixtures/migrations.ts index 7bad6b5..b6b7409 100644 --- a/test/fixtures/migrations.ts +++ b/test/fixtures/migrations.ts @@ -1,6 +1,6 @@ import * as trpcServer from '@trpc/server' import {z} from 'zod' -import {TrpcCliMeta, trpcCli} from '../../src' +import {trpcCli, type TrpcCliMeta} from '../../src' const trpc = trpcServer.initTRPC.meta().create() diff --git a/test/parsing.test.ts b/test/parsing.test.ts new file mode 100644 index 0000000..b67a7fc --- /dev/null +++ b/test/parsing.test.ts @@ -0,0 +1,309 @@ +import {Router, initTRPC} from '@trpc/server' +import stripAnsi from 'strip-ansi' +import {expect, test} from 'vitest' +import {z} from 'zod' +import {trpcCli, TrpcCliMeta} from '../src' + +expect.addSnapshotSerializer({ + test: (val): val is Error => val instanceof Error, + print: val => { + let err = val as Error + const messages = [err.message] + while (err.cause instanceof Error) { + err = err.cause + messages.push(' '.repeat(messages.length) + 'Caused by: ' + err.message) + } + return stripAnsi(messages.join('\n')) + }, +}) + +const t = initTRPC.meta().create() + +const run = (router: Router, argv: string[]) => { + const cli = trpcCli({router}) + return new Promise((resolve, reject) => { + const logs: unknown[][] = [] + const addLogs = (...args: unknown[]) => logs.push(args) + void cli + .run({ + argv, + logger: {info: addLogs, error: addLogs}, + process: { + exit: code => { + if (code === 0) { + resolve(logs.join('\n')) + } else { + reject( + new Error(`CLI exited with code ${code}`, { + cause: new Error('Logs: ' + logs.join('\n')), + }), + ) + } + return code as never + }, + }, + }) + .catch(reject) + }) +} + +test('merging input types', async () => { + const router = t.router({ + foo: t.procedure + .input(z.object({bar: z.string()})) + .input(z.object({baz: z.number()})) + .input(z.object({qux: z.boolean()})) + .query(({input}) => Object.entries(input).join(', ')), + }) + + expect(await run(router, ['foo', '--bar', 'hello', '--baz', '42', '--qux'])).toMatchInlineSnapshot( + `"bar,hello, baz,42, qux,true"`, + ) +}) + +test('string input', async () => { + const router = t.router({ + foo: t.procedure + .input(z.string()) // + .query(({input}) => JSON.stringify(input)), + }) + + expect(await run(router, ['foo', 'hello'])).toMatchInlineSnapshot(`""hello""`) +}) + +test('enum input', async () => { + const router = t.router({ + foo: t.procedure + .input(z.enum(['aa', 'bb'])) // + .query(({input}) => JSON.stringify(input)), + }) + + expect(await run(router, ['foo', 'aa'])).toMatchInlineSnapshot(`""aa""`) + await expect(run(router, ['foo', 'cc'])).rejects.toMatchInlineSnapshot(` + CLI exited with code 1 + Caused by: Logs: Validation error + - Invalid enum value. Expected 'aa' | 'bb', received 'cc' + `) +}) + +test('number input', async () => { + const router = t.router({ + foo: t.procedure + .input(z.number()) // + .query(({input}) => JSON.stringify(input)), + }) + + expect(await run(router, ['foo', '1'])).toMatchInlineSnapshot(`"1"`) + await expect(run(router, ['foo', 'a'])).rejects.toMatchInlineSnapshot(` + CLI exited with code 1 + Caused by: Logs: Validation error + - Expected number, received string + `) +}) + +test('boolean input', async () => { + const router = t.router({ + foo: t.procedure + .input(z.boolean()) // + .query(({input}) => JSON.stringify(input)), + }) + + expect(await run(router, ['foo', 'true'])).toMatchInlineSnapshot(`"true"`) + expect(await run(router, ['foo', 'false'])).toMatchInlineSnapshot(`"false"`) + await expect(run(router, ['foo', 'a'])).rejects.toMatchInlineSnapshot(` + CLI exited with code 1 + Caused by: Logs: Validation error + - Expected boolean, received string + `) +}) + +test('refine in a union pedantry', async () => { + const router = t.router({ + foo: t.procedure + .input(z.union([z.number().int(), z.string()])) // + .query(({input}) => JSON.stringify(input)), + }) + + expect(await run(router, ['foo', '11'])).toBe(JSON.stringify(11)) + expect(await run(router, ['foo', 'aa'])).toBe(JSON.stringify('aa')) + expect(await run(router, ['foo', '1.1'])).toBe(JSON.stringify('1.1')) // technically this *does* match one of the types in the union, just not the number type because that demands ints - it matches the string type +}) + +test('transform in a union', async () => { + const router = t.router({ + foo: t.procedure + .input( + z.union([ + z + .number() + .int() + .transform(n => `Roman numeral: ${'I'.repeat(n)}`), + z.string(), + ]), + ) // + .query(({input}) => JSON.stringify(input)), + }) + + expect(await run(router, ['foo', '3'])).toMatchInlineSnapshot(`""Roman numeral: III""`) + expect(await run(router, ['foo', 'a'])).toMatchInlineSnapshot(`""a""`) + expect(await run(router, ['foo', '3.3'])).toMatchInlineSnapshot(`""3.3""`) +}) + +test('literal input', async () => { + const router = t.router({ + foo: t.procedure + .input(z.literal(2)) // + .query(({input}) => JSON.stringify(input)), + }) + + expect(await run(router, ['foo', '2'])).toMatchInlineSnapshot(`"2"`) + await expect(run(router, ['foo', '3'])).rejects.toMatchInlineSnapshot(` + CLI exited with code 1 + Caused by: Logs: Validation error + - Invalid literal value, expected 2 + `) +}) + +test('optional input', async () => { + const router = t.router({ + foo: t.procedure + .input(z.string().optional()) // + .query(({input}) => JSON.stringify(input || null)), + }) + + expect(await run(router, ['foo', 'a'])).toMatchInlineSnapshot(`""a""`) + expect(await run(router, ['foo'])).toMatchInlineSnapshot(`"null"`) +}) + +test('union input', async () => { + const router = t.router({ + foo: t.procedure + .input(z.union([z.number(), z.string()])) // + .query(({input}) => JSON.stringify(input || null)), + }) + + expect(await run(router, ['foo', 'a'])).toMatchInlineSnapshot(`""a""`) + expect(await run(router, ['foo', '1'])).toMatchInlineSnapshot(`"1"`) +}) + +test('regex input', async () => { + const router = t.router({ + foo: t.procedure + .input(z.string().regex(/hello/).describe('greeting')) // + .query(({input}) => JSON.stringify(input || null)), + }) + + expect(await run(router, ['foo', 'hello abc'])).toMatchInlineSnapshot(`""hello abc""`) + // todo: raise a zod-validation-error issue 👇 not a great error message + await expect(run(router, ['foo', 'goodbye xyz'])).rejects.toMatchInlineSnapshot(` + CLI exited with code 1 + Caused by: Logs: Validation error + - Invalid + `) +}) + +test('boolean, number, string input', async () => { + const router = t.router({ + foo: t.procedure + .input( + z.union([ + z.string(), + z.number(), + z.boolean(), // + ]), + ) + .query(({input}) => JSON.stringify(input || null)), + }) + + expect(await run(router, ['foo', 'true'])).toMatchInlineSnapshot(`"true"`) + expect(await run(router, ['foo', '1'])).toMatchInlineSnapshot(`"1"`) + expect(await run(router, ['foo', 'a'])).toMatchInlineSnapshot(`""a""`) +}) + +test('tuple input', async () => { + const router = t.router({ + foo: t.procedure + .input(z.tuple([z.string(), z.number()])) // + .query(({input}) => JSON.stringify(input || null)), + }) + + expect(await run(router, ['foo', 'hello', '123'])).toMatchInlineSnapshot(`"["hello",123]"`) + await expect(run(router, ['foo', 'hello', 'not a number!'])).rejects.toMatchInlineSnapshot(` + CLI exited with code 1 + Caused by: Logs: Validation error + - Expected number, received string at index 1 + `) +}) + +test('tuple input with flags', async () => { + const router = t.router({ + foo: t.procedure + .input( + z.tuple([ + z.string(), + z.number(), + z.object({foo: z.string()}), // + ]), + ) + .query(({input}) => JSON.stringify(input || null)), + }) + + expect(await run(router, ['foo', 'hello', '123', '--foo', 'bar'])).toMatchInlineSnapshot( + `"["hello",123,{"foo":"bar"}]"`, + ) + await expect(run(router, ['foo', 'hello', '123'])).rejects.toMatchInlineSnapshot(` + CLI exited with code 1 + Caused by: Logs: Validation error + - Required at "[2].foo" + `) + await expect(run(router, ['foo', 'hello', 'not a number!', '--foo', 'bar'])).rejects.toMatchInlineSnapshot(` + CLI exited with code 1 + Caused by: Logs: Validation error + - Expected number, received string at index 1 + `) + await expect(run(router, ['foo', 'hello', 'not a number!'])).rejects.toMatchInlineSnapshot(` + CLI exited with code 1 + Caused by: Logs: Validation error + - Expected number, received string at index 1 + - Required at "[2].foo" + `) +}) + +test('single character flag', async () => { + const router = t.router({ + foo: t.procedure.input(z.object({a: z.string()})).query(({input}) => JSON.stringify(input || null)), + }) + + // todo: support this somehow, not sure why this restriction exists. it comes from type-flag. + await expect(run(router, ['foo', 'hello', '123', '--a', 'b'])).rejects.toMatchInlineSnapshot( + `Flag name "a" must be longer than a character`, + ) +}) + +test('validation', async () => { + const router = t.router({ + tupleOfStrings: t.procedure + .input(z.tuple([z.string().describe('The first string'), z.string().describe('The second string')])) + .query(() => 'ok'), + tupleWithBoolean: t.procedure + .input(z.tuple([z.string(), z.boolean()])) // + .query(() => 'ok'), + tupleWithBooleanThenObject: t.procedure + .input(z.tuple([z.string(), z.boolean(), z.object({foo: z.string()})])) + .query(() => 'ok'), + tupleWithObjectInTheMiddle: t.procedure + .input(z.tuple([z.string(), z.object({foo: z.string()}), z.string()])) + .query(() => 'ok'), + tupleWithRecord: t.procedure + .input(z.tuple([z.string(), z.record(z.string())])) // + .query(() => 'ok'), + }) + const cli = trpcCli({router}) + + expect(cli.ignoredProcedures).toMatchInlineSnapshot(` + { + "tupleWithObjectInTheMiddle": "Invalid input type [ZodString, ZodObject, ZodString]. Positional parameters must be strings or numbers.", + "tupleWithRecord": "Invalid input type [ZodString, ZodRecord]. The last type must accept object inputs.", + } + `) +}) diff --git a/test/zod.test.ts b/test/zod.test.ts new file mode 100644 index 0000000..31adc26 --- /dev/null +++ b/test/zod.test.ts @@ -0,0 +1,74 @@ +import {expect, test} from 'vitest' +import {z} from 'zod' +import {accepts} from '../src/zod-procedure' + +test('accepts strings', async () => { + const acceptsString = accepts(z.string()) + + expect(acceptsString(z.string())).toBe(true) + expect(acceptsString(z.string().nullable())).toBe(true) + expect(acceptsString(z.string().optional())).toBe(true) + expect(acceptsString(z.string().nullish())).toBe(true) + expect(acceptsString(z.number())).toBe(false) + expect(acceptsString(z.union([z.string(), z.number()]))).toBe(true) + expect(acceptsString(z.union([z.number(), z.boolean()]))).toBe(false) + expect(acceptsString(z.intersection(z.string(), z.number()))).toBe(false) + expect(acceptsString(z.intersection(z.string(), z.string().max(10)))).toBe(true) +}) + +test('accepts numbers', async () => { + const acceptsNumber = accepts(z.number()) + + expect(acceptsNumber(z.number())).toBe(true) + expect(acceptsNumber(z.number().nullable())).toBe(true) + expect(acceptsNumber(z.number().optional())).toBe(true) + expect(acceptsNumber(z.number().nullish())).toBe(true) + expect(acceptsNumber(z.string())).toBe(false) + expect(acceptsNumber(z.union([z.number(), z.string()]))).toBe(true) + expect(acceptsNumber(z.union([z.string(), z.boolean()]))).toBe(false) + expect(acceptsNumber(z.intersection(z.number(), z.string()))).toBe(false) + expect(acceptsNumber(z.intersection(z.number(), z.number().max(10)))).toBe(true) +}) + +test('accepts booleans', async () => { + const acceptsBoolean = accepts(z.boolean()) + + expect(acceptsBoolean(z.boolean())).toBe(true) + expect(acceptsBoolean(z.boolean().nullable())).toBe(true) + expect(acceptsBoolean(z.boolean().optional())).toBe(true) + expect(acceptsBoolean(z.boolean().nullish())).toBe(true) + expect(acceptsBoolean(z.string())).toBe(false) + expect(acceptsBoolean(z.union([z.boolean(), z.string()]))).toBe(true) + expect(acceptsBoolean(z.union([z.string(), z.number()]))).toBe(false) + expect(acceptsBoolean(z.intersection(z.boolean(), z.string()))).toBe(false) + expect(acceptsBoolean(z.intersection(z.boolean(), z.boolean()))).toBe(true) +}) + +test('accepts objects', async () => { + const acceptsObject = accepts(z.object({})) + + expect(acceptsObject(z.object({}))).toBe(true) + expect(acceptsObject(z.object({foo: z.string()}))).toBe(true) + expect(acceptsObject(z.object({}).nullable())).toBe(true) + expect(acceptsObject(z.object({}).optional())).toBe(true) + expect(acceptsObject(z.object({}).nullish())).toBe(true) + expect(acceptsObject(z.string())).toBe(false) + expect(acceptsObject(z.union([z.object({}), z.string()]))).toBe(true) + expect(acceptsObject(z.union([z.string(), z.boolean()]))).toBe(false) + expect(acceptsObject(z.intersection(z.object({}), z.string()))).toBe(false) + expect(acceptsObject(z.intersection(z.object({}), z.object({})))).toBe(true) +}) + +test('accepts record', async () => { + const acceptsRecord = accepts(z.record(z.string())) + + expect(acceptsRecord(z.record(z.string()))).toBe(true) + expect(acceptsRecord(z.record(z.string()).nullable())).toBe(true) + expect(acceptsRecord(z.record(z.string()).optional())).toBe(true) + expect(acceptsRecord(z.record(z.string()).nullish())).toBe(true) + expect(acceptsRecord(z.string())).toBe(false) + expect(acceptsRecord(z.union([z.record(z.string()), z.string()]))).toBe(true) + expect(acceptsRecord(z.union([z.string(), z.boolean()]))).toBe(false) + expect(acceptsRecord(z.intersection(z.record(z.string()), z.string()))).toBe(false) + expect(acceptsRecord(z.intersection(z.record(z.string()), z.record(z.string())))).toBe(true) +})