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
- Usage
- Features and Limitations
- More Examples
- Programmatic usage
- Out of scope
- Contributing
Installation
npm install trpc-cli @trpc/server zodUsage
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
aliascallback (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()orz.tuple([z.string(), ...])
- If there's interest, this could be added in future for inputs of type
- 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=1could be added using type-flag's dot-nested flags but edge cases/preprocessing needs proper consideration first.
- e.g.
- Limitation: No
subscriptionsupport.- In theory, this might be supportable via
@inquirer/prompts. Proposals welcome!
- In theory, this might be supportable via
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/promptswhich is type safe and easy to use - No stdout prettiness other than help text - use
tasukuorlistr2
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).