From 251c43bb15656daa50c0b534861d31f5677f8cc5 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky Date: Fri, 24 May 2024 00:00:58 -0400 Subject: [PATCH] refactor into files --- README.md | 8 +- src/index.ts | 303 ++++-------------------------------- src/json-schema.ts | 93 +++++++++++ src/types.ts | 38 +++++ src/zod-procedure.ts | 129 +++++++++++++++ test/cli.test.ts | 43 +++-- test/fixtures/calculator.ts | 2 +- test/fixtures/fs.ts | 2 +- test/fixtures/migrations.ts | 2 +- test/validation.test.ts | 3 +- 10 files changed, 313 insertions(+), 310 deletions(-) create mode 100644 src/json-schema.ts create mode 100644 src/types.ts create mode 100644 src/zod-procedure.ts diff --git a/README.md b/README.md index 8e1ed699..d08c65ed 100644 --- a/README.md +++ b/README.md @@ -151,10 +151,10 @@ Procedures with incompatible inputs will be returned in the `ignoredProcedures` 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() @@ -356,10 +356,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() diff --git a/src/index.ts b/src/index.ts index 8a2f235a..89bb75e5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,50 +1,34 @@ -/* 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 zodToJsonSchema, {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 -} - -/** - * 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 * from './types' // eslint-disable-next-line @typescript-eslint/no-explicit-any export const trpcCli = >({router, context, alias}: TrpcCliParams) => { - const procedures = Object.entries(router._def.procedures).map(([commandName, value]) => { - const procedure = value as Procedure - const procedureResult = parseProcedureInputs(procedure) - if (!procedureResult.success) { - return [commandName, procedureResult.error] as const - } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const procedures = Object.entries>(router._def.procedures as {}).map( + ([commandName, procedure]) => { + const procedureResult = parseProcedureInputs(procedure) + 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' + 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 - }) + return [commandName, {procedure, jsonSchema, properties, incompatiblePairs, type}] as const + }, + ) const procedureEntries = procedures.flatMap(([k, v]) => { return typeof v === 'string' ? [] : [[k, v] as const] @@ -90,7 +74,7 @@ export const trpcCli = >({router, context, alias}: TrpcCli { type: cleyeType, description, - default: propertyValue.default, + default: propertyValue.default as {}, }, ] }), @@ -105,7 +89,7 @@ export const trpcCli = >({router, context, alias}: TrpcCli return cleye.command({ name: commandName, - help: procedure.meta, + help: procedure.meta as {}, parameters: jsonSchema.parameters, flags: flags as {}, }) @@ -115,7 +99,7 @@ export const trpcCli = >({router, context, alias}: TrpcCli params?.argv, ) - const {verboseErrors: _verboseErrors, ...unknownFlags} = parsedArgv.unknownFlags + const {verboseErrors: _verboseErrors, ...unknownFlags} = parsedArgv.unknownFlags as Record verboseErrors = _verboseErrors || parsedArgv.flags.verboseErrors const caller = initTRPC.context>().create({}).createCallerFactory(router)(context) @@ -148,12 +132,12 @@ export const trpcCli = >({router, context, alias}: TrpcCli 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) @@ -232,238 +216,3 @@ function getCleyeType(schema: JsonSchema7Type) { } } } - -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 {} -} - -/** 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) */ -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. - */ -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('; ') || '' - ) -} - -function getInnerType(zodType: z.ZodType): z.ZodType { - if (zodType instanceof z.ZodOptional) { - return getInnerType(zodType._def.innerType) - } - if (zodType instanceof z.ZodNullable) { - return getInnerType(zodType._def.innerType) - } - if (zodType instanceof z.ZodEffects) { - return getInnerType(zodType.innerType()) - } - return zodType -} - -function acceptsStrings(zodType: z.ZodType): boolean { - const innerType = getInnerType(zodType) - if (innerType instanceof z.ZodString) return true - if (innerType instanceof z.ZodEnum) return (innerType.options as unknown[]).some(o => typeof o === 'string') - if (innerType instanceof z.ZodLiteral) return typeof innerType.value === 'string' - if (innerType instanceof z.ZodUnion) return (innerType.options as z.ZodType[]).some(acceptsStrings) - if (innerType instanceof z.ZodIntersection) - return acceptsStrings(innerType._def.left) && acceptsStrings(innerType._def.right) - - return false -} - -function acceptsNumbers(zodType: z.ZodType): boolean { - const innerType = getInnerType(zodType) - if (innerType instanceof z.ZodNumber) return true - if (innerType instanceof z.ZodEnum) return (innerType.options as unknown[]).some(o => typeof o === 'number') - if (innerType instanceof z.ZodLiteral) return typeof innerType.value === 'number' - if (innerType instanceof z.ZodUnion) return (innerType.options as z.ZodType[]).some(acceptsNumbers) - if (innerType instanceof z.ZodIntersection) - return acceptsNumbers(innerType._def.left) && acceptsNumbers(innerType._def.right) - - return false -} - -function acceptsObject(zodType: z.ZodType): boolean { - const innerType = getInnerType(zodType) - if (innerType instanceof z.ZodObject) return true - if (innerType instanceof z.ZodEffects) return acceptsObject(innerType.innerType()) - if (innerType instanceof z.ZodUnion) return (innerType.options as z.ZodType[]).some(acceptsObject) - if (innerType instanceof z.ZodIntersection) - return acceptsObject(innerType._def.left) && acceptsObject(innerType._def.right) - return false -} - -type Result = {success: true; value: T} | {success: false; error: 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 function parseProcedureInputs(value: Procedure): Result { - if (value._def.inputs.length === 0) { - return { - success: true, - value: {parameters: [], flagsSchema: {}, getInput: () => ({})}, - } - } - - 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) - - if (zodSchema instanceof z.ZodTuple) { - const tuple = zodSchema as z.ZodTuple - const nonPositionalIndex = tuple.items.findIndex(item => !acceptsStrings(item) && !acceptsNumbers(item)) - 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 getParameters = (argv: {_: string[]; flags: {}}) => { - return positionalSchemas.map((schema, i) => { - if (acceptsNumbers(schema)) return Number(argv._[i]) - return 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: getParameters}, - } - } - - 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 => [...getParameters(argv), argv.flags], - }, - } - } - - if (!acceptsObject(zodSchema)) { - return { - success: false, - error: `Invalid input type ${getInnerType(zodSchema).constructor.name}, expected object or tuple`, - } - } - - return { - success: true, - value: {parameters: [], flagsSchema: zodToJsonSchema(zodSchema), getInput: argv => argv.flags}, - } -} - -const parameterName = (s: z.ZodType, position: number) => { - const name = s.description || `parameter ${position}` - return s instanceof z.ZodOptional ? `[${name}]` : `<${name}>` -} diff --git a/src/json-schema.ts b/src/json-schema.ts new file mode 100644 index 00000000..a3a654b6 --- /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 00000000..09320695 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,38 @@ +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> = { + router: R + context?: inferRouterContext + 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 00000000..2a4576d0 --- /dev/null +++ b/src/zod-procedure.ts @@ -0,0 +1,129 @@ +import {Procedure} from '@trpc/server' +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) { + return getInnerType(zodType._def.innerType as z.ZodType) + } + if (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 +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function parseProcedureInputs(value: Procedure): Result { + if (value._def.inputs.length === 0) { + return { + success: true, + value: {parameters: [], flagsSchema: {}, getInput: () => ({})}, + } + } + + 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) + + if (zodSchema instanceof z.ZodTuple) { + const tuple = zodSchema as z.ZodTuple<[z.ZodType, ...z.ZodType[]]> + const nonPositionalIndex = tuple.items.findIndex(item => !acceptsStrings(item) && !acceptsNumbers(item)) + 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 getParameters = (argv: {_: string[]; flags: {}}) => { + return positionalSchemas.map((schema, i) => { + if (acceptsNumbers(schema)) return Number(argv._[i]) + return 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: getParameters}, + } + } + + 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 => [...getParameters(argv), argv.flags], + }, + } + } + + if (!acceptsObject(zodSchema)) { + return { + success: false, + error: `Invalid input type ${getInnerType(zodSchema).constructor.name}, expected object or tuple`, + } + } + + return { + success: true, + value: {parameters: [], flagsSchema: zodToJsonSchema(zodSchema), getInput: argv => argv.flags}, + } +} +const parameterName = (s: z.ZodType, position: number) => { + const name = s.description || `parameter ${position}` + return s instanceof z.ZodOptional ? `[${name}]` : `<${name}>` +} + +function acceptsStrings(zodType: z.ZodType): boolean { + const innerType = getInnerType(zodType) + if (innerType instanceof z.ZodString) return true + if (innerType instanceof z.ZodEnum) return (innerType.options as unknown[]).some(o => typeof o === 'string') + if (innerType instanceof z.ZodLiteral) return typeof innerType.value === 'string' + if (innerType instanceof z.ZodUnion) return (innerType.options as z.ZodType[]).some(acceptsStrings) + if (innerType instanceof z.ZodIntersection) + return acceptsStrings(innerType._def.left as z.ZodType) && acceptsStrings(innerType._def.right as z.ZodType) + + return false +} +function acceptsNumbers(zodType: z.ZodType): boolean { + const innerType = getInnerType(zodType) + if (innerType instanceof z.ZodNumber) return true + if (innerType instanceof z.ZodEnum) return (innerType.options as unknown[]).some(o => typeof o === 'number') + if (innerType instanceof z.ZodLiteral) return typeof innerType.value === 'number' + if (innerType instanceof z.ZodUnion) return (innerType.options as z.ZodType[]).some(acceptsNumbers) + if (innerType instanceof z.ZodIntersection) + return acceptsNumbers(innerType._def.left as z.ZodType) && acceptsNumbers(innerType._def.right as z.ZodType) + + return false +} +function acceptsObject(zodType: z.ZodType): boolean { + const innerType = getInnerType(zodType) + if (innerType instanceof z.ZodObject) return true + if (innerType instanceof z.ZodEffects) return acceptsObject(innerType.innerType() as z.ZodType) + if (innerType instanceof z.ZodUnion) return (innerType.options as z.ZodType[]).some(acceptsObject) + if (innerType instanceof z.ZodIntersection) + return acceptsObject(innerType._def.left as z.ZodType) && acceptsObject(innerType._def.right as z.ZodType) + return false +} diff --git a/test/cli.test.ts b/test/cli.test.ts index bf55b6f8..ae51de32 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -37,12 +37,10 @@ test('cli help add', async () => { 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 " `) }) @@ -55,12 +53,10 @@ test('cli help divide', async () => { 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 @@ -77,18 +73,16 @@ test('cli add failure', async () => { const output = await tsx('calculator', ['add', '1', 'notanumber']) expect(output).toMatchInlineSnapshot(` "Validation error - - Expected number, received nan at "--right" + - Expected number, received nan 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 " `) }) @@ -102,18 +96,16 @@ test('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 @@ -258,7 +250,7 @@ test('fs copy help', async () => { "copy Usage: - copy [flags...] [Source path] [Destination path] + copy [flags...] [Destination path] Flags: --force Overwrite destination if it exists @@ -282,17 +274,18 @@ test('fs copy', async () => { ) // invalid enum value: - expect(await tsx('fs', ['copy', 'fileNotFound'])).toMatchInlineSnapshot(` + expect(await tsx('fs', ['diff', 'one', 'fileNotFound'])).toMatchInlineSnapshot(` "Validation error - - Invalid enum value. Expected 'one' | 'two' | 'three' | 'four', received 'fileNotFound' at index 0 - copy + - Invalid enum value. Expected 'one' | 'two' | 'three' | 'four', received 'fileNotFound' at index 1 + diff Usage: - copy [flags...] [Source path] [Destination path] + diff [flags...] Flags: - --force Overwrite destination if it exists - -h, --help Show help + -h, --help Show help + --ignore-whitespace Ignore whitespace changes + --trim Trim start/end whitespace " `) }) diff --git a/test/fixtures/calculator.ts b/test/fixtures/calculator.ts index 01578b0a..2110713d 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() diff --git a/test/fixtures/fs.ts b/test/fixtures/fs.ts index 5a8478ef..846b747c 100644 --- a/test/fixtures/fs.ts +++ b/test/fixtures/fs.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/fixtures/migrations.ts b/test/fixtures/migrations.ts index b55782fc..d629a637 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/validation.test.ts b/test/validation.test.ts index 7f608879..d728ba7b 100644 --- a/test/validation.test.ts +++ b/test/validation.test.ts @@ -1,7 +1,8 @@ import {initTRPC} from '@trpc/server' import {test, expect} from 'vitest' import {z} from 'zod' -import {TrpcCliMeta, trpcCli} from '../src' +import {trpcCli} from '../src' +import {TrpcCliMeta} from '../src/types' const t = initTRPC.meta().create()