diff --git a/README.md b/README.md index cf2832d..99e9ddc 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Turn a [tRPC](https://trpc.io) router into a type-safe, fully-functional, docume - [Positional parameters](#positional-parameters) - [Flags](#flags) - [Both](#both) + - [Default procedure](#default-procedure) - [API docs](#api-docs) - [trpcCli](#trpccli) - [Params](#params) @@ -166,10 +167,40 @@ Procedures with incompatible inputs will be returned in the `ignoredProcedures` >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! +### Default procedure + +If you have a procedure named `default` it will be used as the default procedure for your CLI: + +```ts +t.router({ + default: t.procedure + .input(z.number()) + .mutation(({input}) => console.log(input)), +}) +``` + +The above could be invoked with `path/to/cli 123`. + +You can specify a different command using the `default` parameter: + +```ts +const router = t.router({ + install: t.procedure + .input(z.string().describe('package name')) + .mutation(({input}) => ___), +}) + +trpcCli({router, default: 'install'}) +``` + +This can be run either with `path/to/cli foo` or `path/to/cli install foo`. + +Use `default: false` to ensure there's no default command, even if you have a router procedure named `default`. + ### API docs -#### [trpcCli](./src/index.ts#L27) +#### [trpcCli](./src/index.ts#L28) Run a trpc router as a CLI. @@ -180,6 +211,7 @@ Run a trpc router as a CLI. |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. | +|default|The name of the "default" command @default 'default' | ##### Returns diff --git a/src/index.ts b/src/index.ts index 6c2ca73..2cced4e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,21 +22,22 @@ type AnyProcedure = Procedure * @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. + * @param default The name of the "default" command @default 'default' * @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 const trpcCli = ({router, context, alias}: TrpcCliParams) => { - const procedures = Object.entries(router._def.procedures as {}).map(([commandName, procedure]) => { +export const trpcCli = ({router, context, alias, default: defaultProcedure}: TrpcCliParams) => { + const procedures = Object.entries(router._def.procedures as {}).map(([name, procedure]) => { const procedureResult = parseProcedureInputs(procedure._def.inputs as unknown[]) if (!procedureResult.success) { - return [commandName, procedureResult.error] as const + return [name, 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 type = router._def.procedures[name]._def.mutation ? 'mutation' : 'query' - return [commandName, {procedure, jsonSchema, properties, incompatiblePairs, type}] as const + return [name, {name, procedure, jsonSchema, properties, incompatiblePairs, type}] as const }) const procedureEntries = procedures.flatMap(([k, v]) => { @@ -58,6 +59,48 @@ export const trpcCli = ({router, context, alias}: TrpcCliPa const _process = params?.process || process let verboseErrors: boolean = false + const cleyeCommands = procedureEntries.map( + ([commandName, {procedure, jsonSchema, properties}]): CleyeCommandOptions => { + const flags = Object.fromEntries( + Object.entries(properties).map(([propertyKey, propertyValue]) => { + const cleyeType = getCleyeType(propertyValue) + + let description: string | undefined = getDescription(propertyValue) + if ('required' in jsonSchema.flagsSchema && !jsonSchema.flagsSchema.required?.includes(propertyKey)) { + description = `${description} (optional)`.trim() + } + description ||= undefined + + return [ + propertyKey, + { + type: cleyeType, + description, + default: propertyValue.default as {}, + }, + ] + }), + ) + + Object.entries(flags).forEach(([fullName, flag]) => { + const a = alias?.(fullName, {command: commandName, flags}) + if (a) { + Object.assign(flag, {alias: a}) + } + }) + + return { + name: commandName, + help: procedure.meta as {}, + parameters: jsonSchema.parameters, + flags: flags as {}, + } + }, + ) + + const defaultCommandName = defaultProcedure === false ? {} : defaultProcedure || 'default' + const defaultCommand = cleyeCommands.find(({name}) => name === defaultCommandName) + const parsedArgv = cleye.cli( { flags: { @@ -67,42 +110,8 @@ export const trpcCli = ({router, context, alias}: TrpcCliPa default: false, }, }, - 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.flagsSchema && !jsonSchema.flagsSchema.required?.includes(propertyKey)) { - description = `${description} (optional)`.trim() - } - description ||= undefined - - return [ - propertyKey, - { - type: cleyeType, - description, - default: propertyValue.default as {}, - }, - ] - }), - ) - - Object.entries(flags).forEach(([fullName, flag]) => { - const a = alias?.(fullName, {command: commandName, flags}) - if (a) { - Object.assign(flag, {alias: a}) - } - }) - - return cleye.command({ - name: commandName, - help: procedure.meta as {}, - parameters: jsonSchema.parameters, - flags: flags as {}, - }) - }) as cleye.Command[], + ...defaultCommand, + commands: cleyeCommands.map(cmd => cleye.command(cmd)) as cleye.Command[], }, undefined, params?.argv, @@ -113,7 +122,7 @@ export const trpcCli = ({router, context, alias}: TrpcCliPa const caller = initTRPC.context>().create({}).createCallerFactory(router)(context) - function die(message: string, {cause, help = true}: {cause?: unknown; help?: boolean} = {}) { + const die: Fail = (message: string, {cause, help = true}: {cause?: unknown; help?: boolean} = {}) => { if (verboseErrors !== undefined && verboseErrors) { throw (cause as Error) || new Error(message) } @@ -124,19 +133,15 @@ export const trpcCli = ({router, context, alias}: TrpcCliPa return _process.exit(1) } - const command = parsedArgv.command as string - - if (!command && parsedArgv._.length === 0) { - return die('No command provided.') - } + const command = parsedArgv.command as string | undefined - if (!command) { - return die(`Command "${parsedArgv._.join(' ')}" not recognised.`) - } - - const procedureInfo = procedureMap[command] - if (!procedureInfo) { - return die(`Command "${command}" not found. Available commands: ${Object.keys(procedureMap).join(', ')}.`) + let procedureInfo: (typeof procedureMap)[string] + if (command) { + procedureInfo = procedureMap[command] + } else if (typeof defaultCommandName === 'string') { + procedureInfo = procedureMap[defaultCommandName] + } else { + die('No command provided.') } if (Object.entries(unknownFlags).length > 0) { @@ -159,48 +164,56 @@ export const trpcCli = ({router, context, alias}: TrpcCliPa const input = procedureInfo.jsonSchema.getInput({_: parsedArgv._, flags}) as never try { - const result: unknown = await caller[procedureInfo.type as 'mutation'](parsedArgv.command, input) + const result: unknown = await caller[procedureInfo.type as 'mutation'](procedureInfo.name, input) if (result) logger.info?.(result) _process.exit(0) } catch (err) { - if (err instanceof TRPCError) { - const cause = err.cause - if (cause instanceof ZodError) { - const originalIssues = cause.issues - try { - 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 - ', - issueSeparator: '\n - ', - }) - - return die(prettyError.message, {cause, help: true}) - } finally { - cause.issues = originalIssues - } - } - if (err.code === 'INTERNAL_SERVER_ERROR') { - throw cause - } - if (err.code === 'BAD_REQUEST') { - return die(err.message, {cause: err}) - } - } - throw err + throw transformError(err, die) } } return {run, ignoredProcedures} } -function getCleyeType(schema: JsonSchema7Type) { +type Fail = (message: string, options?: {cause?: unknown; help?: boolean}) => never + +function transformError(err: unknown, fail: Fail): unknown { + if (err instanceof TRPCError) { + const cause = err.cause + if (cause instanceof ZodError) { + const originalIssues = cause.issues + try { + 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 - ', + issueSeparator: '\n - ', + }) + + return fail(prettyError.message, {cause, help: true}) + } finally { + cause.issues = originalIssues + } + } + if (err.code === 'INTERNAL_SERVER_ERROR') { + throw cause + } + if (err.code === 'BAD_REQUEST') { + return fail(err.message, {cause: err}) + } + } +} + +type CleyeCommandOptions = cleye.Command['options'] +type CleyeFlag = NonNullable[string] + +function getCleyeType(schema: JsonSchema7Type): Extract['type'] { const _type = 'type' in schema && typeof schema.type === 'string' ? schema.type : null switch (_type) { case 'string': { diff --git a/src/types.ts b/src/types.ts index 6592586..6a50a2e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,6 +15,11 @@ export type TrpcCliParams> = { * @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 + /** + * The name of the "default" command - this procedure will be run if no command is specified. Default value is `default`, if such a procedure exists. Otherwise there is no default procedure. + * Set to `false` to disable the default command, even when there's a procedure named `'default'`. + */ + default?: keyof R['_def']['procedures'] | false } /** * Optional interface for describing procedures via meta - if your router conforms to this meta shape, it will contribute to the CLI help text. diff --git a/test/parsing.test.ts b/test/parsing.test.ts index b67a7fc..2d35548 100644 --- a/test/parsing.test.ts +++ b/test/parsing.test.ts @@ -2,7 +2,7 @@ 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' +import {trpcCli, TrpcCliMeta, TrpcCliParams} from '../src' expect.addSnapshotSerializer({ test: (val): val is Error => val instanceof Error, @@ -19,8 +19,11 @@ expect.addSnapshotSerializer({ const t = initTRPC.meta().create() -const run = (router: Router, argv: string[]) => { - const cli = trpcCli({router}) +const run = >(router: R, argv: string[]) => { + return runWith({router}, argv) +} +const runWith = >(params: TrpcCliParams, argv: string[]) => { + const cli = trpcCli(params) return new Promise((resolve, reject) => { const logs: unknown[][] = [] const addLogs = (...args: unknown[]) => logs.push(args) @@ -280,6 +283,35 @@ test('single character flag', async () => { ) }) +test('default procedure', async () => { + const router = t.router({ + default: t.procedure + .input(z.tuple([z.string(), z.number()])) // + .query(({input}) => JSON.stringify(input)), + }) + + expect(await run(router, ['hello', '1'])).toMatchInlineSnapshot(`"["hello",1]"`) +}) + +test('custom default procedure', async () => { + const yarn = t.router({ + install: t.procedure + .input(z.object({frozenLockfile: z.boolean().optional()})) + .query(({input}) => 'install: ' + JSON.stringify(input)), + }) + + const params: TrpcCliParams = { + router: yarn, + default: 'install', + } + + const yarnOutput = await runWith(params, ['--frozen-lockfile']) + expect(yarnOutput).toMatchInlineSnapshot(`"install: {"frozenLockfile":true}"`) + + const yarnInstallOutput = await runWith(params, ['install', '--frozen-lockfile']) + expect(yarnInstallOutput).toMatchInlineSnapshot(`"install: {"frozenLockfile":true}"`) +}) + test('validation', async () => { const router = t.router({ tupleOfStrings: t.procedure