Skip to content

Commit

Permalink
default procedure
Browse files Browse the repository at this point in the history
  • Loading branch information
mmkal committed May 24, 2024
1 parent 3ddf94a commit 12d55b8
Show file tree
Hide file tree
Showing 4 changed files with 173 additions and 91 deletions.
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

<!-- codegen:start {preset: markdownFromJsdoc, source: src/index.ts, export: trpcCli} -->
#### [trpcCli](./src/index.ts#L27)
#### [trpcCli](./src/index.ts#L28)

Run a trpc router as a CLI.

Expand All @@ -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

Expand Down
187 changes: 100 additions & 87 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,22 @@ type AnyProcedure = Procedure<any, any>
* @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 = <R extends AnyRouter>({router, context, alias}: TrpcCliParams<R>) => {
const procedures = Object.entries<AnyProcedure>(router._def.procedures as {}).map(([commandName, procedure]) => {
export const trpcCli = <R extends AnyRouter>({router, context, alias, default: defaultProcedure}: TrpcCliParams<R>) => {
const procedures = Object.entries<AnyProcedure>(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]) => {
Expand All @@ -58,6 +59,48 @@ export const trpcCli = <R extends AnyRouter>({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: {
Expand All @@ -67,42 +110,8 @@ export const trpcCli = <R extends AnyRouter>({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,
Expand All @@ -113,7 +122,7 @@ export const trpcCli = <R extends AnyRouter>({router, context, alias}: TrpcCliPa

const caller = initTRPC.context<NonNullable<typeof 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)
}
Expand All @@ -124,19 +133,15 @@ export const trpcCli = <R extends AnyRouter>({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) {
Expand All @@ -159,48 +164,56 @@ export const trpcCli = <R extends AnyRouter>({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<CleyeCommandOptions['flags']>[string]

function getCleyeType(schema: JsonSchema7Type): Extract<CleyeFlag, {type: unknown}>['type'] {
const _type = 'type' in schema && typeof schema.type === 'string' ? schema.type : null
switch (_type) {
case 'string': {
Expand Down
5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export type TrpcCliParams<R extends Router<any>> = {
* @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, unknown>}) => 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.
Expand Down
38 changes: 35 additions & 3 deletions test/parsing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,8 +19,11 @@ expect.addSnapshotSerializer({

const t = initTRPC.meta<TrpcCliMeta>().create()

const run = (router: Router<any>, argv: string[]) => {
const cli = trpcCli({router})
const run = <R extends Router<any>>(router: R, argv: string[]) => {
return runWith({router}, argv)
}
const runWith = <R extends Router<any>>(params: TrpcCliParams<R>, argv: string[]) => {
const cli = trpcCli(params)
return new Promise<string>((resolve, reject) => {
const logs: unknown[][] = []
const addLogs = (...args: unknown[]) => logs.push(args)
Expand Down Expand Up @@ -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<typeof yarn> = {
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
Expand Down

0 comments on commit 12d55b8

Please sign in to comment.