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 zodUsage
// router.js
import * as trpcServer from '@trpc/server'
import {trpcCli} from 'trpc-cli'
import {z} from 'zod'
const trpc = trpcServer.initTRPC.create()
const appRouter = trpc.router({
sum: trpc.procedure
.input(
z.object({
left: z.number(),
right: z.number(),
}),
)
.mutation(({input}) => input.left + input.right),
divide: trpc.procedure
.input(
z.object({
left: z.number(),
right: z.number().refine(n => n !== 0),
}),
)
.query(({input}) => input.left / input.right),
})
const cli = trpcCli({router: appRouter})
cli.run()Then run node router.js --help and you will see formatted help text for the sum and divide commands.
Commands:
sum
divide
Flags:
--full-errors Throw unedited raw errors rather than summarising to make more human-readable.
-h, --help Show helpRunning node router.js sum --help and node router.js divide --help will show help text for the corresponding procedures:
sum
Usage:
sum [flags...]
Flags:
-h, --help Show help
--left <number>
--right <number>Features
Improving help docs
Procedures can define meta value with description, usage and help props. Zod's describe method allows adding descriptions to individual flags.
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),
})Other 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
- Limitaion: 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
Examples
Migrator
Given a migrator 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().describe('Mark this many migrations as executed'),
}),
]),
)
.query(async ({input}) => {
let toBeApplied = migrations
if ('to' in input && typeof input.to === 'string') {
const index = migrations.findIndex(m => m.name === input.to)
if (index === -1) {
throw new Error(`Migration ${input.to} not found`)
}
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:
--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; 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
Out of scope
- No input parsing - I'd recommend using
@inquirer/promptswhich is type safe and easy to use - No special stdout prettiness other than help text - use
tasukuorlistr2
Implementation
- cleye for parsing arguments before passing to trpc
- zod-to-json-schema to convert zod schemas to make them easier to recusive
- zod-validation-error to make bad inputs have readable error messages