Skip to content
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export function createCli<R extends AnyRouter>({router, ...params}: TrpcCliParam
const parsedProcedure = getParsedProcedure(info)
const incompatiblePairs = incompatiblePropertyPairs(parsedProcedure.optionsJsonSchema)
// add meta to the commander command so we can access it in prompt.ts
Object.assign(command, {__trpcCli: {path: procedurePath, meta}})
Object.assign(command, {__trpcCli: {path: procedurePath, meta, originalInputSchema: info.originalInputSchema}})
const optionJsonSchemaProperties = flattenedProperties(parsedProcedure.optionsJsonSchema)
command.exitOverride(ec => {
_process.exit(ec.exitCode)
Expand Down
2 changes: 1 addition & 1 deletion src/parse-procedure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ function handleMergedSchema(mergedSchema: JSONSchema7): Result<ParsedProcedure>
}

// zod-to-json-schema turns `z.string().optional()` into `{"anyOf":[{"not":{}},{"type":"string"}]}`
function isOptional(schema: JSONSchema7Definition) {
export function isOptional(schema: JSONSchema7Definition) {
if (schema && typeof schema === 'object' && 'optional' in schema) return schema.optional === true
if (schemaDefPropValue(schema, 'not') && JSON.stringify(schema) === '{"not":{}}') return true
const anyOf = schemaDefPropValue(schema, 'anyOf')
Expand Down
22 changes: 17 additions & 5 deletions src/parse-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ export type ProcedureInfo = {
meta: TrpcCliMeta
inputSchemas: Result<JSONSchema7[]>
type: 'query' | 'mutation' | null
/** The original input schema (first element of the inputs array), used for progressive schemas */
originalInputSchema?: unknown
}

/**
Expand All @@ -203,8 +205,12 @@ const parseTrpcRouter = ({router, ...dependencies}: {router: Trpc10RouterLike |
const defEntries = Object.entries<TrpcProcedure>(router._def.procedures as {})
return defEntries.map(([procedurePath, procedure]): [string, ProcedureInfo] => {
const meta = getMeta(procedure)
const inputSchemas = getProcedureInputJsonSchemas(procedure._def.inputs as unknown[], dependencies)
return [procedurePath, {meta, inputSchemas, type: procedure._def.type as 'query' | 'mutation'}]
const inputs = procedure._def.inputs as unknown[]
const inputSchemas = getProcedureInputJsonSchemas(inputs, dependencies)
return [
procedurePath,
{meta, inputSchemas, type: procedure._def.type as 'query' | 'mutation', originalInputSchema: inputs[0]},
]
})
}

Expand All @@ -217,7 +223,12 @@ const parseNorpcRouter = ({router, ...dependencies}: {router: NorpcRouterLike} &
const meta = value.meta || {}
entries.push([
childPath,
{meta, inputSchemas: getProcedureInputJsonSchemas([value.input], dependencies), type: null},
{
meta,
inputSchemas: getProcedureInputJsonSchemas([value.input], dependencies),
type: null,
originalInputSchema: value.input,
},
])
return
}
Expand All @@ -240,14 +251,15 @@ const parseOrpcRouter = ({router, ...dependencies}: {router: OrpcRouterLike<any>
for (const p of path) procedure = procedure[p] as Record<string, unknown>
if (!isProcedure(procedure)) return // if it's contract-only, we can't run it via CLI (user may have passed an implemented contract router? should we tell them? it's undefined behaviour so kinda on them)

const inputSchemas = getProcedureInputJsonSchemas([contract['~orpc'].inputSchema], dependencies)
const originalInputSchema = contract['~orpc'].inputSchema as unknown
const inputSchemas = getProcedureInputJsonSchemas([originalInputSchema], dependencies)
if (path.some(p => p.includes('.'))) {
throw new Error(`ORPC procedure path segments cannot contain \`.'. Got: ${JSON.stringify(path)}`)
}

const procedurePath = path.join('.')
const meta = getMeta({_def: {meta: contract['~orpc'].meta as TrpcCliMeta}})
entries.push([procedurePath, {meta, inputSchemas, type: null}])
entries.push([procedurePath, {meta, inputSchemas, type: null, originalInputSchema}])
},
)
if (lazyRoutes.length) {
Expand Down
118 changes: 118 additions & 0 deletions src/progressive-object.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-explicit-any */
import {JSONSchema7} from 'json-schema'
import {toJsonSchema} from './json-schema.js'
import {isOptional} from './parse-procedure.js'
import {StandardSchemaV1} from './standard-schema/contract.js'

export type ProgressiveProp = {
propName: string
propType: StandardSchemaV1<any>
modifier?: (baseType: StandardSchemaV1<any>, soFar: any) => StandardSchemaV1<any>
}

const progressiveObjectSchema = <Shape extends Record<string, StandardSchemaV1<any>>>(
props: ProgressiveProp[],
): ProgressiveObjectSchema<Shape> => {
const schema: StandardSchemaV1<Shape> = {
'~standard': {
version: 1,
vendor: 'progressive-object-schema',
validate: async _input => {
const input = _input as Record<string, unknown>
let obj: Record<string, unknown> = {}
for (const {propName, propType, modifier} of props) {
const type = modifier ? modifier(propType, obj) : propType
const parsed = await type['~standard'].validate(input[propName])
if ('issues' in parsed) {
return {
issues: parsed.issues?.map(iss => ({...iss, path: [propName, ...(iss.path || [])]})),
} as StandardSchemaV1.FailureResult
}
obj = {...obj, [propName]: parsed.value!}
}
return {value: obj} as StandardSchemaV1.SuccessResult<Shape>
},
},
}
return {
...schema,
__progressiveProps: props,
toJsonSchema: () => {
const propertyEntries = props.map(({propName, propType: baseType, modifier}) => {
const usedProps: string[] = []
const propChecker = new Proxy(
{},
{
get: (_target, prop) => {
usedProps.push(prop as string)
return undefined
},
},
)
let propType = baseType
if (modifier) {
propType = modifier(propType, propChecker)
}
// const propType = typeof propTypeOrFn === 'function' ? propTypeOrFn(propChecker) : propTypeOrFn
// todo: use standard-json-schema
const propSchema = toJsonSchema(propType, {})
if (!propSchema.success) {
throw new Error(`Failed to convert property ${propName} to JSON schema: ${propSchema.error}`)
}
if (usedProps.length) {
const message = `Note: this schema may differ at runtime based on the value of ${usedProps.map(p => `\`${p}\``).join(', ')}`
propSchema.value.description = [propSchema.value.description, message].filter(Boolean).join('\n')
}
return [propName, propSchema.value]
})
const required = propertyEntries.flatMap(([name, sch]) => {
if (isOptional(sch as {})) return []
return [name as string]
})
return {
type: 'object',
required,
properties: Object.fromEntries(propertyEntries),
} satisfies JSONSchema7
},
prop: (...args: unknown[]) => {
const [name, type, modifier] = args as [string, ProgressiveProp['propType'], ProgressiveProp['modifier']]
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
return progressiveObjectSchema([
...props,
{propName: name, propType: type, modifier},
]) as ProgressiveObjectSchema<never>
},
}
}

/** @experimental very experimental way of building an object "progressively" so you can set defaults based on prior values https://github.com/mmkal/trpc-cli/pull/179 */
export const obj = progressiveObjectSchema<{}>([])

export type ProgressiveObjectSchema<T extends Record<string, StandardSchemaV1<any>>> = StandardSchemaV1<T> & {
__progressiveProps: ProgressiveProp[]
toJsonSchema: () => JSONSchema7
prop<Name extends string, Type extends StandardSchemaV1<any>>(
name: Name,
type: Type,
): ProgressiveObjectSchema<T & Record<Name, Type>>
prop<Name extends string, BaseType extends StandardSchemaV1<any>, Type extends StandardSchemaV1<any>>(
name: Name,
type: BaseType,
modifier?: (
type: NoInfer<BaseType>,
soFar: Record<string, never> | {[K in keyof T]: NonNullable<T[K]['~standard']['types']>['output']},
) => Type,
): ProgressiveObjectSchema<T & Record<Name, Type>>
}

/** Check if a value is a ProgressiveObjectSchema */
export function isProgressiveObjectSchema(value: unknown): value is ProgressiveObjectSchema<any> {
return (
typeof value === 'object' &&
value !== null &&
'__progressiveProps' in value &&
Array.isArray((value as ProgressiveObjectSchema<any>).__progressiveProps)
)
}
85 changes: 79 additions & 6 deletions src/prompts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import {Argument, Command, Option} from 'commander'
import {getEnumChoices} from './json-schema.js'
import {toJsonSchema} from './json-schema.js'
import {isProgressiveObjectSchema} from './progressive-object.js'
import {
CommanderProgramLike,
EnquirerLike,
Expand Down Expand Up @@ -454,7 +457,9 @@ export const promptify = (program: CommanderProgramLike, prompts: Promptable) =>
}
await prompter.setup?.(baseContext)

const procedureMeta = (analysis.command.original as {__trpcCli?: {meta: TrpcCliMeta}}).__trpcCli?.meta
const trpcCliMeta = (analysis.command.original as {__trpcCli?: TrpcCliCommandMeta}).__trpcCli
const procedureMeta = trpcCliMeta?.meta
const originalInputSchema = trpcCliMeta?.originalInputSchema

let shouldPrompt: boolean
if (typeof procedureMeta?.prompt === 'boolean') {
Expand All @@ -466,6 +471,16 @@ export const promptify = (program: CommanderProgramLike, prompts: Promptable) =>
}

if (shouldPrompt) {
// Track values collected so far for progressive schema support
const collectedValues: Record<string, unknown> = {}

// Initialize with already-specified option values (use attributeName for camelCase property names)
for (const opt of analysis.options) {
if (opt.specified && opt.value !== undefined) {
collectedValues[opt.original.attributeName()] = opt.value
}
}

for (const arg of analysis.arguments) {
const ctx = {...baseContext, argument: arg.original}
if (!arg.specified) {
Expand Down Expand Up @@ -497,14 +512,25 @@ export const promptify = (program: CommanderProgramLike, prompts: Promptable) =>
if (!option.specified) {
const fullFlag = option.original.long || `--${option.original.name()}`
const isBoolean = option.original.isBoolean() || option.original.flags.includes('[boolean]')
// Use attributeName() for property lookup since schema properties are camelCase
const propName = option.original.attributeName()

// Get progressive property info if available (re-evaluates schema with collected values)
const progressiveInfo = originalInputSchema
? getProgressivePropertyInfo(originalInputSchema, propName, collectedValues)
: null

if (isBoolean) {
// For booleans, use progressive default if available
const defaultValue = progressiveInfo?.default ?? (option.original.defaultValue as unknown)
const promptedValue = await prompter.confirm(
{
message: getMessage(option.original),
default: (option.original.defaultValue as boolean | undefined) ?? false,
default: (defaultValue as boolean | undefined) ?? false,
},
ctx,
)
collectedValues[propName] = promptedValue
if (promptedValue) nextArgv.push(fullFlag)
} else if (option.original.variadic && option.original.argChoices) {
const choices = option.original.argChoices.slice()
Expand All @@ -519,26 +545,31 @@ export const promptify = (program: CommanderProgramLike, prompts: Promptable) =>
},
ctx,
)
collectedValues[propName] = results
results.forEach(result => {
if (typeof result === 'string') nextArgv.push(fullFlag, result)
})
} else if (option.original.argChoices) {
const choices = option.original.argChoices.slice()
} else if (option.original.argChoices || progressiveInfo?.choices) {
// Use progressive choices if available, otherwise fall back to static choices
const choices = progressiveInfo?.choices ?? option.original.argChoices?.slice() ?? []
const defaultValue = progressiveInfo?.default ?? (option.original.defaultValue as unknown)
const set = new Set(choices)
const promptedValue = await prompter.select(
{
message: getMessage(option.original),
choices,
default: option.original.defaultValue as string,
default: defaultValue as string,
// required: option.original.required,
},
ctx,
)
collectedValues[propName] = promptedValue
if (set.has(promptedValue)) {
nextArgv.push(fullFlag, promptedValue)
}
} else if (option.original.variadic) {
const values: string[] = []
const collectedArray: string[] = []
do {
const promptedValue = await prompter.input(
{
Expand All @@ -549,17 +580,21 @@ export const promptify = (program: CommanderProgramLike, prompts: Promptable) =>
)
if (!promptedValue) break
values.push(fullFlag, promptedValue)
collectedArray.push(promptedValue)
} while (values)
collectedValues[propName] = collectedArray
nextArgv.push(...values)
} else {
// let's handle this as a string - but the `parseArg` function could turn it into a number or boolean or whatever
const getParsedValue = (input: string) => {
return option.original.parseArg ? option.original.parseArg(input, undefined as string | undefined) : input
}
// Use progressive default if available
const defaultValue = progressiveInfo?.default ?? option.value
const promptedValue = await prompter.input(
{
message: getMessage(option.original),
default: option.value,
default: defaultValue as string | undefined,
required: option.original.required,
validate: input => {
const parsed = getParsedValue(input)
Expand All @@ -569,6 +604,7 @@ export const promptify = (program: CommanderProgramLike, prompts: Promptable) =>
},
ctx,
)
collectedValues[propName] = promptedValue
if (promptedValue) nextArgv.push(fullFlag, getParsedValue(promptedValue) ?? promptedValue)
}
}
Expand All @@ -593,3 +629,40 @@ export const promptify = (program: CommanderProgramLike, prompts: Promptable) =>
},
}) satisfies CommanderProgramLike
}

type TrpcCliCommandMeta = {
meta: TrpcCliMeta
originalInputSchema?: unknown
}

/**
* For progressive schemas, evaluates the property schema given the values collected so far.
* Returns the choices and default for the property, if applicable.
*/
const getProgressivePropertyInfo = (
originalInputSchema: unknown,
propertyName: string,
collectedValues: Record<string, unknown>,
): {choices?: string[]; default?: unknown} | null => {
if (!isProgressiveObjectSchema(originalInputSchema)) {
return null
}

const prop = originalInputSchema.__progressiveProps.find(p => p.propName === propertyName)
if (!prop) return null

// Get the actual schema by evaluating the function with collected values
const propType = typeof prop.modifier === 'function' ? prop.modifier(prop.propType, collectedValues) : prop.propType

// Convert to JSON schema to extract choices and default
const jsonSchemaResult = toJsonSchema(propType, {})
if (!jsonSchemaResult.success) return null

const jsonSchema = jsonSchemaResult.value
const enumChoices = getEnumChoices(jsonSchema)

return {
choices: enumChoices?.type === 'string_enum' ? enumChoices.choices : undefined,
default: jsonSchema.default,
}
}
29 changes: 29 additions & 0 deletions test/fixtures/progressive-object.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as prompts from '@inquirer/prompts'
import * as trpcServer from '@trpc/server'
import {z} from 'zod/v4'
import {createCli, type TrpcCliMeta} from '../../src/index.js'
import {obj} from '../../src/progressive-object.js'

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

const router = trpc.router({
createApp: trpc.procedure
.input(
obj
.prop('framework', z.enum(['react', 'vue']))
.prop('rpcLibrary', z.enum(['trpc', 'orpc']), (e, inputs) =>
e.default(inputs.framework === 'react' ? 'trpc' : 'orpc'),
)
.prop(
'clientLibrary',
z.enum(['react-query', 'tanstack-query', 'react-query-v5', 'tanstack-query-v5']),
(e, inputs) => e.default(inputs.rpcLibrary === 'trpc' ? 'react-query' : 'tanstack-query'),
)
.prop('typescript', z.boolean(), (b, inputs) =>
b.default(inputs.framework === 'react' && inputs.clientLibrary !== 'react-query-v5'),
),
)
.query(({input}) => JSON.stringify(input)),
})

void createCli({router}).run({prompts})
Loading
Loading