Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support trpc v11 too #38

Merged
merged 13 commits into from
Sep 17, 2024
2 changes: 1 addition & 1 deletion .github/workflows/autofix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- run: corepack enable
- run: pnpm install
- run: pnpm install --no-frozen-lockfile
- run: pnpm run lint --fix
- uses: autofix-ci/action@ff86a557419858bb967097bfc916833f5647fa8c
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,10 @@ jobs:
- run: pnpm build
- run: pnpm lint
- run: pnpm test
test_trpc_vnext:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: corepack enable
- run: pnpm install @trpc/server@next
- run: pnpm test e2e
2 changes: 1 addition & 1 deletion .github/workflows/deps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@ jobs:
run:
runs-on: ubuntu-latest
steps:
- uses: mmkal/runovate@dfd2ed9736174024361e67d3d70e10fed21bce35
- uses: mmkal/runovate@676a208cba11fc44d3e06177e64f4601ae05f340
55 changes: 33 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Turn a [tRPC](https://trpc.io) router into a type-safe, fully-functional, docume
- [Ignored procedures](#ignored-procedures)
- [API docs](#api-docs)
- [Calculator example](#calculator-example)
- [tRPC v10 vs v11](#trpc-v10-vs-v11)
- [Output and lifecycle](#output-and-lifecycle)
- [Testing your CLI](#testing-your-cli)
- [Features and Limitations](#features-and-limitations)
Expand Down Expand Up @@ -41,10 +42,12 @@ npm install trpc-cli

### Quickstart

The fastest way to get going is to write a normal tRPC router, using `trpc` and `zod` exports from this library, and turn it into a fully-functional CLI by passing it to `createCli`:
The fastest way to get going is to write a normal tRPC router, using `trpcServer` and `zod` exports from this library, and turn it into a fully-functional CLI by passing it to `createCli`:

```ts
import {trpc as t, zod as z, createCli} from 'trpc-cli'
import {trpcServer, zod as z, createCli, TrpcCliMeta} from 'trpc-cli'

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

const router = t.router({
add: t.procedure
Expand Down Expand Up @@ -245,7 +248,7 @@ Note: by design, `createCli` simply collects these procedures rather than throwi
### API docs

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

Run a trpc router as a CLI.

Expand All @@ -268,10 +271,9 @@ A CLI object with a `run` method that can be called to run the CLI. The `run` me
Here's a more involved example, along with what it outputs:

<!-- codegen:start {preset: custom, require: tsx/cjs, source: ./readme-codegen.ts, export: dump, file: test/fixtures/calculator.ts} -->
<!-- hash:54cb14f5071e3f48dd048b83ec94836b -->
<!-- hash:88401aa19b6a08abc634d5be37ffab3a -->
```ts
import * as trpcServer from '@trpc/server'
import {createCli, type TrpcCliMeta} from 'trpc-cli'
import {createCli, type TrpcCliMeta, trpcServer} from 'trpc-cli'
import {z} from 'zod'

const trpc = trpcServer.initTRPC.meta<TrpcCliMeta>().create()
Expand Down Expand Up @@ -415,6 +417,17 @@ const appRouter = trpc.router({
})
```

## tRPC v10 vs v11

Both versions 10 and 11 of `@trpc/server` are both supported, but if using tRPC v11 you must pass in the `createCallerFactory` function to `createCli`:

```ts
import {initTRPC} from '@trpc/server'

const {createCallerFactory} = initTRPC.create()
const cli = createCli({router, createCallerFactory})
```

## 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.:
Expand Down Expand Up @@ -517,11 +530,10 @@ In general, you should rely on `trpc-cli` to correctly handle the lifecycle and
Given a migrations router looking like this:

<!-- codegen:start {preset: custom, require: tsx/cjs, source: ./readme-codegen.ts, export: dump, file: test/fixtures/migrations.ts} -->
<!-- hash:1473b37f6ec855149a81ab0a2364afe2 -->
<!-- hash:dfcdb95c59b99a4e1a8bd95597ee80de -->
```ts
import * as trpcServer from '@trpc/server'
import {createCli, type TrpcCliMeta} from 'trpc-cli'
import {z} from 'zod'
import {createCli, type TrpcCliMeta, trpcServer, z} from 'trpc-cli'
import * as trpcCompat from '../../src/trpc-compat'

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

Expand All @@ -546,7 +558,7 @@ const searchProcedure = trpc.procedure
})

const router = trpc.router({
apply: trpc.procedure
up: trpc.procedure
.meta({
description:
'Apply migrations. By default all pending migrations will be applied.',
Expand Down Expand Up @@ -615,7 +627,7 @@ const router = trpc.router({
)
}),
}),
})
}) satisfies trpcCompat.Trpc10RouterLike

const cli = createCli({
router,
Expand Down Expand Up @@ -667,7 +679,7 @@ Here's how the CLI will work:

```
Commands:
apply Apply migrations. By default all pending migrations will be applied.
up 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
Expand All @@ -684,17 +696,16 @@ Flags:
`node path/to/migrations apply --help` output:

```
apply

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

Usage:
apply [flags...]
Commands:
up 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
--step <number> Mark this many migrations as executed; Exclusive minimum: 0
--to <string> Mark migrations up to this one as exectued
-h, --help Show help
--verbose-errors Throw raw errors (by default errors are summarised)

```
<!-- codegen:end -->
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,16 @@
"zod": ">=3"
},
"dependencies": {
"@trpc/server": "10.45.2",
"@trpc/server": "^10.45.2",
"cleye": "^1.3.2",
"picocolors": "^1.0.1",
"zod": "3.23.8",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.23.0",
"zod-validation-error": "^3.3.0"
},
"devDependencies": {
"trpcserver10": "npm:@trpc/server@10.45.2",
"trpcserver11": "npm:@trpc/server@11.0.0-rc.502",
"@types/node": "20.16.5",
"eslint-plugin-mmkal": "0.9.0",
"execa": "9.3.1",
Expand Down
15 changes: 13 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions renovate.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,5 @@
"extends": [
"config:recommended"
],
"fetchChangeLogs": "branch",
"commitBody": "{{#if logJSON.hasReleaseNotes}}{{#each logJSON.versions as |release|}}{{# if release.releaseNotes}}##### [`v{{{release.version}}}]({{{release.releaseNotes.url}}})\n\n{{{release.releaseNotes.body}}}{{/if}}{{/each}}{{/if}}"
"fetchChangeLogs": "branch"
}
83 changes: 41 additions & 42 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import {Procedure, Router, TRPCError, initTRPC} from '@trpc/server'
import * as trpcServer from '@trpc/server'
import * as cleye from 'cleye'
import colors from 'picocolors'
import {ZodError} from 'zod'
import {type JsonSchema7Type} from 'zod-to-json-schema'
import * as zodValidationError from 'zod-validation-error'
import {flattenedProperties, incompatiblePropertyPairs, getDescription} from './json-schema'
import {lineByLineConsoleLogger} from './logging'
import {Logger, TrpcCliMeta, TrpcCliParams} from './types'
import {AnyProcedure, AnyRouter, CreateCallerFactoryLike, isTrpc11Procedure} from './trpc-compat'
import {Logger, TrpcCliParams} from './types'
import {looksLikeInstanceof} from './util'
import {parseProcedureInputs} from './zod-procedure'

Expand All @@ -16,43 +17,18 @@ export * from './types'
export {z} from 'zod'
export * as zod from 'zod'

export {
/**
* `initTRPC` from `@trpc/server`
* @example
* ```ts
* import {initTRPC, TrpcCliMeta} from 'trpc-cli'
*
* const t = initTRPC.meta<TrpcCliMeta>().context<{foo: string}>().create()
*
* const router = t.router({
* getFoo: t.procedure
* .meta({description: 'Get foo from context'})
* .query(({ctx}) => ctx.foo)
* })
* ```
*/
initTRPC,
} from '@trpc/server'
export * as trpcServer from '@trpc/server'

/**
* A "starter" trpc instance. Useful to get a new project started without needing to set up `@trpc/server` manually.
* Equivalent to `initTRPC.meta<TrpcCliMeta>().create()`.
* Note: if you need to specify a context, use {@linkcode createTrpc}.
*/
export const trpc = initTRPC.meta<TrpcCliMeta>().create()
/** re-export of the @trpc/server package, just to avoid needing to install manually when getting started */

/**
* Create a "starter" trpc instance, with context.
* Equivalent to `initTRPC.meta<TrpcCliMeta>().context<Context>()`.
* Note: if you don't need to specify a context, just use {@linkcode trpc}.
*/
export const createTrpc = <Context extends {}>() => initTRPC.meta<TrpcCliMeta>().context<Context>().create()

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AnyRouter = Router<any>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AnyProcedure = Procedure<any, any>

export {AnyRouter, AnyProcedure} from './trpc-compat'

export interface TrpcCli {
run: (params?: {argv?: string[]; logger?: Logger; process?: {exit: (code: number) => never}}) => Promise<void>
ignoredProcedures: {procedure: string; reason: string}[]
}

/**
* Run a trpc router as a CLI.
Expand All @@ -63,7 +39,7 @@ export type AnyProcedure = Procedure<any, any>
* @param 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.
*/
export const createCli = <R extends AnyRouter>({router, ...params}: TrpcCliParams<R>) => {
export function createCli<R extends AnyRouter>({router, ...params}: TrpcCliParams<R>): TrpcCli {
const procedures = Object.entries<AnyProcedure>(router._def.procedures as {}).map(([name, procedure]) => {
const procedureResult = parseProcedureInputs(procedure._def.inputs as unknown[])
if (!procedureResult.success) {
Expand All @@ -73,7 +49,22 @@ export const createCli = <R extends AnyRouter>({router, ...params}: TrpcCliParam
const jsonSchema = procedureResult.value
const properties = flattenedProperties(jsonSchema.flagsSchema)
const incompatiblePairs = incompatiblePropertyPairs(jsonSchema.flagsSchema)
const type = router._def.procedures[name]._def.mutation ? 'mutation' : 'query'

// trpc types are a bit of a lie - they claim to be `router._def.procedures.foo.bar` but really they're `router._def.procedures['foo.bar']`
const trpcProcedure = router._def.procedures[name] as AnyProcedure
let type: 'mutation' | 'query' | 'subscription'
if (isTrpc11Procedure(trpcProcedure)) {
type = trpcProcedure._def.type
} else if (trpcProcedure._def.mutation) {
type = 'mutation'
} else if (trpcProcedure._def.query) {
type = 'query'
} else if (trpcProcedure._def.subscription) {
type = 'subscription'
} else {
const keys = Object.keys(trpcProcedure._def).join(', ')
throw new Error(`Unknown procedure type for procedure object with keys ${keys}`)
}

return [name, {name, procedure, jsonSchema, properties, incompatiblePairs, type}] as const
})
Expand Down Expand Up @@ -123,7 +114,7 @@ export const createCli = <R extends AnyRouter>({router, ...params}: TrpcCliParam

return {
name: commandName,
help: procedure.meta as {},
help: procedure._def.meta,
parameters: jsonSchema.parameters,
flags: flags as {},
}
Expand Down Expand Up @@ -155,7 +146,11 @@ export const createCli = <R extends AnyRouter>({router, ...params}: TrpcCliParam

type Context = NonNullable<typeof params.context>

const caller = initTRPC.context<Context>().create({}).createCallerFactory(router)(params.context)
const createCallerFactory =
params.createCallerFactory ||
(trpcServer.initTRPC.context<Context>().create({}).createCallerFactory as CreateCallerFactoryLike)

const caller = createCallerFactory(router)(params.context)

const die: Fail = (message: string, {cause, help = true}: {cause?: unknown; help?: boolean} = {}) => {
if (verboseErrors !== undefined && verboseErrors) {
Expand Down Expand Up @@ -202,7 +197,7 @@ export const createCli = <R extends AnyRouter>({router, ...params}: TrpcCliParam
const input = procedureInfo.jsonSchema.getInput({_: parsedArgv._, flags}) as never

try {
const result: unknown = await caller[procedureInfo.type as 'mutation'](procedureInfo.name, input)
const result: unknown = await (caller[procedureInfo.name] as Function)(input)
if (result) logger.info?.(result)
_process.exit(0)
} catch (err) {
Expand All @@ -219,7 +214,10 @@ export const trpcCli = createCli
type Fail = (message: string, options?: {cause?: unknown; help?: boolean}) => never

function transformError(err: unknown, fail: Fail): unknown {
if (looksLikeInstanceof(err, TRPCError)) {
if (looksLikeInstanceof(err, Error) && err.message.includes('This is a client-only function')) {
return new Error('createCallerFactory version mismatch - pass in createCallerFactory explicitly', {cause: err})
}
if (looksLikeInstanceof(err, trpcServer.TRPCError)) {
const cause = err.cause
if (looksLikeInstanceof(cause, ZodError)) {
const originalIssues = cause.issues
Expand Down Expand Up @@ -249,6 +247,7 @@ function transformError(err: unknown, fail: Fail): unknown {
return fail(err.message, {cause: err})
}
}
return err
}

type CleyeCommandOptions = cleye.Command['options']
Expand Down
Loading