JSPM

  • Created
  • Published
  • Downloads 168739
  • Score
    100M100P100Q155460F
  • License Apache-2.0

Turn a tRPC router into a type-safe, fully-functional, documented CLI

Package Exports

  • trpc-cli
  • trpc-cli/dist/index.js

This package does not declare an exports field, so the exports above have been automatically detected and optimized by JSPM instead. If any package subpath is missing, it is recommended to post an issue to the original package (trpc-cli) to support the "exports" field. If that is not possible, create a JSPM override to customize the exports field for this package.

Readme

trpc-cli

Turn a trpc router into a type-safe, fully-functional, documented CLI.

Installation

npm install trpc-cli @trpc/server zod

Usage

Start by writing a normal tRPC router:

import {initTRPC} from '@trpc/server'
import {z} from 'zod'

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),
})

Then you can turn it into a fully-functional CLI by passing it to trpcCli

import {trpcCli} from 'trpc-cli'
import {router} from './router'

const cli = trpcCli({router})
cli.run()

And that's it! Your tRPC router is now a CLI program with help text and input validation.

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.

Calculator example

Here's a more involved example, along with what it outputs:

import * as trpcServer from '@trpc/server'
import {TrpcCliMeta, trpcCli} from 'trpc-cli'
import {z} from 'zod'

const trpc = trpcServer.initTRPC.meta<TrpcCliMeta>().create()

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.',
    })
    .input(
      z.object({
        left: z.number().describe('The first number'),
        right: z.number().describe('The second number'),
      }),
    )
    .query(({input}) => input.left + input.right),
  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),
  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),
  divide: trpc.procedure
    .meta({
      version: '1.0.0',
      description:
        "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.",
      examples: 'divide --left 8 --right 4',
    })
    .input(
      z.object({
        left: z.number().describe('The numerator of the division operation.'),
        right: z
          .number()
          .refine(n => n !== 0)
          .describe(
            'The denominator of the division operation. Note: must not be zero.',
          ),
      }),
    )
    .mutation(({input}) => input.left / input.right),
})

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.

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.
  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

You can also show help text for the corresponding procedures (which become "commands" in the CLI):

node path/to/calculator add --help output:

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.

Usage:
  add [flags...]

Flags:
  -h, --help                  Show help
      --left <number>         The first number
      --right <number>        The second number

When passing a command along with its flags, the return value will be logged to stdout:

node path/to/calculator add --left 2 --right 3 output:

5

Invalid inputs are helpfully displayed, along with help text for the associated command:

node path/to/calculator add --left 2 --right notanumber output:

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.

Usage:
  add [flags...]

Flags:
  -h, --help                  Show help
      --left <number>         The first number
      --right <number>        The second number

Validation error
  - Expected number, received nan at "--right"

Note that procedures can define meta value with description, usage and help props. Zod's describe method allows adding descriptions to individual flags.

import {type TrpcCliMeta} from 'trpc-cli'

const trpc = initTRPC.meta<TrpcCliMeta>().create() // `TrpcCliMeta` is a helper interface with description, usage, and examples, but you can use your own `meta` interface, anything with a `description?: string` property will be fine

const appRouter = trpc.router({
  divide: trpc.procedure
    .meta({
      description:
        'Divide two numbers. Useful when you have a pizza and you want to share it equally between friends.',
    })
    .input(
      z.object({
        left: z.number().describe('The numerator of the division operator'),
        right: z.number().describe('The denominator of the division operator'),
      }),
    )
    .mutation(({input}) => input.left / input.right),
})

Features and Limitations

  • Nested subrouters (example) - command will be dot separated e.g. search.byId
  • Middleware, ctx, multi-inputs work as normal
  • 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 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
    • 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}'
    • If there's interest, support for --foo.bar=1 could be added using type-flag's dot-nested flags but edge cases/preprocessing needs proper consideration first.
  • Limitation: No subscription support.
    • In theory, this might be supportable via @inquirer/prompts. Proposals welcome!

More Examples

Migrator example

Given a migrations router looking like this:

import * as trpcServer from '@trpc/server'
import {TrpcCliMeta, trpcCli} from 'trpc-cli'
import {z} from 'zod'

const trpc = trpcServer.initTRPC.meta<TrpcCliMeta>().create()

const migrations = getMigrations()

const searchProcedure = trpc.procedure
  .input(
    z.object({
      status: z
        .enum(['executed', 'pending'])
        .optional()
        .describe('Filter to only show migrations with this status'),
    }),
  )
  .use(async ({next, input}) => {
    return next({
      ctx: {
        filter: (list: typeof migrations) =>
          list.filter(m => !input.status || m.status === input.status),
      },
    })
  })

const router = trpc.router({
  apply: trpc.procedure
    .meta({
      description:
        'Apply migrations. By default all pending migrations will be applied.',
    })
    .input(
      z.union([
        z.object({
          to: z
            .string()
            .optional()
            .describe('Mark migrations up to this one as exectued'),
          step: z.never().optional(),
        }),
        z.object({
          to: z.never().optional(),
          step: z
            .number()
            .int()
            .positive()
            .describe('Mark this many migrations as executed'),
        }),
      ]),
    )
    .query(async ({input}) => {
      let toBeApplied = migrations
      if (typeof input.to === 'string') {
        const index = migrations.findIndex(m => m.name === input.to)
        toBeApplied = migrations.slice(0, index + 1)
      }
      if (typeof input.step === 'number') {
        const start = migrations.findIndex(m => m.status === 'pending')
        toBeApplied = migrations.slice(0, start + input.step)
      }
      toBeApplied.forEach(m => (m.status = 'executed'))
      return migrations.map(m => `${m.name}: ${m.status}`)
    }),
  create: trpc.procedure
    .meta({description: 'Create a new migration'})
    .input(
      z.object({name: z.string(), content: z.string()}), //
    )
    .mutation(async ({input}) => {
      migrations.push({...input, status: 'pending'})
      return migrations
    }),
  list: searchProcedure
    .meta({description: 'List all migrations'})
    .query(({ctx}) => ctx.filter(migrations)),
  search: trpc.router({
    byName: searchProcedure
      .meta({description: 'Look for migrations by name'})
      .input(z.object({name: z.string()}))
      .query(({ctx, input}) => {
        return ctx.filter(migrations.filter(m => m.name === input.name))
      }),
    byContent: searchProcedure
      .meta({description: 'Look for migrations by their script content'})
      .input(
        z.object({
          searchTerm: z
            .string()
            .describe(
              'Only show migrations whose `content` value contains this string',
            ),
        }),
      )
      .query(({ctx, input}) => {
        return ctx.filter(
          migrations.filter(m => m.content.includes(input.searchTerm)),
        )
      }),
  }),
})

const cli = trpcCli({
  router,
  alias: (fullName, {command}) => {
    if (fullName === 'status') {
      return 's'
    }
    if (fullName === 'searchTerm' && command.startsWith('search.')) {
      return 'q'
    }
    return undefined
  },
})

void cli.run()

function getMigrations() {
  return [
    {
      name: 'one',
      content: 'create table one(id int, name text)',
      status: 'executed',
    },
    {
      name: 'two',
      content: 'create view two as select name from one',
      status: 'executed',
    },
    {
      name: 'three',
      content: 'create table three(id int, foo int)',
      status: 'pending',
    },
    {
      name: 'four',
      content: 'create view four as select foo from three',
      status: 'pending',
    },
    {name: 'five', content: 'create table five(id int)', status: 'pending'},
  ]
}

Here's how the CLI will work:

node path/to/migrations --help output:

Commands:
  apply                   Apply migrations. By default all pending migrations will be applied.
  create                  Create a new migration
  list                    List all migrations
  search.byName           Look for migrations by name
  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

node path/to/migrations apply --help output:

apply

Apply migrations. By default all pending migrations will be applied.

Usage:
  apply [flags...]

Flags:
  -h, --help                 Show help
      --step <number>        Mark this many migrations as executed; exclusiveMinimum: 0; Do not use with: --to
      --to <string>          Mark migrations up to this one as exectued; Do not use with: --step

node path/to/migrations search.byContent --help output:

search.byContent

Look for migrations by their script content

Usage:
  search.byContent [flags...]

Flags:
  -h, --help                        Show help
  -q, --search-term <string>        Only show migrations whose `content` value contains this string
  -s, --status <string>             Filter to only show migrations with this status; enum: executed,pending

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. But if you really need to for some reason, you could override the console.error and process.exit calls:

import {trpcCli} from 'trpc-cli'

const cli = trpcCli({router: yourAppRouter})

const runCli = async (argv: string[]) => {
  return new Promise<void>((resolve, reject) => {
    cli.run({
      argv,
      logger: yourLogger, // needs `info` and `error` methods, at least
      process: {
        exit: code => {
          if (code === 0) {
            resolve()
          } else {
            reject(`CLI failed with exit code ${code}`)
          }
        },
      },
    })
  })
}

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 - it could be solved by exposing some cleye configuration to the run method.

Out of scope

  • No stdin reading - I'd recommend using @inquirer/prompts which is type safe and easy to use
  • No stdout prettiness other than help text - use tasuku or listr2

Contributing

Implementation and dependencies

  • cleye for parsing arguments before passing to trpc
  • zod-to-json-schema to convert zod schemas to make them easier to recurse and format help text from
  • zod-validation-error to make bad inputs have readable error messages

zod and @tprc/server are peer dependencies - right now only zod 3 and @trpc/server v11 have been tested, but it may work with most versions of zod, and @trpc/server >= 10.

Testing

vitest is used for testing, but the tests consists of the example fixtures from this readme, executed as CLIs via a subprocess. Avoiding mocks this way ensures fully realistic outputs (the tradeoff being test-speed, but they're acceptably fast for now).