diff --git a/README.md b/README.md index 96f50d3..0259d0d 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ You can also pass an existing tRPC router that's primarily designed to be deploy Here's a more involved example, along with what it outputs: - + ```ts import * as trpcServer from '@trpc/server' import {TrpcCliMeta, trpcCli} from 'trpc-cli' @@ -70,7 +70,7 @@ const router = trpc.router({ add: trpc.procedure .meta({ description: - 'Add two numbers. Use this if you have apples, and someone else has some other apples, and you want to know how many apples in total you have.', + '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({ @@ -129,21 +129,21 @@ void trpcCli({router}).run() -Then run `node path/to/yourfile.js --help` and you will see formatted help text for the `sum` and `divide` commands. +Run `node path/to/yourfile.js --help` for formatted help text for the `sum` and `divide` commands. `node path/to/calculator --help` output: ``` Commands: - add Add two numbers. Use this if you have apples, and someone else has some other apples, and you want to know how many apples in total you have. + 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. subtract Subtract two numbers. Useful if you have a number and you want to make it smaller. multiply Multiply two numbers together. Useful if you want to count the number of tiles on your bathroom wall and are short on time. divide 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. Flags: - --full-errors Throw unedited raw errors rather than summarising to make more human-readable. - -h, --help Show help + -h, --help Show help + --verbose-errors Throw raw errors (by default errors are summarised) ``` @@ -156,7 +156,7 @@ You can also show help text for the corresponding procedures (which become "comm ``` add -Add two numbers. Use this if you have apples, and someone else has some other apples, and you want to know how many apples in total you have. +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...] @@ -188,7 +188,7 @@ Invalid inputs are helpfully displayed, along with help text for the associated ``` add -Add two numbers. Use this if you have apples, and someone else has some other apples, and you want to know how many apples in total you have. +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...] @@ -234,10 +234,11 @@ const appRouter = trpc.router({ - Return values are logged using `console.info` (can be configured to pass in a custom logger) - `process.exit(...)` called with either 0 or 1 depending on successful resolve - Help text shown on invalid inputs +- Support kebab-case flag aliases - Support flag aliases via `alias` callback (see migrations example below) - Union types work, but they should ideally be non-overlapping for best results - Limitation: Only zod types are supported right now -- Limitation: Onlly object types are allowed as input. No positional arguments supported +- Limitation: Only object types are allowed as input. No positional arguments supported - If there's interest, this could be added in future for inputs of type `z.string()` or `z.tuple([z.string(), ...])` - Limitation: Nested-object input props must be passed as json - e.g. `z.object({ foo: z.object({ bar: z.number() }) }))` can be supplied via using `--foo '{"bar": 123}'` @@ -413,8 +414,8 @@ Commands: search.byContent Look for migrations by their script content Flags: - --full-errors Throw unedited raw errors rather than summarising to make more human-readable. - -h, --help Show help + -h, --help Show help + --verbose-errors Throw raw errors (by default errors are summarised) ``` @@ -432,7 +433,7 @@ Usage: Flags: -h, --help Show help - --step Mark this many migrations as executed; exclusiveMinimum: 0; Do not use with: --to + --step Mark this many migrations as executed; Exclusive minimum: 0; Do not use with: --to --to Mark migrations up to this one as exectued; Do not use with: --step ``` @@ -452,7 +453,7 @@ Usage: Flags: -h, --help Show help -q, --search-term Only show migrations whose `content` value contains this string - -s, --status Filter to only show migrations with this status; enum: executed,pending + -s, --status Filter to only show migrations with this status; Enum: executed,pending ``` diff --git a/src/index.ts b/src/index.ts index 157d54e..c909e47 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import {Procedure, Router, TRPCError, inferRouterContext, initTRPC} from '@trpc/ import * as cleye from 'cleye' import colors from 'picocolors' import {ZodError, z} from 'zod' -import ztjs from 'zod-to-json-schema' +import ztjs, {JsonSchema7ObjectType, type JsonSchema7Type} from 'zod-to-json-schema' import * as zodValidationError from 'zod-validation-error' export type TrpcCliParams> = { @@ -41,9 +41,9 @@ export const trpcCli = >({router: appRouter, context, alia const parsedArgv = cleye.cli( { flags: { - fullErrors: { + verboseErrors: { type: Boolean, - description: `Throw unedited raw errors rather than summarising to make more human-readable.`, + description: `Throw raw errors (by default errors are summarised)`, default: false, }, }, @@ -54,112 +54,18 @@ export const trpcCli = >({router: appRouter, context, alia throw new TypeError(`Only zod schemas are supported, got ${input?.constructor.name}`) } }) - 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) - - const jsonSchema = value._def.inputs.length > 0 ? ztjs(zodSchema) : {} // todo: inspect zod schema directly, don't convert to json-schema first - - const objectProperties = (sch: typeof jsonSchema) => ('properties' in sch ? sch.properties : {}) - - const flattenedProperties = (sch: typeof jsonSchema): ReturnType => { - if ('properties' in sch) { - return sch.properties - } - if ('allOf' in sch) { - return Object.fromEntries( - sch.allOf!.flatMap(subSchema => Object.entries(flattenedProperties(subSchema as typeof jsonSchema))), - ) - } - if ('anyOf' in sch) { - const isExcluded = (v: typeof jsonSchema) => Object.keys(v).join(',') === 'not' - const entries = sch.anyOf!.flatMap(subSchema => { - const flattened = flattenedProperties(subSchema as typeof jsonSchema) - 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 jsonSchema = procedureInputsToJsonSchema(value) // todo: inspect zod schema directly, don't convert to json-schema first const properties = flattenedProperties(jsonSchema) if (Object.entries(properties).length === 0) { - // throw new TypeError(`Schemas looking like ${Object.keys(jsonSchema).join(', ')} are not supported`) + // todo: disallow non-object schemas, while still allowing for no schema + // throw new TypeError(`Schemas looking like ${Object.keys(jsonSchema).join(', ')} are not supported`) } const flags = Object.fromEntries( Object.entries(properties).map(([propertyKey, propertyValue]) => { - const jsonSchemaType = - 'type' in propertyValue && typeof propertyValue.type === 'string' ? propertyValue.type : null - let cliType - switch (jsonSchemaType) { - case 'string': { - cliType = String - break - } - case 'integer': - case 'number': { - cliType = Number - break - } - case 'boolean': { - cliType = Boolean - break - } - case 'array': { - cliType = [String] - break - } - case 'object': { - cliType = (s: string) => JSON.parse(s) as {} - break - } - default: { - jsonSchemaType satisfies 'null' | null // make sure we were exhaustive (forgot integer at one point) - cliType = (x: unknown) => x - break - } - } - - const getDescription = (v: typeof propertyValue): string => { - if ('items' in v) { - return [getDescription(v.items as typeof propertyValue), '(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 `${k}: ${vv}` - }) - .join('; ') || '' - ) - } + const cleyeType = getCleyeType(propertyValue) let description: string | undefined = getDescription(propertyValue) if ('required' in jsonSchema && !jsonSchema.required?.includes(propertyKey)) { @@ -170,7 +76,7 @@ export const trpcCli = >({router: appRouter, context, alia return [ propertyKey, { - type: cliType, + type: cleyeType, description, default: propertyValue.default, }, @@ -196,13 +102,13 @@ export const trpcCli = >({router: appRouter, context, alia props?.argv, ) - let {fullErrors, ...unknownFlags} = parsedArgv.unknownFlags - fullErrors ||= parsedArgv.flags.fullErrors + let {verboseErrors, ...unknownFlags} = parsedArgv.unknownFlags + verboseErrors ||= parsedArgv.flags.verboseErrors const caller = initTRPC.context>().create({}).createCallerFactory(appRouter)(context) const die = (message: string, {cause, help = true}: {cause?: unknown; help?: boolean} = {}) => { - if (fullErrors) { + if (verboseErrors) { throw (cause as Error) || new Error(message) } logger.error?.(colors.red(message)) @@ -268,3 +174,105 @@ 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 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) +} + +function getCleyeType(schema: JsonSchema7Type) { + const _type = 'type' in schema && typeof schema.type === 'string' ? schema.type : null + switch (_type) { + case 'string': { + return String + } + case 'integer': + case 'number': { + return Number + } + case 'boolean': { + return Boolean + } + case 'array': { + return [String] + } + case 'object': { + return (s: string) => JSON.parse(s) as {} + } + default: { + _type satisfies 'null' | null // make sure we were exhaustive (forgot integer at one point) + return (x: unknown) => x + } + } +} diff --git a/test/fixtures/calculator.ts b/test/fixtures/calculator.ts index 2565d2f..279710f 100644 --- a/test/fixtures/calculator.ts +++ b/test/fixtures/calculator.ts @@ -8,7 +8,7 @@ const router = trpc.router({ add: trpc.procedure .meta({ description: - 'Add two numbers. Use this if you have apples, and someone else has some other apples, and you want to know how many apples in total you have.', + '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({ diff --git a/test/index.test.ts b/test/index.test.ts index 37ce6cc..f781718 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -3,33 +3,31 @@ import * as path from 'path' import stripAnsi from 'strip-ansi' import {expect, test} from 'vitest' -const runner = (file: string) => async (args: string[]) => { - const {all} = await execa('./node_modules/.bin/tsx', [`test/fixtures/${file}`, ...args], { +const tsx = (file: string) => async (args: string[]) => { + const {all} = await execa('./node_modules/.bin/tsx', [file, ...args], { all: true, reject: false, cwd: path.join(__dirname, '..'), - }).catch(e => { - throw new Error(`${file} ${args.join(' ')}\n${e}`) }) return stripAnsi(all) } -const calculator = runner('calculator.ts') -const migrator = runner('migrations.ts') +const calculator = tsx('test/fixtures/calculator.ts') +const migrator = tsx('test/fixtures/migrations.ts') test('cli help', async () => { const output = await calculator(['--help']) expect(output.replaceAll(/(commands:|flags:)/gi, s => s[0].toUpperCase() + s.slice(1).toLowerCase())) .toMatchInlineSnapshot(` "Commands: - add Add two numbers. Use this if you have apples, and someone else has some other apples, and you want to know how many apples in total you have. + 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. subtract Subtract two numbers. Useful if you have a number and you want to make it smaller. multiply Multiply two numbers together. Useful if you want to count the number of tiles on your bathroom wall and are short on time. divide 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. Flags: - --full-errors Throw unedited raw errors rather than summarising to make more human-readable. - -h, --help Show help + -h, --help Show help + --verbose-errors Throw raw errors (by default errors are summarised) " `) }) @@ -39,7 +37,7 @@ test('cli help add', async () => { expect(output).toMatchInlineSnapshot(` "add - Add two numbers. Use this if you have apples, and someone else has some other apples, and you want to know how many apples in total you have. + 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...] @@ -85,7 +83,7 @@ test('cli add failure', async () => { - Expected number, received nan at "--right" add - Add two numbers. Use this if you have apples, and someone else has some other apples, and you want to know how many apples in total you have. + 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...] @@ -137,8 +135,8 @@ test('migrations help', async () => { search.byContent Look for migrations by their script content Flags: - --full-errors Throw unedited raw errors rather than summarising to make more human-readable. - -h, --help Show help + -h, --help Show help + --verbose-errors Throw raw errors (by default errors are summarised) " `) }) @@ -181,7 +179,7 @@ test('migrations search.byName help', async () => { Flags: -h, --help Show help --name - -s, --status Filter to only show migrations with this status; enum: executed,pending + -s, --status Filter to only show migrations with this status; Enum: executed,pending " `) })