├── .gitignore ├── docs ├── deps.png ├── usage-demo.gif └── prompts-demo.gif ├── .gitattributes ├── eslint.config.js ├── src ├── zod-to-json-schema │ ├── parsers │ │ ├── boolean.ts │ │ ├── branded.ts │ │ ├── catch.ts │ │ ├── readonly.ts │ │ ├── unknown.ts │ │ ├── enum.ts │ │ ├── undefined.ts │ │ ├── promise.ts │ │ ├── null.ts │ │ ├── default.ts │ │ ├── effects.ts │ │ ├── never.ts │ │ ├── any.ts │ │ ├── optional.ts │ │ ├── nativeEnum.ts │ │ ├── pipeline.ts │ │ ├── literal.ts │ │ ├── set.ts │ │ ├── map.ts │ │ ├── tuple.ts │ │ ├── array.ts │ │ ├── nullable.ts │ │ ├── date.ts │ │ ├── intersection.ts │ │ ├── object.ts │ │ ├── bigint.ts │ │ ├── number.ts │ │ ├── record.ts │ │ └── union.ts │ ├── getRelativePath.ts │ ├── errorMessages.ts │ ├── ZodFirstPartyTypeKind.ts │ ├── Refs.ts │ ├── index.ts │ ├── parseTypes.ts │ ├── parseDef.ts │ ├── Options.ts │ ├── zodToJsonSchema.ts │ └── selectParser.ts ├── standard-schema │ ├── utils.ts │ ├── errors.ts │ └── contract.ts ├── errors.ts ├── util.ts ├── logging.ts ├── proxify.ts ├── completions.ts ├── json.ts ├── trpc-compat.ts ├── bin.ts └── json-schema.ts ├── tsconfig.lib.json ├── vite.config.ts ├── tsconfig.json ├── .github └── workflows │ ├── deps.yml │ ├── post-release.yml │ ├── autofix.yml │ ├── pkg.pr.new.yml │ └── ci.yml ├── renovate.json ├── test ├── fixtures │ ├── migra.ts │ ├── promptable.ts │ ├── calculator.ts │ ├── fs.ts │ ├── completable.ts │ └── migrations.ts ├── setup.ts ├── types.test.ts ├── proxy.test.ts ├── logging.test.ts ├── effect.test.ts ├── test-run.ts ├── completions.test.ts ├── help.test.ts ├── orpc.test.ts ├── lifecycle.test.ts ├── prompts.test.ts ├── validation-library-codegen.ts ├── trpc-compat.test.ts └── parse.test.ts ├── readme-codegen.ts ├── package.json ├── cp-zod-to-json-schema.sh └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | *ignoreme* 4 | *.log 5 | -------------------------------------------------------------------------------- /docs/deps.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmkal/trpc-cli/HEAD/docs/deps.png -------------------------------------------------------------------------------- /docs/usage-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmkal/trpc-cli/HEAD/docs/usage-demo.gif -------------------------------------------------------------------------------- /docs/prompts-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmkal/trpc-cli/HEAD/docs/prompts-demo.gif -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | src/zod-to-json-schema/** linguist-generated=true 2 | pnpm-lock.yaml linguist-generated=true 3 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import * as mmkal from 'eslint-plugin-mmkal' 2 | 3 | export default [ 4 | ...mmkal.recommendedFlatConfigs, // 5 | {ignores: ['src/zod-to-json-schema/**']}, 6 | ] 7 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/boolean.ts: -------------------------------------------------------------------------------- 1 | export type JsonSchema7BooleanType = { 2 | type: "boolean"; 3 | }; 4 | 5 | export function parseBooleanDef(): JsonSchema7BooleanType { 6 | return { 7 | type: "boolean", 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["./tsconfig.json"], 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./dist", 6 | "noEmit": false, 7 | "declaration": true 8 | }, 9 | "include": ["src"] 10 | } 11 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/branded.ts: -------------------------------------------------------------------------------- 1 | import type { ZodBrandedDef } from "zod"; 2 | import { parseDef } from "../parseDef.js"; 3 | import type { Refs } from "../Refs.js"; 4 | 5 | export function parseBrandedDef(_def: ZodBrandedDef, refs: Refs) { 6 | return parseDef(_def.type._def, refs); 7 | } 8 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/catch.ts: -------------------------------------------------------------------------------- 1 | import type { ZodCatchDef } from "zod"; 2 | import { parseDef } from "../parseDef.js"; 3 | import type { Refs } from "../Refs.js"; 4 | 5 | export const parseCatchDef = (def: ZodCatchDef, refs: Refs) => { 6 | return parseDef(def.innerType._def, refs); 7 | }; 8 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/readonly.ts: -------------------------------------------------------------------------------- 1 | import type { ZodReadonlyDef } from "zod"; 2 | import { parseDef } from "../parseDef.js"; 3 | import type { Refs } from "../Refs.js"; 4 | 5 | export const parseReadonlyDef = (def: ZodReadonlyDef, refs: Refs) => { 6 | return parseDef(def.innerType._def, refs); 7 | }; 8 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/unknown.ts: -------------------------------------------------------------------------------- 1 | import type { Refs } from "../Refs.js"; 2 | import { type JsonSchema7AnyType, parseAnyDef } from "./any.js"; 3 | 4 | export type JsonSchema7UnknownType = JsonSchema7AnyType; 5 | 6 | export function parseUnknownDef(refs: Refs): JsonSchema7UnknownType { 7 | return parseAnyDef(refs); 8 | } 9 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/getRelativePath.ts: -------------------------------------------------------------------------------- 1 | 2 | export const getRelativePath = (pathA: string[], pathB: string[]) => { 3 | let i = 0; 4 | for (; i < pathA.length && i < pathB.length; i++) { 5 | if (pathA[i] !== pathB[i]) break; 6 | } 7 | return [(pathA.length - i).toString(), ...pathB.slice(i)].join("/"); 8 | }; 9 | 10 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/enum.ts: -------------------------------------------------------------------------------- 1 | import type { ZodEnumDef } from "zod"; 2 | 3 | export type JsonSchema7EnumType = { 4 | type: "string"; 5 | enum: string[]; 6 | }; 7 | 8 | export function parseEnumDef(def: ZodEnumDef): JsonSchema7EnumType { 9 | return { 10 | type: "string", 11 | enum: Array.from(def.values), 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | exclude: ['*ignoreme*', 'node_modules'], 6 | setupFiles: ['./test/setup.ts'], 7 | typecheck: { 8 | enabled: true, 9 | include: ['test/types.test.ts'], 10 | }, 11 | testTimeout: 10_000, 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2022"], 5 | "esModuleInterop": true, 6 | "module": "NodeNext", 7 | "moduleResolution": "NodeNext", 8 | "strict": true, 9 | "noEmit": true, 10 | "skipLibCheck": true 11 | }, 12 | "include": ["src", "test", "*.ts"] 13 | } -------------------------------------------------------------------------------- /.github/workflows/deps.yml: -------------------------------------------------------------------------------- 1 | name: deps-sync 2 | on: 3 | push: 4 | branches: [main, deps] 5 | pull_request: 6 | types: [edited] 7 | permissions: 8 | contents: write 9 | actions: write 10 | pull-requests: write 11 | 12 | jobs: 13 | run: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: mmkal/runovate@d72c93c725713366780f67639cdcd9389a682565 17 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/undefined.ts: -------------------------------------------------------------------------------- 1 | import type { Refs } from "../Refs.js"; 2 | import { type JsonSchema7AnyType, parseAnyDef } from "./any.js"; 3 | 4 | export type JsonSchema7UndefinedType = { 5 | not: JsonSchema7AnyType; 6 | }; 7 | 8 | export function parseUndefinedDef(refs: Refs): JsonSchema7UndefinedType { 9 | return { 10 | not: parseAnyDef(refs), 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/promise.ts: -------------------------------------------------------------------------------- 1 | import type { ZodPromiseDef } from "zod"; 2 | import { parseDef } from "../parseDef.js"; 3 | import type { JsonSchema7Type } from "../parseTypes.js"; 4 | import type { Refs } from "../Refs.js"; 5 | 6 | export function parsePromiseDef( 7 | def: ZodPromiseDef, 8 | refs: Refs, 9 | ): JsonSchema7Type | undefined { 10 | return parseDef(def.type._def, refs); 11 | } 12 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/null.ts: -------------------------------------------------------------------------------- 1 | import type { Refs } from "../Refs.js"; 2 | 3 | export type JsonSchema7NullType = { 4 | type: "null"; 5 | }; 6 | 7 | export function parseNullDef(refs: Refs): JsonSchema7NullType { 8 | return refs.target === "openApi3" 9 | ? ({ 10 | enum: ["null"], 11 | nullable: true, 12 | } as any) 13 | : { 14 | type: "null", 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "baseBranches": ["deps"], 4 | "automerge": true, 5 | "automergeType": "branch", 6 | "extends": [ 7 | "config:recommended" 8 | ], 9 | "packageRules": [ 10 | { 11 | "groupName": "orpc dependencies", 12 | "groupSlug": "orpc", 13 | "matchPackageNames": ["@orpc/*"] 14 | } 15 | ], 16 | "fetchChangeLogs": "branch" 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/post-release.yml: -------------------------------------------------------------------------------- 1 | name: Post-release 2 | on: 3 | release: 4 | types: 5 | - published 6 | - edited 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: apexskier/github-release-commenter@v1 12 | with: 13 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 14 | comment-template: This is included in {release_link}. 15 | label-template: released 16 | skip-label: released 17 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/default.ts: -------------------------------------------------------------------------------- 1 | import type { ZodDefaultDef } from "zod"; 2 | import { parseDef } from "../parseDef.js"; 3 | import type { JsonSchema7Type } from "../parseTypes.js"; 4 | import type { Refs } from "../Refs.js"; 5 | 6 | export function parseDefaultDef( 7 | _def: ZodDefaultDef, 8 | refs: Refs, 9 | ): JsonSchema7Type & { default: any } { 10 | return { 11 | ...parseDef(_def.innerType._def, refs), 12 | default: _def.defaultValue(), 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /src/standard-schema/utils.ts: -------------------------------------------------------------------------------- 1 | import {StandardSchemaV1} from './contract.js' 2 | 3 | export const looksLikeStandardSchemaFailure = (error: unknown): error is StandardSchemaV1.FailureResult => { 4 | return !!error && typeof error === 'object' && 'issues' in error && Array.isArray(error.issues) 5 | } 6 | 7 | export const looksLikeStandardSchema = (thing: unknown): thing is StandardSchemaV1 => { 8 | return !!thing && typeof thing === 'object' && '~standard' in thing && typeof thing['~standard'] === 'object' 9 | } 10 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/effects.ts: -------------------------------------------------------------------------------- 1 | import type { ZodEffectsDef } from "zod"; 2 | import { parseDef } from "../parseDef.js"; 3 | import type { JsonSchema7Type } from "../parseTypes.js"; 4 | import type { Refs } from "../Refs.js"; 5 | import { parseAnyDef } from "./any.js"; 6 | 7 | export function parseEffectsDef( 8 | _def: ZodEffectsDef, 9 | refs: Refs, 10 | ): JsonSchema7Type | undefined { 11 | return refs.effectStrategy === "input" 12 | ? parseDef(_def.schema._def, refs) 13 | : parseAnyDef(refs); 14 | } 15 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/never.ts: -------------------------------------------------------------------------------- 1 | import type { Refs } from "../Refs.js"; 2 | import { type JsonSchema7AnyType, parseAnyDef } from "./any.js"; 3 | 4 | export type JsonSchema7NeverType = { 5 | not: JsonSchema7AnyType; 6 | }; 7 | 8 | export function parseNeverDef(refs: Refs): JsonSchema7NeverType | undefined { 9 | return refs.target === "openAi" 10 | ? undefined 11 | : { 12 | not: parseAnyDef({ 13 | ...refs, 14 | currentPath: [...refs.currentPath, "not"], 15 | }), 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/autofix.yml: -------------------------------------------------------------------------------- 1 | name: autofix.ci 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | autofix: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - run: npm install -g corepack@0.31.0 # todo: delete if https://github.com/nodejs/corepack/issues/612 is resolved 16 | - run: corepack enable 17 | - run: pnpm install --no-frozen-lockfile 18 | - run: pnpm run lint --fix 19 | - run: git diff 20 | - uses: autofix-ci/action@551dded8c6cc8a1054039c8bc0b8b48c51dfc6ef 21 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/any.ts: -------------------------------------------------------------------------------- 1 | import type { Refs } from "../Refs.js"; 2 | import { getRelativePath } from "../getRelativePath.js"; 3 | 4 | export type JsonSchema7AnyType = { $ref?: string }; 5 | 6 | export function parseAnyDef(refs: Refs): JsonSchema7AnyType { 7 | if (refs.target !== "openAi") { 8 | return {}; 9 | } 10 | 11 | const anyDefinitionPath = [ 12 | ...refs.basePath, 13 | refs.definitionPath, 14 | refs.openAiAnyTypeName, 15 | ]; 16 | 17 | refs.flags.hasReferencedOpenAiAnyType = true; 18 | 19 | return { 20 | $ref: 21 | refs.$refStrategy === "relative" 22 | ? getRelativePath(anyDefinitionPath, refs.currentPath) 23 | : anyDefinitionPath.join("/"), 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | /** An error thrown when the trpc procedure results in a bad request */ 2 | 3 | export class CliValidationError extends Error {} 4 | /** An error which is only thrown when a custom \`process\` parameter is used. Under normal circumstances, this should not be used, even internally. */ 5 | 6 | export class FailedToExitError extends Error { 7 | readonly exitCode: number 8 | constructor(message: string, {exitCode, cause}: {exitCode: number; cause: unknown}) { 9 | const fullMessage = `${message}. The process was expected to exit with exit code ${exitCode} but did not. This may be because a custom \`process\` parameter was used. The exit reason is in the \`cause\` property.` 10 | super(fullMessage, {cause}) 11 | this.exitCode = exitCode 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/pkg.pr.new.yml: -------------------------------------------------------------------------------- 1 | name: pkg.pr.new 2 | on: 3 | push: {} 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - run: npm install -g corepack@0.31.0 # todo: delete if https://github.com/nodejs/corepack/issues/612 is resolved 11 | - run: corepack enable 12 | - name: set package.json version 13 | # note: if dependencies are a "real" version that matches the repo's package.json, pnpm uses the cached version. So patch to a dev, timestamped version. 14 | run: | 15 | sed -i 's|"version":|"version": "0.0.0-dev-todaysdate","oldversion":|g' package.json 16 | sed -i "s|todaysdate|$(date +%Y%m%d%H%M%S)|g" package.json 17 | - run: pnpm install 18 | - run: pnpm build 19 | - run: pnpm pkg-pr-new publish 20 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/optional.ts: -------------------------------------------------------------------------------- 1 | import type { ZodOptionalDef } from "zod"; 2 | import { parseDef } from "../parseDef.js"; 3 | import type { JsonSchema7Type } from "../parseTypes.js"; 4 | import type { Refs } from "../Refs.js"; 5 | import { parseAnyDef } from "./any.js"; 6 | 7 | export const parseOptionalDef = ( 8 | def: ZodOptionalDef, 9 | refs: Refs, 10 | ): JsonSchema7Type | undefined => { 11 | if (refs.currentPath.toString() === refs.propertyPath?.toString()) { 12 | return parseDef(def.innerType._def, refs); 13 | } 14 | 15 | const innerSchema = parseDef(def.innerType._def, { 16 | ...refs, 17 | currentPath: [...refs.currentPath, "anyOf", "1"], 18 | }); 19 | 20 | return innerSchema 21 | ? { 22 | anyOf: [ 23 | { 24 | not: parseAnyDef(refs), 25 | }, 26 | innerSchema, 27 | ], 28 | } 29 | : parseAnyDef(refs); 30 | }; 31 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/nativeEnum.ts: -------------------------------------------------------------------------------- 1 | import type { ZodNativeEnumDef } from "zod"; 2 | 3 | export type JsonSchema7NativeEnumType = { 4 | type: "string" | "number" | ["string", "number"]; 5 | enum: (string | number)[]; 6 | }; 7 | 8 | export function parseNativeEnumDef( 9 | def: ZodNativeEnumDef, 10 | ): JsonSchema7NativeEnumType { 11 | const object = def.values; 12 | const actualKeys = Object.keys(def.values).filter((key: string) => { 13 | return typeof object[object[key]] !== "number"; 14 | }); 15 | 16 | const actualValues = actualKeys.map((key: string) => object[key]); 17 | 18 | const parsedTypes = Array.from( 19 | new Set(actualValues.map((values: string | number) => typeof values)), 20 | ); 21 | 22 | return { 23 | type: 24 | parsedTypes.length === 1 25 | ? parsedTypes[0] === "string" 26 | ? "string" 27 | : "number" 28 | : ["string", "number"], 29 | enum: actualValues, 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/pipeline.ts: -------------------------------------------------------------------------------- 1 | import type { ZodPipelineDef } from "zod"; 2 | import { parseDef } from "../parseDef.js"; 3 | import type { JsonSchema7Type } from "../parseTypes.js"; 4 | import type { Refs } from "../Refs.js"; 5 | import type { JsonSchema7AllOfType } from "./intersection.js"; 6 | 7 | export const parsePipelineDef = ( 8 | def: ZodPipelineDef, 9 | refs: Refs, 10 | ): JsonSchema7AllOfType | JsonSchema7Type | undefined => { 11 | if (refs.pipeStrategy === "input") { 12 | return parseDef(def.in._def, refs); 13 | } else if (refs.pipeStrategy === "output") { 14 | return parseDef(def.out._def, refs); 15 | } 16 | 17 | const a = parseDef(def.in._def, { 18 | ...refs, 19 | currentPath: [...refs.currentPath, "allOf", "0"], 20 | }); 21 | const b = parseDef(def.out._def, { 22 | ...refs, 23 | currentPath: [...refs.currentPath, "allOf", a ? "1" : "0"], 24 | }); 25 | 26 | return { 27 | allOf: [a, b].filter((x): x is JsonSchema7Type => x !== undefined), 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Pretty much like the `instanceof` operator, but should work across different realms. Necessary for zod because some installations 3 | * might result in this library using the commonjs zod export, while the user's code uses the esm export. 4 | * https://github.com/mmkal/trpc-cli/issues/7 5 | * 6 | * Tradeoff: It's possible that this function will return false positives if the target class has the same name as an unrelated class in the current realm. 7 | * So, only use it for classes that are unlikely to have name conflicts like `ZodAbc` or `TRPCDef`. 8 | */ 9 | 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | export const looksLikeInstanceof = (value: unknown, target: string | (new (...args: any[]) => T)): value is T => { 12 | let current = value?.constructor 13 | while (current?.name) { 14 | if (current?.name === (typeof target === 'string' ? target : target.name)) return true 15 | current = Object.getPrototypeOf(current) as typeof current // parent class 16 | } 17 | return false 18 | } 19 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/literal.ts: -------------------------------------------------------------------------------- 1 | import type { ZodLiteralDef } from "zod"; 2 | import type { Refs } from "../Refs.js"; 3 | 4 | export type JsonSchema7LiteralType = 5 | | { 6 | type: "string" | "number" | "integer" | "boolean"; 7 | const: string | number | boolean; 8 | } 9 | | { 10 | type: "object" | "array"; 11 | }; 12 | 13 | export function parseLiteralDef( 14 | def: ZodLiteralDef, 15 | refs: Refs, 16 | ): JsonSchema7LiteralType { 17 | const parsedType = typeof def.value; 18 | if ( 19 | parsedType !== "bigint" && 20 | parsedType !== "number" && 21 | parsedType !== "boolean" && 22 | parsedType !== "string" 23 | ) { 24 | return { 25 | type: Array.isArray(def.value) ? "array" : "object", 26 | }; 27 | } 28 | 29 | if (refs.target === "openApi3") { 30 | return { 31 | type: parsedType === "bigint" ? "integer" : parsedType, 32 | enum: [def.value], 33 | } as any; 34 | } 35 | 36 | return { 37 | type: parsedType === "bigint" ? "integer" : parsedType, 38 | const: def.value, 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /test/fixtures/migra.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import * as trpcServer from '@trpc/server' 3 | import {z} from 'zod/v4' 4 | import {createCli, type TrpcCliMeta} from '../../src/index.js' 5 | 6 | const trpc = trpcServer.initTRPC.meta().create() 7 | 8 | const router = trpc.router({ 9 | migra: trpc.procedure 10 | .meta({description: 'Diff two schemas.', default: true}) 11 | .input( 12 | z.tuple([ 13 | z.string().describe('Base database URL'), // 14 | z.string().describe('Head database URL'), 15 | z.object({ 16 | unsafe: z.boolean().default(false).describe('Allow destructive commands'), 17 | }), 18 | ]), 19 | ) 20 | .query(async ({input: [base, head, opts]}) => { 21 | console.log('connecting to...', base) 22 | console.log('connecting to...', head) 23 | const statements = ['create table foo(id int)'] 24 | if (opts.unsafe) { 25 | statements.push('drop table bar') 26 | } 27 | return statements.join('\n') 28 | }), 29 | }) 30 | 31 | const cli = createCli({router}) 32 | void cli.run() 33 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/errorMessages.ts: -------------------------------------------------------------------------------- 1 | import type { JsonSchema7TypeUnion } from "./parseTypes.js"; 2 | import type { Refs } from "./Refs.js"; 3 | 4 | export type ErrorMessages< 5 | T extends JsonSchema7TypeUnion | { format: string } | { pattern: string }, 6 | OmitProperties extends string = "", 7 | > = Partial< 8 | Omit<{ [key in keyof T]: string }, OmitProperties | "type" | "errorMessages"> 9 | >; 10 | 11 | export function addErrorMessage< 12 | T extends { errorMessage?: ErrorMessages }, 13 | >(res: T, key: keyof T, errorMessage: string | undefined, refs: Refs) { 14 | if (!refs?.errorMessages) return; 15 | if (errorMessage) { 16 | res.errorMessage = { 17 | ...res.errorMessage, 18 | [key]: errorMessage, 19 | }; 20 | } 21 | } 22 | 23 | export function setResponseValueAndErrors< 24 | Json7Type extends JsonSchema7TypeUnion & { 25 | errorMessage?: ErrorMessages; 26 | }, 27 | Key extends keyof Omit, 28 | >( 29 | res: Json7Type, 30 | key: Key, 31 | value: Json7Type[Key], 32 | errorMessage: string | undefined, 33 | refs: Refs, 34 | ) { 35 | res[key] = value; 36 | addErrorMessage(res, key, errorMessage, refs); 37 | } 38 | -------------------------------------------------------------------------------- /test/fixtures/promptable.ts: -------------------------------------------------------------------------------- 1 | import * as prompts from '@inquirer/prompts' 2 | import * as trpcServer from '@trpc/server' 3 | import {z} from 'zod/v4' 4 | import {createCli, type TrpcCliMeta} from '../../src/index.js' 5 | 6 | const trpc = trpcServer.initTRPC.meta().create() 7 | 8 | const router = trpc.router({ 9 | challenge: trpc.router({ 10 | harshly: trpc.procedure 11 | .meta({ 12 | description: 'Challenge the user', 13 | }) 14 | .input( 15 | z.object({ 16 | why: z.string().describe('Why are you doing this?'), 17 | }), 18 | ) 19 | .query(({input}) => JSON.stringify(input)), 20 | gently: trpc.procedure 21 | .meta({ 22 | description: 'Check on the user', 23 | }) 24 | .input( 25 | z.object({ 26 | how: z.string().describe('How are you doing?'), 27 | }), 28 | ) 29 | .query(({input}) => JSON.stringify(input)), 30 | }), 31 | ingratiate: trpc.router({ 32 | modestly: trpc.procedure.query(() => 'nice to see you'), 33 | extravagantly: trpc.procedure.query(() => 'you are a sight for sore eyes'), 34 | }), 35 | }) 36 | 37 | void createCli({router}).run({prompts}) 38 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * You can end up with "require is not defined" errors when we do disguised eval require calls like: 3 | * ```ts 4 | * const disguisedEval = eval 5 | * disguisedEval(`require('foo')`) 6 | * ``` 7 | * 8 | * Seems vitest/vite try to helpfully handle all requires for us, but where we're doing that we just want to use the builtin require functionality. 9 | */ 10 | globalThis.require = require // you can end up with "require is not defined" errors when we do disguised `eval(`require('foo')`)` type calls 11 | 12 | // some of the tests snapshot `--help` output, which does "smart" line wrapping based on the width of the terminal. 13 | // this varies between CI and local machines, so set isTTY to false so commander does its (consistent) default wrapping behaviour. 14 | // https://github.com/tj/commander.js/blob/e6f56c888c96d1339c2b974fee7e6ba4f2e3d218/lib/command.js#L66 15 | 16 | // note: if this breaks some day, we could hook into the `Command` class and use `configureOutput` but easier to do globally for now. 17 | 18 | for (const stream of [process.stdout, process.stderr]) { 19 | stream.columns = Infinity // ridiculous value to make sure I notice if the below line is removed 20 | stream.isTTY = false // set to false to simulate CI behaviour and avoid snapshot failures 21 | } 22 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/ZodFirstPartyTypeKind.ts: -------------------------------------------------------------------------------- 1 | /** copy-pasted from zod v3, to minimize diff vs zod-to-json-schema */ 2 | export enum ZodFirstPartyTypeKind { 3 | ZodString = "ZodString", 4 | ZodNumber = "ZodNumber", 5 | ZodNaN = "ZodNaN", 6 | ZodBigInt = "ZodBigInt", 7 | ZodBoolean = "ZodBoolean", 8 | ZodDate = "ZodDate", 9 | ZodSymbol = "ZodSymbol", 10 | ZodUndefined = "ZodUndefined", 11 | ZodNull = "ZodNull", 12 | ZodAny = "ZodAny", 13 | ZodUnknown = "ZodUnknown", 14 | ZodNever = "ZodNever", 15 | ZodVoid = "ZodVoid", 16 | ZodArray = "ZodArray", 17 | ZodObject = "ZodObject", 18 | ZodUnion = "ZodUnion", 19 | ZodDiscriminatedUnion = "ZodDiscriminatedUnion", 20 | ZodIntersection = "ZodIntersection", 21 | ZodTuple = "ZodTuple", 22 | ZodRecord = "ZodRecord", 23 | ZodMap = "ZodMap", 24 | ZodSet = "ZodSet", 25 | ZodFunction = "ZodFunction", 26 | ZodLazy = "ZodLazy", 27 | ZodLiteral = "ZodLiteral", 28 | ZodEnum = "ZodEnum", 29 | ZodEffects = "ZodEffects", 30 | ZodNativeEnum = "ZodNativeEnum", 31 | ZodOptional = "ZodOptional", 32 | ZodNullable = "ZodNullable", 33 | ZodDefault = "ZodDefault", 34 | ZodCatch = "ZodCatch", 35 | ZodPromise = "ZodPromise", 36 | ZodBranded = "ZodBranded", 37 | ZodPipeline = "ZodPipeline", 38 | ZodReadonly = "ZodReadonly" 39 | } 40 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/set.ts: -------------------------------------------------------------------------------- 1 | import type { ZodSetDef } from "zod"; 2 | import { type ErrorMessages, setResponseValueAndErrors } from "../errorMessages.js"; 3 | import { parseDef } from "../parseDef.js"; 4 | import type { JsonSchema7Type } from "../parseTypes.js"; 5 | import type { Refs } from "../Refs.js"; 6 | 7 | export type JsonSchema7SetType = { 8 | type: "array"; 9 | uniqueItems: true; 10 | items?: JsonSchema7Type; 11 | minItems?: number; 12 | maxItems?: number; 13 | errorMessage?: ErrorMessages; 14 | }; 15 | 16 | export function parseSetDef(def: ZodSetDef, refs: Refs): JsonSchema7SetType { 17 | const items = parseDef(def.valueType._def, { 18 | ...refs, 19 | currentPath: [...refs.currentPath, "items"], 20 | }); 21 | 22 | const schema: JsonSchema7SetType = { 23 | type: "array", 24 | uniqueItems: true, 25 | items, 26 | }; 27 | 28 | if (def.minSize) { 29 | setResponseValueAndErrors( 30 | schema, 31 | "minItems", 32 | def.minSize.value, 33 | def.minSize.message, 34 | refs, 35 | ); 36 | } 37 | 38 | if (def.maxSize) { 39 | setResponseValueAndErrors( 40 | schema, 41 | "maxItems", 42 | def.maxSize.value, 43 | def.maxSize.message, 44 | refs, 45 | ); 46 | } 47 | 48 | return schema; 49 | } 50 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/map.ts: -------------------------------------------------------------------------------- 1 | import type { ZodMapDef } from "zod"; 2 | import { parseDef } from "../parseDef.js"; 3 | import type { JsonSchema7Type } from "../parseTypes.js"; 4 | import type { Refs } from "../Refs.js"; 5 | import { type JsonSchema7RecordType, parseRecordDef } from "./record.js"; 6 | import { parseAnyDef } from "./any.js"; 7 | 8 | export type JsonSchema7MapType = { 9 | type: "array"; 10 | maxItems: 125; 11 | items: { 12 | type: "array"; 13 | items: [JsonSchema7Type, JsonSchema7Type]; 14 | minItems: 2; 15 | maxItems: 2; 16 | }; 17 | }; 18 | 19 | export function parseMapDef( 20 | def: ZodMapDef, 21 | refs: Refs, 22 | ): JsonSchema7MapType | JsonSchema7RecordType { 23 | if (refs.mapStrategy === "record") { 24 | return parseRecordDef(def, refs); 25 | } 26 | 27 | const keys = 28 | parseDef(def.keyType._def, { 29 | ...refs, 30 | currentPath: [...refs.currentPath, "items", "items", "0"], 31 | }) || parseAnyDef(refs); 32 | const values = 33 | parseDef(def.valueType._def, { 34 | ...refs, 35 | currentPath: [...refs.currentPath, "items", "items", "1"], 36 | }) || parseAnyDef(refs); 37 | return { 38 | type: "array", 39 | maxItems: 125, 40 | items: { 41 | type: "array", 42 | items: [keys, values], 43 | minItems: 2, 44 | maxItems: 2, 45 | }, 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /test/types.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/unbound-method */ 2 | import {test, expectTypeOf} from 'vitest' 3 | import {z} from 'zod/v4' 4 | import {EnquirerLike, InquirerPromptsLike, Promptable} from '../src/index.js' 5 | 6 | test('prompt types', async () => { 7 | expectTypeOf().toExtend() 8 | expectTypeOf().toExtend() 9 | 10 | expectTypeOf().toExtend() 11 | expectTypeOf().toExtend() 12 | }) 13 | 14 | test('zod meta', async () => { 15 | expectTypeOf(z.string()) 16 | .toHaveProperty('meta') 17 | .parameter(0) 18 | .exclude() 19 | .toHaveProperty('positional') 20 | .toEqualTypeOf() 21 | expectTypeOf(z.string()) 22 | .toHaveProperty('meta') 23 | .parameter(0) 24 | .exclude() 25 | .toHaveProperty('alias') 26 | .toEqualTypeOf() 27 | 28 | expectTypeOf(z.string().meta).toBeCallableWith({positional: true, alias: 'a'}) 29 | // @ts-expect-error - this is a type error 30 | expectTypeOf(z.string().meta).toBeCallableWith({positional: 1, alias: 'a'}) 31 | // @ts-expect-error - this is a type error 32 | expectTypeOf(z.string().meta).toBeCallableWith({positional: true, alias: true}) 33 | }) 34 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/Refs.ts: -------------------------------------------------------------------------------- 1 | import type { ZodTypeDef } from "zod"; 2 | import { getDefaultOptions, type Options, type Targets } from "./Options.js"; 3 | import type { JsonSchema7Type } from "./parseTypes.js"; 4 | 5 | export type Refs = { 6 | seen: Map; 7 | currentPath: string[]; 8 | propertyPath: string[] | undefined; 9 | flags: { hasReferencedOpenAiAnyType: boolean }; 10 | } & Options; 11 | 12 | export type Seen = { 13 | def: ZodTypeDef; 14 | path: string[]; 15 | jsonSchema: JsonSchema7Type | undefined; 16 | }; 17 | 18 | export const getRefs = (options?: string | Partial>): Refs => { 19 | const _options = getDefaultOptions(options); 20 | const currentPath = 21 | _options.name !== undefined 22 | ? [..._options.basePath, _options.definitionPath, _options.name] 23 | : _options.basePath; 24 | return { 25 | ..._options, 26 | flags: { hasReferencedOpenAiAnyType: false }, 27 | currentPath: currentPath, 28 | propertyPath: undefined, 29 | seen: new Map( 30 | Object.entries(_options.definitions).map(([name, def]) => [ 31 | def._def, 32 | { 33 | def: def._def, 34 | path: [..._options.basePath, _options.definitionPath, name], 35 | // Resolution of references will be forced even though seen, so it's ok that the schema is undefined here for now. 36 | jsonSchema: undefined, 37 | }, 38 | ]), 39 | ), 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /readme-codegen.ts: -------------------------------------------------------------------------------- 1 | import {createHash} from 'crypto' 2 | import {execaCommandSync} from 'execa' 3 | import stripAnsi from 'strip-ansi' 4 | 5 | export const command: import('eslint-plugin-mmkal').CodegenPreset<{command: string; reject?: false}> = ({ 6 | options, 7 | meta, 8 | }) => { 9 | const result = execaCommandSync(options.command, {all: true, reject: options.reject}) 10 | if (!stripAnsi(result.all)) throw new Error(`Command ${options.command} had no output`) 11 | const output = [ 12 | `\`${options.command.replace(/.* test\/fixtures\//, 'node path/to/')}\` output:`, 13 | '', 14 | '```', 15 | stripAnsi(result.all), // includes stderr 16 | '```', 17 | ].join('\n') 18 | 19 | const noWhitespace = (s: string) => s.replaceAll(/\s+/g, '') 20 | 21 | if (noWhitespace(output) === noWhitespace(meta.existingContent)) { 22 | return meta.existingContent 23 | } 24 | 25 | return output 26 | } 27 | 28 | export const dump: import('eslint-plugin-mmkal').CodegenPreset<{file: string}> = ({dependencies, options, meta}) => { 29 | const content = dependencies.fs.readFileSync(options.file, 'utf8').replaceAll(/'(\.\.\/)+src'/g, `'trpc-cli'`) 30 | const hash = createHash('md5').update(content).digest('hex') 31 | const header = `` 32 | if (meta.existingContent.includes(header)) { 33 | return meta.existingContent // eslint-plugin-markdown "prettifies" the content - if the input hash is the same, let it be. 34 | } 35 | return [header, '```' + options.file.split('.').pop(), content, '```'].join('\n') 36 | } 37 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Options.js"; 2 | export * from "./Refs.js"; 3 | export * from "./errorMessages.js"; 4 | export * from "./getRelativePath.js"; 5 | export * from "./parseDef.js"; 6 | export * from "./parseTypes.js"; 7 | export * from "./parsers/any.js"; 8 | export * from "./parsers/array.js"; 9 | export * from "./parsers/bigint.js"; 10 | export * from "./parsers/boolean.js"; 11 | export * from "./parsers/branded.js"; 12 | export * from "./parsers/catch.js"; 13 | export * from "./parsers/date.js"; 14 | export * from "./parsers/default.js"; 15 | export * from "./parsers/effects.js"; 16 | export * from "./parsers/enum.js"; 17 | export * from "./parsers/intersection.js"; 18 | export * from "./parsers/literal.js"; 19 | export * from "./parsers/map.js"; 20 | export * from "./parsers/nativeEnum.js"; 21 | export * from "./parsers/never.js"; 22 | export * from "./parsers/null.js"; 23 | export * from "./parsers/nullable.js"; 24 | export * from "./parsers/number.js"; 25 | export * from "./parsers/object.js"; 26 | export * from "./parsers/optional.js"; 27 | export * from "./parsers/pipeline.js"; 28 | export * from "./parsers/promise.js"; 29 | export * from "./parsers/readonly.js"; 30 | export * from "./parsers/record.js"; 31 | export * from "./parsers/set.js"; 32 | export * from "./parsers/string.js"; 33 | export * from "./parsers/tuple.js"; 34 | export * from "./parsers/undefined.js"; 35 | export * from "./parsers/union.js"; 36 | export * from "./parsers/unknown.js"; 37 | export * from "./selectParser.js"; 38 | export * from "./zodToJsonSchema.js"; 39 | import { zodToJsonSchema } from "./zodToJsonSchema.js"; 40 | export default zodToJsonSchema -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/tuple.ts: -------------------------------------------------------------------------------- 1 | import type { ZodTupleDef, ZodTupleItems, ZodTypeAny } from "zod"; 2 | import { parseDef } from "../parseDef.js"; 3 | import type { JsonSchema7Type } from "../parseTypes.js"; 4 | import type { Refs } from "../Refs.js"; 5 | 6 | export type JsonSchema7TupleType = { 7 | type: "array"; 8 | minItems: number; 9 | items: JsonSchema7Type[]; 10 | } & ( 11 | | { 12 | maxItems: number; 13 | } 14 | | { 15 | additionalItems?: JsonSchema7Type; 16 | } 17 | ); 18 | 19 | export function parseTupleDef( 20 | def: ZodTupleDef, 21 | refs: Refs, 22 | ): JsonSchema7TupleType { 23 | if (def.rest) { 24 | return { 25 | type: "array", 26 | minItems: def.items.length, 27 | items: def.items 28 | .map((x, i) => 29 | parseDef(x._def, { 30 | ...refs, 31 | currentPath: [...refs.currentPath, "items", `${i}`], 32 | }), 33 | ) 34 | .reduce( 35 | (acc: JsonSchema7Type[], x) => (x === undefined ? acc : [...acc, x]), 36 | [], 37 | ), 38 | additionalItems: parseDef(def.rest._def, { 39 | ...refs, 40 | currentPath: [...refs.currentPath, "additionalItems"], 41 | }), 42 | }; 43 | } else { 44 | return { 45 | type: "array", 46 | minItems: def.items.length, 47 | maxItems: def.items.length, 48 | items: def.items 49 | .map((x, i) => 50 | parseDef(x._def, { 51 | ...refs, 52 | currentPath: [...refs.currentPath, "items", `${i}`], 53 | }), 54 | ) 55 | .reduce( 56 | (acc: JsonSchema7Type[], x) => (x === undefined ? acc : [...acc, x]), 57 | [], 58 | ), 59 | }; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/array.ts: -------------------------------------------------------------------------------- 1 | import {ZodFirstPartyTypeKind} from "../ZodFirstPartyTypeKind.js"; 2 | import type { ZodArrayDef } from "zod"; 3 | import { type ErrorMessages, setResponseValueAndErrors } from "../errorMessages.js"; 4 | import { parseDef } from "../parseDef.js"; 5 | import type { JsonSchema7Type } from "../parseTypes.js"; 6 | import type { Refs } from "../Refs.js"; 7 | 8 | export type JsonSchema7ArrayType = { 9 | type: "array"; 10 | items?: JsonSchema7Type; 11 | minItems?: number; 12 | maxItems?: number; 13 | errorMessages?: ErrorMessages; 14 | }; 15 | 16 | export function parseArrayDef(def: ZodArrayDef, refs: Refs) { 17 | const res: JsonSchema7ArrayType = { 18 | type: "array", 19 | }; 20 | if ( 21 | def.type?._def && 22 | def.type?._def?.typeName !== ZodFirstPartyTypeKind.ZodAny 23 | ) { 24 | res.items = parseDef(def.type._def, { 25 | ...refs, 26 | currentPath: [...refs.currentPath, "items"], 27 | }); 28 | } 29 | 30 | if (def.minLength) { 31 | setResponseValueAndErrors( 32 | res, 33 | "minItems", 34 | def.minLength.value, 35 | def.minLength.message, 36 | refs, 37 | ); 38 | } 39 | if (def.maxLength) { 40 | setResponseValueAndErrors( 41 | res, 42 | "maxItems", 43 | def.maxLength.value, 44 | def.maxLength.message, 45 | refs, 46 | ); 47 | } 48 | if (def.exactLength) { 49 | setResponseValueAndErrors( 50 | res, 51 | "minItems", 52 | def.exactLength.value, 53 | def.exactLength.message, 54 | refs, 55 | ); 56 | setResponseValueAndErrors( 57 | res, 58 | "maxItems", 59 | def.exactLength.value, 60 | def.exactLength.message, 61 | refs, 62 | ); 63 | } 64 | return res; 65 | } 66 | -------------------------------------------------------------------------------- /src/logging.ts: -------------------------------------------------------------------------------- 1 | import {Log, Logger} from './types.js' 2 | 3 | export const lineByLineLogger = getLoggerTransformer(log => { 4 | /** 5 | * @param args values to log. if `logger.info('a', 1)` is called, `args` will be `['a', 1]` 6 | * @param depth tracks whether the current call recursive. Used to make sure we don't flatten nested arrays 7 | */ 8 | const wrapper = (args: unknown[], depth: number) => { 9 | if (args.length === 1 && Array.isArray(args[0]) && depth === 0) { 10 | args[0].forEach(item => wrapper([item], 1)) 11 | } else if (args.every(isPrimitive)) { 12 | log(...args) 13 | } else if (args.length === 1) { 14 | log(JSON.stringify(args[0], null, 2)) 15 | } else { 16 | log(JSON.stringify(args, null, 2)) 17 | } 18 | } 19 | 20 | return (...args) => wrapper(args, 0) 21 | }) 22 | 23 | const isPrimitive = (value: unknown): value is string | number | boolean => { 24 | const type = typeof value 25 | return type === 'string' || type === 'number' || type === 'boolean' 26 | } 27 | 28 | /** Takes a function that wraps an individual log function, and returns a function that wraps the `info` and `error` functions for a logger */ 29 | function getLoggerTransformer(transform: (log: Log) => Log) { 30 | return (logger: Logger): Logger => { 31 | const info = logger.info && transform(logger.info) 32 | const error = logger.error && transform(logger.error) 33 | return {info, error} 34 | } 35 | } 36 | 37 | /** 38 | * A logger which uses `console.log` and `console.error` to log in the following way: 39 | * - Primitives are logged directly 40 | * - Arrays are logged item-by-item 41 | * - Objects are logged as JSON 42 | * 43 | * This is useful for logging structured data in a human-readable way, and for piping logs to other tools. 44 | */ 45 | export const lineByLineConsoleLogger = lineByLineLogger(console) 46 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/nullable.ts: -------------------------------------------------------------------------------- 1 | import type { ZodNullableDef } from "zod"; 2 | import { parseDef } from "../parseDef.js"; 3 | import type { JsonSchema7Type } from "../parseTypes.js"; 4 | import type { Refs } from "../Refs.js"; 5 | import type { JsonSchema7NullType } from "./null.js"; 6 | import { primitiveMappings } from "./union.js"; 7 | 8 | export type JsonSchema7NullableType = 9 | | { 10 | anyOf: [JsonSchema7Type, JsonSchema7NullType]; 11 | } 12 | | { 13 | type: [string, "null"]; 14 | }; 15 | 16 | export function parseNullableDef( 17 | def: ZodNullableDef, 18 | refs: Refs, 19 | ): JsonSchema7NullableType | undefined { 20 | if ( 21 | ["ZodString", "ZodNumber", "ZodBigInt", "ZodBoolean", "ZodNull"].includes( 22 | def.innerType._def.typeName, 23 | ) && 24 | (!def.innerType._def.checks || !def.innerType._def.checks.length) 25 | ) { 26 | if (refs.target === "openApi3") { 27 | return { 28 | type: primitiveMappings[ 29 | def.innerType._def.typeName as keyof typeof primitiveMappings 30 | ], 31 | nullable: true, 32 | } as any; 33 | } 34 | 35 | return { 36 | type: [ 37 | primitiveMappings[ 38 | def.innerType._def.typeName as keyof typeof primitiveMappings 39 | ], 40 | "null", 41 | ], 42 | }; 43 | } 44 | 45 | if (refs.target === "openApi3") { 46 | const base = parseDef(def.innerType._def, { 47 | ...refs, 48 | currentPath: [...refs.currentPath], 49 | }); 50 | 51 | if (base && "$ref" in base) return { allOf: [base], nullable: true } as any; 52 | 53 | return base && ({ ...base, nullable: true } as any); 54 | } 55 | 56 | const base = parseDef(def.innerType._def, { 57 | ...refs, 58 | currentPath: [...refs.currentPath, "anyOf", "0"], 59 | }); 60 | 61 | return base && { anyOf: [base, { type: "null" }] }; 62 | } 63 | -------------------------------------------------------------------------------- /src/proxify.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-return */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 3 | /* eslint-disable @typescript-eslint/no-explicit-any */ 4 | /* eslint-disable @typescript-eslint/no-unsafe-argument */ 5 | import {initTRPC} from '@trpc/server' 6 | import {StandardSchemaV1} from './standard-schema/contract.js' 7 | import {AnyProcedure, AnyRouter} from './trpc-compat.js' 8 | 9 | /** 10 | * EXPERIMENTAL: Don't use unless you're willing to help figure out the API, and whether it should even exist. 11 | * See description in https://github.com/mmkal/trpc-cli/pull/153 12 | */ 13 | export const proxify = (router: R, getClient: (procedurePath: string) => unknown) => { 14 | const trpc = initTRPC.create() 15 | const outputRouterRecord = {} 16 | const entries = Object.entries((router as any)._def.procedures) 17 | for (const [procedurePath, oldProc] of entries) { 18 | const parts = procedurePath.split('.') 19 | let currentRouter: any = outputRouterRecord 20 | for (const part of parts.slice(0, -1)) { 21 | currentRouter = currentRouter[part] ||= {} 22 | } 23 | let newProc: any = trpc.procedure 24 | 25 | const inputs = oldProc._def.inputs as StandardSchemaV1[] 26 | 27 | inputs?.forEach(input => { 28 | newProc = newProc.input(input) 29 | }) 30 | if (oldProc._def.type === 'query') { 31 | newProc = newProc.query(async ({input}: any) => { 32 | const client: any = await getClient(procedurePath) 33 | return client[procedurePath].query(input) 34 | }) 35 | } else if (oldProc._def.type === 'mutation') { 36 | newProc = newProc.mutation(async ({input}: any) => { 37 | const client: any = await getClient(procedurePath) 38 | return client[procedurePath].mutate(input) 39 | }) 40 | } 41 | 42 | currentRouter[parts[parts.length - 1]] = newProc 43 | } 44 | 45 | return trpc.router(outputRouterRecord) as unknown as R 46 | } 47 | -------------------------------------------------------------------------------- /src/standard-schema/errors.ts: -------------------------------------------------------------------------------- 1 | import {StandardSchemaV1} from './contract.js' 2 | import {looksLikeStandardSchemaFailure} from './utils.js' 3 | 4 | export const prettifyStandardSchemaError = (error: unknown): string | null => { 5 | if (!looksLikeStandardSchemaFailure(error)) return null 6 | 7 | const issues = [...error.issues] 8 | .map(issue => { 9 | const path = issue.path || [] 10 | const primitivePathSegments = path.map(segment => { 11 | if (typeof segment === 'string' || typeof segment === 'number' || typeof segment === 'symbol') return segment 12 | return segment.key 13 | }) 14 | const dotPath = toDotPath(primitivePathSegments) 15 | return { 16 | issue, 17 | path, 18 | primitivePathSegments, 19 | dotPath, 20 | } 21 | }) 22 | .sort((a, b) => a.path.length - b.path.length) 23 | 24 | const lines: string[] = [] 25 | 26 | for (const {issue, dotPath} of issues) { 27 | let message = `✖ ${issue.message}` 28 | if (dotPath) message += ` → at ${dotPath}` 29 | lines.push(message) 30 | } 31 | 32 | return lines.join('\n') 33 | } 34 | 35 | export function toDotPath(path: (string | number | symbol)[]): string { 36 | const segs: string[] = [] 37 | for (const seg of path) { 38 | if (typeof seg === 'number') segs.push(`[${seg}]`) 39 | else if (typeof seg === 'symbol') segs.push(`[${JSON.stringify(String(seg))}]`) 40 | else if (/[^\w$]/.test(seg)) segs.push(`[${JSON.stringify(seg)}]`) 41 | else { 42 | if (segs.length) segs.push('.') 43 | segs.push(seg) 44 | } 45 | } 46 | 47 | return segs.join('') 48 | } 49 | 50 | export class StandardSchemaV1Error extends Error implements StandardSchemaV1.FailureResult { 51 | issues: StandardSchemaV1.FailureResult['issues'] 52 | constructor(failure: StandardSchemaV1.FailureResult, options?: {cause?: Error}) { 53 | super('Standard Schema error - details in `issues`.', options) 54 | this.issues = failure.issues 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/completions.ts: -------------------------------------------------------------------------------- 1 | import {OmeletteInstanceLike} from './index.js' 2 | import {Command} from 'commander' 3 | import type omelette from 'omelette' 4 | 5 | /** uses omelette to add completions to a commander program */ 6 | export function addCompletions(program: Command, completion: OmeletteInstanceLike) { 7 | const commandSymbol = Symbol('command') 8 | 9 | type TreeNode = omelette.TreeValue & {[commandSymbol]?: Command} 10 | const cTree = {} as TreeNode 11 | function addCommandCompletions(command: Command, cTreeNode: TreeNode) { 12 | command.commands.forEach(c => { 13 | const node = (cTreeNode[c.name()] ||= {}) as TreeNode 14 | Object.defineProperty(node, commandSymbol, {value: c, enumerable: false}) 15 | addCommandCompletions(c, node) 16 | }) 17 | } 18 | 19 | addCommandCompletions(program, cTree) 20 | 21 | completion.on('complete', (fragment, params) => { 22 | const segments = params.line.split(/ +/).slice(1, params.fragment) 23 | const last = segments.at(-1) 24 | let node = cTree 25 | const existingFlags = new Set() 26 | for (const segment of segments) { 27 | if (segment.startsWith('-')) { 28 | existingFlags.add(segment) 29 | continue 30 | } 31 | 32 | if (existingFlags.size > 0) continue 33 | node = node[segment] as TreeNode 34 | if (!node) return 35 | } 36 | const correspondingCommand = node[commandSymbol] 37 | if (correspondingCommand?.options?.length) { 38 | const suggestions: string[] = [] 39 | for (const o of correspondingCommand.options) { 40 | if (last === o.long || last === o.short) { 41 | if (o.argChoices) suggestions.push(...o.argChoices) 42 | if (!o.isBoolean()) break 43 | } 44 | 45 | if (existingFlags.has(o.long!)) continue 46 | if (existingFlags.has(o.short!)) continue 47 | 48 | suggestions.push(o.long!) 49 | } 50 | return void params.reply(suggestions) 51 | } 52 | }) 53 | 54 | completion.tree(cTree as {}).init() 55 | } 56 | -------------------------------------------------------------------------------- /test/proxy.test.ts: -------------------------------------------------------------------------------- 1 | import {createTRPCClient, httpLink} from '@trpc/client' 2 | import {initTRPC} from '@trpc/server' 3 | import {createHTTPServer} from '@trpc/server/adapters/standalone' 4 | import {afterAll, beforeAll, expect, test} from 'vitest' 5 | import {z} from 'zod' 6 | import {TrpcCliMeta} from '../src/index.js' 7 | import {proxify} from '../src/proxify.js' 8 | import {run} from './test-run.js' 9 | 10 | const t = initTRPC.meta().create() 11 | 12 | const router = t.router({ 13 | greeting: t.procedure 14 | .input( 15 | z.object({ 16 | name: z.string(), 17 | }), 18 | ) 19 | .query(({input}) => `Hello ${input.name}`), 20 | deeply: { 21 | nested: { 22 | farewell: t.procedure 23 | .input( 24 | z.object({ 25 | name: z.string(), 26 | }), 27 | ) 28 | .query(({input}) => `Goodbye ${input.name}`), 29 | }, 30 | }, 31 | }) 32 | 33 | const runServer = async () => { 34 | const server = createHTTPServer({router}) 35 | server.listen(7500) 36 | 37 | const client = createTRPCClient({ 38 | links: [httpLink({url: 'http://localhost:7500'})], 39 | }) 40 | for (let i = 0; i <= 10; i++) { 41 | const success = await client.greeting.query({name: 'Bob'}).then( 42 | r => !!r, 43 | () => false, 44 | ) 45 | if (success) break 46 | if (i === 10) throw new Error('Failed to connect to server') 47 | if (!success) continue 48 | } 49 | 50 | return server 51 | } 52 | 53 | let server: Awaited> 54 | 55 | beforeAll(async () => { 56 | server = await runServer() 57 | }) 58 | 59 | afterAll(async () => { 60 | server.close() 61 | }) 62 | 63 | test('proxy', async () => { 64 | const proxiedRouter = proxify(router, async () => { 65 | return createTRPCClient({ 66 | links: [httpLink({url: 'http://localhost:7500'})], 67 | }) 68 | }) 69 | expect(await run(proxiedRouter, ['greeting', '--name', 'Bob'])).toMatchInlineSnapshot(`"Hello Bob"`) 70 | expect(await run(proxiedRouter, ['deeply', 'nested', 'farewell', '--name', 'Bob'])).toMatchInlineSnapshot( 71 | `"Goodbye Bob"`, 72 | ) 73 | }) 74 | -------------------------------------------------------------------------------- /test/fixtures/calculator.ts: -------------------------------------------------------------------------------- 1 | import * as trpcServer from '@trpc/server' 2 | import {z} from 'zod/v4' 3 | import {createCli, type TrpcCliMeta} from '../../src/index.js' 4 | 5 | const trpc = trpcServer.initTRPC.meta().create() 6 | 7 | const router = trpc.router({ 8 | add: trpc.procedure 9 | .meta({ 10 | description: 11 | '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.', 12 | }) 13 | .input(z.tuple([z.number(), z.number()])) 14 | .query(({input}) => input[0] + input[1]), 15 | subtract: trpc.procedure 16 | .meta({ 17 | description: 'Subtract two numbers. Useful if you have a number and you want to make it smaller.', 18 | }) 19 | .input(z.tuple([z.number(), z.number()])) 20 | .query(({input}) => input[0] - input[1]), 21 | multiply: trpc.procedure 22 | .meta({ 23 | description: 24 | 'Multiply two numbers together. Useful if you want to count the number of tiles on your bathroom wall and are short on time.', 25 | }) 26 | .input(z.tuple([z.number(), z.number()])) 27 | .query(({input}) => input[0] * input[1]), 28 | divide: trpc.procedure 29 | .meta({ 30 | version: '1.0.0', 31 | description: 32 | "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.", 33 | examples: 'divide --left 8 --right 4', 34 | }) 35 | .input( 36 | z.tuple([ 37 | z.number().describe('numerator'), 38 | z 39 | .number() 40 | .refine(n => n !== 0) 41 | .describe('denominator'), 42 | ]), 43 | ) 44 | .mutation(({input}) => input[0] / input[1]), 45 | squareRoot: trpc.procedure 46 | .meta({ 47 | description: 48 | 'Square root of a number. Useful if you have a square, know the area, and want to find the length of the side.', 49 | }) 50 | .input(z.number()) 51 | .query(({input}) => { 52 | if (input < 0) throw new Error(`Get real`) 53 | return Math.sqrt(input) 54 | }), 55 | }) 56 | 57 | void createCli({router, name: 'calculator', version: '1.0.0'}).run() 58 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/date.ts: -------------------------------------------------------------------------------- 1 | import type { ZodDateDef } from "zod"; 2 | import type { Refs } from "../Refs.js"; 3 | import { type ErrorMessages, setResponseValueAndErrors } from "../errorMessages.js"; 4 | import type { JsonSchema7NumberType } from "./number.js"; 5 | import type { DateStrategy } from "../Options.js"; 6 | 7 | export type JsonSchema7DateType = 8 | | { 9 | type: "integer" | "string"; 10 | format: "unix-time" | "date-time" | "date"; 11 | minimum?: number; 12 | maximum?: number; 13 | errorMessage?: ErrorMessages; 14 | } 15 | | { 16 | anyOf: JsonSchema7DateType[]; 17 | }; 18 | 19 | export function parseDateDef( 20 | def: ZodDateDef, 21 | refs: Refs, 22 | overrideDateStrategy?: DateStrategy, 23 | ): JsonSchema7DateType { 24 | const strategy = overrideDateStrategy ?? refs.dateStrategy; 25 | 26 | if (Array.isArray(strategy)) { 27 | return { 28 | anyOf: strategy.map((item, i) => parseDateDef(def, refs, item)), 29 | }; 30 | } 31 | 32 | switch (strategy) { 33 | case "string": 34 | case "format:date-time": 35 | return { 36 | type: "string", 37 | format: "date-time", 38 | }; 39 | case "format:date": 40 | return { 41 | type: "string", 42 | format: "date", 43 | }; 44 | case "integer": 45 | return integerDateParser(def, refs); 46 | } 47 | } 48 | 49 | const integerDateParser = (def: ZodDateDef, refs: Refs) => { 50 | const res: JsonSchema7DateType = { 51 | type: "integer", 52 | format: "unix-time", 53 | }; 54 | 55 | if (refs.target === "openApi3") { 56 | return res; 57 | } 58 | 59 | for (const check of def.checks) { 60 | switch (check.kind) { 61 | case "min": 62 | setResponseValueAndErrors( 63 | res, 64 | "minimum", 65 | check.value, // This is in milliseconds 66 | check.message, 67 | refs, 68 | ); 69 | break; 70 | case "max": 71 | setResponseValueAndErrors( 72 | res, 73 | "maximum", 74 | check.value, // This is in milliseconds 75 | check.message, 76 | refs, 77 | ); 78 | break; 79 | } 80 | } 81 | 82 | return res; 83 | }; 84 | -------------------------------------------------------------------------------- /test/fixtures/fs.ts: -------------------------------------------------------------------------------- 1 | import * as trpcServer from '@trpc/server' 2 | import {z} from 'zod/v4' 3 | import {createCli, type TrpcCliMeta} from '../../src/index.js' 4 | 5 | const trpc = trpcServer.initTRPC.meta().create() 6 | 7 | const fakeFileSystem = getFakeFileSystem() 8 | 9 | const router = trpc.router({ 10 | copy: trpc.procedure 11 | .input( 12 | z.tuple([ 13 | z.string().describe('Source path'), // 14 | z.string().nullish().describe('Destination path'), 15 | z.object({ 16 | force: z.boolean().optional().default(false).describe('Overwrite destination if it exists'), 17 | }), 18 | ]), 19 | ) 20 | .mutation(async ({input: [source, destination = `${source}.copy`, options]}) => { 21 | // ...copy logic... 22 | return {source, destination, options} 23 | }), 24 | diff: trpc.procedure 25 | .input( 26 | z.tuple([ 27 | z.enum(['one', 'two', 'three', 'four']).describe('Base path'), 28 | z.enum(['one', 'two', 'three', 'four']).describe('Head path'), 29 | z.object({ 30 | ignoreWhitespace: z.boolean().optional().default(false).describe('Ignore whitespace changes'), 31 | trim: z.boolean().optional().default(false).describe('Trim start/end whitespace'), 32 | }), 33 | ]), 34 | ) 35 | .query(async ({input: [base, head, options]}) => { 36 | const [left, right] = [base, head].map(path => { 37 | let content = fakeFileSystem[path] 38 | if (options?.trim) content = content.trim() 39 | if (options?.ignoreWhitespace) content = content.replaceAll(/\s/g, '') 40 | return content 41 | }) 42 | 43 | if (left === right) return null 44 | if (left.length !== right.length) return `base has length ${left.length} and head has length ${right.length}` 45 | const firstDiffIndex = left.split('').findIndex((char, i) => char !== right[i]) 46 | return `base and head differ at index ${firstDiffIndex} (${JSON.stringify(left[firstDiffIndex])} !== ${JSON.stringify(right[firstDiffIndex])})` 47 | }), 48 | }) 49 | 50 | function getFakeFileSystem(): Record { 51 | return { 52 | one: 'a,b,c', 53 | two: 'a,b,c', 54 | three: 'x,y,z', 55 | four: 'x,y,z ', 56 | } 57 | } 58 | 59 | void createCli({router}).run() 60 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parseTypes.ts: -------------------------------------------------------------------------------- 1 | import type { JsonSchema7AnyType } from "./parsers/any.js"; 2 | import type { JsonSchema7ArrayType } from "./parsers/array.js"; 3 | import type { JsonSchema7BigintType } from "./parsers/bigint.js"; 4 | import type { JsonSchema7BooleanType } from "./parsers/boolean.js"; 5 | import type { JsonSchema7DateType } from "./parsers/date.js"; 6 | import type { JsonSchema7EnumType } from "./parsers/enum.js"; 7 | import type { JsonSchema7AllOfType } from "./parsers/intersection.js"; 8 | import type { JsonSchema7LiteralType } from "./parsers/literal.js"; 9 | import type { JsonSchema7MapType } from "./parsers/map.js"; 10 | import type { JsonSchema7NativeEnumType } from "./parsers/nativeEnum.js"; 11 | import type { JsonSchema7NeverType } from "./parsers/never.js"; 12 | import type { JsonSchema7NullType } from "./parsers/null.js"; 13 | import type { JsonSchema7NullableType } from "./parsers/nullable.js"; 14 | import type { JsonSchema7NumberType } from "./parsers/number.js"; 15 | import type { JsonSchema7ObjectType } from "./parsers/object.js"; 16 | import type { JsonSchema7RecordType } from "./parsers/record.js"; 17 | import type { JsonSchema7SetType } from "./parsers/set.js"; 18 | import type { JsonSchema7StringType } from "./parsers/string.js"; 19 | import type { JsonSchema7TupleType } from "./parsers/tuple.js"; 20 | import type { JsonSchema7UndefinedType } from "./parsers/undefined.js"; 21 | import type { JsonSchema7UnionType } from "./parsers/union.js"; 22 | import type { JsonSchema7UnknownType } from "./parsers/unknown.js"; 23 | 24 | type JsonSchema7RefType = { $ref: string }; 25 | type JsonSchema7Meta = { 26 | title?: string; 27 | default?: any; 28 | description?: string; 29 | markdownDescription?: string; 30 | }; 31 | 32 | export type JsonSchema7TypeUnion = 33 | | JsonSchema7StringType 34 | | JsonSchema7ArrayType 35 | | JsonSchema7NumberType 36 | | JsonSchema7BigintType 37 | | JsonSchema7BooleanType 38 | | JsonSchema7DateType 39 | | JsonSchema7EnumType 40 | | JsonSchema7LiteralType 41 | | JsonSchema7NativeEnumType 42 | | JsonSchema7NullType 43 | | JsonSchema7NumberType 44 | | JsonSchema7ObjectType 45 | | JsonSchema7RecordType 46 | | JsonSchema7TupleType 47 | | JsonSchema7UnionType 48 | | JsonSchema7UndefinedType 49 | | JsonSchema7RefType 50 | | JsonSchema7NeverType 51 | | JsonSchema7MapType 52 | | JsonSchema7AnyType 53 | | JsonSchema7NullableType 54 | | JsonSchema7AllOfType 55 | | JsonSchema7UnknownType 56 | | JsonSchema7SetType; 57 | 58 | export type JsonSchema7Type = JsonSchema7TypeUnion & JsonSchema7Meta; 59 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/intersection.ts: -------------------------------------------------------------------------------- 1 | import type { ZodIntersectionDef } from "zod"; 2 | import { parseDef } from "../parseDef.js"; 3 | import type { JsonSchema7Type } from "../parseTypes.js"; 4 | import type { Refs } from "../Refs.js"; 5 | import type { JsonSchema7StringType } from "./string.js"; 6 | 7 | export type JsonSchema7AllOfType = { 8 | allOf: JsonSchema7Type[]; 9 | unevaluatedProperties?: boolean; 10 | }; 11 | 12 | const isJsonSchema7AllOfType = ( 13 | type: JsonSchema7Type | JsonSchema7StringType, 14 | ): type is JsonSchema7AllOfType => { 15 | if ("type" in type && type.type === "string") return false; 16 | return "allOf" in type; 17 | }; 18 | 19 | export function parseIntersectionDef( 20 | def: ZodIntersectionDef, 21 | refs: Refs, 22 | ): JsonSchema7AllOfType | JsonSchema7Type | undefined { 23 | const allOf = [ 24 | parseDef(def.left._def, { 25 | ...refs, 26 | currentPath: [...refs.currentPath, "allOf", "0"], 27 | }), 28 | parseDef(def.right._def, { 29 | ...refs, 30 | currentPath: [...refs.currentPath, "allOf", "1"], 31 | }), 32 | ].filter((x): x is JsonSchema7Type => !!x); 33 | 34 | let unevaluatedProperties: 35 | | Pick 36 | | undefined = 37 | refs.target === "jsonSchema2019-09" 38 | ? { unevaluatedProperties: false } 39 | : undefined; 40 | 41 | const mergedAllOf: JsonSchema7Type[] = []; 42 | // If either of the schemas is an allOf, merge them into a single allOf 43 | allOf.forEach((schema) => { 44 | if (isJsonSchema7AllOfType(schema)) { 45 | mergedAllOf.push(...schema.allOf); 46 | if (schema.unevaluatedProperties === undefined) { 47 | // If one of the schemas has no unevaluatedProperties set, 48 | // the merged schema should also have no unevaluatedProperties set 49 | unevaluatedProperties = undefined; 50 | } 51 | } else { 52 | let nestedSchema: JsonSchema7Type = schema; 53 | if ( 54 | "additionalProperties" in schema && 55 | schema.additionalProperties === false 56 | ) { 57 | const { additionalProperties, ...rest } = schema; 58 | nestedSchema = rest; 59 | } else { 60 | // As soon as one of the schemas has additionalProperties set not to false, we allow unevaluatedProperties 61 | unevaluatedProperties = undefined; 62 | } 63 | mergedAllOf.push(nestedSchema); 64 | } 65 | }); 66 | return mergedAllOf.length 67 | ? { 68 | allOf: mergedAllOf, 69 | ...unevaluatedProperties, 70 | } 71 | : undefined; 72 | } 73 | -------------------------------------------------------------------------------- /test/logging.test.ts: -------------------------------------------------------------------------------- 1 | import {beforeEach, expect, test, vi} from 'vitest' 2 | import {lineByLineLogger} from '../src/logging.js' 3 | 4 | const info = vi.fn() 5 | const error = vi.fn() 6 | const mocks = {info, error} 7 | const jsonish = lineByLineLogger(mocks) 8 | 9 | beforeEach(() => { 10 | vi.clearAllMocks() 11 | }) 12 | 13 | expect.addSnapshotSerializer({ 14 | test: val => val?.mock?.calls, 15 | print: (val: any) => val.mock.calls.map((call: unknown[]) => call.join(' ')).join('\n'), 16 | }) 17 | 18 | expect.addSnapshotSerializer({ 19 | test: val => val?.cause && val.message, 20 | serialize(val, config, indentation, depth, refs, printer) { 21 | indentation += ' ' 22 | return `[${val.constructor.name}: ${val.message}]\n${indentation}Caused by: ${printer(val.cause, config, indentation, depth + 1, refs)}` 23 | }, 24 | }) 25 | 26 | test('an error', () => { 27 | const e = new Error('outer', {cause: new Error('middle', {cause: new Error('inner')})}) 28 | expect(e).toMatchInlineSnapshot(` 29 | [Error: outer] 30 | Caused by: [Error: middle] 31 | Caused by: [Error: inner] 32 | `) 33 | }) 34 | 35 | test('logging', async () => { 36 | jsonish.info!('Hello', 'world') 37 | 38 | expect(info).toMatchInlineSnapshot(`Hello world`) 39 | }) 40 | 41 | test('string array', async () => { 42 | jsonish.info!(['m1', 'm2', 'm3']) 43 | 44 | expect(info).toMatchInlineSnapshot(` 45 | m1 46 | m2 47 | m3 48 | `) 49 | }) 50 | 51 | test('primitives array', async () => { 52 | jsonish.info!(['m1', 'm2', 11, true, 'm3']) 53 | 54 | expect(info).toMatchInlineSnapshot(` 55 | m1 56 | m2 57 | 11 58 | true 59 | m3 60 | `) 61 | }) 62 | 63 | test('array array', async () => { 64 | jsonish.info!([ 65 | ['m1', 'm2'], 66 | ['m3', 'm4'], 67 | ]) 68 | 69 | expect(info).toMatchInlineSnapshot(` 70 | [ 71 | "m1", 72 | "m2" 73 | ] 74 | [ 75 | "m3", 76 | "m4" 77 | ] 78 | `) 79 | }) 80 | 81 | test('multi primitives', async () => { 82 | jsonish.info!('m1', 11, true, 'm2') 83 | jsonish.info!('m1', 12, false, 'm2') 84 | 85 | expect(info).toMatchInlineSnapshot(` 86 | m1 11 true m2 87 | m1 12 false m2 88 | `) 89 | }) 90 | 91 | test('object array', async () => { 92 | jsonish.info!([{name: 'm1'}, {name: 'm2'}, {name: 'm3'}]) 93 | 94 | expect(info).toMatchInlineSnapshot(` 95 | { 96 | "name": "m1" 97 | } 98 | { 99 | "name": "m2" 100 | } 101 | { 102 | "name": "m3" 103 | } 104 | `) 105 | }) 106 | -------------------------------------------------------------------------------- /src/standard-schema/contract.ts: -------------------------------------------------------------------------------- 1 | // from https://github.com/standard-schema/standard-schema 2 | 3 | /** The Standard Schema interface. */ 4 | export interface StandardSchemaV1 { 5 | /** The Standard Schema properties. */ 6 | readonly '~standard': StandardSchemaV1.Props 7 | } 8 | 9 | export declare namespace StandardSchemaV1 { 10 | /** The Standard Schema properties interface. */ 11 | export interface Props { 12 | /** The version number of the standard. */ 13 | readonly version: 1 14 | /** The vendor name of the schema library. */ 15 | readonly vendor: string 16 | /** Validates unknown input values. */ 17 | readonly validate: (value: unknown) => Result | Promise> 18 | /** Inferred types associated with the schema. */ 19 | readonly types?: Types | undefined 20 | } 21 | 22 | /** The result interface of the validate function. */ 23 | export type Result = SuccessResult | FailureResult 24 | 25 | /** The result interface if validation succeeds. */ 26 | export interface SuccessResult { 27 | /** The typed output value. */ 28 | readonly value: Output 29 | /** The non-existent issues. */ 30 | readonly issues?: undefined 31 | } 32 | 33 | /** The result interface if validation fails. */ 34 | export interface FailureResult { 35 | /** The issues of failed validation. */ 36 | readonly issues: ReadonlyArray 37 | } 38 | 39 | /** The issue interface of the failure output. */ 40 | export interface Issue { 41 | /** The error message of the issue. */ 42 | readonly message: string 43 | /** The path of the issue, if any. */ 44 | readonly path?: ReadonlyArray | undefined 45 | } 46 | 47 | /** The path segment interface of the issue. */ 48 | export interface PathSegment { 49 | /** The key representing a path segment. */ 50 | readonly key: PropertyKey 51 | } 52 | 53 | /** The Standard Schema types interface. */ 54 | export interface Types { 55 | /** The input type of the schema. */ 56 | readonly input: Input 57 | /** The output type of the schema. */ 58 | readonly output: Output 59 | } 60 | 61 | /** Infers the input type of a Standard Schema. */ 62 | export type InferInput = NonNullable['input'] 63 | 64 | /** Infers the output type of a Standard Schema. */ 65 | export type InferOutput = NonNullable['output'] 66 | } 67 | -------------------------------------------------------------------------------- /test/effect.test.ts: -------------------------------------------------------------------------------- 1 | import {Schema} from 'effect' 2 | import {initTRPC} from 'trpcserver11' 3 | import {expect, test} from 'vitest' 4 | import {TrpcCliMeta} from '../src/index.js' 5 | import {run, snapshotSerializer} from './test-run.js' 6 | 7 | expect.addSnapshotSerializer(snapshotSerializer) 8 | 9 | const t = initTRPC.meta().create() 10 | 11 | test('string input', async () => { 12 | const router = t.router({ 13 | foo: t.procedure 14 | .input(Schema.standardSchemaV1(Schema.String)) // 15 | .query(({input}) => JSON.stringify(input)), 16 | }) 17 | 18 | expect(await run(router, ['foo', 'hello'])).toMatchInlineSnapshot(`""hello""`) 19 | }) 20 | 21 | test('number input', async () => { 22 | const router = t.router({ 23 | foo: t.procedure 24 | .input(Schema.standardSchemaV1(Schema.Number)) // 25 | .query(({input}) => JSON.stringify(input)), 26 | }) 27 | 28 | expect(await run(router, ['foo', '123'])).toMatchInlineSnapshot(`"123"`) 29 | await expect(run(router, ['foo', 'abc'])).rejects.toMatchInlineSnapshot(` 30 | CLI exited with code 1 31 | Caused by: CommanderError: error: command-argument value 'abc' is invalid for argument 'number'. Invalid number: abc 32 | `) 33 | }) 34 | 35 | test('enum input', async () => { 36 | const router = t.router({ 37 | foo: t.procedure 38 | .input(Schema.standardSchemaV1(Schema.Union(Schema.Literal('aa'), Schema.Literal('bb')))) // 39 | .query(({input}) => JSON.stringify(input)), 40 | }) 41 | 42 | expect(await run(router, ['foo', 'aa'])).toMatchInlineSnapshot(`""aa""`) 43 | await expect(run(router, ['foo', 'cc'])).rejects.toMatchInlineSnapshot(` 44 | CLI exited with code 1 45 | Caused by: CliValidationError: ✖ Expected "aa", actual "cc" 46 | ✖ Expected "bb", actual "cc" 47 | `) 48 | }) 49 | 50 | test('options', async () => { 51 | const router = t.router({ 52 | foo: t.procedure 53 | .input( 54 | Schema.standardSchemaV1( 55 | Schema.Struct({ 56 | userId: Schema.Number, 57 | name: Schema.String, 58 | }), 59 | ), 60 | ) 61 | .query(({input}) => JSON.stringify(input)), 62 | }) 63 | 64 | expect(await run(router, ['foo', '--user-id', '123', '--name', 'bob'])).toMatchInlineSnapshot( 65 | `"{"userId":123,"name":"bob"}"`, 66 | ) 67 | await expect(run(router, ['foo', '--name', 'bob'])).rejects.toMatchInlineSnapshot(` 68 | CLI exited with code 1 69 | Caused by: CommanderError: error: required option '--user-id ' not specified 70 | `) 71 | await expect(run(router, ['foo', '--user-id', '123'])).rejects.toMatchInlineSnapshot(` 72 | CLI exited with code 1 73 | Caused by: CommanderError: error: required option '--name ' not specified 74 | `) 75 | }) 76 | -------------------------------------------------------------------------------- /test/fixtures/completable.ts: -------------------------------------------------------------------------------- 1 | import * as trpcServer from '@trpc/server' 2 | import {z} from 'zod/v4' 3 | import {createCli, type TrpcCliMeta} from '../../src/index.js' 4 | 5 | const trpc = trpcServer.initTRPC.meta().create() 6 | 7 | const router = trpc.router({ 8 | deeply: trpc.router({ 9 | nested: trpc.router({ 10 | one: trpc.procedure 11 | .meta({default: true, description: 'This is command ONE'}) 12 | .input(z.object({foo1: z.string()})) 13 | .query(({input}) => 'ok:' + JSON.stringify(input)), 14 | two: trpc.procedure.input(z.object({foo2: z.string()})).query(({input}) => 'ok:' + JSON.stringify(input)), 15 | }), 16 | within: trpc.router({ 17 | three: trpc.procedure.input(z.object({foo3: z.string()})).query(({input}) => 'ok:' + JSON.stringify(input)), 18 | four: trpc.procedure.input(z.object({foo4: z.string()})).query(({input}) => 'ok:' + JSON.stringify(input)), 19 | }), 20 | }), 21 | profoundly: trpc.router({ 22 | recursive: trpc.router({ 23 | moreRecursive: trpc.router({ 24 | first: trpc.procedure 25 | .meta({default: true}) 26 | .input( 27 | z.object({ 28 | foo1: z.enum(['aa', 'bb', 'cc']), 29 | foo2: z.string(), 30 | }), 31 | ) 32 | .query(({input}) => 'ok:' + JSON.stringify(input)), 33 | second: trpc.procedure.input(z.object({foo2: z.string()})).query(({input}) => 'ok:' + JSON.stringify(input)), 34 | }), 35 | evenMoreRecursive: trpc.router({ 36 | third: trpc.procedure.input(z.object({foo3: z.string()})).query(({input}) => 'ok:' + JSON.stringify(input)), 37 | fourth: trpc.procedure.input(z.object({foo4: z.string()})).query(({input}) => 'ok:' + JSON.stringify(input)), 38 | }), 39 | }), 40 | matryoshka: trpc.router({ 41 | anotherRecursive: trpc.router({ 42 | fifth: trpc.procedure.input(z.object({foo5: z.string()})).query(({input}) => 'ok:' + JSON.stringify(input)), 43 | sixth: trpc.procedure.input(z.object({foo6: z.string()})).query(({input}) => 'ok:' + JSON.stringify(input)), 44 | }), 45 | wowAnotherLevel: trpc.router({ 46 | seventh: trpc.procedure.input(z.object({foo7: z.string()})).query(({input}) => 'ok:' + JSON.stringify(input)), 47 | eighth: trpc.procedure.input(z.object({foo8: z.string()})).query(({input}) => 'ok:' + JSON.stringify(input)), 48 | }), 49 | }), 50 | }), 51 | }) 52 | 53 | void createCli({ 54 | router, 55 | }).run({ 56 | completion: async () => { 57 | const completion = await import('omelette').then(m => m.default('completable')) 58 | if (process.argv.includes('--setupCompletions')) { 59 | completion.setupShellInitFile(process.env.SHELL_INIT_FILE) 60 | } 61 | if (process.argv.includes('--removeCompletions')) { 62 | completion.cleanupShellInitFile(process.env.SHELL_INIT_FILE) 63 | } 64 | return completion 65 | }, 66 | }) 67 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/object.ts: -------------------------------------------------------------------------------- 1 | import type { ZodObjectDef, ZodTypeAny } from "zod"; 2 | import { parseDef } from "../parseDef.js"; 3 | import type { JsonSchema7Type } from "../parseTypes.js"; 4 | import type { Refs } from "../Refs.js"; 5 | 6 | export type JsonSchema7ObjectType = { 7 | type: "object"; 8 | properties: Record; 9 | additionalProperties?: boolean | JsonSchema7Type; 10 | required?: string[]; 11 | }; 12 | 13 | export function parseObjectDef(def: ZodObjectDef, refs: Refs) { 14 | const forceOptionalIntoNullable = refs.target === "openAi"; 15 | 16 | const result: JsonSchema7ObjectType = { 17 | type: "object", 18 | properties: {}, 19 | }; 20 | 21 | const required: string[] = []; 22 | 23 | const shape = def.shape(); 24 | 25 | for (const propName in shape) { 26 | let propDef = shape[propName]; 27 | 28 | if (propDef === undefined || propDef._def === undefined) { 29 | continue; 30 | } 31 | 32 | let propOptional = safeIsOptional(propDef); 33 | 34 | if (propOptional && forceOptionalIntoNullable) { 35 | if (propDef._def.typeName === "ZodOptional") { 36 | propDef = propDef._def.innerType; 37 | } 38 | 39 | if (!propDef.isNullable()) { 40 | propDef = propDef.nullable(); 41 | } 42 | 43 | propOptional = false; 44 | } 45 | 46 | const parsedDef = parseDef(propDef._def, { 47 | ...refs, 48 | currentPath: [...refs.currentPath, "properties", propName], 49 | propertyPath: [...refs.currentPath, "properties", propName], 50 | }); 51 | 52 | if (parsedDef === undefined) { 53 | continue; 54 | } 55 | 56 | result.properties[propName] = parsedDef; 57 | 58 | if (!propOptional) { 59 | required.push(propName); 60 | } 61 | } 62 | 63 | if (required.length) { 64 | result.required = required; 65 | } 66 | 67 | const additionalProperties = decideAdditionalProperties(def, refs); 68 | 69 | if (additionalProperties !== undefined) { 70 | result.additionalProperties = additionalProperties; 71 | } 72 | 73 | return result; 74 | } 75 | 76 | function decideAdditionalProperties(def: ZodObjectDef, refs: Refs) { 77 | if (def.catchall._def.typeName !== "ZodNever") { 78 | return parseDef(def.catchall._def, { 79 | ...refs, 80 | currentPath: [...refs.currentPath, "additionalProperties"], 81 | }); 82 | } 83 | 84 | switch (def.unknownKeys) { 85 | case "passthrough": 86 | return refs.allowedAdditionalProperties; 87 | case "strict": 88 | return refs.rejectedAdditionalProperties; 89 | case "strip": 90 | return refs.removeAdditionalStrategy === "strict" 91 | ? refs.allowedAdditionalProperties 92 | : refs.rejectedAdditionalProperties; 93 | } 94 | } 95 | 96 | function safeIsOptional(schema: ZodTypeAny): boolean { 97 | try { 98 | return schema.isOptional(); 99 | } catch { 100 | return true; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/bigint.ts: -------------------------------------------------------------------------------- 1 | import type { ZodBigIntDef } from "zod"; 2 | import type { Refs } from "../Refs.js"; 3 | import { type ErrorMessages, setResponseValueAndErrors } from "../errorMessages.js"; 4 | 5 | export type JsonSchema7BigintType = { 6 | type: "integer"; 7 | format: "int64"; 8 | minimum?: BigInt; 9 | exclusiveMinimum?: BigInt; 10 | maximum?: BigInt; 11 | exclusiveMaximum?: BigInt; 12 | multipleOf?: BigInt; 13 | errorMessage?: ErrorMessages; 14 | }; 15 | 16 | export function parseBigintDef( 17 | def: ZodBigIntDef, 18 | refs: Refs, 19 | ): JsonSchema7BigintType { 20 | const res: JsonSchema7BigintType = { 21 | type: "integer", 22 | format: "int64", 23 | }; 24 | 25 | if (!def.checks) return res; 26 | 27 | for (const check of def.checks) { 28 | switch (check.kind) { 29 | case "min": 30 | if (refs.target === "jsonSchema7") { 31 | if (check.inclusive) { 32 | setResponseValueAndErrors( 33 | res, 34 | "minimum", 35 | check.value, 36 | check.message, 37 | refs, 38 | ); 39 | } else { 40 | setResponseValueAndErrors( 41 | res, 42 | "exclusiveMinimum", 43 | check.value, 44 | check.message, 45 | refs, 46 | ); 47 | } 48 | } else { 49 | if (!check.inclusive) { 50 | res.exclusiveMinimum = true as any; 51 | } 52 | setResponseValueAndErrors( 53 | res, 54 | "minimum", 55 | check.value, 56 | check.message, 57 | refs, 58 | ); 59 | } 60 | break; 61 | case "max": 62 | if (refs.target === "jsonSchema7") { 63 | if (check.inclusive) { 64 | setResponseValueAndErrors( 65 | res, 66 | "maximum", 67 | check.value, 68 | check.message, 69 | refs, 70 | ); 71 | } else { 72 | setResponseValueAndErrors( 73 | res, 74 | "exclusiveMaximum", 75 | check.value, 76 | check.message, 77 | refs, 78 | ); 79 | } 80 | } else { 81 | if (!check.inclusive) { 82 | res.exclusiveMaximum = true as any; 83 | } 84 | setResponseValueAndErrors( 85 | res, 86 | "maximum", 87 | check.value, 88 | check.message, 89 | refs, 90 | ); 91 | } 92 | break; 93 | case "multipleOf": 94 | setResponseValueAndErrors( 95 | res, 96 | "multipleOf", 97 | check.value, 98 | check.message, 99 | refs, 100 | ); 101 | break; 102 | } 103 | } 104 | return res; 105 | } 106 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/number.ts: -------------------------------------------------------------------------------- 1 | import type { ZodNumberDef } from "zod"; 2 | import { addErrorMessage, type ErrorMessages, setResponseValueAndErrors } from "../errorMessages.js"; 3 | import type { Refs } from "../Refs.js"; 4 | 5 | export type JsonSchema7NumberType = { 6 | type: "number" | "integer"; 7 | minimum?: number; 8 | exclusiveMinimum?: number; 9 | maximum?: number; 10 | exclusiveMaximum?: number; 11 | multipleOf?: number; 12 | errorMessage?: ErrorMessages; 13 | }; 14 | 15 | export function parseNumberDef( 16 | def: ZodNumberDef, 17 | refs: Refs, 18 | ): JsonSchema7NumberType { 19 | const res: JsonSchema7NumberType = { 20 | type: "number", 21 | }; 22 | 23 | if (!def.checks) return res; 24 | 25 | for (const check of def.checks) { 26 | switch (check.kind) { 27 | case "int": 28 | res.type = "integer"; 29 | addErrorMessage(res, "type", check.message, refs); 30 | break; 31 | case "min": 32 | if (refs.target === "jsonSchema7") { 33 | if (check.inclusive) { 34 | setResponseValueAndErrors( 35 | res, 36 | "minimum", 37 | check.value, 38 | check.message, 39 | refs, 40 | ); 41 | } else { 42 | setResponseValueAndErrors( 43 | res, 44 | "exclusiveMinimum", 45 | check.value, 46 | check.message, 47 | refs, 48 | ); 49 | } 50 | } else { 51 | if (!check.inclusive) { 52 | res.exclusiveMinimum = true as any; 53 | } 54 | setResponseValueAndErrors( 55 | res, 56 | "minimum", 57 | check.value, 58 | check.message, 59 | refs, 60 | ); 61 | } 62 | break; 63 | case "max": 64 | if (refs.target === "jsonSchema7") { 65 | if (check.inclusive) { 66 | setResponseValueAndErrors( 67 | res, 68 | "maximum", 69 | check.value, 70 | check.message, 71 | refs, 72 | ); 73 | } else { 74 | setResponseValueAndErrors( 75 | res, 76 | "exclusiveMaximum", 77 | check.value, 78 | check.message, 79 | refs, 80 | ); 81 | } 82 | } else { 83 | if (!check.inclusive) { 84 | res.exclusiveMaximum = true as any; 85 | } 86 | setResponseValueAndErrors( 87 | res, 88 | "maximum", 89 | check.value, 90 | check.message, 91 | refs, 92 | ); 93 | } 94 | break; 95 | case "multipleOf": 96 | setResponseValueAndErrors( 97 | res, 98 | "multipleOf", 99 | check.value, 100 | check.message, 101 | refs, 102 | ); 103 | break; 104 | } 105 | } 106 | return res; 107 | } 108 | -------------------------------------------------------------------------------- /test/test-run.ts: -------------------------------------------------------------------------------- 1 | globalThis.require = require 2 | 3 | import {expect} from 'vitest' 4 | import {AnyRouter, FailedToExitError, TrpcCliParams, createCli} from '../src/index.js' 5 | import {looksLikeInstanceof} from '../src/util.js' 6 | 7 | export const run = (router: R, argv: string[], {expectJsonInput = false} = {}) => { 8 | return runWith({router}, argv, {expectJsonInput}) 9 | } 10 | export const runWith = async ( 11 | params: TrpcCliParams, 12 | argv: string[], 13 | {expectJsonInput = false} = {}, 14 | ): Promise => { 15 | const cli = createCli(params) 16 | const logs = [] as unknown[][] 17 | const addLogs = (...args: unknown[]) => logs.push(args) 18 | const result: string = await cli 19 | .run({ 20 | argv, 21 | logger: {info: addLogs, error: addLogs}, 22 | process: {exit: _ => 0 as never}, 23 | }) 24 | .then(String) 25 | .catch(async e => { 26 | if (e instanceof FailedToExitError) { 27 | if (e.exitCode === 0 && (e.cause as any).message === '(outputHelp)') return logs[0][0] as string // should be the help text 28 | if (e.exitCode === 0) return e.cause as string 29 | // eslint-disable-next-line promise/no-nesting 30 | const help = argv.includes('--help') ? '' : await runWith(params, argv.concat(['--help'])).catch(String) 31 | const print = (obj: Record) => { 32 | const lines = Object.entries(obj).map(([k, v]) => `<${k}>\n${v.trim()}\n`) 33 | return lines.join('\n\n') 34 | } 35 | // add to the FailedToExitError message so it's easier to debug when tests fail 36 | e.message = print({argv: argv.join(' '), FailedToExitError: String(e), cause: String(e.cause), help}) 37 | } 38 | throw e 39 | }) 40 | 41 | // Usually when the result includes `--input [json]` it's because there's a bug in this library - we've failed to convert to json-schema 42 | // or failed to process some weird json-schema, meaning the cli just accepts one big json object. In these cases if the test tries to do 43 | // `mycli --foo bar` it'll fail with a message that includes `--input [json]` in the help text because it's expecting `--input '{"foo":"bar"}'` 44 | const hasJsonInput = result.includes('--input [json]') 45 | if (result.includes('--') && hasJsonInput !== expectJsonInput) { 46 | throw new Error(`${hasJsonInput ? 'Got' : 'Did not get'} --input [json]:\n\n${result}`) 47 | } 48 | return result 49 | } 50 | 51 | export const snapshotSerializer = { 52 | test: val => looksLikeInstanceof(val, Error), 53 | serialize(val, config, indentation, depth, refs, printer) { 54 | let topLine = `${val.constructor.name}: ${val.message}` 55 | if (val.constructor.name === 'FailedToExitError') topLine = `CLI exited with code ${val.exitCode}` 56 | 57 | if (!val.cause) return topLine 58 | indentation += ' ' 59 | return `${topLine}\n${indentation}Caused by: ${printer(val.cause, config, indentation, depth + 1, refs)}` 60 | .split(/(---|Usage:)/)[0] // strip out the usage line and the --- line which is added for debugging when tests fail 61 | .trim() 62 | }, 63 | } satisfies Parameters[0] 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "trpc-cli", 3 | "version": "0.12.1", 4 | "description": "Turn a tRPC router into a type-safe, fully-functional, documented CLI", 5 | "type": "module", 6 | "main": "dist/index.js", 7 | "types": "dist/index.d.ts", 8 | "bin": "dist/bin.js", 9 | "files": [ 10 | "dist" 11 | ], 12 | "engines": { 13 | "node": ">=18" 14 | }, 15 | "packageManager": "pnpm@10.12.4", 16 | "scripts": { 17 | "ztjs": "./cp-zod-to-json-schema.sh", 18 | "prepare": "pnpm build", 19 | "lint": "eslint --max-warnings=0 .", 20 | "clean": "rm -rf dist", 21 | "compile": "tsc -p tsconfig.lib.json", 22 | "dts-ignore": "sed -i.bak \"s#declare module#\\n$(cat src/index.ts | grep ts-ignore | head -n 1)\\ndeclare module#g\" dist/index.d.ts && rm dist/index.d.ts.bak", 23 | "build": "pnpm clean && pnpm ztjs && pnpm compile && pnpm dts-ignore", 24 | "dev": "cd test/fixtures && tsx", 25 | "test": "vitest run" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/mmkal/trpc-cli.git" 30 | }, 31 | "keywords": [ 32 | "tprc", 33 | "cli", 34 | "typescript" 35 | ], 36 | "author": "mmkal", 37 | "license": "Apache-2.0", 38 | "bugs": { 39 | "url": "https://github.com/mmkal/trpc-cli/issues" 40 | }, 41 | "homepage": "https://github.com/mmkal/trpc-cli#readme", 42 | "peerDependencies": { 43 | "@orpc/server": "^1.0.0", 44 | "@trpc/server": "^10.45.2 || ^11.0.1", 45 | "@valibot/to-json-schema": "^1.1.0", 46 | "effect": "^3.14.2 || ^4.0.0", 47 | "valibot": "^1.1.0", 48 | "zod": "^3.24.0 || ^4.0.0" 49 | }, 50 | "peerDependenciesMeta": { 51 | "@orpc/server": { 52 | "optional": true 53 | }, 54 | "@trpc/server": { 55 | "optional": true 56 | }, 57 | "@valibot/to-json-schema": { 58 | "optional": true 59 | }, 60 | "effect": { 61 | "optional": true 62 | }, 63 | "valibot": { 64 | "optional": true 65 | }, 66 | "zod": { 67 | "optional": true 68 | } 69 | }, 70 | "dependencies": { 71 | "commander": "^14.0.0" 72 | }, 73 | "devDependencies": { 74 | "@clack/prompts": "0.11.0", 75 | "@inquirer/prompts": "7.5.1", 76 | "@orpc/contract": "1.10.0", 77 | "@orpc/server": "1.10.0", 78 | "@trpc/client": "11.4.3", 79 | "@trpc/server": "11.4.3", 80 | "@types/json-schema": "7.0.15", 81 | "@types/node": "20.17.48", 82 | "@types/omelette": "0.4.5", 83 | "@types/prompts": "2.4.9", 84 | "@valibot/to-json-schema": "1.3.0", 85 | "arktype": "^2.1.20", 86 | "effect": "3.16.12", 87 | "enquirer": "2.4.1", 88 | "eslint": "9.38.0", 89 | "eslint-plugin-mmkal": "0.11.3", 90 | "execa": "9.3.1", 91 | "expect-type": "1.2.1", 92 | "fs-syncer": "0.5.3", 93 | "np": "10.2.0", 94 | "pkg-pr-new": "^0.0.54", 95 | "prompts": "2.4.2", 96 | "strip-ansi": "7.1.0", 97 | "trpcserver10": "npm:@trpc/server@^10.45.2", 98 | "trpcserver11": "npm:@trpc/server@^11.1.1", 99 | "tsx": "4.20.3", 100 | "typescript": "5.8.3", 101 | "valibot": "1.2.0", 102 | "vitest": "3.2.4", 103 | "zod": "3.25.76", 104 | "zod-to-json-schema": "3.24.6" 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/json.ts: -------------------------------------------------------------------------------- 1 | import {Command} from 'commander' 2 | 3 | /** 4 | * JSON representation of a commander `Command` instance 5 | * Note: this is not necessarily a _complete_ representation of the command - it aims to be a big enough subset to be useful for generating documentation etc. 6 | */ 7 | export type CommandJSON = { 8 | name?: string 9 | version?: string 10 | description?: string 11 | usage?: string 12 | commands?: CommandJSON[] 13 | arguments?: { 14 | name: string 15 | description?: string 16 | required: boolean 17 | defaultValue?: {} 18 | defaultValueDescription?: string 19 | variadic: boolean 20 | choices?: string[] 21 | }[] 22 | options?: { 23 | name: string 24 | description?: string 25 | required: boolean 26 | defaultValue?: {} 27 | defaultValueDescription?: string 28 | variadic: boolean 29 | attributeName?: string 30 | flags?: string 31 | short?: string 32 | negate: boolean 33 | optional: boolean 34 | choices?: string[] 35 | }[] 36 | } 37 | 38 | /** 39 | * Convert a commander `Command` instance to a JSON object. 40 | * 41 | * Note: in theory you could use this with any `Command` instance, it doesn't have 42 | * to be one built by `trpc-cli`. Implementing here because it's pretty simple to do and `commander` doesn't seem to provide a way to do it. 43 | * 44 | * Note: falsy values for strings are replaced with `undefined` in the output - e.g. if there's an empty description, it will be `undefined` in the output. 45 | */ 46 | export const commandToJSON = (command: Command): CommandJSON => { 47 | const json: CommandJSON = {} 48 | const name = command.name() 49 | 50 | if (name) json.name = name 51 | const version = command.version() 52 | if (version) json.version = version 53 | const description = command.description() 54 | if (description) json.description = description 55 | const usage = command.usage() 56 | if (usage) json.usage = usage 57 | 58 | json.arguments = command.registeredArguments.map(arg => { 59 | const result = {name: arg.name()} as NonNullable[number] 60 | 61 | result.variadic = arg.variadic 62 | result.required = arg.required 63 | 64 | if (arg.description) result.description = arg.description 65 | if (arg.defaultValue) result.defaultValue = arg.defaultValue as {} 66 | if (arg.defaultValueDescription) result.defaultValueDescription = arg.defaultValueDescription 67 | if (arg.argChoices) result.choices = arg.argChoices 68 | return result 69 | }) 70 | 71 | json.options = command.options.map(o => { 72 | const result = {name: o.name()} as NonNullable[number] 73 | 74 | result.required = o.required 75 | result.optional = o.optional 76 | result.negate = o.negate 77 | result.variadic = o.variadic 78 | 79 | if (o.flags) result.flags = o.flags 80 | if (o.short) result.short = o.short 81 | if (o.description) result.description = o.description 82 | if (o.argChoices) result.choices = o.argChoices 83 | 84 | const attributeName = o.attributeName() 85 | if (attributeName) result.attributeName = attributeName 86 | 87 | if (o.defaultValue) result.defaultValue = o.defaultValue as {} 88 | if (o.defaultValueDescription) result.defaultValueDescription = o.defaultValueDescription 89 | 90 | return result 91 | }) 92 | 93 | json.commands = command.commands.map(c => commandToJSON(c)) 94 | 95 | return json as {} 96 | } 97 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parseDef.ts: -------------------------------------------------------------------------------- 1 | import type { ZodTypeDef } from "zod"; 2 | import type { Refs, Seen } from "./Refs.js"; 3 | import { ignoreOverride } from "./Options.js"; 4 | import type { JsonSchema7Type } from "./parseTypes.js"; 5 | import { selectParser } from "./selectParser.js"; 6 | import { getRelativePath } from "./getRelativePath.js"; 7 | import { parseAnyDef } from "./parsers/any.js"; 8 | 9 | export function parseDef( 10 | def: ZodTypeDef, 11 | refs: Refs, 12 | forceResolution = false, // Forces a new schema to be instantiated even though its def has been seen. Used for improving refs in definitions. See https://github.com/StefanTerdell/zod-to-json-schema/pull/61. 13 | ): JsonSchema7Type | undefined { 14 | const seenItem = refs.seen.get(def); 15 | 16 | if (refs.override) { 17 | const overrideResult = refs.override?.( 18 | def, 19 | refs, 20 | seenItem, 21 | forceResolution, 22 | ); 23 | 24 | if (overrideResult !== ignoreOverride) { 25 | return overrideResult; 26 | } 27 | } 28 | 29 | if (seenItem && !forceResolution) { 30 | const seenSchema = get$ref(seenItem, refs); 31 | 32 | if (seenSchema !== undefined) { 33 | return seenSchema; 34 | } 35 | } 36 | 37 | const newItem: Seen = { def, path: refs.currentPath, jsonSchema: undefined }; 38 | 39 | refs.seen.set(def, newItem); 40 | 41 | const jsonSchemaOrGetter = selectParser(def, (def as any).typeName, refs); 42 | 43 | // If the return was a function, then the inner definition needs to be extracted before a call to parseDef (recursive) 44 | const jsonSchema = 45 | typeof jsonSchemaOrGetter === "function" 46 | ? parseDef(jsonSchemaOrGetter(), refs) 47 | : jsonSchemaOrGetter; 48 | 49 | if (jsonSchema) { 50 | addMeta(def, refs, jsonSchema); 51 | } 52 | 53 | if (refs.postProcess) { 54 | const postProcessResult = refs.postProcess(jsonSchema, def, refs); 55 | 56 | newItem.jsonSchema = jsonSchema; 57 | 58 | return postProcessResult; 59 | } 60 | 61 | newItem.jsonSchema = jsonSchema; 62 | 63 | return jsonSchema; 64 | } 65 | 66 | const get$ref = ( 67 | item: Seen, 68 | refs: Refs, 69 | ): 70 | | { 71 | $ref: string; 72 | } 73 | | {} 74 | | undefined => { 75 | switch (refs.$refStrategy) { 76 | case "root": 77 | return { $ref: item.path.join("/") }; 78 | case "relative": 79 | return { $ref: getRelativePath(refs.currentPath, item.path) }; 80 | case "none": 81 | case "seen": { 82 | if ( 83 | item.path.length < refs.currentPath.length && 84 | item.path.every((value, index) => refs.currentPath[index] === value) 85 | ) { 86 | console.warn( 87 | `Recursive reference detected at ${refs.currentPath.join( 88 | "/", 89 | )}! Defaulting to any`, 90 | ); 91 | 92 | return parseAnyDef(refs); 93 | } 94 | 95 | return refs.$refStrategy === "seen" ? parseAnyDef(refs) : undefined; 96 | } 97 | } 98 | }; 99 | 100 | const addMeta = ( 101 | def: ZodTypeDef, 102 | refs: Refs, 103 | jsonSchema: JsonSchema7Type, 104 | ): JsonSchema7Type => { 105 | if (def.description) { 106 | jsonSchema.description = def.description; 107 | 108 | if (refs.markdownDescription) { 109 | jsonSchema.markdownDescription = def.description; 110 | } 111 | } 112 | return jsonSchema; 113 | }; 114 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/record.ts: -------------------------------------------------------------------------------- 1 | import {ZodFirstPartyTypeKind} from "../ZodFirstPartyTypeKind.js"; 2 | import type { ZodMapDef, ZodRecordDef, ZodTypeAny } from "zod"; 3 | import { parseDef } from "../parseDef.js"; 4 | import type { JsonSchema7Type } from "../parseTypes.js"; 5 | import type { Refs } from "../Refs.js"; 6 | import type { JsonSchema7EnumType } from "./enum.js"; 7 | import type { JsonSchema7ObjectType } from "./object.js"; 8 | import { type JsonSchema7StringType, parseStringDef } from "./string.js"; 9 | import { parseBrandedDef } from "./branded.js"; 10 | import { parseAnyDef } from "./any.js"; 11 | 12 | type JsonSchema7RecordPropertyNamesType = 13 | | Omit 14 | | Omit; 15 | 16 | export type JsonSchema7RecordType = { 17 | type: "object"; 18 | additionalProperties?: JsonSchema7Type | true; 19 | propertyNames?: JsonSchema7RecordPropertyNamesType; 20 | }; 21 | 22 | export function parseRecordDef( 23 | def: ZodRecordDef | ZodMapDef, 24 | refs: Refs, 25 | ): JsonSchema7RecordType { 26 | if (refs.target === "openAi") { 27 | console.warn( 28 | "Warning: OpenAI may not support records in schemas! Try an array of key-value pairs instead.", 29 | ); 30 | } 31 | 32 | if ( 33 | refs.target === "openApi3" && 34 | def.keyType?._def.typeName === ZodFirstPartyTypeKind.ZodEnum 35 | ) { 36 | return { 37 | type: "object", 38 | required: def.keyType._def.values, 39 | properties: def.keyType._def.values.reduce( 40 | (acc: Record, key: string) => ({ 41 | ...acc, 42 | [key]: 43 | parseDef(def.valueType._def, { 44 | ...refs, 45 | currentPath: [...refs.currentPath, "properties", key], 46 | }) ?? parseAnyDef(refs), 47 | }), 48 | {}, 49 | ), 50 | additionalProperties: refs.rejectedAdditionalProperties, 51 | } satisfies JsonSchema7ObjectType as any; 52 | } 53 | 54 | const schema: JsonSchema7RecordType = { 55 | type: "object", 56 | additionalProperties: 57 | parseDef(def.valueType._def, { 58 | ...refs, 59 | currentPath: [...refs.currentPath, "additionalProperties"], 60 | }) ?? refs.allowedAdditionalProperties, 61 | }; 62 | 63 | if (refs.target === "openApi3") { 64 | return schema; 65 | } 66 | 67 | if ( 68 | def.keyType?._def.typeName === ZodFirstPartyTypeKind.ZodString && 69 | def.keyType._def.checks?.length 70 | ) { 71 | const { type, ...keyType } = parseStringDef(def.keyType._def, refs); 72 | 73 | return { 74 | ...schema, 75 | propertyNames: keyType, 76 | }; 77 | } else if (def.keyType?._def.typeName === ZodFirstPartyTypeKind.ZodEnum) { 78 | return { 79 | ...schema, 80 | propertyNames: { 81 | enum: def.keyType._def.values, 82 | }, 83 | }; 84 | } else if ( 85 | def.keyType?._def.typeName === ZodFirstPartyTypeKind.ZodBranded && 86 | def.keyType._def.type._def.typeName === ZodFirstPartyTypeKind.ZodString && 87 | def.keyType._def.type._def.checks?.length 88 | ) { 89 | const { type, ...keyType } = parseBrandedDef( 90 | def.keyType._def, 91 | refs, 92 | ) as JsonSchema7StringType; 93 | 94 | return { 95 | ...schema, 96 | propertyNames: keyType, 97 | }; 98 | } 99 | 100 | return schema; 101 | } 102 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/Options.ts: -------------------------------------------------------------------------------- 1 | import type { ZodSchema, ZodTypeDef } from "zod"; 2 | import type { Refs, Seen } from "./Refs.js"; 3 | import type { JsonSchema7Type } from "./parseTypes.js"; 4 | 5 | export type Targets = 6 | | "jsonSchema7" 7 | | "jsonSchema2019-09" 8 | | "openApi3" 9 | | "openAi"; 10 | 11 | export type DateStrategy = 12 | | "format:date-time" 13 | | "format:date" 14 | | "string" 15 | | "integer"; 16 | 17 | export const ignoreOverride = Symbol( 18 | "Let zodToJsonSchema decide on which parser to use", 19 | ); 20 | 21 | export type OverrideCallback = ( 22 | def: ZodTypeDef, 23 | refs: Refs, 24 | seen: Seen | undefined, 25 | forceResolution?: boolean, 26 | ) => JsonSchema7Type | undefined | typeof ignoreOverride; 27 | 28 | export type PostProcessCallback = ( 29 | jsonSchema: JsonSchema7Type | undefined, 30 | def: ZodTypeDef, 31 | refs: Refs, 32 | ) => JsonSchema7Type | undefined; 33 | 34 | export const jsonDescription: PostProcessCallback = (jsonSchema, def) => { 35 | if (def.description) { 36 | try { 37 | return { 38 | ...jsonSchema, 39 | ...JSON.parse(def.description), 40 | }; 41 | } catch {} 42 | } 43 | 44 | return jsonSchema; 45 | }; 46 | 47 | export type Options = { 48 | name: string | undefined; 49 | $refStrategy: "root" | "relative" | "none" | "seen"; 50 | basePath: string[]; 51 | effectStrategy: "input" | "any"; 52 | pipeStrategy: "input" | "output" | "all"; 53 | dateStrategy: DateStrategy | DateStrategy[]; 54 | mapStrategy: "entries" | "record"; 55 | removeAdditionalStrategy: "passthrough" | "strict"; 56 | allowedAdditionalProperties: true | undefined; 57 | rejectedAdditionalProperties: false | undefined; 58 | target: Target; 59 | strictUnions: boolean; 60 | definitionPath: string; 61 | definitions: Record; 62 | errorMessages: boolean; 63 | markdownDescription: boolean; 64 | patternStrategy: "escape" | "preserve"; 65 | applyRegexFlags: boolean; 66 | emailStrategy: "format:email" | "format:idn-email" | "pattern:zod"; 67 | base64Strategy: "format:binary" | "contentEncoding:base64" | "pattern:zod"; 68 | nameStrategy: "ref" | "title"; 69 | override?: OverrideCallback; 70 | postProcess?: PostProcessCallback; 71 | openAiAnyTypeName: string 72 | }; 73 | 74 | export const defaultOptions: Options = { 75 | name: undefined, 76 | $refStrategy: "root", 77 | basePath: ["#"], 78 | effectStrategy: "input", 79 | pipeStrategy: "all", 80 | dateStrategy: "format:date-time", 81 | mapStrategy: "entries", 82 | removeAdditionalStrategy: "passthrough", 83 | allowedAdditionalProperties: true, 84 | rejectedAdditionalProperties: false, 85 | definitionPath: "definitions", 86 | target: "jsonSchema7", 87 | strictUnions: false, 88 | definitions: {}, 89 | errorMessages: false, 90 | markdownDescription: false, 91 | patternStrategy: "escape", 92 | applyRegexFlags: false, 93 | emailStrategy: "format:email", 94 | base64Strategy: "contentEncoding:base64", 95 | nameStrategy: "ref", 96 | openAiAnyTypeName: "OpenAiAnyType" 97 | }; 98 | 99 | export const getDefaultOptions = ( 100 | options: Partial> | string | undefined, 101 | ) => 102 | (typeof options === "string" 103 | ? { 104 | ...defaultOptions, 105 | name: options, 106 | } 107 | : { 108 | ...defaultOptions, 109 | ...options, 110 | }) as Options; 111 | -------------------------------------------------------------------------------- /test/completions.test.ts: -------------------------------------------------------------------------------- 1 | import {execa} from 'execa' 2 | import * as fs from 'fs' 3 | import * as path from 'path' 4 | import stripAnsi from 'strip-ansi' 5 | import {expect, test as baseTest} from 'vitest' 6 | import '../src' // make sure vitest reruns this file after every change 7 | 8 | const test = process.env.RUN_COMPLETIONS_TESTS ? baseTest : baseTest.skip 9 | 10 | const execAll = async (program: string, args: string[], options: import('execa').Options = {}) => { 11 | const {all} = await execa(program, args, { 12 | reject: false, 13 | cwd: path.join(__dirname, '..'), 14 | ...(options as {}), 15 | all: true, 16 | }) 17 | return stripAnsi(all) 18 | } 19 | 20 | test('completions', async () => { 21 | const SHELL_INIT_FILE = path.join(__dirname, 'completions-test-ignoreme.sh') 22 | fs.writeFileSync(SHELL_INIT_FILE, '') 23 | const setup = await execAll('node_modules/.bin/tsx', ['test/fixtures/completable', '--setupCompletions'], { 24 | env: { 25 | SHELL_INIT_FILE, 26 | }, 27 | }) 28 | expect(setup).toMatchInlineSnapshot(` 29 | "setupShellInitFile /Users/mmkal/src/trpc-cli/test/completions-test-ignoreme.sh 30 | # begin completable completion 31 | . <(completable --completion) 32 | # end completable completion 33 | " 34 | `) 35 | 36 | expect(fs.readFileSync(SHELL_INIT_FILE, 'utf8')).toMatchInlineSnapshot(` 37 | " 38 | # begin completable completion 39 | . <(completable --completion) 40 | # end completable completion 41 | " 42 | `) 43 | 44 | fs.appendFileSync(SHELL_INIT_FILE, `completable() { node_modules/.bin/tsx test/fixtures/completable "$@"; }`) 45 | 46 | const completions = await execAll('node_modules/.bin/tsx', ['test/fixtures/completable', '--completion'], {}) 47 | expect(completions).toMatchInlineSnapshot(` 48 | "### completable completion - begin. generated by omelette.js ### 49 | if type compdef &>/dev/null; then 50 | _completable_completion() { 51 | compadd -- \`completable --compzsh --compgen "\${CURRENT}" "\${words[CURRENT-1]}" "\${BUFFER}"\` 52 | } 53 | compdef _completable_completion completable 54 | elif type complete &>/dev/null; then 55 | _completable_completion() { 56 | local cur prev nb_colon 57 | _get_comp_words_by_ref -n : cur prev 58 | nb_colon=$(grep -o ":" <<< "$COMP_LINE" | wc -l) 59 | 60 | COMPREPLY=( $(compgen -W '$(completable --compbash --compgen "$((COMP_CWORD - (nb_colon * 2)))" "$prev" "\${COMP_LINE}")' -- "$cur") ) 61 | 62 | __ltrim_colon_completions "$cur" 63 | } 64 | complete -F _completable_completion completable 65 | elif type compctl &>/dev/null; then 66 | _completable_completion () { 67 | local cword line point si 68 | read -Ac words 69 | read -cn cword 70 | read -l line 71 | si="$IFS" 72 | if ! IFS=$' 73 | ' reply=($(completable --compzsh --compgen "\${cword}" "\${words[cword-1]}" "\${line}")); then 74 | local ret=$? 75 | IFS="$si" 76 | return $ret 77 | fi 78 | IFS="$si" 79 | } 80 | compctl -K _completable_completion completable 81 | fi 82 | ### completable completion - end ###" 83 | `) 84 | 85 | const cleanup = await execAll('node_modules/.bin/tsx', ['test/fixtures/completable', '--removeCompletions'], { 86 | env: { 87 | SHELL_INIT_FILE, 88 | }, 89 | }) 90 | expect(cleanup).toMatchInlineSnapshot(`""`) 91 | expect(fs.readFileSync(SHELL_INIT_FILE, 'utf8')).toMatchInlineSnapshot( 92 | `"completable() { node_modules/.bin/tsx test/fixtures/completable "$@"; }"`, 93 | ) 94 | }) 95 | -------------------------------------------------------------------------------- /src/trpc-compat.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | import {StandardSchemaV1} from './standard-schema/contract.js' 4 | 5 | /** 6 | * Type which looks *enough* like a trpc v11(+?) router to infer its types correctly 7 | * This is written from scratch to avoid any kind of dependency on @trpc/server v11+ 8 | */ 9 | export type Trpc11RouterLike = { 10 | _def: { 11 | _config: { 12 | $types: {meta: any; ctx: any} 13 | } 14 | procedures: Record> 15 | } 16 | } 17 | 18 | /** Even though you use `t.router({})` to create a sub-router, the actual type is a record of procedures and sub-routers rather than a root-level router */ 19 | export interface Trpc11ProcedureRecordLike { 20 | [key: string]: Trpc11ProcedureLike | Trpc11ProcedureRecordLike 21 | } 22 | 23 | export type Trpc11ProcedureLike = { 24 | _def: { 25 | type: 'mutation' | 'query' | 'subscription' 26 | _type?: undefined 27 | meta?: any 28 | inputs?: unknown[] // this isn't actually exposed by trpc v11 (as of 11.0.0-rc.502) 29 | $types: {input: any; output: any} 30 | } 31 | } 32 | 33 | export type Trpc10RouterLike = { 34 | _def: { 35 | _config: { 36 | $types: {meta: any; ctx: any} 37 | } 38 | procedures: Record 39 | } 40 | } 41 | 42 | export type Trpc10ProcedureLike = { 43 | _def: { 44 | type?: undefined 45 | mutation?: boolean 46 | query?: boolean 47 | subscription?: boolean 48 | meta?: any 49 | inputs: unknown[] 50 | _input_in: any 51 | _output_out: any 52 | } 53 | } 54 | 55 | export type OrpcProcedureLike = { 56 | '~orpc': { 57 | __initialContext?: (context: Ctx) => unknown 58 | inputSchema?: StandardSchemaV1 59 | } 60 | } 61 | 62 | export type OrpcRouterLike = { 63 | [key: string]: OrpcProcedureLike | OrpcRouterLike 64 | } 65 | 66 | export type CreateCallerFactoryLike unknown>> = ( 67 | router: any, 68 | ) => (context: any) => Procedures 69 | 70 | export type AnyRouter = Trpc10RouterLike | Trpc11RouterLike | OrpcRouterLike 71 | 72 | export type AnyProcedure = Trpc10ProcedureLike | Trpc11ProcedureLike 73 | 74 | export type inferRouterContext = R extends Trpc10RouterLike | Trpc11RouterLike 75 | ? R['_def']['_config']['$types']['ctx'] 76 | : R extends OrpcRouterLike 77 | ? Ctx 78 | : never 79 | 80 | export const isTrpc11Procedure = (procedure: AnyProcedure): procedure is Trpc11ProcedureLike => { 81 | return 'type' in procedure._def && typeof procedure._def.type === 'string' 82 | } 83 | 84 | export const isTrpc11Router = (router: AnyRouter): router is Trpc11RouterLike => { 85 | if (isOrpcRouter(router)) return false 86 | const procedure = Object.values(router._def.procedures)[0] as AnyProcedure | undefined 87 | return Boolean(procedure && isTrpc11Procedure(procedure)) 88 | } 89 | 90 | // no way to actually check a router, because they are just records of procedures and sub-routers. 91 | // so recursively check values for procedures and sub-routers 92 | export const isOrpcRouter = (router: AnyRouter): router is OrpcRouterLike => { 93 | const values: never[] = [] 94 | for (const v of Object.values(router)) { 95 | if (typeof v === 'function') return false 96 | values.push(v as never) 97 | } 98 | return values.every(v => isOrpcProcedure(v) || isOrpcRouter(v)) 99 | } 100 | 101 | export const isOrpcProcedure = (procedure: {}): procedure is OrpcProcedureLike => { 102 | return typeof procedure === 'object' && '~orpc' in procedure && typeof procedure['~orpc'] === 'object' 103 | } 104 | -------------------------------------------------------------------------------- /src/bin.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import {createCli} from './index.js' 3 | import {Command} from 'commander' 4 | import * as path from 'path' 5 | import {isOrpcRouter, Trpc11RouterLike} from './trpc-compat.js' 6 | 7 | const program = new Command('trpc-cli') 8 | 9 | program.allowExcessArguments() 10 | program.allowUnknownOption() 11 | program.enablePositionalOptions() 12 | program.passThroughOptions() 13 | program.helpOption(false) 14 | 15 | program.argument('filepath', 'The filepath of the module with the trpc router') 16 | 17 | program.option( 18 | '-e, --export [export]', 19 | 'The name of the export to use from the module. If not provided, all exports will be checked for a trpc router.', 20 | ) 21 | program.option( 22 | '-r, --require [module]', 23 | 'A module (or comma-separated modules) to require before running the cli. Can be used to pass in options for the trpc router. e.g. --require dotenv/config', 24 | ) 25 | program.option( 26 | '-i, --import [module]', 27 | 'A module (or comma-separated modules) to import before running the cli. Can be used to pass in options for the trpc router. e.g. --import tsx/esm', 28 | ) 29 | 30 | program.action(async () => { 31 | const [filepath, ...argv] = program.args 32 | if (filepath === '-h' || filepath === '--help') { 33 | program.help() 34 | return 35 | } 36 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion 37 | const options = program.opts() as {export?: string; require?: string; import?: string} 38 | for (const r of options.require?.split(',') || []) { 39 | // eslint-disable-next-line @typescript-eslint/no-require-imports 40 | require(r) 41 | } 42 | for (const m of options.import?.split(',') || []) { 43 | await import(m) 44 | } 45 | 46 | if (!options.require && !options.import) { 47 | try { 48 | // eslint-disable-next-line @typescript-eslint/no-require-imports 49 | require('tsx/cjs') 50 | // @ts-expect-error - this might not be available, that's why we're catching 51 | await import('tsx/esm') 52 | } catch { 53 | // don't worry 54 | } 55 | } 56 | 57 | const fullpath = path.resolve(process.cwd(), filepath) 58 | let importedModule = (await import(fullpath)) as Record 59 | while ('module.exports' in importedModule && importedModule?.['module.exports'] !== importedModule) { 60 | // this is a cjs-like module, possibly what tsx gives us 61 | importedModule = importedModule?.['module.exports'] as never 62 | } 63 | while ('default' in importedModule && importedModule?.default !== importedModule) { 64 | // depending on how it's loaded we can end up with weird stuff like `{default: {default: {myRouter: ...}}}` 65 | importedModule = importedModule?.default as never 66 | } 67 | let router: Trpc11RouterLike 68 | const looksLikeARouter = (value: unknown): value is Trpc11RouterLike => 69 | Boolean((value as Trpc11RouterLike)?._def?.procedures) || isOrpcRouter(value as never) 70 | if (options.export) { 71 | router = (importedModule as {[key: string]: Trpc11RouterLike})[options.export] 72 | if (!looksLikeARouter(router)) { 73 | throw new Error(`Expected a trpc router in ${filepath}.${options.export}, got ${typeof router}`) 74 | } 75 | } else { 76 | const exports = Object.values(importedModule) 77 | const routerExports = exports.filter(looksLikeARouter) 78 | if (routerExports.length === 1) { 79 | router = routerExports[0] 80 | } else if (looksLikeARouter(importedModule)) { 81 | router = importedModule 82 | } else { 83 | throw new Error( 84 | `Expected exactly one trpc router in ${filepath}, found ${routerExports.length}. Exports: ${Object.keys(importedModule).join(', ')}`, 85 | ) 86 | } 87 | } 88 | 89 | const cli = createCli({router}) 90 | await cli.run({argv}) 91 | }) 92 | 93 | program.parse(process.argv) 94 | -------------------------------------------------------------------------------- /test/fixtures/migrations.ts: -------------------------------------------------------------------------------- 1 | import * as trpcServer from '@trpc/server' 2 | import {z} from 'zod/v4' 3 | import {createCli, type TrpcCliMeta} from '../../src/index.js' 4 | import * as trpcCompat from '../../src/trpc-compat.js' 5 | 6 | const trpc = trpcServer.initTRPC.meta().create() 7 | 8 | const migrations = getMigrations() 9 | 10 | const searchProcedure = trpc.procedure 11 | .meta({ 12 | aliases: { 13 | options: {status: 's'}, 14 | }, 15 | }) 16 | .input( 17 | z.object({ 18 | status: z.enum(['executed', 'pending']).optional().describe('Filter to only show migrations with this status'), 19 | }), 20 | ) 21 | .use(async ({next, input}) => { 22 | return next({ 23 | ctx: { 24 | filter: (list: typeof migrations) => list.filter(m => !input.status || m.status === input.status), 25 | }, 26 | }) 27 | }) 28 | 29 | export const router = trpc.router({ 30 | up: trpc.procedure 31 | .meta({description: 'Apply migrations. By default all pending migrations will be applied.'}) 32 | .input( 33 | z.union([ 34 | z.object({}).strict(), // use strict here to make sure `{step: 1}` doesn't "match" this first, just by having an ignore `step` property 35 | z.object({ 36 | to: z.string().describe('Mark migrations up to this one as exectued'), 37 | }), 38 | z.object({ 39 | step: z.number().int().positive().describe('Mark this many migrations as executed'), 40 | }), 41 | ]), 42 | ) 43 | .query(async ({input}) => { 44 | let toBeApplied = migrations 45 | if ('to' in input) { 46 | const index = migrations.findIndex(m => m.name === input.to) 47 | toBeApplied = migrations.slice(0, index + 1) 48 | } 49 | if ('step' in input) { 50 | const start = migrations.findIndex(m => m.status === 'pending') 51 | toBeApplied = migrations.slice(0, start + input.step) 52 | } 53 | toBeApplied.forEach(m => (m.status = 'executed')) 54 | return migrations.map(m => `${m.name}: ${m.status}`) 55 | }), 56 | create: trpc.procedure 57 | .meta({description: 'Create a new migration'}) 58 | .input( 59 | z.object({name: z.string(), content: z.string()}), // 60 | ) 61 | .mutation(async ({input}) => { 62 | migrations.push({...input, status: 'pending'}) 63 | return migrations 64 | }), 65 | list: searchProcedure.meta({description: 'List all migrations'}).query(({ctx}) => ctx.filter(migrations)), 66 | search: trpc.router({ 67 | byName: searchProcedure 68 | .meta({description: 'Look for migrations by name'}) 69 | .input(z.object({name: z.string()})) 70 | .query(({ctx, input}) => { 71 | return ctx.filter(migrations.filter(m => m.name === input.name)) 72 | }), 73 | byContent: searchProcedure 74 | .meta({ 75 | description: 'Look for migrations by their script content', 76 | aliases: { 77 | options: {searchTerm: 'q'}, 78 | }, 79 | }) 80 | .input( 81 | z.object({searchTerm: z.string().describe('Only show migrations whose `content` value contains this string')}), 82 | ) 83 | .query(({ctx, input}) => { 84 | return ctx.filter(migrations.filter(m => m.content.includes(input.searchTerm))) 85 | }), 86 | }), 87 | }) satisfies trpcCompat.Trpc11RouterLike 88 | 89 | const cli = createCli({ 90 | router, 91 | name: 'migrations', 92 | version: '1.0.0', 93 | description: 'Manage migrations', 94 | }) 95 | 96 | void cli.run() 97 | 98 | function getMigrations() { 99 | return [ 100 | {name: 'one', content: 'create table one(id int, name text)', status: 'executed'}, 101 | {name: 'two', content: 'create view two as select name from one', status: 'executed'}, 102 | {name: 'three', content: 'create table three(id int, foo int)', status: 'pending'}, 103 | {name: 'four', content: 'create view four as select foo from three', status: 'pending'}, 104 | {name: 'five', content: 'create table five(id int)', status: 'pending'}, 105 | ] 106 | } 107 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/zodToJsonSchema.ts: -------------------------------------------------------------------------------- 1 | import type { ZodSchema } from "zod"; 2 | import type { Options, Targets } from "./Options.js"; 3 | import { parseDef } from "./parseDef.js"; 4 | import type { JsonSchema7Type } from "./parseTypes.js"; 5 | import { getRefs } from "./Refs.js"; 6 | import { parseAnyDef } from "./parsers/any.js"; 7 | 8 | const zodToJsonSchema = ( 9 | schema: ZodSchema, 10 | options?: Partial> | string, 11 | ): (Target extends "jsonSchema7" ? JsonSchema7Type : object) & { 12 | $schema?: string; 13 | definitions?: { 14 | [key: string]: Target extends "jsonSchema7" 15 | ? JsonSchema7Type 16 | : Target extends "jsonSchema2019-09" 17 | ? JsonSchema7Type 18 | : object; 19 | }; 20 | } => { 21 | const refs = getRefs(options); 22 | 23 | let definitions = 24 | typeof options === "object" && options.definitions 25 | ? Object.entries(options.definitions).reduce( 26 | (acc: { [key: string]: JsonSchema7Type }, [name, schema]) => ({ 27 | ...acc, 28 | [name]: 29 | parseDef( 30 | schema._def, 31 | { 32 | ...refs, 33 | currentPath: [...refs.basePath, refs.definitionPath, name], 34 | }, 35 | true, 36 | ) ?? parseAnyDef(refs), 37 | }), 38 | {}, 39 | ) 40 | : undefined; 41 | 42 | const name = 43 | typeof options === "string" 44 | ? options 45 | : options?.nameStrategy === "title" 46 | ? undefined 47 | : options?.name; 48 | 49 | const main = 50 | parseDef( 51 | schema._def, 52 | name === undefined 53 | ? refs 54 | : { 55 | ...refs, 56 | currentPath: [...refs.basePath, refs.definitionPath, name], 57 | }, 58 | false, 59 | ) ?? (parseAnyDef(refs) as JsonSchema7Type); 60 | 61 | const title = 62 | typeof options === "object" && 63 | options.name !== undefined && 64 | options.nameStrategy === "title" 65 | ? options.name 66 | : undefined; 67 | 68 | if (title !== undefined) { 69 | main.title = title; 70 | } 71 | 72 | if (refs.flags.hasReferencedOpenAiAnyType) { 73 | if (!definitions) { 74 | definitions = {}; 75 | } 76 | 77 | if (!definitions[refs.openAiAnyTypeName]) { 78 | definitions[refs.openAiAnyTypeName] = { 79 | // Skipping "object" as no properties can be defined and additionalProperties must be "false" 80 | type: ["string", "number", "integer", "boolean", "array", "null"], 81 | items: { 82 | $ref: 83 | refs.$refStrategy === "relative" 84 | ? "1" 85 | : [ 86 | ...refs.basePath, 87 | refs.definitionPath, 88 | refs.openAiAnyTypeName, 89 | ].join("/"), 90 | }, 91 | } as JsonSchema7Type; 92 | } 93 | } 94 | 95 | const combined: ReturnType> = 96 | name === undefined 97 | ? definitions 98 | ? { 99 | ...main, 100 | [refs.definitionPath]: definitions, 101 | } 102 | : main 103 | : { 104 | $ref: [ 105 | ...(refs.$refStrategy === "relative" ? [] : refs.basePath), 106 | refs.definitionPath, 107 | name, 108 | ].join("/"), 109 | [refs.definitionPath]: { 110 | ...definitions, 111 | [name]: main, 112 | }, 113 | }; 114 | 115 | if (refs.target === "jsonSchema7") { 116 | combined.$schema = "http://json-schema.org/draft-07/schema#"; 117 | } else if (refs.target === "jsonSchema2019-09" || refs.target === "openAi") { 118 | combined.$schema = "https://json-schema.org/draft/2019-09/schema#"; 119 | } 120 | 121 | if ( 122 | refs.target === "openAi" && 123 | ("anyOf" in combined || 124 | "oneOf" in combined || 125 | "allOf" in combined || 126 | ("type" in combined && Array.isArray(combined.type))) 127 | ) { 128 | console.warn( 129 | "Warning: OpenAI may not support schemas with unions as roots! Try wrapping it in an object property.", 130 | ); 131 | } 132 | 133 | return combined; 134 | }; 135 | 136 | export { zodToJsonSchema }; 137 | -------------------------------------------------------------------------------- /cp-zod-to-json-schema.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | # Find the version from package.json (devDependencies or dependencies) 5 | VERSION=$(jq -r '.devDependencies["zod-to-json-schema"] // .dependencies["zod-to-json-schema"]' package.json) 6 | if [[ -z "$VERSION" || "$VERSION" == "null" ]]; then 7 | echo "Could not find zod-to-json-schema version in package.json" >&2 8 | exit 1 9 | fi 10 | 11 | echo "zod-to-json-schema version: $VERSION" 12 | 13 | # Get the git commit hash for that version from npm 14 | GIT_HEAD=$(npm view zod-to-json-schema@"$VERSION" gitHead) 15 | # rm quotes that npm annoyingly adds if parent command has --json 16 | GIT_HEAD="${GIT_HEAD%\"}" 17 | GIT_HEAD="${GIT_HEAD#\"}" 18 | if [[ -z "$GIT_HEAD" ]]; then 19 | echo "Could not find git commit hash for zod-to-json-schema@$VERSION from npm" >&2 20 | exit 1 21 | fi 22 | 23 | echo "Git commit hash: $GIT_HEAD" 24 | 25 | # Remove temp dir if it exists 26 | rm -rf /tmp/zod-to-json-schema-temp 27 | 28 | # Clone the repo 29 | if ! git clone https://github.com/StefanTerdell/zod-to-json-schema.git /tmp/zod-to-json-schema-temp; then 30 | echo "Failed to clone zod-to-json-schema repo" >&2 31 | exit 1 32 | fi 33 | 34 | cd /tmp/zod-to-json-schema-temp 35 | 36 | # Checkout the correct commit 37 | if ! git checkout "$GIT_HEAD"; then 38 | echo "Failed to checkout commit $GIT_HEAD" >&2 39 | exit 1 40 | fi 41 | 42 | cd - > /dev/null 43 | 44 | # Copy src directory 45 | rm -rf src/zod-to-json-schema 46 | if ! cp -r /tmp/zod-to-json-schema-temp/src src/zod-to-json-schema; then 47 | echo "Failed to copy src directory" >&2 48 | exit 1 49 | fi 50 | 51 | # Clean up 52 | echo "Cleaning up temp directory" 53 | rm -rf /tmp/zod-to-json-schema-temp 54 | 55 | find src/zod-to-json-schema -type f -name '*.ts' -exec node -e ' 56 | import * as fs from "fs"; 57 | import * as path from "path"; 58 | 59 | let content = fs.readFileSync("{}", "utf8"); 60 | 61 | content = content 62 | .replaceAll(`from "../Refs";`, `from "../Refs.js";`) 63 | .replaceAll(`from "./Refs";`, `from "./Refs.js";`) 64 | .replaceAll(`from "./parseTypes";`, `from "./parseTypes.js";`) 65 | .split(";") 66 | .map(s => { 67 | if (!s.trim().startsWith("import {")) return s; 68 | const imports = s.split("{")[1].split("}")[0].trim() 69 | .split(",") 70 | .map(i => i.trim()) 71 | .filter(i => i !== "ZodFirstPartyTypeKind") 72 | .filter(Boolean); 73 | 74 | if (imports.length === 0) { 75 | return ""; 76 | } 77 | 78 | if (imports.every(i => i[0] === i[0].toUpperCase())) { 79 | return `import type { ${imports.join(", ")} }${s.split("}")[1]}`; 80 | } 81 | const typeified = imports.map(i => i.match(/^[A-Z]/) ? `type ${i}` : i); 82 | return `import { ${typeified.join(", ")} }${s.split("}")[1]}`; 83 | }) 84 | .filter(Boolean) 85 | .join(";"); 86 | 87 | content = content.replaceAll(";import", ";\nimport"); 88 | 89 | 90 | if (content.includes("ZodFirstPartyTypeKind")) { 91 | let relPath = path.relative(path.dirname("{}"), path.join(process.cwd(), "src/zod-to-json-schema/ZodFirstPartyTypeKind.js")); 92 | if (!relPath.startsWith(".")) relPath = "./" + relPath; 93 | content = [ 94 | `import {ZodFirstPartyTypeKind} from ${JSON.stringify(relPath)};`, 95 | content, 96 | ].join("\n"); 97 | } 98 | 99 | fs.writeFileSync("{}", content); 100 | ' {} ';' 101 | 102 | echo '/** copy-pasted from zod v3, to minimize diff vs zod-to-json-schema */ 103 | export enum ZodFirstPartyTypeKind { 104 | ZodString = "ZodString", 105 | ZodNumber = "ZodNumber", 106 | ZodNaN = "ZodNaN", 107 | ZodBigInt = "ZodBigInt", 108 | ZodBoolean = "ZodBoolean", 109 | ZodDate = "ZodDate", 110 | ZodSymbol = "ZodSymbol", 111 | ZodUndefined = "ZodUndefined", 112 | ZodNull = "ZodNull", 113 | ZodAny = "ZodAny", 114 | ZodUnknown = "ZodUnknown", 115 | ZodNever = "ZodNever", 116 | ZodVoid = "ZodVoid", 117 | ZodArray = "ZodArray", 118 | ZodObject = "ZodObject", 119 | ZodUnion = "ZodUnion", 120 | ZodDiscriminatedUnion = "ZodDiscriminatedUnion", 121 | ZodIntersection = "ZodIntersection", 122 | ZodTuple = "ZodTuple", 123 | ZodRecord = "ZodRecord", 124 | ZodMap = "ZodMap", 125 | ZodSet = "ZodSet", 126 | ZodFunction = "ZodFunction", 127 | ZodLazy = "ZodLazy", 128 | ZodLiteral = "ZodLiteral", 129 | ZodEnum = "ZodEnum", 130 | ZodEffects = "ZodEffects", 131 | ZodNativeEnum = "ZodNativeEnum", 132 | ZodOptional = "ZodOptional", 133 | ZodNullable = "ZodNullable", 134 | ZodDefault = "ZodDefault", 135 | ZodCatch = "ZodCatch", 136 | ZodPromise = "ZodPromise", 137 | ZodBranded = "ZodBranded", 138 | ZodPipeline = "ZodPipeline", 139 | ZodReadonly = "ZodReadonly" 140 | }' > src/zod-to-json-schema/ZodFirstPartyTypeKind.ts 141 | 142 | echo "Done! src/zod-to-json-schema is now at zod-to-json-schema@$VERSION ($GIT_HEAD)" -------------------------------------------------------------------------------- /src/zod-to-json-schema/parsers/union.ts: -------------------------------------------------------------------------------- 1 | import type { ZodDiscriminatedUnionDef, ZodLiteralDef, ZodTypeAny, ZodUnionDef } from "zod"; 2 | import { parseDef } from "../parseDef.js"; 3 | import type { JsonSchema7Type } from "../parseTypes.js"; 4 | import type { Refs } from "../Refs.js"; 5 | 6 | export const primitiveMappings = { 7 | ZodString: "string", 8 | ZodNumber: "number", 9 | ZodBigInt: "integer", 10 | ZodBoolean: "boolean", 11 | ZodNull: "null", 12 | } as const; 13 | type ZodPrimitive = keyof typeof primitiveMappings; 14 | type JsonSchema7Primitive = 15 | (typeof primitiveMappings)[keyof typeof primitiveMappings]; 16 | 17 | export type JsonSchema7UnionType = 18 | | JsonSchema7PrimitiveUnionType 19 | | JsonSchema7AnyOfType; 20 | 21 | type JsonSchema7PrimitiveUnionType = 22 | | { 23 | type: JsonSchema7Primitive | JsonSchema7Primitive[]; 24 | } 25 | | { 26 | type: JsonSchema7Primitive | JsonSchema7Primitive[]; 27 | enum: (string | number | bigint | boolean | null)[]; 28 | }; 29 | 30 | type JsonSchema7AnyOfType = { 31 | anyOf: JsonSchema7Type[]; 32 | }; 33 | 34 | export function parseUnionDef( 35 | def: ZodUnionDef | ZodDiscriminatedUnionDef, 36 | refs: Refs, 37 | ): JsonSchema7PrimitiveUnionType | JsonSchema7AnyOfType | undefined { 38 | if (refs.target === "openApi3") return asAnyOf(def, refs); 39 | 40 | const options: readonly ZodTypeAny[] = 41 | def.options instanceof Map ? Array.from(def.options.values()) : def.options; 42 | 43 | // This blocks tries to look ahead a bit to produce nicer looking schemas with type array instead of anyOf. 44 | if ( 45 | options.every( 46 | (x) => 47 | x._def.typeName in primitiveMappings && 48 | (!x._def.checks || !x._def.checks.length), 49 | ) 50 | ) { 51 | // all types in union are primitive and lack checks, so might as well squash into {type: [...]} 52 | 53 | const types = options.reduce((types: JsonSchema7Primitive[], x) => { 54 | const type = primitiveMappings[x._def.typeName as ZodPrimitive]; //Can be safely casted due to row 43 55 | return type && !types.includes(type) ? [...types, type] : types; 56 | }, []); 57 | 58 | return { 59 | type: types.length > 1 ? types : types[0], 60 | }; 61 | } else if ( 62 | options.every((x) => x._def.typeName === "ZodLiteral" && !x.description) 63 | ) { 64 | // all options literals 65 | 66 | const types = options.reduce( 67 | (acc: JsonSchema7Primitive[], x: { _def: ZodLiteralDef }) => { 68 | const type = typeof x._def.value; 69 | switch (type) { 70 | case "string": 71 | case "number": 72 | case "boolean": 73 | return [...acc, type]; 74 | case "bigint": 75 | return [...acc, "integer" as const]; 76 | case "object": 77 | if (x._def.value === null) return [...acc, "null" as const]; 78 | case "symbol": 79 | case "undefined": 80 | case "function": 81 | default: 82 | return acc; 83 | } 84 | }, 85 | [], 86 | ); 87 | 88 | if (types.length === options.length) { 89 | // all the literals are primitive, as far as null can be considered primitive 90 | 91 | const uniqueTypes = types.filter((x, i, a) => a.indexOf(x) === i); 92 | return { 93 | type: uniqueTypes.length > 1 ? uniqueTypes : uniqueTypes[0], 94 | enum: options.reduce( 95 | (acc, x) => { 96 | return acc.includes(x._def.value) ? acc : [...acc, x._def.value]; 97 | }, 98 | [] as (string | number | bigint | boolean | null)[], 99 | ), 100 | }; 101 | } 102 | } else if (options.every((x) => x._def.typeName === "ZodEnum")) { 103 | return { 104 | type: "string", 105 | enum: options.reduce( 106 | (acc: string[], x) => [ 107 | ...acc, 108 | ...x._def.values.filter((x: string) => !acc.includes(x)), 109 | ], 110 | [], 111 | ), 112 | }; 113 | } 114 | 115 | return asAnyOf(def, refs); 116 | } 117 | 118 | const asAnyOf = ( 119 | def: ZodUnionDef | ZodDiscriminatedUnionDef, 120 | refs: Refs, 121 | ): JsonSchema7PrimitiveUnionType | JsonSchema7AnyOfType | undefined => { 122 | const anyOf = ( 123 | (def.options instanceof Map 124 | ? Array.from(def.options.values()) 125 | : def.options) as any[] 126 | ) 127 | .map((x, i) => 128 | parseDef(x._def, { 129 | ...refs, 130 | currentPath: [...refs.currentPath, "anyOf", `${i}`], 131 | }), 132 | ) 133 | .filter( 134 | (x): x is JsonSchema7Type => 135 | !!x && 136 | (!refs.strictUnions || 137 | (typeof x === "object" && Object.keys(x).length > 0)), 138 | ); 139 | 140 | return anyOf.length ? { anyOf } : undefined; 141 | }; 142 | -------------------------------------------------------------------------------- /test/help.test.ts: -------------------------------------------------------------------------------- 1 | import {initTRPC} from '@trpc/server' 2 | import {expect, test} from 'vitest' 3 | import {z} from 'zod' 4 | import {AnyRouter, createCli, TrpcCliMeta, TrpcCliParams} from '../src/index.js' 5 | import {looksLikeInstanceof} from '../src/util.js' 6 | 7 | expect.addSnapshotSerializer({ 8 | test: val => looksLikeInstanceof(val, Error), 9 | serialize(val, config, indentation, depth, refs, printer) { 10 | let topLine = `${val.constructor.name}: ${val.message}` 11 | if (val.constructor.name === 'FailedToExitError') topLine = `CLI exited with code ${val.exitCode}` 12 | 13 | if (!val.cause) return topLine 14 | indentation += ' ' 15 | return `${topLine}\n${indentation}Caused by: ${printer(val.cause, config, indentation, depth + 1, refs)}` 16 | .split(/(---|Usage:)/)[0] // strip out the usage line and the --- line which is added for debugging when tests fail 17 | .trim() 18 | }, 19 | }) 20 | 21 | const t = initTRPC.meta().create() 22 | 23 | const run = (router: R, argv: string[]) => { 24 | return runWith({router}, argv) 25 | } 26 | const runWith = (params: TrpcCliParams, argv: string[]) => { 27 | const cli = createCli(params) 28 | const logs = [] as unknown[][] 29 | const addLogs = (...args: unknown[]) => logs.push(args) 30 | return cli 31 | .run({ 32 | argv, 33 | logger: {info: addLogs, error: addLogs}, 34 | process: {exit: _ => 0 as never}, 35 | }) 36 | .catch(e => { 37 | if (e.exitCode === 0 && e.cause.message === '(outputHelp)') return logs?.[0]?.[0] // should be the help text 38 | if (e.exitCode === 0) return e.cause 39 | throw e 40 | }) 41 | } 42 | 43 | test('options with various modifiers', async () => { 44 | const router = t.router({ 45 | test: t.procedure 46 | .input( 47 | z.object({ 48 | stringWithDefault: z.string().default('hello'), 49 | literalWithDefault: z.literal('hi').default('hi'), 50 | unionWithDefault: z.union([z.literal('foo'), z.literal('bar')]).default('foo'), 51 | numberWithDefault: z.number().default(42), 52 | booleanWithDefault: z.boolean().default(true), 53 | booleanOrNumber: z.union([z.boolean(), z.number()]), 54 | enumWithDefault: z.enum(['foo', 'bar']).default('foo'), 55 | arrayWithDefault: z.array(z.string()).default(['hello']), 56 | objectWithDefault: z.object({foo: z.string()}).default({foo: 'bar'}), 57 | arrayOfObjectsWithDefault: z.array(z.object({foo: z.string()})).default([{foo: 'bar'}]), 58 | arrayOfEnumsWithDefault: z.array(z.enum(['foo', 'bar'])).default(['foo']), 59 | arrayOfUnionsWithDefault: z.array(z.union([z.literal('foo'), z.literal('bar')])).default(['foo']), 60 | arrayOfNumbersWithDefault: z.array(z.number()).default([42]), 61 | arrayOfBooleansWithDefault: z.array(z.boolean()).default([true]), 62 | 63 | numberWithMinAndMax: z.number().min(0).max(10), 64 | regex: z.string().regex(/^[a-z]+$/), 65 | }), 66 | ) 67 | .query(({input}) => Object.entries(input).join(', ')), 68 | }) 69 | 70 | // fix for annoying leading space: https://github.com/tj/commander.js/pull/2348 71 | expect(await run(router, ['test', '--help'])).toMatchInlineSnapshot(` 72 | "Usage: program test [options] 73 | 74 | Options: 75 | --string-with-default [string] (default: "hello") 76 | --literal-with-default [string] Const: hi (default: "hi") 77 | --union-with-default [string] (choices: "foo", "bar", default: "foo") 78 | --number-with-default [number] (default: 42) 79 | --boolean-with-default [boolean] (default: true) 80 | --boolean-or-number [value] type: boolean or number (default: false) 81 | --enum-with-default [string] (choices: "foo", "bar", default: "foo") 82 | --array-with-default [values...] Type: string array (default: ["hello"]) 83 | --object-with-default [json] Object (json formatted); Required: ["foo"] (default: {"foo":"bar"}) 84 | --array-of-objects-with-default [values...] Type: object; Object (json formatted); Required: ["foo"] array (default: [{"foo":"bar"}]) 85 | --array-of-enums-with-default [values...] Type: string array (choices: "foo", "bar", default: ["foo"]) 86 | --array-of-unions-with-default [values...] Type: string array (choices: "foo", "bar", default: ["foo"]) 87 | --array-of-numbers-with-default [values...] Type: number array (default: [42]) 88 | --array-of-booleans-with-default [values...] Type: boolean array (default: [true]) 89 | --number-with-min-and-max Minimum: 0; Maximum: 10 90 | --regex Pattern: ^[a-z]+$ 91 | -h, --help display help for command 92 | " 93 | `) 94 | }) 95 | -------------------------------------------------------------------------------- /src/zod-to-json-schema/selectParser.ts: -------------------------------------------------------------------------------- 1 | import {ZodFirstPartyTypeKind} from "./ZodFirstPartyTypeKind.js"; 2 | import { parseAnyDef } from "./parsers/any.js"; 3 | import { parseArrayDef } from "./parsers/array.js"; 4 | import { parseBigintDef } from "./parsers/bigint.js"; 5 | import { parseBooleanDef } from "./parsers/boolean.js"; 6 | import { parseBrandedDef } from "./parsers/branded.js"; 7 | import { parseCatchDef } from "./parsers/catch.js"; 8 | import { parseDateDef } from "./parsers/date.js"; 9 | import { parseDefaultDef } from "./parsers/default.js"; 10 | import { parseEffectsDef } from "./parsers/effects.js"; 11 | import { parseEnumDef } from "./parsers/enum.js"; 12 | import { parseIntersectionDef } from "./parsers/intersection.js"; 13 | import { parseLiteralDef } from "./parsers/literal.js"; 14 | import { parseMapDef } from "./parsers/map.js"; 15 | import { parseNativeEnumDef } from "./parsers/nativeEnum.js"; 16 | import { parseNeverDef } from "./parsers/never.js"; 17 | import { parseNullDef } from "./parsers/null.js"; 18 | import { parseNullableDef } from "./parsers/nullable.js"; 19 | import { parseNumberDef } from "./parsers/number.js"; 20 | import { parseObjectDef } from "./parsers/object.js"; 21 | import { parseOptionalDef } from "./parsers/optional.js"; 22 | import { parsePipelineDef } from "./parsers/pipeline.js"; 23 | import { parsePromiseDef } from "./parsers/promise.js"; 24 | import { parseRecordDef } from "./parsers/record.js"; 25 | import { parseSetDef } from "./parsers/set.js"; 26 | import { parseStringDef } from "./parsers/string.js"; 27 | import { parseTupleDef } from "./parsers/tuple.js"; 28 | import { parseUndefinedDef } from "./parsers/undefined.js"; 29 | import { parseUnionDef } from "./parsers/union.js"; 30 | import { parseUnknownDef } from "./parsers/unknown.js"; 31 | import type { Refs } from "./Refs.js"; 32 | import { parseReadonlyDef } from "./parsers/readonly.js"; 33 | import type { JsonSchema7Type } from "./parseTypes.js"; 34 | 35 | export type InnerDefGetter = () => any; 36 | 37 | export const selectParser = ( 38 | def: any, 39 | typeName: ZodFirstPartyTypeKind, 40 | refs: Refs, 41 | ): JsonSchema7Type | undefined | InnerDefGetter => { 42 | switch (typeName) { 43 | case ZodFirstPartyTypeKind.ZodString: 44 | return parseStringDef(def, refs); 45 | case ZodFirstPartyTypeKind.ZodNumber: 46 | return parseNumberDef(def, refs); 47 | case ZodFirstPartyTypeKind.ZodObject: 48 | return parseObjectDef(def, refs); 49 | case ZodFirstPartyTypeKind.ZodBigInt: 50 | return parseBigintDef(def, refs); 51 | case ZodFirstPartyTypeKind.ZodBoolean: 52 | return parseBooleanDef(); 53 | case ZodFirstPartyTypeKind.ZodDate: 54 | return parseDateDef(def, refs); 55 | case ZodFirstPartyTypeKind.ZodUndefined: 56 | return parseUndefinedDef(refs); 57 | case ZodFirstPartyTypeKind.ZodNull: 58 | return parseNullDef(refs); 59 | case ZodFirstPartyTypeKind.ZodArray: 60 | return parseArrayDef(def, refs); 61 | case ZodFirstPartyTypeKind.ZodUnion: 62 | case ZodFirstPartyTypeKind.ZodDiscriminatedUnion: 63 | return parseUnionDef(def, refs); 64 | case ZodFirstPartyTypeKind.ZodIntersection: 65 | return parseIntersectionDef(def, refs); 66 | case ZodFirstPartyTypeKind.ZodTuple: 67 | return parseTupleDef(def, refs); 68 | case ZodFirstPartyTypeKind.ZodRecord: 69 | return parseRecordDef(def, refs); 70 | case ZodFirstPartyTypeKind.ZodLiteral: 71 | return parseLiteralDef(def, refs); 72 | case ZodFirstPartyTypeKind.ZodEnum: 73 | return parseEnumDef(def); 74 | case ZodFirstPartyTypeKind.ZodNativeEnum: 75 | return parseNativeEnumDef(def); 76 | case ZodFirstPartyTypeKind.ZodNullable: 77 | return parseNullableDef(def, refs); 78 | case ZodFirstPartyTypeKind.ZodOptional: 79 | return parseOptionalDef(def, refs); 80 | case ZodFirstPartyTypeKind.ZodMap: 81 | return parseMapDef(def, refs); 82 | case ZodFirstPartyTypeKind.ZodSet: 83 | return parseSetDef(def, refs); 84 | case ZodFirstPartyTypeKind.ZodLazy: 85 | return () => (def as any).getter()._def; 86 | case ZodFirstPartyTypeKind.ZodPromise: 87 | return parsePromiseDef(def, refs); 88 | case ZodFirstPartyTypeKind.ZodNaN: 89 | case ZodFirstPartyTypeKind.ZodNever: 90 | return parseNeverDef(refs); 91 | case ZodFirstPartyTypeKind.ZodEffects: 92 | return parseEffectsDef(def, refs); 93 | case ZodFirstPartyTypeKind.ZodAny: 94 | return parseAnyDef(refs); 95 | case ZodFirstPartyTypeKind.ZodUnknown: 96 | return parseUnknownDef(refs); 97 | case ZodFirstPartyTypeKind.ZodDefault: 98 | return parseDefaultDef(def, refs); 99 | case ZodFirstPartyTypeKind.ZodBranded: 100 | return parseBrandedDef(def, refs); 101 | case ZodFirstPartyTypeKind.ZodReadonly: 102 | return parseReadonlyDef(def, refs); 103 | case ZodFirstPartyTypeKind.ZodCatch: 104 | return parseCatchDef(def, refs); 105 | case ZodFirstPartyTypeKind.ZodPipeline: 106 | return parsePipelineDef(def, refs); 107 | case ZodFirstPartyTypeKind.ZodFunction: 108 | case ZodFirstPartyTypeKind.ZodVoid: 109 | case ZodFirstPartyTypeKind.ZodSymbol: 110 | return undefined; 111 | default: 112 | /* c8 ignore next */ 113 | return ((_: never) => undefined)(typeName); 114 | } 115 | }; 116 | -------------------------------------------------------------------------------- /test/orpc.test.ts: -------------------------------------------------------------------------------- 1 | globalThis.require = require 2 | 3 | /* eslint-disable @typescript-eslint/no-shadow */ 4 | import {oc} from '@orpc/contract' 5 | import {implement, os, unlazyRouter} from '@orpc/server' 6 | import * as v from 'valibot' 7 | import {expect, test} from 'vitest' 8 | import {z} from 'zod/v4' 9 | import {run, snapshotSerializer} from './test-run.js' 10 | 11 | expect.addSnapshotSerializer({ 12 | test: val => val instanceof z.ZodType, 13 | print: val => (val as object)?.constructor.name, 14 | }) 15 | 16 | expect.addSnapshotSerializer(snapshotSerializer) 17 | 18 | expect.addSnapshotSerializer({ 19 | test: val => typeof val === 'string', 20 | serialize: val => 21 | `"${val}"`.replaceAll('json-schema.org/draft-2020-12/schema', 'json-schema.org/draft/2020-12/schema'), 22 | }) 23 | 24 | const o = os.$context<{x: number}>() 25 | const router = o.router({ 26 | hello: o 27 | .input( 28 | z.object({ 29 | foo: z.string(), 30 | bar: z.number(), 31 | }), 32 | ) 33 | .handler(({input}) => `hello ${input.foo} ${input.bar}`), 34 | withValibot: o 35 | .input( 36 | v.object({ 37 | abc: v.string(), 38 | def: v.number(), 39 | }), 40 | ) 41 | .handler(({input}) => `abc is ${input.abc} and def is ${input.def}`), 42 | deeply: { 43 | nested: { 44 | greeting: o.input(z.string()).handler(({input}) => `hello ${input}`), 45 | }, 46 | }, 47 | }) 48 | 49 | test('orpc-cli', async () => { 50 | expect(await run(router, ['hello', '--foo', 'world', '--bar', '42'])).toMatchInlineSnapshot(`"hello world 42"`) 51 | expect(await run(router, ['with-valibot', '--abc', 'hello', '--def', '42'])).toMatchInlineSnapshot( 52 | `"abc is hello and def is 42"`, 53 | ) 54 | expect(await run(router, ['deeply', 'nested', 'greeting', 'hi'])).toMatchInlineSnapshot(`"hello hi"`) 55 | }) 56 | 57 | test('lazy router', async () => { 58 | const lazyRouter = os.router({ 59 | greeting: { 60 | casual: os.input(z.string()).handler(({input}) => `hi ${input}`), 61 | formal: os.input(z.string()).handler(({input}) => `hello ${input}`), 62 | }, 63 | departure: os.lazy(async () => ({ 64 | // default needed because os.lazy is designed for `os.lazy(() => import('./somemodule'))` 65 | default: { 66 | casual: os.input(z.string()).handler(({input}) => `bye ${input}`), 67 | formal: os.input(z.string()).handler(({input}) => `goodbye ${input}`), 68 | }, 69 | })), 70 | }) 71 | 72 | // @ts-expect-error - we want an error here - that means users will get a type error if they try to use a lazy router without unlazying it first 73 | await expect(run(lazyRouter, ['greeting', 'casual', 'bob'])).rejects.toMatchInlineSnapshot( 74 | `Error: Lazy routers are not supported. Please use \`import {unlazyRouter} from '@orpc/server'\` to unlazy the router before passing it to trpc-cli. Lazy routes detected: departure`, 75 | ) 76 | 77 | const {departure, ...eagerRouterSubset} = lazyRouter 78 | expect(await run(eagerRouterSubset, ['greeting', 'casual', 'bob'])).toMatchInlineSnapshot(`"hi bob"`) 79 | 80 | const unlazy = await unlazyRouter(lazyRouter) 81 | await expect(run(unlazy, ['departure', 'casual', 'bob'])).resolves.toMatchInlineSnapshot(`"bye bob"`) 82 | }) 83 | 84 | test('contract-based router', async () => { 85 | const contract = oc.router({ 86 | hello: oc.input(z.string()).output(z.string()), 87 | deeply: { 88 | nested: { 89 | greeting: oc.input(z.string()).output(z.string()), 90 | }, 91 | }, 92 | }) 93 | 94 | const os = implement(contract) 95 | 96 | const router = os.router({ 97 | hello: os.hello.handler(({input}) => `hello ${input}`), 98 | deeply: { 99 | nested: { 100 | greeting: os.deeply.nested.greeting.handler(({input}) => `hi ${input}`), 101 | }, 102 | }, 103 | }) 104 | 105 | expect(await run(router, ['hello', 'world'])).toMatchInlineSnapshot(`"hello world"`) 106 | expect(await run(router, ['deeply', 'nested', 'greeting', 'bob'])).toMatchInlineSnapshot(`"hi bob"`) 107 | }) 108 | 109 | test('orpc unjsonifiable schema', async () => { 110 | const router = o.router({ 111 | hello: o 112 | .input( 113 | z.custom<{foo: string; bar: number}>(v => { 114 | const value = v as Record 115 | return typeof value?.foo === 'string' && typeof value.bar === 'number' 116 | }), 117 | ) 118 | .handler(({input}) => `foo is ${input.foo} and bar is ${input.bar}`), 119 | }) 120 | 121 | expect(await run(router, ['hello', '--help'], {expectJsonInput: true})).toMatchInlineSnapshot(` 122 | "Usage: program hello [options] 123 | 124 | Options: 125 | --input [json] Input formatted as JSON (procedure's schema couldn't be 126 | converted to CLI arguments: Invalid input type { '$schema': 127 | 'https://json-schema.org/draft/2020-12/schema' }, expected 128 | object or tuple.) 129 | -h, --help display help for command 130 | " 131 | `) 132 | expect(await run(router, ['hello', '--input', '{"foo": "world", "bar": 42}'])).toMatchInlineSnapshot( 133 | `"foo is world and bar is 42"`, 134 | ) 135 | }) 136 | 137 | test('orpc json input via meta', async () => { 138 | const router = o.router({ 139 | hello: o 140 | .meta({jsonInput: true}) 141 | .input(z.object({foo: z.string(), bar: z.number()})) 142 | .handler(({input}) => `foo is ${input.foo} and bar is ${input.bar}`), 143 | }) 144 | 145 | expect(await run(router, ['hello', '--help'], {expectJsonInput: true})).toMatchInlineSnapshot(` 146 | "Usage: program hello [options] 147 | 148 | Options: 149 | --input [json] Input formatted as JSON 150 | -h, --help display help for command 151 | " 152 | `) 153 | expect(await run(router, ['hello', '--input', '{"foo": "world", "bar": 42}'])).toMatchInlineSnapshot( 154 | `"foo is world and bar is 42"`, 155 | ) 156 | }) 157 | -------------------------------------------------------------------------------- /test/lifecycle.test.ts: -------------------------------------------------------------------------------- 1 | import {initTRPC} from '@trpc/server' 2 | import {Command} from 'commander' 3 | import {expect, test, vi} from 'vitest' 4 | import {z} from 'zod/v3' 5 | import {FailedToExitError} from '../src/errors.js' 6 | import {createCli, TrpcCliMeta} from '../src/index.js' 7 | 8 | const t = initTRPC.meta().create() 9 | 10 | // these tests just make sure it's possible to override process.exit if you want to capture low-level errors 11 | 12 | test('override of process.exit happy path', async () => { 13 | const router = t.router({ 14 | foo: t.procedure.input(z.object({bar: z.number()})).query(({input}) => JSON.stringify(input)), 15 | }) 16 | 17 | const cli = createCli({router}) 18 | 19 | const exit = vi.fn() as any 20 | const log = vi.fn() 21 | const result = await cli 22 | .run({ 23 | argv: ['foo', '--bar', '1'], 24 | process: {exit}, // prevent process.exit 25 | logger: {info: log}, 26 | }) 27 | .catch(err => err) 28 | 29 | expect(exit).toHaveBeenCalledWith(0) 30 | expect(log).toHaveBeenCalledWith('{"bar":1}') 31 | expect(result).toBeInstanceOf(FailedToExitError) 32 | expect(result.exitCode).toBe(0) 33 | expect(result.cause).toBe('{"bar":1}') 34 | }) 35 | 36 | test('override of process.exit and pass in bad option', async () => { 37 | const router = t.router({ 38 | foo: t.procedure.input(z.object({bar: z.number()})).query(({input}) => Object.entries(input).join(', ')), 39 | }) 40 | 41 | const cli = createCli({router}) 42 | 43 | const result = await cli 44 | .run({ 45 | argv: ['foo', '--bar', 'notanumber'], 46 | process: {exit: () => void 0 as never}, // prevent process.exit 47 | logger: {error: () => void 0}, 48 | }) 49 | .catch(err => err) 50 | 51 | expect(result).toMatchInlineSnapshot( 52 | `[Error: Program exit after failure. The process was expected to exit with exit code 1 but did not. This may be because a custom \`process\` parameter was used. The exit reason is in the \`cause\` property.]`, 53 | ) 54 | expect(result.exitCode).toBe(1) 55 | expect(result.cause).toMatchInlineSnapshot(` 56 | [Error: ✖ Expected number, received string → at bar 57 | 58 | Usage: program foo [options] 59 | 60 | Options: 61 | --bar 62 | -h, --help display help for command 63 | ] 64 | `) 65 | expect(result.cause.cause).toMatchInlineSnapshot(`undefined`) 66 | }) 67 | 68 | test('override of process.exit with parse error', async () => { 69 | const router = t.router({ 70 | foo: t.procedure.input(z.object({bar: z.number()})).query(({input}) => Object.entries(input).join(', ')), 71 | }) 72 | 73 | const cli = createCli({router}) 74 | 75 | const result = await cli 76 | .run({ 77 | argv: ['footypo', '--bar', 'notanumber'], 78 | process: {exit: () => void 0 as never}, // prevent process.exit 79 | logger: {error: () => void 0}, 80 | }) 81 | .catch(err => err) 82 | 83 | expect(result).toMatchInlineSnapshot( 84 | `[Error: Root command exitOverride. The process was expected to exit with exit code 1 but did not. This may be because a custom \`process\` parameter was used. The exit reason is in the \`cause\` property.]`, 85 | ) 86 | expect(result.cause).toMatchInlineSnapshot(`[CommanderError: error: unknown command 'footypo']`) 87 | }) 88 | 89 | const calculatorRouter = t.router({ 90 | add: t.procedure.input(z.tuple([z.number(), z.number()])).query(({input}) => { 91 | return input[0] + input[1] 92 | }), 93 | squareRoot: t.procedure.input(z.number()).query(({input}) => { 94 | if (input < 0) throw new Error(`Get real`) 95 | return Math.sqrt(input) 96 | }), 97 | }) 98 | 99 | const run = async (argv: string[]) => { 100 | const cli = createCli({router: calculatorRouter}) 101 | return cli 102 | .run({ 103 | argv, 104 | process: {exit: () => void 0 as never}, 105 | logger: {info: () => {}, error: () => {}}, 106 | }) 107 | .catch(err => { 108 | // this will always throw, because our `exit` handler doesn't throw or exit the process 109 | while (err instanceof FailedToExitError) { 110 | if (err.exitCode === 0) { 111 | return err.cause // this is the return value of the procedure that was invoked 112 | } 113 | err = err.cause // use the underlying error that caused the exit 114 | } 115 | throw err 116 | }) 117 | } 118 | 119 | test('make sure parsing works correctly', async () => { 120 | await expect(run(['add', '2', '3'])).resolves.toBe(5) 121 | await expect(run(['square-root', '--', '4'])).resolves.toBe(2) 122 | await expect(run(['square-root', '--', '-1'])).rejects.toMatchInlineSnapshot(`[Error: Get real]`) 123 | await expect(run(['add', '2', 'notanumber'])).rejects.toMatchInlineSnapshot( 124 | `[CommanderError: error: command-argument value 'notanumber' is invalid for argument 'parameter_2'. Invalid number: notanumber]`, 125 | ) 126 | }) 127 | 128 | test('modify commander program manually', async () => { 129 | const cli = createCli({router: calculatorRouter}) 130 | const program = cli.buildProgram() as Command 131 | program.usage('Here is how to use: `calculator add 1 1`') 132 | program.addHelpText('afterAll', `Good luck have fun`) 133 | const mockLog = vi.fn() 134 | const result = await cli 135 | .run( 136 | { 137 | argv: ['--help'], 138 | process: {exit: () => void 0 as never}, 139 | logger: {...console, error: mockLog, info: mockLog}, 140 | }, 141 | program, 142 | ) 143 | .catch(err => err?.cause) 144 | 145 | expect(result).toMatchInlineSnapshot(`[CommanderError: (outputHelp)]`) 146 | expect(mockLog.mock.calls.join('\n')).toMatchInlineSnapshot(` 147 | "Usage: program Here is how to use: \`calculator add 1 1\` 148 | 149 | Available subcommands: add, square-root 150 | 151 | Options: 152 | -h, --help display help for command 153 | 154 | Commands: 155 | add 156 | square-root 157 | help [command] display help for command 158 | 159 | Good luck have fun 160 | " 161 | `) 162 | }) 163 | -------------------------------------------------------------------------------- /test/prompts.test.ts: -------------------------------------------------------------------------------- 1 | import * as trpcServer from '@trpc/server' 2 | import {Command} from 'commander' 3 | import {expect, expectTypeOf, test, vi} from 'vitest' 4 | import {describe} from 'vitest' 5 | import {z} from 'zod/v3' 6 | import {AnyRouter, createCli, TrpcCliParams, TrpcCliRunParams} from '../src/index.js' 7 | 8 | describe('types', () => { 9 | const t = trpcServer.initTRPC.create() 10 | const router = t.router({ 11 | hi: t.procedure.input(z.string()).query(({input}) => `hi ${input}`), 12 | }) 13 | 14 | test('clack types', async () => { 15 | const prompts = await import('@clack/prompts') 16 | expectTypeOf(createCli({router}).run).toBeCallableWith({prompts}) 17 | }) 18 | 19 | test('inquirer types', async () => { 20 | const prompts = await import('@inquirer/prompts') 21 | expectTypeOf(createCli({router}).run).toBeCallableWith({prompts}) 22 | }) 23 | 24 | test('enquirer types', async () => { 25 | const prompts = await import('enquirer') 26 | expectTypeOf(createCli({router}).run).toBeCallableWith({prompts}) 27 | }) 28 | 29 | test('prompts types', async () => { 30 | const prompts = await import('prompts') 31 | expectTypeOf(createCli({router}).run).toBeCallableWith({prompts}) 32 | }) 33 | }) 34 | 35 | test('custom prompter', async () => { 36 | const log = vi.fn() 37 | const t = trpcServer.initTRPC.create() 38 | 39 | const router = t.router({ 40 | create: t.procedure 41 | .meta({default: true}) 42 | .input( 43 | z.object({ 44 | projectName: z.string().describe('What will your project be called?').default('my-app'), 45 | language: z.enum(['typescript', 'javascript']).describe('What language will you be using?'), 46 | packages: z 47 | .enum(['better-auth', 'pgkit', 'tailwind', 'trpc']) 48 | .array() 49 | .describe('What packages will you be using?'), 50 | gitInit: z.boolean().describe('Initialize a git repository?').default(true), 51 | packageManager: z 52 | .enum(['npm', 'yarn', 'pnpm']) 53 | .describe('What package manager will you be using?') 54 | .default('pnpm'), 55 | install: z.boolean().describe('Install dependencies?'), 56 | }), 57 | ) 58 | .mutation(async ({input}) => JSON.stringify(input, null, 2)), 59 | }) 60 | 61 | const runOptions: Parameters[2] = { 62 | prompts: command => { 63 | // example of how you can customize prompts however you want - this one doesn't "prompt" at all, it uses silly rules to decide on values. 64 | return { 65 | setup: async ctx => { 66 | // here you could use ctx to render one big form if you like, then stub out the other methods. 67 | // the arguments and options that the user provided are in `ctx.inputs`. 68 | // see the log snapshot below to see what it looks like. 69 | log({ 70 | command: ctx.command.name(), 71 | argv: ctx.inputs.argv, 72 | inputs: { 73 | arguments: ctx.inputs.arguments.map(a => ({name: a.name, value: a.value, specified: a.specified})), 74 | options: ctx.inputs.options.map(o => ({name: o.name, value: o.value, specified: o.specified})), 75 | }, 76 | }) 77 | }, 78 | input: async (params, ctx) => { 79 | const commanderOptions = (command as Command).options.filter(o => o.name() === ctx.option?.name()) 80 | if (commanderOptions.length === 1) { 81 | return commanderOptions[0].defaultValue + '-foo' 82 | } 83 | return `a value in response to: ${params.message}` 84 | }, 85 | select: async (params, _ctx) => { 86 | const first = params.choices.at(-1)! // always choose the last 87 | return typeof first === 'string' ? first : first.value 88 | }, 89 | confirm: async (params, ctx) => { 90 | if (ctx.option?.name() === 'git-init') return false 91 | return true 92 | }, 93 | checkbox: async (params, _ctx) => { 94 | return params.choices.flatMap((c, i) => (i % 2 === 0 ? [c.value] : [])) 95 | }, 96 | } 97 | }, 98 | } 99 | const result = await runWith({router}, ['create', '--package-manager', 'yarn'], runOptions) 100 | expect(JSON.parse(result)).toMatchObject({packageManager: 'yarn'}) 101 | 102 | expect(log.mock.calls[0][0]).toMatchObject({ 103 | inputs: { 104 | options: expect.arrayContaining([{name: 'package-manager', specified: true, value: 'yarn'}]), 105 | }, 106 | }) 107 | 108 | expect(result).toMatchInlineSnapshot( 109 | ` 110 | "{ 111 | "projectName": "a value in response to: --project-name [string] What will your project be called? (default: my-app):", 112 | "language": "javascript", 113 | "packages": [ 114 | "better-auth", 115 | "tailwind" 116 | ], 117 | "gitInit": true, 118 | "packageManager": "yarn", 119 | "install": true 120 | }" 121 | `, 122 | ) 123 | 124 | expect(log.mock.calls[0][0]).toMatchInlineSnapshot(` 125 | { 126 | "argv": [ 127 | "create", 128 | "--package-manager", 129 | "yarn", 130 | ], 131 | "command": "create", 132 | "inputs": { 133 | "arguments": [], 134 | "options": [ 135 | { 136 | "name": "project-name", 137 | "specified": false, 138 | "value": undefined, 139 | }, 140 | { 141 | "name": "language", 142 | "specified": false, 143 | "value": undefined, 144 | }, 145 | { 146 | "name": "packages", 147 | "specified": false, 148 | "value": undefined, 149 | }, 150 | { 151 | "name": "git-init", 152 | "specified": false, 153 | "value": undefined, 154 | }, 155 | { 156 | "name": "package-manager", 157 | "specified": true, 158 | "value": "yarn", 159 | }, 160 | { 161 | "name": "install", 162 | "specified": false, 163 | "value": undefined, 164 | }, 165 | ], 166 | }, 167 | } 168 | `) 169 | }) 170 | 171 | async function runWith( 172 | params: TrpcCliParams, 173 | argv: string[], 174 | runParams: Omit = {}, 175 | ): Promise { 176 | const cli = createCli(params) 177 | const logs = [] as unknown[][] 178 | const addLogs = (...args: unknown[]) => logs.push(args) 179 | const result: string = await cli 180 | .run({ 181 | logger: {info: addLogs, error: addLogs}, 182 | process: {exit: _ => 0 as never}, 183 | ...runParams, 184 | argv, 185 | }) 186 | .catch(e => { 187 | if (e.exitCode === 0 && e.cause.message === '(outputHelp)') return logs[0][0] // should be the help text 188 | if (e.exitCode === 0) return e.cause 189 | throw e 190 | }) 191 | 192 | return result 193 | } 194 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: {} 4 | pull_request: {} 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - run: corepack enable 12 | - run: pnpm install 13 | - run: pnpm build 14 | - run: pnpm test 15 | - run: pnpm lint 16 | create_tgz: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - run: corepack enable 21 | - run: pnpm install 22 | - run: pnpm build 23 | - run: npm pack 24 | - name: rename tgz 25 | run: mv $(ls | grep .tgz) pkg.tgz 26 | - name: arethetypeswrong 27 | run: npx --yes @arethetypeswrong/cli ./pkg.tgz --profile esm-only 28 | - name: write devDependencies to dist # we're only going to install some devDependencies, this helps ensure we get the right ones 29 | run: cat package.json | jq .devDependencies > dist/devDependencies.json 30 | - uses: actions/upload-artifact@v4 31 | with: 32 | name: tarball 33 | path: pkg.tgz 34 | test_tgz: 35 | runs-on: ubuntu-latest 36 | needs: [create_tgz] 37 | strategy: 38 | matrix: 39 | node: [23, 22, 18] 40 | rpc_lib: 41 | # - '@trpc/server@^10.45.2' # doesn't work w valibot 42 | - '@trpc/server@^11.0.1' 43 | - '@orpc/server@^1.0.0' 44 | schema_lib: 45 | # - 'zod@3.0.0' # doesn't work 46 | - 'zod@~3.24.4' 47 | - 'zod@~3.25.76' 48 | - 'zod@^4.0.0' 49 | - 'valibot@^1.0.0' 50 | steps: 51 | - uses: actions/setup-node@v4 52 | with: 53 | node-version: ${{ matrix.node }} 54 | - uses: actions/download-artifact@v4 55 | with: 56 | name: tarball 57 | - run: ls 58 | - run: mkdir test-dir 59 | - name: setup test-dir 60 | working-directory: test-dir 61 | run: | 62 | npm init -y 63 | npm pkg set type=module 64 | npm install ../pkg.tgz --save-dev --save-exact 65 | installDev() { 66 | npm install "$@@$(cat ./node_modules/trpc-cli/dist/devDependencies.json | jq -r ".$@")" 67 | } 68 | installDev typescript 69 | installDev tsx 70 | npm install ${{ matrix.rpc_lib }} 71 | npm install ${{ matrix.schema_lib }} 72 | 73 | if [[ "${{ matrix.schema_lib }}" =~ "valibot" ]]; then 74 | installDev @valibot/to-json-schema 75 | fi 76 | 77 | echo '{ 78 | "compilerOptions": { 79 | "target": "ES2022", 80 | "lib": ["ES2022"], 81 | "skipLibCheck": true, 82 | "strict": true, 83 | "declaration": true, 84 | "esModuleInterop": true, 85 | "module": "NodeNext", 86 | "moduleResolution": "NodeNext" 87 | }, 88 | "include": ["*.ts"] 89 | }' > tsconfig.json 90 | 91 | if [[ "${{ matrix.rpc_lib }}" =~ "@trpc/server" ]]; then 92 | echo ' 93 | import {createCli} from "trpc-cli" 94 | import * as trpcServer from "@trpc/server" 95 | import {InputSchema} from "./input-schema.js" 96 | 97 | const t = trpcServer.initTRPC.create() 98 | 99 | export const router = t.router({ 100 | sayHello: t.procedure 101 | .input(InputSchema) 102 | .query(({input: [name, {enthusiasm}]}) => { 103 | return `Hello ${name}` + "!".repeat(enthusiasm) 104 | }) 105 | }) 106 | 107 | void createCli({router}).run() 108 | ' > trpc-cli-test.ts 109 | fi 110 | 111 | if [[ "${{ matrix.rpc_lib }}" =~ "@orpc/server" ]]; then 112 | echo ' 113 | import {createCli} from "trpc-cli" 114 | import {os} from "@orpc/server" 115 | import {InputSchema} from "./input-schema.js" 116 | 117 | export const router = os.router({ 118 | sayHello: os 119 | .input(InputSchema) 120 | .handler(({input: [name, {enthusiasm}]}) => { 121 | return `Hello ${name}` + "!".repeat(enthusiasm) 122 | }) 123 | }) 124 | 125 | void createCli({router}).run() 126 | ' > trpc-cli-test.ts 127 | fi 128 | 129 | if [[ "${{ matrix.schema_lib }}" =~ "zod" ]]; then 130 | echo ' 131 | import {z} from "zod" 132 | 133 | export const InputSchema = z.tuple([ 134 | z.string().describe("name"), 135 | z.object({ 136 | enthusiasm: z.number().int().positive().describe("exclamation marks"), 137 | }) 138 | ]) 139 | ' > input-schema.ts 140 | fi 141 | 142 | if [[ "${{ matrix.schema_lib }}" =~ "valibot" ]]; then 143 | echo ' 144 | import * as v from "valibot" 145 | 146 | export const InputSchema = v.tuple([ 147 | v.pipe(v.string(), v.description("name")), 148 | v.object({ 149 | enthusiasm: v.pipe(v.number(), v.integer(), v.description("exclamation marks")), 150 | }), 151 | ]) 152 | ' > input-schema.ts 153 | fi 154 | 155 | # let's create an equivalent file without using `trpc-cli` imports at all - we'll test the bin script on this 156 | # to make sure it's possible to use the package's CLI on an existing trpc router. 157 | cat trpc-cli-test.ts | grep -v .run > normal-router-test.ts 158 | 159 | cat trpc-cli-test.ts 160 | cat normal-router-test.ts 161 | - name: bundle 162 | working-directory: test-dir 163 | # tsdown and other bundlers sometimes complain about requires of peerDependencies, let's make sure the output is clean 164 | run: | 165 | npm install tsdown --save-dev --save-exact 166 | npx tsdown trpc-cli-test.ts | tee tsdown-output.txt 167 | 168 | if grep -i "warning" tsdown-output.txt || grep -i "error" tsdown-output.txt; then 169 | echo "tsdown output had problems" 170 | exit 1 171 | fi 172 | - name: compile 173 | working-directory: test-dir 174 | run: npx tsc -p . 175 | 176 | - name: run test 177 | working-directory: test-dir 178 | run: | 179 | echo testing --help 180 | node trpc-cli-test.js --help 181 | 182 | echo checking --help output 183 | node trpc-cli-test.js --help | grep say-hello 184 | 185 | echo testing say-hello 186 | node trpc-cli-test.js say-hello mmkal --enthusiasm 3 187 | 188 | echo checking say-hello output 189 | node trpc-cli-test.js say-hello mmkal --enthusiasm 3 | grep 'Hello mmkal!' 190 | - name: test bin script 191 | working-directory: test-dir 192 | if: matrix.node >= 22 193 | run: | 194 | echo testing --help 195 | ./node_modules/.bin/trpc-cli normal-router-test.ts --help 196 | 197 | echo checking --help output 198 | ./node_modules/.bin/trpc-cli normal-router-test.ts --help | grep say-hello 199 | 200 | echo testing say-hello 201 | ./node_modules/.bin/trpc-cli normal-router-test.ts say-hello mmkal --enthusiasm 3 202 | 203 | echo checking say-hello output 204 | ./node_modules/.bin/trpc-cli normal-router-test.ts say-hello mmkal --enthusiasm 3 | grep 'Hello mmkal!' 205 | - name: tsx test 206 | if: failure() 207 | working-directory: test-dir 208 | run: | 209 | npx tsx normal-router-test.ts --help 210 | npx tsx normal-router-test.ts say-hello mmkal --enthusiasm 3 211 | - run: ls -R && cat test-dir/normal-router-test.ts 212 | if: always() 213 | -------------------------------------------------------------------------------- /test/validation-library-codegen.ts: -------------------------------------------------------------------------------- 1 | export const testSuite: import('eslint-plugin-mmkal').CodegenPreset = ({ 2 | dependencies: {path, fs, recast, babelParser}, 3 | context, 4 | meta, 5 | }) => { 6 | const parseTestFile = (content: string) => { 7 | const lines = content.split('\n').map(line => (line.trim() ? line : '')) 8 | const firstNonImportLine = lines.findIndex(line => line && !line.startsWith('import') && !line.startsWith('//')) 9 | // const codeBeforeImports = lines.slice(0, firstNonImportLine).join('\n').trim() 10 | const codeAfterImports = lines.slice(firstNonImportLine).join('\n').trim() 11 | 12 | const tests: Array<{name: string; code: string; startLine: number; endLine: number}> = [] 13 | 14 | let currentTest: {name: string; code: string; startLine: number; endLine: number} | undefined 15 | for (const [index, line] of lines.entries()) { 16 | const testPrefix = 'test(' 17 | if (line.startsWith(testPrefix)) { 18 | if (currentTest) throw new Error('test already started') 19 | const quote = line.at(testPrefix.length) 20 | if (quote !== '"' && quote !== "'") throw new Error('test name must be quoted') 21 | const name = line.slice(testPrefix.length + 1, line.indexOf(quote, testPrefix.length + 1)) 22 | currentTest = {name, code: line, startLine: index, endLine: index} 23 | } else if (currentTest) { 24 | currentTest.code += `\n${line}` 25 | if (line.startsWith('})')) { 26 | currentTest.endLine = index 27 | tests.push(currentTest) 28 | currentTest = undefined 29 | } 30 | } 31 | } 32 | 33 | return {lines, tests, codeAfterImports, firstNonImportLine} 34 | } 35 | 36 | const zod3Filename = path.join(path.dirname(context.physicalFilename), 'zod3.test.ts') 37 | const zod3 = parseTestFile(fs.readFileSync(zod3Filename, 'utf8')) 38 | 39 | const current = parseTestFile(meta.existingContent) 40 | 41 | const parseTest = (testCode: string, ast: ReturnType = recast.parse(testCode)) => { 42 | type CallExpression = any // todo: extract useful parts from here: https://github.com/mmkal/eslint-plugin-codegen/commit/8e9a73a3bfaa9a905526e35c031867b1809b87e9 import('eslint-plugin-mmkal').codegen.dependencies.recast.types.namedTypes.CallExpression 43 | const replacements = { 44 | inputs: [] as {argumentsCode: string}[], 45 | snapshots: [] as {calleeCode: string; argumentsCode: string; arguments: CallExpression['arguments']}[], 46 | } 47 | 48 | recast.visit(ast, { 49 | visitCallExpression(p) { 50 | if ( 51 | p.node.callee.type === 'MemberExpression' && 52 | p.node.callee.property.type === 'Identifier' && 53 | p.node.callee.property.name === 'input' && 54 | p.node.arguments.length === 1 55 | ) { 56 | const index = replacements.inputs.push({argumentsCode: recast.print(p.node.arguments[0]).code}) - 1 57 | p.node.arguments = [{type: 'StringLiteral', value: `INPUT_PLACEHOLDER:${index}`}] 58 | } 59 | const calleeCode = recast.print(p.node.callee).code 60 | if (calleeCode.trim().endsWith('toMatchInlineSnapshot') && p.node.arguments.length === 1) { 61 | const index = 62 | replacements.snapshots.push({ 63 | calleeCode, 64 | argumentsCode: recast.print(p.node.arguments[0]).code, 65 | arguments: p.node.arguments, 66 | }) - 1 67 | p.node.arguments = [{type: 'StringLiteral', value: `SNAPSHOT_PLACEHOLDER:${index}`}] 68 | } 69 | this.traverse(p) 70 | }, 71 | }) 72 | 73 | return { 74 | ast, 75 | codeWithPlaceholders: recast.print(ast).code, 76 | replacements, 77 | } 78 | } 79 | 80 | function getRidOfCertainComments(code: string) { 81 | return code 82 | .replaceAll('// expect', 'expect') // allow manually commenting out specific assertions 83 | .replaceAll('// await expect', 'await expect') // allow manually commenting out specific assertions 84 | .split('// extra assertions')[0] // allow adding some extra assertions 85 | .replaceAll('//\n', '') // get rid of comments that are just forcing prettier to make line breaks 86 | .trim() 87 | } 88 | 89 | function removeLineComments(code: string) { 90 | return code 91 | .split('\n') 92 | .filter(line => !line.trim().startsWith('//')) 93 | .join('\n') 94 | } 95 | 96 | let expected = zod3.tests 97 | .map(sourceTest => { 98 | const sourceParsed = parseTest(removeLineComments(getRidOfCertainComments(sourceTest.code))) 99 | 100 | const existingTargetTest = current.tests.find(x => x.name === sourceTest.name) 101 | if (!existingTargetTest) return sourceTest.code 102 | 103 | const existingCode = getRidOfCertainComments(existingTargetTest.code) 104 | const existingParsed = parseTest(existingCode) 105 | 106 | // the expected code is the *source* code, but we're going to swap in specific values from the existing (target) test code 107 | let expectedCode = sourceParsed.codeWithPlaceholders 108 | const inputPlaceholders = [...expectedCode.matchAll(/"INPUT_PLACEHOLDER:(\d+)"/g)].map(x => ({ 109 | string: x[0], 110 | index: Number(x[1]), 111 | })) 112 | const findAndReplaces = [] as Array<{find: string; replace: string}> 113 | for (const pl of inputPlaceholders) { 114 | const existingInput = existingParsed.replacements.inputs[pl.index] 115 | if (existingInput) findAndReplaces.push({find: pl.string, replace: existingInput.argumentsCode}) 116 | } 117 | const snapshotPlaceholders = [...expectedCode.matchAll(/"SNAPSHOT_PLACEHOLDER:(\d+)"/g)].map(x => ({ 118 | string: x[0], 119 | index: Number(x[1]), 120 | })) 121 | for (const pl of snapshotPlaceholders) { 122 | const {calleeCode: calleeSource, argumentsCode: calleeArgs} = sourceParsed.replacements.snapshots[pl.index] 123 | const targetReplacement = existingParsed.replacements.snapshots.find(x => x.calleeCode === calleeSource) 124 | if (targetReplacement) findAndReplaces.push({find: pl.string, replace: targetReplacement.argumentsCode}) 125 | else findAndReplaces.push({find: pl.string, replace: calleeArgs}) 126 | if (existingTargetTest.code.includes(`// ${calleeSource}`)) { 127 | // if the assertion has been manually commented out, keep it commented out in the expected code 128 | findAndReplaces.push({find: calleeSource, replace: `// ${calleeSource}`}) 129 | } 130 | if (existingTargetTest.code.includes(`// await ${calleeSource}`)) { 131 | // if the assertion has been manually commented out, keep it commented out in the expected code 132 | findAndReplaces.push({find: calleeSource, replace: `// await ${calleeSource}`}) 133 | } 134 | } 135 | 136 | for (const r of findAndReplaces) { 137 | expectedCode = expectedCode.replaceAll(r.find, r.replace) 138 | } 139 | 140 | const prettyCode = (input: string) => { 141 | const ast = babelParser.parse(input, {sourceType: 'unambiguous', plugins: ['typescript'], attachComment: false}) 142 | return recast.prettyPrint(ast).code 143 | } 144 | /** ignore uninteresting differences in indentation - can occur even after pretty-printing because of snapshot indentations, which vitest ignores */ 145 | const unindentAllLines = (input: string) => { 146 | const lines = input.split('\n') 147 | return lines.map(line => line.trimStart()).join('\n') 148 | } 149 | const comparableCode = (input: string) => 150 | removeLineComments(unindentAllLines(prettyCode(getRidOfCertainComments(input)))) 151 | 152 | if (comparableCode(expectedCode) === comparableCode(existingCode)) { 153 | return existingTargetTest.code 154 | } 155 | 156 | return expectedCode 157 | }) 158 | .join('\n\n') 159 | 160 | expected = [ 161 | '', 162 | `// NOTE: the below tests are ✨generated✨ based on the hand-written tests in ${path.relative(context.physicalFilename, zod3Filename)}`, 163 | '// But the zod types are expected to be replaced with equivalent types (written by hand).', 164 | '// If you change anything other than `.input(...)` types, the linter will just undo your changes.', 165 | '', 166 | expected, 167 | ].join('\n') 168 | 169 | return expected 170 | } 171 | -------------------------------------------------------------------------------- /src/json-schema.ts: -------------------------------------------------------------------------------- 1 | import {JSONSchema7} from 'json-schema' 2 | 3 | const capitaliseFromCamelCase = (camel: string) => { 4 | const parts = camel.split(/(?=[A-Z])/) 5 | return capitalise(parts.map(p => p.toLowerCase()).join(' ')) 6 | } 7 | 8 | const capitalise = (s: string) => s.slice(0, 1).toUpperCase() + s.slice(1) 9 | 10 | export const flattenedProperties = (sch: JSONSchema7): Record => { 11 | if ('properties' in sch) { 12 | return sch.properties as Record 13 | } 14 | if ('allOf' in sch) { 15 | return Object.fromEntries( 16 | sch.allOf!.flatMap(subSchema => Object.entries(flattenedProperties(subSchema as JSONSchema7))), 17 | ) 18 | } 19 | if ('anyOf' in sch) { 20 | const isExcluded = (v: JSONSchema7) => Object.keys(v).join(',') === 'not' 21 | const entries = sch.anyOf!.flatMap(subSchema => { 22 | const flattened = flattenedProperties(subSchema as JSONSchema7) 23 | const excluded = Object.entries(flattened).flatMap(([name, propSchema]) => { 24 | return isExcluded(propSchema) ? [`--${name}`] : [] 25 | }) 26 | return Object.entries(flattened).map(([k, v]): [typeof k, typeof v] => { 27 | if (!isExcluded(v) && excluded.length > 0) { 28 | return [k, Object.assign({}, v, {'Do not use with': excluded}) as typeof v] 29 | } 30 | return [k, v] 31 | }) 32 | }) 33 | 34 | return Object.fromEntries( 35 | entries.sort((a, b) => { 36 | const scores = [a, b].map(([_k, v]) => (isExcluded(v) ? 0 : 1)) // Put the excluded ones first, so that `Object.fromEntries` will override them with the non-excluded ones (`Object.fromEntries([['a', 1], ['a', 2]])` => `{a: 2}`) 37 | return scores[0] - scores[1] 38 | }), 39 | ) 40 | } 41 | return {} 42 | } 43 | /** For a union type, returns a list of pairs of properties which *shouldn't* be used together (because they don't appear in the same type variant) */ 44 | export const incompatiblePropertyPairs = (sch: JSONSchema7): Array<[string, string]> => { 45 | const isUnion = 'anyOf' in sch 46 | if (!isUnion) return [] 47 | 48 | const sets = sch.anyOf!.map(subSchema => { 49 | const keys = Object.keys(flattenedProperties(subSchema as JSONSchema7)) 50 | return {keys, set: new Set(keys)} 51 | }) 52 | 53 | const compatiblityEntries = sets.flatMap(({keys}) => { 54 | return keys.map(key => { 55 | return [key, new Set(sets.filter(other => other.set.has(key)).flatMap(other => other.keys))] as const 56 | }) 57 | }) 58 | const allKeys = sets.flatMap(({keys}) => keys) 59 | 60 | return compatiblityEntries.flatMap(([key, compatibleWith]) => { 61 | const incompatibleEntries = allKeys 62 | .filter(other => key < other && !compatibleWith.has(other)) 63 | .map((other): [string, string] => [key, other]) 64 | return incompatibleEntries 65 | }) 66 | } 67 | /** 68 | * Tries fairly hard to build a roughly human-readable description of a json-schema type. 69 | * A few common properties are given special treatment, most others are just stringified and output in `key: value` format. 70 | */ 71 | export const getDescription = (v: JSONSchema7, depth = 0): string => { 72 | if ('items' in v && v.items) { 73 | const {items, ...rest} = v 74 | return [getDescription(items as JSONSchema7, 1), getDescription(rest), 'array'].filter(Boolean).join(' ') 75 | } 76 | return ( 77 | Object.entries(v) 78 | .filter(([k, vv]) => { 79 | if (k === 'default' || k === 'additionalProperties' || k === 'optional') return false 80 | if (k === 'type' && typeof vv === 'string') return depth > 0 // don't show type: string at depth 0, that's the default 81 | if (k.startsWith('$')) return false // helpers props to add on to a few different external library output formats 82 | if (k === 'maximum' && vv === Number.MAX_SAFE_INTEGER) return false // zod adds this for `z.number().int().positive()` 83 | if (depth <= 1 && k === 'enum' && getEnumChoices(v)?.type === 'string_enum') return false // don't show Enum: ["a","b"], that's handled by commander's `choices` 84 | return true 85 | }) 86 | .sort(([a], [b]) => { 87 | const scores = [a, b].map(k => (k === 'description' ? 0 : 1)) 88 | return scores[0] - scores[1] 89 | }) 90 | .map(([k, vv], i) => { 91 | if (k === 'type' && Array.isArray(vv)) return `type: ${vv.join(' or ')}` 92 | if (k === 'description' && i === 0) return String(vv) 93 | if (k === 'properties') return `Object (json formatted)` 94 | if (typeof vv === 'object') return `${capitaliseFromCamelCase(k)}: ${JSON.stringify(vv)}` 95 | return `${capitaliseFromCamelCase(k)}: ${vv}` 96 | }) 97 | .join('; ') || '' 98 | ) 99 | } 100 | 101 | export const getSchemaTypes = ( 102 | propertyValue: JSONSchema7, 103 | ): Array<'string' | 'boolean' | 'number' | 'integer' | (string & {})> => { 104 | const array: string[] = [] 105 | if ('type' in propertyValue) { 106 | array.push(...[propertyValue.type!].flat()) 107 | } 108 | if ('enum' in propertyValue && Array.isArray(propertyValue.enum)) { 109 | array.push(...propertyValue.enum.flatMap(s => typeof s)) 110 | } 111 | if ('const' in propertyValue && propertyValue.const === null) { 112 | array.push('null') 113 | } else if ('const' in propertyValue) { 114 | array.push(typeof propertyValue.const) 115 | } 116 | if ('oneOf' in propertyValue) { 117 | array.push(...(propertyValue.oneOf as JSONSchema7[]).flatMap(getSchemaTypes)) 118 | } 119 | if ('anyOf' in propertyValue) { 120 | array.push(...(propertyValue.anyOf as JSONSchema7[]).flatMap(getSchemaTypes)) 121 | } 122 | 123 | return [...new Set(array)] 124 | } 125 | 126 | /** Returns a list of all allowed subschemas. If the schema is not a union, returns a list with a single item. */ 127 | export const getAllowedSchemas = (schema: JSONSchema7): JSONSchema7[] => { 128 | if (!schema) return [] 129 | if ('anyOf' in schema && Array.isArray(schema.anyOf)) 130 | return (schema.anyOf as JSONSchema7[]).flatMap(getAllowedSchemas) 131 | if ('oneOf' in schema && Array.isArray(schema.oneOf)) 132 | return (schema.oneOf as JSONSchema7[]).flatMap(getAllowedSchemas) 133 | const types = getSchemaTypes(schema) 134 | if (types.length === 1) return [schema] 135 | return types.map(type => ({...schema, type}) as JSONSchema7) 136 | } 137 | 138 | export const getEnumChoices = (propertyValue: JSONSchema7) => { 139 | if (!propertyValue) return null 140 | if (!('enum' in propertyValue && Array.isArray(propertyValue.enum))) { 141 | // arktype prefers {anyOf: [{const: 'foo'}, {const: 'bar'}]} over {enum: ['foo', 'bar']} 🤷 142 | if ( 143 | 'anyOf' in propertyValue && 144 | propertyValue.anyOf?.every(subSchema => { 145 | if ( 146 | subSchema && 147 | typeof subSchema === 'object' && 148 | 'const' in subSchema && 149 | Object.keys(subSchema).length === 1 && 150 | typeof subSchema.const === 'string' 151 | ) { 152 | return true 153 | } 154 | return false 155 | }) 156 | ) { 157 | // all the subschemas are string literals, so we can use them as choices 158 | return { 159 | type: 'string_enum', 160 | choices: propertyValue.anyOf.map(subSchema => (subSchema as {const: string}).const), 161 | } as const 162 | } 163 | 164 | if ( 165 | 'anyOf' in propertyValue && 166 | propertyValue.anyOf?.every(subSchema => { 167 | if ( 168 | subSchema && 169 | typeof subSchema === 'object' && 170 | 'const' in subSchema && 171 | Object.keys(subSchema).length === 1 && 172 | typeof subSchema.const === 'number' 173 | ) { 174 | return true 175 | } 176 | return false 177 | }) 178 | ) { 179 | // all the subschemas are string literals, so we can use them as choices 180 | return { 181 | type: 'number_enum', 182 | choices: propertyValue.anyOf.map(subSchema => (subSchema as {const: number}).const), 183 | } as const 184 | } 185 | 186 | return null 187 | } 188 | 189 | if (propertyValue.enum.every(s => typeof s === 'string')) { 190 | return { 191 | type: 'string_enum', 192 | choices: propertyValue.enum, 193 | } as const 194 | } 195 | 196 | // commander doesn't like number enums - could enable with a parser but let's avoid for now 197 | if (propertyValue.enum.every(s => typeof s === 'number')) { 198 | return { 199 | type: 'number_enum', 200 | choices: propertyValue.enum, 201 | } as const 202 | } 203 | 204 | return null 205 | } 206 | -------------------------------------------------------------------------------- /test/trpc-compat.test.ts: -------------------------------------------------------------------------------- 1 | import {initTRPC as initTRPC_v10} from 'trpcserver10' 2 | import {initTRPC as initTRPC_v11} from 'trpcserver11' 3 | import {expect, expectTypeOf, test} from 'vitest' 4 | import {z} from 'zod/v3' 5 | import {createCli, TrpcCliMeta, TrpcServerModuleLike} from '../src/index.js' 6 | import {isOrpcRouter, Trpc10RouterLike, Trpc11RouterLike} from '../src/trpc-compat.js' 7 | 8 | expect.addSnapshotSerializer({ 9 | test: val => val?.cause && val.message, 10 | serialize(val, config, indentation, depth, refs, printer) { 11 | indentation += ' ' 12 | return `[${val.constructor.name}: ${val.message}]\n${indentation}Caused by: ${printer(val.cause, config, indentation, depth + 1, refs)}` 13 | }, 14 | }) 15 | 16 | test('trpc v10 shape check', async () => { 17 | expectTypeOf(await import('trpcserver10')).toExtend() 18 | 19 | const t = initTRPC_v10.context<{customContext: true}>().meta().create() 20 | 21 | const router = t.router({ 22 | add: t.procedure 23 | .input(z.tuple([z.number(), z.number()])) // 24 | .mutation(({input}) => { 25 | return input[0] + input[1] 26 | }), 27 | foo: t.router({ 28 | bar: t.procedure.query(() => 'baz'), 29 | }), 30 | deeply: t.router({ 31 | nested1: t.router({ 32 | command1: t.procedure.query(() => 'ok'), 33 | }), 34 | }), 35 | }) satisfies Trpc10RouterLike // this satisfies makes sure people can write a normal router and they'll be allowed to pass it in 36 | 37 | expect(router._def.procedures).toHaveProperty('foo.bar') 38 | expect(router._def.procedures).not.toHaveProperty('foo') 39 | 40 | expectTypeOf(router).toExtend() 41 | 42 | expect(router._def.procedures.add._def.mutation).toBe(true) 43 | expect(router._def.procedures.add._def.query).toBeUndefined() 44 | expect(router._def.procedures.add._def.subscription).toBeUndefined() 45 | // at some point maybe _type was defined? It was in this codebase, just test that it's undefined explicitly 46 | expect((router._def.procedures.add._def as any).type).toBeUndefined() 47 | expect((router._def.procedures.add._def as any)._type).toBeUndefined() 48 | 49 | if (Math.random() > 10) { 50 | // just some satisfies statements to help build type types in src/trpc-compat.ts 51 | router._def._config.$types satisfies {ctx: {customContext: true}; meta: TrpcCliMeta} 52 | router._def.procedures.add._type satisfies 'mutation' 53 | router._def.procedures.add._def.inputs satisfies unknown[] 54 | router._def.procedures.add._def._input_in satisfies [number, number] 55 | router._def.procedures.add._def._output_out satisfies number 56 | } 57 | }) 58 | 59 | test('trpc v11 shape check', async () => { 60 | expectTypeOf(await import('trpcserver11')).toExtend() 61 | 62 | const t = initTRPC_v11.context<{customContext: true}>().meta().create() 63 | 64 | const trpc = t 65 | const router = t.router({ 66 | add: t.procedure 67 | .meta({description: 'Add two numbers'}) 68 | .input(z.tuple([z.number(), z.number()])) // 69 | .mutation(({input}) => { 70 | return input[0] + input[1] 71 | }), 72 | foo: { 73 | bar: t.procedure.query(() => 'baz'), 74 | }, 75 | abc: t.router({ 76 | def: t.procedure.query(() => 'baz'), 77 | }), 78 | deeply: trpc.router({ 79 | nested2: trpc.router({ 80 | command3: trpc.procedure.input(z.object({foo3: z.string()})).query(({input}) => 'ok:' + JSON.stringify(input)), 81 | command4: trpc.procedure.input(z.object({foo4: z.string()})).query(({input}) => 'ok:' + JSON.stringify(input)), 82 | }), 83 | }), 84 | }) satisfies Trpc11RouterLike // this satisfies makes sure people can write a normal router and they'll be allowed to pass it in 85 | 86 | expect(router._def.procedures).toHaveProperty('foo.bar') 87 | expect(router._def.procedures).not.toHaveProperty('foo') 88 | expect(router._def.procedures).toHaveProperty('abc.def') 89 | expect(router._def.procedures).not.toHaveProperty('abc') 90 | 91 | // NO LONGER A `@ts-expect-error`: for some reason trpc11 didn't expose `.inputs` at the type level 92 | expect(router._def.procedures.add._def.inputs).toEqual([expect.any(z.ZodType)]) 93 | expect(router._def.procedures.add._def.meta).toEqual({description: 'Add two numbers'}) 94 | expect((router._def.procedures.add._def as any).mutation).toBeUndefined() 95 | expect((router._def.procedures.add._def as any).query).toBeUndefined() 96 | expect((router._def.procedures.add._def as any).subscription).toBeUndefined() 97 | expect((router._def.procedures.add._def as any).type).toBe('mutation') 98 | // at some point maybe _type was defined? It was in this codebase, just test that it's undefined explicitly 99 | expect((router._def.procedures.add._def as any)._type).toBeUndefined() 100 | if (Math.random() > 10) { 101 | // just some satisfies statements to help build type types in src/trpc-compat.ts 102 | router._def.procedures.add._def.type satisfies 'mutation' 103 | router._def._config.$types satisfies {ctx: {customContext: true}; meta: TrpcCliMeta} 104 | router._def.procedures.add._def.$types.input satisfies [number, number] 105 | router._def.procedures.add._def.$types.output satisfies number 106 | } 107 | }) 108 | 109 | test('trpc v11 works without hoop-jumping', async () => { 110 | const t = initTRPC_v11.context<{customContext: true}>().meta().create() 111 | 112 | const router = t.router({ 113 | add: t.procedure 114 | .meta({description: 'Add two numbers'}) 115 | .input(z.tuple([z.number(), z.number()])) // 116 | .mutation(({input}) => { 117 | return input[0] + input[1] 118 | }), 119 | }) satisfies Trpc11RouterLike // this satisfies makes sure people can write a normal router and they'll be allowed to pass it in 120 | 121 | const cli = createCli({router}) 122 | 123 | const runAndCaptureProcessExit = async ({argv}: {argv: string[]}): Promise => { 124 | return cli 125 | .run({argv, logger: {info: () => {}, error: () => {}}, process: {exit: () => void 0 as never}}) 126 | .catch(e => e) 127 | } 128 | const error = await runAndCaptureProcessExit({argv: ['add', '1', '2']}) 129 | expect(error).toMatchObject({exitCode: 0}) 130 | expect(error?.cause).toBe(3) 131 | }) 132 | 133 | test('trpc v10 works when passing in trpcServer', async () => { 134 | const t = initTRPC_v10.context<{customContext: true}>().meta().create() 135 | 136 | const router = t.router({ 137 | add: t.procedure 138 | .meta({description: 'Add two numbers'}) 139 | .input(z.tuple([z.number(), z.number()])) // 140 | .mutation(({input}) => { 141 | return input[0] + input[1] 142 | }), 143 | }) 144 | 145 | const cli = createCli({router, trpcServer: import('trpcserver10')}) 146 | 147 | const runAndCaptureProcessExit = async ({argv}: {argv: string[]}): Promise => { 148 | return cli 149 | .run({argv, logger: {info: () => {}, error: () => {}}, process: {exit: () => void 0 as never}}) 150 | .catch(e => e) 151 | } 152 | const error = await runAndCaptureProcessExit({argv: ['add', '1', '2']}) 153 | expect(error).toMatchObject({exitCode: 0}) 154 | expect(error?.cause).toBe(3) 155 | }) 156 | 157 | test('trpc v10 has helpful error when not passing in trpcServer', async () => { 158 | const t = initTRPC_v10.context<{customContext: true}>().meta().create() 159 | 160 | const router = t.router({ 161 | add: t.procedure 162 | .meta({description: 'Add two numbers'}) 163 | .input(z.tuple([z.number(), z.number()])) // 164 | .mutation(({input}) => { 165 | return input[0] + input[1] 166 | }), 167 | }) 168 | 169 | const cli = createCli({router}) 170 | 171 | const runAndCaptureProcessExit = async ({argv}: {argv: string[]}): Promise => { 172 | return cli 173 | .run({argv, logger: {info: () => {}, error: () => {}}, process: {exit: () => void 0 as never}}) 174 | .catch(e => e) 175 | } 176 | const error = await runAndCaptureProcessExit({argv: ['add', '1', '2']}) 177 | expect(error).toMatchObject({exitCode: 1}) 178 | expect(error?.cause).toMatchInlineSnapshot( 179 | `[Error: Failed to create trpc caller. If using trpc v10, either upgrade to v11 or pass in the \`@trpc/server\` module to \`createCli\` explicitly]`, 180 | ) 181 | }) 182 | 183 | test('isOrpcRouter', async () => { 184 | const {os} = await import('@orpc/server') 185 | // expect(isOrpcRouter(os.router({}))).toBe(true) // fails, because we only now how to look for procedures really 186 | expect(isOrpcRouter(os.router({hello: os.handler(() => 'ok')}))).toBe(true) 187 | expect(isOrpcRouter(os.router({hello: os.router({nested: os.handler(() => 'ok')})}))).toBe(true) 188 | expect(isOrpcRouter({hello: {nested: os.handler(() => 'ok')}})).toBe(true) 189 | 190 | const {initTRPC} = await import('trpcserver11') 191 | const t = initTRPC.create() 192 | expect(isOrpcRouter(t.router({}))).toBe(false) 193 | expect(isOrpcRouter(t.router({hello: t.procedure.query(() => 'ok')}))).toBe(false) 194 | }) 195 | -------------------------------------------------------------------------------- /test/parse.test.ts: -------------------------------------------------------------------------------- 1 | import {initTRPC} from '@trpc/server' 2 | import {expect, test} from 'vitest' 3 | import {z} from 'zod/v4' 4 | import {kebabCase, TrpcCliMeta} from '../src/index.js' 5 | import {run, snapshotSerializer} from './test-run.js' 6 | 7 | expect.addSnapshotSerializer(snapshotSerializer) 8 | 9 | const t = initTRPC.meta().create() 10 | 11 | test('kebab case', () => { 12 | expect(kebabCase('foo')).toMatchInlineSnapshot(`"foo"`) 13 | expect(kebabCase('fooBar')).toMatchInlineSnapshot(`"foo-bar"`) 14 | expect(kebabCase('fooBarBaz')).toMatchInlineSnapshot(`"foo-bar-baz"`) 15 | expect(kebabCase('foBaBa')).toMatchInlineSnapshot(`"fo-ba-ba"`) 16 | expect(kebabCase('useMCPServer')).toMatchInlineSnapshot(`"use-mcp-server"`) 17 | expect(kebabCase('useMCP')).toMatchInlineSnapshot(`"use-mcp"`) 18 | expect(kebabCase('useMCP1')).toMatchInlineSnapshot(`"use-mcp1"`) 19 | expect(kebabCase('foo1')).toMatchInlineSnapshot(`"foo1"`) 20 | expect(kebabCase('HTML')).toMatchInlineSnapshot(`"html"`) 21 | }) 22 | 23 | test('default command', async () => { 24 | const router = t.router({ 25 | foo: t.procedure 26 | .meta({default: true}) 27 | .input(z.object({bar: z.number()})) 28 | .query(({input}) => JSON.stringify(input)), 29 | }) 30 | 31 | expect(await run(router, ['foo', '--bar', '1'])).toMatchInlineSnapshot(`"{"bar":1}"`) 32 | 33 | expect(await run(router, ['--bar', '1'])).toMatchInlineSnapshot(`"{"bar":1}"`) 34 | }) 35 | 36 | test('optional positional', async () => { 37 | const router = t.router({ 38 | foo: t.procedure 39 | .input( 40 | z.tuple([ 41 | z.string().optional().describe('name'), 42 | z.object({ 43 | bar: z.number().optional().describe('bar'), 44 | }), 45 | ]), 46 | ) 47 | .query(({input}) => JSON.stringify(input)), 48 | }) 49 | 50 | expect(await run(router, ['foo', 'abc', '--bar', '1'])).toMatchInlineSnapshot(`"["abc",{"bar":1}]"`) 51 | expect(await run(router, ['foo', '--bar', '1'])).toMatchInlineSnapshot(`"[null,{"bar":1}]"`) 52 | expect(await run(router, ['foo'])).toMatchInlineSnapshot(`"[null,{}]"`) 53 | expect(await run(router, ['foo', 'def'])).toMatchInlineSnapshot(`"["def",{}]"`) 54 | }) 55 | 56 | test('required positional', async () => { 57 | const router = t.router({ 58 | foo: t.procedure 59 | .input( 60 | z.tuple([ 61 | z.string().describe('name'), 62 | z.object({ 63 | bar: z.number().optional().describe('bar'), 64 | }), 65 | ]), 66 | ) 67 | .query(({input}) => JSON.stringify(input)), 68 | }) 69 | 70 | expect(await run(router, ['foo', 'abc', '--bar', '1'])).toMatchInlineSnapshot(`"["abc",{"bar":1}]"`) 71 | await expect(run(router, ['foo', '--bar', '1'])).rejects.toMatchInlineSnapshot( 72 | ` 73 | CLI exited with code 1 74 | Caused by: CommanderError: error: missing required argument 'name' 75 | `, 76 | ) 77 | await expect(run(router, ['foo'])).rejects.toMatchInlineSnapshot( 78 | ` 79 | CLI exited with code 1 80 | Caused by: CommanderError: error: missing required argument 'name' 81 | `, 82 | ) 83 | expect(await run(router, ['foo', 'def'])).toMatchInlineSnapshot(`"["def",{}]"`) 84 | }) 85 | 86 | test('json option', async () => { 87 | const router = t.router({ 88 | foo: t.procedure 89 | .input( 90 | z.object({ 91 | obj: z.object({ 92 | abc: z.string(), 93 | def: z.number(), 94 | }), 95 | }), 96 | ) 97 | .query(({input}) => JSON.stringify(input)), 98 | }) 99 | 100 | expect(await run(router, ['foo', '--obj', '{"abc":"abc","def":1}'])).toMatchInlineSnapshot( 101 | `"{"obj":{"abc":"abc","def":1}}"`, 102 | ) 103 | await expect(run(router, ['foo', '--obj', `{abc: 'abc', def: 1}`])).rejects.toMatchInlineSnapshot( 104 | ` 105 | CLI exited with code 1 106 | Caused by: CommanderError: error: option '--obj [json]' argument '{abc: 'abc', def: 1}' is invalid. Malformed JSON. 107 | `, 108 | ) 109 | await expect(run(router, ['foo', '--obj', '{"abc":"abc"}'])).rejects.toMatchInlineSnapshot( 110 | ` 111 | CLI exited with code 1 112 | Caused by: CliValidationError: ✖ Invalid input: expected number, received undefined → at obj.def 113 | `, 114 | ) 115 | await expect(run(router, ['foo', '--obj', '{"def":1}'])).rejects.toMatchInlineSnapshot( 116 | ` 117 | CLI exited with code 1 118 | Caused by: CliValidationError: ✖ Invalid input: expected string, received undefined → at obj.abc 119 | `, 120 | ) 121 | }) 122 | 123 | test('default value in union subtype', async () => { 124 | const router = t.router({ 125 | foo: t.procedure 126 | .input( 127 | z.object({ 128 | foo: z.union([z.boolean().default(true), z.number().default(1)]), 129 | }), 130 | ) 131 | .query(({input}) => JSON.stringify(input)), 132 | }) 133 | 134 | expect(await run(router, ['foo'])).toMatchInlineSnapshot(`"{"foo":true}"`) 135 | expect(await run(router, ['foo', '--foo', 'true'])).toMatchInlineSnapshot(`"{"foo":true}"`) 136 | expect(await run(router, ['foo', '--foo', '1'])).toMatchInlineSnapshot(`"{"foo":1}"`) 137 | }) 138 | 139 | test('primitive option union', async () => { 140 | const router = t.router({ 141 | foo: t.procedure 142 | .input(z.object({foo: z.union([z.boolean(), z.number(), z.object({bar: z.string()})])})) 143 | .query(({input}) => JSON.stringify(input)), 144 | }) 145 | 146 | expect(await run(router, ['foo'])).toMatchInlineSnapshot(`"{"foo":false}"`) 147 | expect(await run(router, ['foo', '--foo'])).toMatchInlineSnapshot(`"{"foo":true}"`) 148 | expect(await run(router, ['foo', '--foo', 'true'])).toMatchInlineSnapshot(`"{"foo":true}"`) 149 | expect(await run(router, ['foo', '--foo', 'false'])).toMatchInlineSnapshot(`"{"foo":false}"`) 150 | await expect(run(router, ['foo', '--no-foo'])).rejects.toMatchInlineSnapshot(` 151 | CLI exited with code 1 152 | Caused by: CommanderError: error: unknown option '--no-foo' 153 | (Did you mean --foo?) 154 | `) 155 | expect(await run(router, ['foo', '--foo', '1'])).toMatchInlineSnapshot(`"{"foo":1}"`) 156 | expect(await run(router, ['foo', '--foo', '{"bar":"abc"}'])).toMatchInlineSnapshot(`"{"foo":{"bar":"abc"}}"`) 157 | }) 158 | 159 | test('option union array with enum', async () => { 160 | const router = t.router({ 161 | foo: t.procedure 162 | .input(z.object({foo: z.union([z.boolean(), z.number(), z.enum(['abc', 'def'])]).array()})) 163 | .query(({input}) => JSON.stringify(input)), 164 | }) 165 | 166 | await expect(run(router, ['foo', '--foo'])).rejects.toMatchInlineSnapshot(` 167 | CLI exited with code 1 168 | Caused by: CliValidationError: ✖ Invalid input: expected array, received boolean → at foo 169 | `) 170 | expect(await run(router, ['foo'])).toMatchInlineSnapshot(`"{"foo":[]}"`) 171 | expect(await run(router, ['foo', '--foo', 'true'])).toMatchInlineSnapshot(`"{"foo":[true]}"`) 172 | expect(await run(router, ['foo', '--foo', 'false'])).toMatchInlineSnapshot(`"{"foo":[false]}"`) 173 | await expect(run(router, ['foo', '--no-foo'])).rejects.toMatchInlineSnapshot(` 174 | CLI exited with code 1 175 | Caused by: CommanderError: error: unknown option '--no-foo' 176 | (Did you mean --foo?) 177 | `) 178 | expect(await run(router, ['foo', '--foo', '1'])).toMatchInlineSnapshot(`"{"foo":[1]}"`) 179 | expect(await run(router, ['foo', '--foo', 'abc'])).toMatchInlineSnapshot(`"{"foo":["abc"]}"`) 180 | expect(await run(router, ['foo', '--foo', 'abc', '--foo', 'true', '--foo', '1'])).toMatchInlineSnapshot( 181 | `"{"foo":["abc",true,1]}"`, 182 | ) 183 | await expect(run(router, ['foo', '--foo', 'wrong'])).rejects.toMatchInlineSnapshot(` 184 | CLI exited with code 1 185 | Caused by: CliValidationError: ✖ Invalid input → at foo[0] 186 | `) 187 | }) 188 | 189 | test('non-primitive option union', async () => { 190 | const router = t.router({ 191 | foo: t.procedure 192 | .input(z.object({foo: z.union([z.boolean(), z.number(), z.string(), z.object({bar: z.string()})])})) 193 | .query(({input}) => JSON.stringify(input)), 194 | }) 195 | 196 | expect(await run(router, ['foo'])).toMatchInlineSnapshot(`"{"foo":false}"`) 197 | expect(await run(router, ['foo', '--foo'])).toMatchInlineSnapshot(`"{"foo":true}"`) 198 | expect(await run(router, ['foo', '--foo', 'true'])).toMatchInlineSnapshot(`"{"foo":true}"`) 199 | expect(await run(router, ['foo', '--foo', 'false'])).toMatchInlineSnapshot(`"{"foo":false}"`) 200 | await expect(run(router, ['foo', '--no-foo'])).rejects.toMatchInlineSnapshot(` 201 | CLI exited with code 1 202 | Caused by: CommanderError: error: unknown option '--no-foo' 203 | (Did you mean --foo?) 204 | `) 205 | expect(await run(router, ['foo', '--foo', '1'])).toMatchInlineSnapshot(`"{"foo":1}"`) 206 | expect(await run(router, ['foo', '--foo', '{"bar":"abc"}'])).toMatchInlineSnapshot(`"{"foo":{"bar":"abc"}}"`) 207 | await expect(run(router, ['foo', '--foo', 'abc123'])).rejects.toMatchInlineSnapshot( 208 | ` 209 | CLI exited with code 1 210 | Caused by: CommanderError: error: option '--foo [value]' argument 'abc123' is invalid. Malformed JSON. If passing a string, pass it as a valid JSON string with quotes ("abc123") 211 | `, 212 | ) 213 | }) 214 | 215 | test('positional array with title', async () => { 216 | const router = t.router({ 217 | foo: t.procedure 218 | .input(z.array(z.string()).describe('files')) // 219 | .query(({input}) => JSON.stringify(input)), 220 | bar: t.procedure 221 | .input(z.array(z.string().describe('files'))) // 222 | .query(({input}) => JSON.stringify(input)), 223 | baz: t.procedure 224 | .input(z.array(z.string().describe('one single file')).describe('file collection')) 225 | .query(({input}) => JSON.stringify(input)), 226 | }) 227 | 228 | expect(await run(router, ['foo', 'abc', 'def'])).toMatchInlineSnapshot(`"["abc","def"]"`) 229 | expect((await run(router, ['foo', '--help'])).split('\n')[0]).toMatchInlineSnapshot( 230 | `"Usage: program foo [options] "`, 231 | ) 232 | expect((await run(router, ['bar', '--help'])).split('\n')[0]).toMatchInlineSnapshot( 233 | `"Usage: program bar [options] "`, 234 | ) 235 | expect((await run(router, ['baz', '--help'])).split('\n')[0]).toMatchInlineSnapshot( 236 | `"Usage: program baz [options] "`, 237 | ) 238 | }) 239 | 240 | test('option with acronym', async () => { 241 | const router = t.router({ 242 | foo: t.procedure 243 | .input( 244 | z.object({ 245 | addHTTPHeaders: z.boolean().meta({negatable: true}), 246 | }), 247 | ) 248 | .query(({input}) => JSON.stringify(input)), 249 | }) 250 | 251 | expect(await run(router, ['foo', '--add-http-headers'])).toEqual(`{"addHTTPHeaders":true}`) 252 | expect(await run(router, ['foo', '--no-add-http-headers'])).toEqual(`{"addHTTPHeaders":false}`) 253 | }) 254 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Misha Kaletsky 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | 15 | Apache License 16 | Version 2.0, January 2004 17 | http://www.apache.org/licenses/ 18 | 19 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 20 | 21 | 1. Definitions. 22 | 23 | "License" shall mean the terms and conditions for use, reproduction, 24 | and distribution as defined by Sections 1 through 9 of this document. 25 | 26 | "Licensor" shall mean the copyright owner or entity authorized by 27 | the copyright owner that is granting the License. 28 | 29 | "Legal Entity" shall mean the union of the acting entity and all 30 | other entities that control, are controlled by, or are under common 31 | control with that entity. For the purposes of this definition, 32 | "control" means (i) the power, direct or indirect, to cause the 33 | direction or management of such entity, whether by contract or 34 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 35 | outstanding shares, or (iii) beneficial ownership of such entity. 36 | 37 | "You" (or "Your") shall mean an individual or Legal Entity 38 | exercising permissions granted by this License. 39 | 40 | "Source" form shall mean the preferred form for making modifications, 41 | including but not limited to software source code, documentation 42 | source, and configuration files. 43 | 44 | "Object" form shall mean any form resulting from mechanical 45 | transformation or translation of a Source form, including but 46 | not limited to compiled object code, generated documentation, 47 | and conversions to other media types. 48 | 49 | "Work" shall mean the work of authorship, whether in Source or 50 | Object form, made available under the License, as indicated by a 51 | copyright notice that is included in or attached to the work 52 | (an example is provided in the Appendix below). 53 | 54 | "Derivative Works" shall mean any work, whether in Source or Object 55 | form, that is based on (or derived from) the Work and for which the 56 | editorial revisions, annotations, elaborations, or other modifications 57 | represent, as a whole, an original work of authorship. For the purposes 58 | of this License, Derivative Works shall not include works that remain 59 | separable from, or merely link (or bind by name) to the interfaces of, 60 | the Work and Derivative Works thereof. 61 | 62 | "Contribution" shall mean any work of authorship, including 63 | the original version of the Work and any modifications or additions 64 | to that Work or Derivative Works thereof, that is intentionally 65 | submitted to Licensor for inclusion in the Work by the copyright owner 66 | or by an individual or Legal Entity authorized to submit on behalf of 67 | the copyright owner. For the purposes of this definition, "submitted" 68 | means any form of electronic, verbal, or written communication sent 69 | to the Licensor or its representatives, including but not limited to 70 | communication on electronic mailing lists, source code control systems, 71 | and issue tracking systems that are managed by, or on behalf of, the 72 | Licensor for the purpose of discussing and improving the Work, but 73 | excluding communication that is conspicuously marked or otherwise 74 | designated in writing by the copyright owner as "Not a Contribution." 75 | 76 | "Contributor" shall mean Licensor and any individual or Legal Entity 77 | on behalf of whom a Contribution has been received by Licensor and 78 | subsequently incorporated within the Work. 79 | 80 | 2. Grant of Copyright License. Subject to the terms and conditions of 81 | this License, each Contributor hereby grants to You a perpetual, 82 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 83 | copyright license to reproduce, prepare Derivative Works of, 84 | publicly display, publicly perform, sublicense, and distribute the 85 | Work and such Derivative Works in Source or Object form. 86 | 87 | 3. Grant of Patent License. Subject to the terms and conditions of 88 | this License, each Contributor hereby grants to You a perpetual, 89 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 90 | (except as stated in this section) patent license to make, have made, 91 | use, offer to sell, sell, import, and otherwise transfer the Work, 92 | where such license applies only to those patent claims licensable 93 | by such Contributor that are necessarily infringed by their 94 | Contribution(s) alone or by combination of their Contribution(s) 95 | with the Work to which such Contribution(s) was submitted. If You 96 | institute patent litigation against any entity (including a 97 | cross-claim or counterclaim in a lawsuit) alleging that the Work 98 | or a Contribution incorporated within the Work constitutes direct 99 | or contributory patent infringement, then any patent licenses 100 | granted to You under this License for that Work shall terminate 101 | as of the date such litigation is filed. 102 | 103 | 4. Redistribution. You may reproduce and distribute copies of the 104 | Work or Derivative Works thereof in any medium, with or without 105 | modifications, and in Source or Object form, provided that You 106 | meet the following conditions: 107 | 108 | (a) You must give any other recipients of the Work or 109 | Derivative Works a copy of this License; and 110 | 111 | (b) You must cause any modified files to carry prominent notices 112 | stating that You changed the files; and 113 | 114 | (c) You must retain, in the Source form of any Derivative Works 115 | that You distribute, all copyright, patent, trademark, and 116 | attribution notices from the Source form of the Work, 117 | excluding those notices that do not pertain to any part of 118 | the Derivative Works; and 119 | 120 | (d) If the Work includes a "NOTICE" text file as part of its 121 | distribution, then any Derivative Works that You distribute must 122 | include a readable copy of the attribution notices contained 123 | within such NOTICE file, excluding those notices that do not 124 | pertain to any part of the Derivative Works, in at least one 125 | of the following places: within a NOTICE text file distributed 126 | as part of the Derivative Works; within the Source form or 127 | documentation, if provided along with the Derivative Works; or, 128 | within a display generated by the Derivative Works, if and 129 | wherever such third-party notices normally appear. The contents 130 | of the NOTICE file are for informational purposes only and 131 | do not modify the License. You may add Your own attribution 132 | notices within Derivative Works that You distribute, alongside 133 | or as an addendum to the NOTICE text from the Work, provided 134 | that such additional attribution notices cannot be construed 135 | as modifying the License. 136 | 137 | You may add Your own copyright statement to Your modifications and 138 | may provide additional or different license terms and conditions 139 | for use, reproduction, or distribution of Your modifications, or 140 | for any such Derivative Works as a whole, provided Your use, 141 | reproduction, and distribution of the Work otherwise complies with 142 | the conditions stated in this License. 143 | 144 | 5. Submission of Contributions. Unless You explicitly state otherwise, 145 | any Contribution intentionally submitted for inclusion in the Work 146 | by You to the Licensor shall be under the terms and conditions of 147 | this License, without any additional terms or conditions. 148 | Notwithstanding the above, nothing herein shall supersede or modify 149 | the terms of any separate license agreement you may have executed 150 | with Licensor regarding such Contributions. 151 | 152 | 6. Trademarks. This License does not grant permission to use the trade 153 | names, trademarks, service marks, or product names of the Licensor, 154 | except as required for reasonable and customary use in describing the 155 | origin of the Work and reproducing the content of the NOTICE file. 156 | 157 | 7. Disclaimer of Warranty. Unless required by applicable law or 158 | agreed to in writing, Licensor provides the Work (and each 159 | Contributor provides its Contributions) on an "AS IS" BASIS, 160 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 161 | implied, including, without limitation, any warranties or conditions 162 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 163 | PARTICULAR PURPOSE. You are solely responsible for determining the 164 | appropriateness of using or redistributing the Work and assume any 165 | risks associated with Your exercise of permissions under this License. 166 | 167 | 8. Limitation of Liability. In no event and under no legal theory, 168 | whether in tort (including negligence), contract, or otherwise, 169 | unless required by applicable law (such as deliberate and grossly 170 | negligent acts) or agreed to in writing, shall any Contributor be 171 | liable to You for damages, including any direct, indirect, special, 172 | incidental, or consequential damages of any character arising as a 173 | result of this License or out of the use or inability to use the 174 | Work (including but not limited to damages for loss of goodwill, 175 | work stoppage, computer failure or malfunction, or any and all 176 | other commercial damages or losses), even if such Contributor 177 | has been advised of the possibility of such damages. 178 | 179 | 9. Accepting Warranty or Additional Liability. While redistributing 180 | the Work or Derivative Works thereof, You may choose to offer, 181 | and charge a fee for, acceptance of support, warranty, indemnity, 182 | or other liability obligations and/or rights consistent with this 183 | License. However, in accepting such obligations, You may act only 184 | on Your own behalf and on Your sole responsibility, not on behalf 185 | of any other Contributor, and only if You agree to indemnify, 186 | defend, and hold each Contributor harmless for any liability 187 | incurred by, or claims asserted against, such Contributor by reason 188 | of your accepting any such warranty or additional liability. 189 | 190 | END OF TERMS AND CONDITIONS 191 | --------------------------------------------------------------------------------