mmkal / trpc-cli

Turn a tRPC router into a type-safe, fully-functional, documented CLI
Other
49 stars 0 forks source link
cli tprc typescript

trpc-cli Build Status npm

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

Motivation

tRPC offers best-in-class type-safety and DX for building "procedures" that validate their inputs, and abide by their own contracts. This library gives you all those DX benefits, and allows mapping the procedures directly to a CLI. This offers the easiest way to build a CLI while mapping parsed flags into strongly-typed inputs, and automatically outputs --help documentation that's always up-to-date.

This isn't just the easiest and safest way to build a CLI, but you also get all the benefits of tRPC (and zod). For inputs, you can use zod regex types, transforms, refinements, and those will map directly into useful help-text for CLI users, and corresponding type correctness when maintaining your CLI program. You can also use tRPC context and middleware functionality just like you could if you were building a server. And as an added bonus, it becomes trivially easy to turn your CLI program into a fully-functional HTTP server. Or, you could add a "programmatic usage" to your library, just by wrapping your server with the built-in createCaller function from tRPC. This would all, of course, have runtime and compile-time type safety.

Installation

npm install trpc-cli @trpc/server zod

Usage

Start by writing a normal tRPC router (docs here if you're not familiar with tRPC):

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

const t = initTRPC.create()

export const router = t.router({
  add: t.procedure
    .input(z.object({left: z.number(), right: z.number()}))
    .query(({input}) => input.left + input.right),
})

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 run it with node path/to/script.js add --left 2 --right 3.

Disclaimer

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, and any breaking changes will be published via release notes.

Parameters and flags

CLI positional parameters and flags are derived from each procedure's input type. Inputs use zod types for the procedure to be mapped to a CLI command.

Positional parameters

Positional parameters passed to the CLI can be declared with types representing strings, numbers or booleans:

t.router({
  double: t.procedure
    .input(z.number()) //
    .query(({input}) => input * 2),
})

You can also use anything that accepts string, number, or boolean inputs, like z.enum(['up', 'down']), z.number().int(), z.literal(123), z.string().regex(/^\w+$/) etc.

Multiple positional parameters can use a z.tuple(...) input type:

t.router({
  add: t.procedure
    .input(z.tuple([z.number(), z.number()]))
    .query(({input}) => input[0] + input[1]),
})

Which is invoked like path/to/cli add 2 3 (outputting 5).

Note: positional parameters can use .optional() or .nullish(), but not .nullable().

Note: positional parameters can be named using .describe('name of parameter'), but names should not include any special characters.

Note: positional parameters are parsed based on the expected target type. Booleans must be written as true or false, spelled out. In most cases, though, you'd be better off using flags for boolean inputs.

Flags

z.object(...) inputs become flags (passed with --foo bar or --foo=bar) syntax. Values are accepted in either --camelCase or --kebab-case, and are parsed like in most CLI programs:

Strings:

Booleans:

Note: it's usually better to use z.boolean().default(false) or z.boolean().optional() than z.boolean(), otherwise CLI users will have to pass in --foo=false explicitly.

Numbers:

Other types:

Unions and intersections should also work as expected, but make sure to test them thoroughly, especially if they are deeply-nested.

Both

To use positional parameters and flags, use a tuple with an object at the end:

t.router({
  copy: t.procedure
    .input(
      z.tuple([
        z.string().describe('source'),
        z.string().describe('target'),
        z.object({
          mkdirp: z
            .boolean()
            .optional()
            .describe("Ensure target's parent directory exists before copying"),
        }),
      ]),
    )
    .mutation(async ({input: [source, target, opts]}) => {
      if (opts.mkdirp) {
        await fs.mkdir(path.dirname(target, {recursive: true}))
      }
      await fs.copyFile(source, target)
    }),
})

You might use the above with a command like:

path/to/cli copy a.txt b.txt --mkdirp

Note: object types for flags must appear last in the .input(...) tuple, when being used with positional parameters. So z.tuple([z.string(), z.object({mkdirp: z.boolean()}), z.string()]) would not be allowed.

Procedures with incompatible inputs will be returned in the ignoredProcedures property.

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.

Default command

You can define a default command for your CLI - set this to the procedure that should be invoked directly when calling your CLI. Useful for simple CLIs that only do one thing, or when you want to make the most common command very quick to type (e.g. yarn being an alias for yarn install):

#!/usr/bin/env node
// filename: yarn
const router = t.router({
  install: t.procedure //
    .mutation(() => console.log('installing...')),
})

const cli = trpcCli({
  router,
  default: {procedure: 'install'},
})

cli.run()

The above can be invoked with either yarn or yarn install.

Ignored procedures

If a procedure has an input that cannot be mapped to positional parameters and CLI flags, it will be ignored. You can access the ignored procedures, along with the associated error messages encountered when trying to map those procedures into commands, with the .ignoredProcedures property.

const router = t.router({
  foo: t.procedure
    // input can't be mapped to a command - tuples must start with string/number positional parameters:
    .input(z.tuple([z.string(), z.object({abc: z.string()}), z.string()]))
    .query(() => 'ok'),
})

const cli = trpcCli({router})

if (cli.ignoredProcedures.length > 0) {
  throw new Error(
    `Some procedures weren't mapped into commands: ${JSON.stringify(cli.ignoredProcedures, null, 2)}`,
  )
}

The above will throw an error looking like:

Some procedures weren't mapped into commands: [
  {
    "procedure": "foo",
    "reason": "Invalid input type [ZodString, ZodObject, ZodString]. Positional parameters must be strings or numbers."
  }
]

Note: by design, trpcCli simply collects these procedures rather than throwing so that you can pass any router to it - the procedures which can be mapped into commands will still work. It is up to you if you want to throw if some are ignored.

API docs

trpcCli

Run a trpc router as a CLI.

Params
name description
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 A procedure to use as the default command when the user doesn't specify one.
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.

Calculator example

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

import * as trpcServer from '@trpc/server'
import {trpcCli, type TrpcCliMeta} 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 and your friend both have apples, and you want to know how many apples there are in total.',
    })
    .input(z.tuple([z.number(), z.number()]))
    .query(({input}) => input[0] + input[1]),
  subtract: trpc.procedure
    .meta({
      description:
        'Subtract two numbers. Useful if you have a number and you want to make it smaller.',
    })
    .input(z.tuple([z.number(), z.number()]))
    .query(({input}) => input[0] - input[1]),
  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.tuple([z.number(), z.number()]))
    .query(({input}) => input[0] * input[1]),
  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.tuple([
        z.number().describe('numerator'),
        z
          .number()
          .refine(n => n !== 0)
          .describe('denominator'),
      ]),
    )
    .mutation(({input}) => input[0] / input[1]),
})

void trpcCli({router}).run()

Run node path/to/cli --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 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:
  -h, --help                  Show help
      --verbose-errors        Throw raw errors (by default errors are summarised)

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 and your friend both have apples, and you want to know how many apples there are in total.

Usage:
  add [flags...] <parameter 1> <parameter 2>

Flags:
  -h, --help        Show help

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

node path/to/calculator add 2 3 output:

5

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

node path/to/calculator add 2 notanumber output:

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...] <parameter 1> <parameter 2>

Flags:
  -h, --help        Show help

Validation error
  - Expected number, received string at index 1

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

Output and lifecycle

The output of the command will be logged if it is truthy. The log algorithm aims to be friendly for bash-piping, usage with jq etc.:

So if the procedure returns ['one', 'two', 'three] this will be written to stdout:

one
two
three

If the procedure returns [{name: 'one'}, {name: 'two'}, {name: 'three'}] this will be written to stdout:

{
  "name": "one"
}
{
  "name": "two"
}
{
  "name": "three"
}

This is to make it as easy as possible to use with other command line tools like xargs, jq etc. via bash-piping. If you don't want to rely on this logging, you can always log inside your procedures however you like and avoid returning a value.

The process will exit with code 0 if the command was successful, or 1 otherwise.

You can also override the logger and process properties of the run method to change the default return-value logging and/or process.exit behaviour:

import {trpcCli} from 'trpc-cli'

const cli = trpcCli({router: yourRouter})

cli.run({
  logger: yourLogger, // should define `.info` and `.error` methods
  process: {
    exit: code => {
      if (code === 0) process.exit(0)
      else process.exit(123)
    },
  },
})

You could also override process.exit to avoid killing the process at all - see programmatic usage for an example.

Testing your CLI

Rather than testing your CLI via a subprocess, which is slow and doesn't provide great DX, it's better to use the router that is passed to it directly with createCallerFactory:

import {initTRPC} from '@trpc/server'
import {test, expect} from 'your-test-library'
import {router} from '../src'

const caller = initTRPC.create().createCallerFactory(router)({})

test('add', async () => {
  expect(await caller.add([2, 3])).toBe(5)
})

This will give you strong types for inputs and outputs, and is essentially what trpc-cli does under the hood after parsing and validating command-line input.

In general, you should rely on trpc-cli to correctly handle the lifecycle and output etc. when it's invoked as a CLI by end-users. If there are any problems there, they should be fixed on this repo - please raise an issue.

Features and Limitations

More Examples

Migrator example

Given a migrations router looking like this:

import * as trpcServer from '@trpc/server'
import {trpcCli, type TrpcCliMeta} 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({}).strict(), // use strict here to make sure `{step: 1}` doesn't "match" this first, just by having an ignore `step` property
        z.object({
          to: z.string().describe('Mark migrations up to this one as exectued'),
        }),
        z.object({
          step: z
            .number()
            .int()
            .positive()
            .describe('Mark this many migrations as executed'),
        }),
      ]),
    )
    .query(async ({input}) => {
      let toBeApplied = migrations
      if ('to' in input) {
        const index = migrations.findIndex(m => m.name === input.to)
        toBeApplied = migrations.slice(0, index + 1)
      }
      if ('step' in input) {
        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:
  -h, --help                  Show help
      --verbose-errors        Throw raw errors (by default errors are summarised)

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; Exclusive minimum: 0
      --to <string>          Mark migrations up to this one as exectued

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 (including the built-in createCaller helper bundled with @trpc/server).

The .run() function does return a value, but it's typed as unknown since the input is just argv: string[] . 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 is handled by cleye which prints directly to stdout and exits the process. In a future version this will be solved by either exposing some cleye configuration to the run method, or controlling the help text rendering directly.

Out of scope

Contributing

Implementation and dependencies

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

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