diff --git a/.gitignore b/.gitignore index 56d750ab..76413f17 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ dist *ignoreme* *.log session-ses_*.md +tasks/*interview* +tasks/**/*interview* diff --git a/README.md b/README.md index 106b69ed..6f6b7d60 100644 --- a/README.md +++ b/README.md @@ -656,15 +656,77 @@ void createCli({filename: '/path/to/commands.ts'}).run() trpc-cli reads the module's *source text* and dynamically imports it: each exported function becomes a command (kebab-cased, e.g. `listVersions` → `list-versions`), the jsdoc above the function becomes the command description, and parameter type annotations are parsed by the vendored `Type.Script` into real JSON schemas - property jsdoc comments become flag descriptions, and inputs are validated before your function runs (`mycli add --package-name left-pad --dev`). A default-exported function becomes the default command, so `export default function hola(...)` can be run as either `mycli ...` or `mycli hola ...`. +Exported functions whose signatures cannot be converted into CLI inputs are ignored as ordinary non-command exports. This gives helper exports an escape hatch without adding trpc-cli-specific metadata: + +```ts +export const loadSomeInternalThing = (params: NoInfer<{foo: string}>) => { + return loadIt(params.foo) // not a command +} +``` + +Command and option aliases can be added with `@alias` tags in the same jsdoc comments: + +```ts +/** install dependencies + * @alias i + */ +export function install(options: { + /** fail if the lockfile is out of date + * @alias f + */ + frozenLockfile?: boolean +}) { + // mycli i -f +} +``` + File-backed module mode can compose command modules too: ```ts // commands.ts export * from './workspace.ts' // merges named commands into this level +export {Users} from './users.ts' // re-exports one selected command/group export * as users from './users.ts' // creates a nested `users` sub-router ``` -Relative re-export specifiers are resolved from the containing file. Exact paths win first (`'./users.ts'`, `'./users.mts'`, etc.); extensionless specifiers then probe common TypeScript/JavaScript extensions such as `.ts`, `.mts`, `.js`, and `.mjs`. `export * from './workspace.ts'` follows ESM semantics and does not merge that module's default export. `export * as users from './users.ts'` keeps the child router intact, including a default export as the default command for `users`. +Relative re-export specifiers are resolved from the containing file. Exact paths win first (`'./users.ts'`, `'./users.mts'`, etc.); extensionless specifiers then probe common TypeScript/JavaScript extensions such as `.ts`, `.mts`, `.js`, and `.mjs`. `.js`/`.mjs`/`.cjs` specifiers may also resolve to sibling TypeScript source files such as `.ts`/`.mts`/`.cts`, matching the common TypeScript ESM pattern. `export * from './workspace.ts'` follows ESM semantics and does not merge that module's default export. `export {Users} from './users.ts'` exposes that child command/group at the current level. `export * as users from './users.ts'` keeps the child router intact, including a default export as the default command for `users`. + +Same-file subcommand groups can be written as exported classes. The class is not instantiated when help/schema information is built; trpc-cli creates a fresh instance only when one of the class's method commands runs. + +```ts +// commands.ts +export class Users { + /** invite a user */ + invite(options: { + /** address to invite */ + email: string + }) { + this.#audit('invite') + return `invite ${options.email}` // mycli users invite --email ada@example.com + } + + #audit(action: string) { + // private implementation detail, not a command + } +} +``` + +Run it like: + +```console +$ tsx commands.ts users invite --email bob@example.com +invite bob@example.com +``` + +Class command groups must be direct `export class Name { ... }` declarations. Classes without a base class may omit the constructor; classes with `extends` must declare an explicit zero-argument constructor. Classes with constructor parameters, unsupported inheritance, or no public command methods are ignored as ordinary exports. Public instance methods declared directly in the class body become commands. Static methods, inherited methods, private/protected methods, getters and setters are not commands. A default-exported class exposes its public methods at the current level instead of under a class-name subcommand: + +```ts +export default class Commands { + invite(options: {email: string}) { + return `invite ${options.email}` // mycli invite --email ada@example.com + } +} +``` Multi-parameter functions work too: leading string/number/boolean parameters become positional arguments, and a trailing object parameter becomes flags - the same convention as [tuple inputs](#combining-positional-arguments-and-options): @@ -692,9 +754,9 @@ Details and limitations: - `filename` accepts an absolute path (robust - works from any directory), `import.meta` (when the call lives in the commands file itself), or a `URL` like `new URL('./commands.ts', import.meta.url)` (resolves relative to the importing file, so a distributed CLI works wherever it's invoked). A *relative* path string is resolved against `process.cwd()`, so it's only reliable when the CLI is run from a known directory - fine for quick scripts, but it breaks the moment a globally-installed CLI runs somewhere else, so prefer `import.meta`/`URL` for anything you distribute. For `.ts` modules, run under tsx, bun, deno, or node >=22.18 (which strip types natively). - `createCli(import.meta)` re-imports the commands file to read its exports, so when the call lives in that same file it's a *self-import*. That's fine as long as the call is at the **bottom** of the file (so all `export const` arrow functions above it are initialized) and is **not** top-level-`await`ed - `void createCli(import.meta).run()` is the safe form. A top-level `await createCli(import.meta).run()` would deadlock (the await suspends the module before the self-import can resolve). When another module imports this file, the `.run()` call is a no-op, so the exported command functions remain importable as plain functions. -- Parameter types can be inline literals (`{foo: string}`, `'fast' | 'slow'`) or references to a `type X = {...}`/`interface X {...}` declared in the same file (intersections of object literals like `type X = {a: string} & {b: number}` are flattened into one set of flags). Functions with no parameters become commands with no arguments. +- Parameter types can be inline literals (`{foo: string}`, `'fast' | 'slow'`), references to a `type X = {...}`/`interface X {...}` declared in the same file, or relative file-backed type imports such as `import type {Options} from './types.ts'`. Interface `extends` clauses and intersections of object literals like `type X = {a: string} & {b: number}` are flattened into one set of flags when possible. Functions with no parameters become commands with no arguments. - Only the *last* parameter can be an object type (it maps to flags); the others must be strings, numbers, booleans, or arrays of those (a `files: string[]` parameter becomes a variadic positional). Rest parameters and destructured positional parameters aren't supported. -- Supported declaration syntaxes: `export function f(...)`, `export async function f(...)`, `export const f = (...) => ...` (including `export const f = async (...) => ...`; type-annotated consts like `export const f: Cmd = ...` aren't parsed), `export default function f(...)` (anonymous default functions become a command named `default`), `export * as group from './group'`, and `export * from './group'`. The module must export *only* commands: any other exported function - an `export {f}` statement, a re-export form other than `export *`/`export * as`, or a declaration the extractor can't parse - makes the CLI fail at startup with an error naming the offending export (failing loudly beats silently dropping a command). Keep helpers in a separate, un-re-exported module. +- Supported declaration syntaxes: `export function f(...)`, `export async function f(...)`, `export const f = (...) => ...` (including `export const f = async (...) => ...`; type-annotated consts like `export const f: Cmd = ...` aren't parsed), `export default function f(...)` (anonymous default functions become a command named `default`), `export class Group { method(...) {} }`, `export default class Commands { method(...) {} }`, `export * as group from './group'`, `export * from './group'`, and `export {commandOrGroup} from './group'`. The module must export *only* commands: any other exported function - a local `export {f}` statement or a declaration the extractor can't parse - makes the CLI fail at startup with an error naming the offending export (failing loudly beats silently dropping a command). Keep helpers in a separate, un-re-exported module. - Overloaded functions work, with a simple rule: only the *first* overload signature is used; later overloads and the implementation signature are ignored. TypeScript resolves calls against overload signatures in order, so the first one is the primary documented shape - and a CLI can only present one calling convention. - For bundlers/browsers (no filesystem, no dynamic import), pass the source and exports explicitly: `createCli({source: rawSourceText, exports: await import('./commands.js')})`. Re-exported command modules are not supported in this form because trpc-cli has no filesystem location to resolve child modules from. - In this mode `run`/`buildProgram`/`toJSON` are all **async** (the module is loaded asynchronously): `const program = await createCli(import.meta).buildProgram()`. With a router, `buildProgram`/`toJSON` are synchronous. @@ -1399,7 +1461,21 @@ const cli = createCli({router: myRouter}) cli.run({prompts: true}) ``` -The user will then be asked to input any missing arguments or options. Booleans, numbers, enums etc. will get appropriate user-friendly prompts, along with input validation. The built-in prompts intentionally use plain line input: selects are numbered and checkboxes accept comma-separated numbers. If you want a richer terminal UI, install and pass `enquirer`, `prompts`, `@clack/prompts`, or `@inquirer/prompts` instead: +The user will then be asked to input any missing arguments or options. Booleans, numbers, enums etc. will get appropriate user-friendly prompts, along with input validation. The built-in prompts intentionally use plain line input: selects are numbered and checkboxes accept comma-separated numbers. + +You can pass a boolean expression too. `false` disables prompting, so this uses the built-in prompts unless trpc-cli detects a coding-agent environment: + +```ts +import {createCli, isAgent} from 'trpc-cli' + +const cli = createCli({router: myRouter}) + +await cli.run({ + prompts: !isAgent(), +}) +``` + +If you want a richer terminal UI, install and pass `enquirer`, `prompts`, `@clack/prompts`, or `@inquirer/prompts` instead: ```ts import * as prompts from '@inquirer/prompts' // or import * as prompts from 'enquirer', or import * as prompts from 'prompts' diff --git a/src/module-commands.ts b/src/module-commands.ts index 5be7062d..367f34be 100644 --- a/src/module-commands.ts +++ b/src/module-commands.ts @@ -1,20 +1,25 @@ /** - * @experimental Derive a CLI from a plain TypeScript module of exported functions - no schema library, no router. + * @experimental Derive a CLI from a plain TypeScript module of exported functions/classes - no schema library, no router. * * Runtime functions carry no type information, so this works from two inputs: the module's *source text* (to extract - * each exported function's parameter types and jsdoc) and its *live exports* (to actually call the functions). + * each exported function/class method's parameter types and jsdoc) and its *live exports* (to actually call the functions). * The extracted parameter type text is handed to the vendored `Type.Script` (see ./typebox), which turns it into a * JSON Schema - including jsdoc comments as property descriptions - with a `~standard` validator attached. Each * function becomes a norpc procedure, so the rest of trpc-cli treats the module like any other router: leading * scalar parameters become positional arguments and a trailing object-literal parameter becomes flags (the same - * convention as trpc-cli's tuple inputs), while single-object-parameter functions are flags-only. - * File-backed modules can re-export other command modules: `export * as group from './group'` creates a nested - * router, and `export * from './group'` merges named child commands into the current router. + * convention as trpc-cli's tuple inputs), while single-object-parameter functions are flags-only. Exported + * functions whose signatures cannot be converted into CLI inputs are ignored as ordinary non-command exports. + * Command-group-shaped exported classes become nested command groups whose public instance methods are invoked on a + * fresh class instance only when the command runs; a default-exported class puts its methods at the current router + * level. Classes with constructor arguments, no public command methods, or `extends` without an explicit + * zero-argument constructor are ignored as ordinary non-command exports. File-backed modules can re-export other + * command modules: `export * as group from './group'` creates a nested router, `export * from './group'` merges + * named child commands into the current router, and `export {Foo} from './foo'` re-exports selected commands. * * The source "parser" here is deliberately a lightweight hand-rolled extractor, not the TypeScript compiler API: - * it only needs to find exported function declarations, the jsdoc immediately preceding them, and each parameter's - * name + balanced `{...}` (or named-reference) type annotation text. The heavy lifting - turning type syntax into - * JSON Schema - is all `Type.Script`. + * it only needs to find exported function/class method declarations, the jsdoc immediately preceding them, and each + * parameter's name + balanced `{...}` (or named-reference) type annotation text. The heavy lifting - turning type + * syntax into JSON Schema - is all `Type.Script`. */ import {t} from './norpc.js' import {NorpcProcedureLike, NorpcRouterLike} from './parse-router.js' @@ -25,19 +30,24 @@ import {getSchemaTypes, kebabCase} from './util.js' type AnyFn = (...args: any[]) => any type SourceCliModule = {source: string; exports: Record} +type FileSourceModule = {source: string; filepath: string} type FileCliModule = SourceCliModule & {filepath: string} interface ModuleFileLoader { load: (filepath: string | URL) => Promise loadSpecifier: (parentFilepath: string, specifier: string) => Promise + loadSourceSpecifier: (parentFilepath: string, specifier: string) => Promise } interface ModuleReexport { - kind: 'all' | 'namespace' + kind: 'all' | 'namespace' | 'named' specifier: string name: string | undefined + names?: Array<{imported: string; exported: string}> } +class SkippedModuleCommandError extends Error {} + const moduleFileExtensions = ['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs'] /** @@ -68,6 +78,16 @@ export interface ExtractedCommand { params: ExtractedParam[] } +export interface ExtractedClass { + /** the export name, e.g. `Users` - becomes the (kebab-cased) command group name */ + name: string + /** the runtime module export to instantiate; differs from `name` for `export default class ...` */ + exportName: string + /** true for `export default class`, whose methods become root commands */ + default: boolean + methods: ExtractedCommand[] +} + /** A parameter extracted from a function declaration's parameter list. */ export interface ExtractedParam { /** the parameter name, e.g. `left` - becomes the (kebab-cased) positional argument name. Undefined for destructured patterns like `{force}` */ @@ -102,16 +122,29 @@ const createModuleFileLoader = async (): Promise => { import('node:url'), ]) - const cache = new Map>() + const sourceCache = new Map>() + const moduleCache = new Map>() - const loadResolvedPath = (fullpath: string) => { + const loadSourceResolvedPath = (fullpath: string) => { const normalized = path.resolve(fullpath) - const cached = cache.get(normalized) + const cached = sourceCache.get(normalized) if (cached) return cached - const promise = (async (): Promise => { + const promise = (async (): Promise => { const source = await fs.readFile(normalized, 'utf8').catch((e: unknown) => { throw new Error(`Could not read module source at ${normalized}`, {cause: e}) }) + return {source, filepath: normalized} + })() + sourceCache.set(normalized, promise) + return promise + } + + const loadResolvedPath = (fullpath: string) => { + const normalized = path.resolve(fullpath) + const cached = moduleCache.get(normalized) + if (cached) return cached + const promise = (async (): Promise => { + const {source} = await loadSourceResolvedPath(normalized) const exports = (await import(url.pathToFileURL(normalized).href).catch((e: unknown) => { throw new Error( `Could not import module at ${normalized}. For TypeScript modules, run under tsx, bun, deno, or node >=22.18 (which strip types natively).`, @@ -120,7 +153,7 @@ const createModuleFileLoader = async (): Promise => { })) as Record return {source, exports: {...exports}, filepath: normalized} })() - cache.set(normalized, promise) + moduleCache.set(normalized, promise) return promise } @@ -138,7 +171,15 @@ const createModuleFileLoader = async (): Promise => { } const exact = path.resolve(path.dirname(parentFilepath), specifier) - const candidates = path.extname(exact) ? [exact] : [exact, ...moduleFileExtensions.map(ext => `${exact}${ext}`)] + const extension = path.extname(exact) + const extensionAlternates: Record = { + '.cjs': ['.cts', '.ts'], + '.js': ['.ts', '.tsx'], + '.mjs': ['.mts', '.ts'], + } + const candidates = extension + ? [exact, ...(extensionAlternates[extension] || []).map(ext => `${exact.slice(0, -extension.length)}${ext}`)] + : [exact, ...moduleFileExtensions.map(ext => `${exact}${ext}`)] for (const candidate of candidates) { if (await fileExists(candidate)) return candidate } @@ -156,15 +197,17 @@ const createModuleFileLoader = async (): Promise => { }, loadSpecifier: async (parentFilepath, specifier) => loadResolvedPath(await resolveSpecifier(parentFilepath, specifier)), + loadSourceSpecifier: async (parentFilepath, specifier) => + loadSourceResolvedPath(await resolveSpecifier(parentFilepath, specifier)), } } /** * @experimental Build a norpc router from a module's source text + live exports. Exported functions become - * procedures: source order determines command order, function jsdoc becomes the command description, and parameter - * type annotations (inline literals, or references to a `type`/`interface` declared in the same file) are parsed by - * the vendored `Type.Script` into the input schema. Leading scalar parameters become positional arguments; a - * trailing object parameter becomes flags. + * procedures when their signatures can be converted into CLI inputs: source order determines command order, + * function jsdoc becomes the command description, and parameter type annotations (inline literals, or references to + * a `type`/`interface` declared in the same file) are parsed by the vendored `Type.Script` into the input schema. + * Leading scalar parameters become positional arguments; a trailing object parameter becomes flags. */ export const buildRouterFromModule = (resolved: { source: string @@ -177,8 +220,7 @@ export const buildRouterFromModule = (resolved: { ) } - const {procedures, claimedExportNames} = buildLocalProcedures(resolved) - assertNoUnmatchedFunctionExports(resolved.exports, claimedExportNames) + const procedures = buildLocalProcedures(resolved, buildDeclarationContext(resolved.source)) assertHasProcedures(procedures) return t.router(procedures) } @@ -192,7 +234,8 @@ const buildRouterFromFileModule = async ( throw new Error(`Circular module re-export detected: ${[...ancestors, resolved.filepath].join(' -> ')}`) } - const {procedures, claimedExportNames} = buildLocalProcedures(resolved) + const context = await buildFileDeclarationContext(resolved, loader) + const procedures = buildLocalProcedures(resolved, context) const childAncestors = [...ancestors, resolved.filepath] for (const reexport of extractModuleReexports(resolved.source)) { const child = await loader.loadSpecifier(resolved.filepath, reexport.specifier) @@ -200,7 +243,15 @@ const buildRouterFromFileModule = async ( if (reexport.kind === 'namespace') { addProcedureOrRouter(procedures, reexport.name!, childRouter, resolved.filepath, child.filepath) - claimedExportNames.add(reexport.name!) + continue + } + + if (reexport.kind === 'named') { + for (const {imported, exported} of reexport.names || []) { + const procedureOrRouter = childRouter[imported] + if (!procedureOrRouter) continue + addProcedureOrRouter(procedures, exported, procedureOrRouter, resolved.filepath, child.filepath) + } continue } @@ -209,50 +260,77 @@ const buildRouterFromFileModule = async ( // on the parent module namespace, so only merge names that the runtime import actually exposed. if (!(name in resolved.exports)) continue addProcedureOrRouter(procedures, name, procedureOrRouter, resolved.filepath, child.filepath) - claimedExportNames.add(name) } } - assertNoUnmatchedFunctionExports(resolved.exports, claimedExportNames) assertHasProcedures(procedures) return t.router(procedures) } -const buildLocalProcedures = (resolved: SourceCliModule) => { +const buildLocalProcedures = (resolved: SourceCliModule, context: Record) => { const {source, exports} = resolved const commands = extractModuleCommands(source) - const context = buildDeclarationContext(source) + const classes = extractModuleClasses(source) const procedures: Record = {} - const claimedExportNames = new Set() for (const command of commands) { - claimedExportNames.add(command.exportName) const fn = exports[command.exportName] if (typeof fn !== 'function') continue // e.g. `export const x = (2 + 3)` - extractor can match non-functions; runtime is the source of truth - procedures[command.name] = buildProcedure(command, fn as AnyFn, context) + const procedure = tryBuildProcedure(command, fn as AnyFn, context) + if (procedure) addLocalProcedureOrRouter(procedures, command.name, procedure) + } + for (const extractedClass of classes) { + const ClassCtor = exports[extractedClass.exportName] + if (typeof ClassCtor !== 'function') continue + if (ClassCtor.length > 0) continue + const childProcedures: Record = {} + for (const method of extractedClass.methods) { + const procedure = tryBuildProcedure( + method, + (...args: unknown[]) => { + const instance = new (ClassCtor as new () => Record)() + return (instance[method.name] as (...args: unknown[]) => unknown)(...args) + }, + context, + ) + if (!procedure) continue + if (extractedClass.default) addLocalProcedureOrRouter(procedures, method.name, procedure) + else childProcedures[method.name] = procedure + } + if (!extractedClass.default && Object.keys(childProcedures).length > 0) { + addLocalProcedureOrRouter(procedures, extractedClass.name, t.router(childProcedures)) + } } - return {procedures, claimedExportNames} + return procedures } -const assertNoUnmatchedFunctionExports = (exports: Record, claimedExportNames: Set) => { - const unmatched = Object.entries(exports) - .filter(([name, value]) => typeof value === 'function' && !claimedExportNames.has(name)) - .map(([name]) => name) - if (unmatched.length > 0) { - throw new Error( - `Could not find a parseable declaration for exported function(s) ${unmatched.map(n => JSON.stringify(n)).join(', ')}. ` + - `Every exported function becomes a command, and must be declared directly in the module source as \`export function name(...)\`, \`export async function name(...)\`, \`export const name = (...) => ...\` or \`export default function name(...)\` - ` + - `re-exports like \`export {name} from './helpers.js'\` can't be parsed yet. ` + - `If these exports aren't meant to be commands, move them to a separate module that the commands module doesn't re-export.`, - ) +const addLocalProcedureOrRouter = ( + procedures: Record, + name: string, + procedureOrRouter: NorpcProcedureLike | NorpcRouterLike, +) => { + if (name in procedures) throw new Error(`Module command ${JSON.stringify(name)} is declared more than once.`) + procedures[name] = procedureOrRouter +} + +const tryBuildProcedure = ( + command: ExtractedCommand, + fn: AnyFn, + context: Record, +): NorpcProcedureLike | undefined => { + try { + return buildProcedure(command, fn, context) + } catch (error) { + if (error instanceof SkippedModuleCommandError) return undefined + throw error } } const assertHasProcedures = (procedures: Record) => { if (Object.keys(procedures).length === 0) { throw new Error( - `No commands found in module. Export functions with \`export function name(...)\`, \`export async function name(...)\`, \`export const name = (...) => ...\` or \`export default function name(...)\`.`, + `No commands found in module. Export functions with \`export function name(...)\`, \`export async function name(...)\`, \`export const name = (...) => ...\` or \`export default function name(...)\`, or export a command-group-shaped class.`, ) } } @@ -273,9 +351,11 @@ const addProcedureOrRouter = ( } const buildProcedure = (command: ExtractedCommand, fn: AnyFn, context: Record): NorpcProcedureLike => { + const commandDoc = parseCliJsdoc(command.description) const meta = { - ...(command.description ? {description: command.description} : {}), + ...(commandDoc.description ? {description: commandDoc.description} : {}), ...(command.default ? {default: true} : {}), + ...(commandDoc.aliases.length > 0 ? {aliases: {command: commandDoc.aliases}} : {}), } const builder = Object.keys(meta).length > 0 ? t.procedure.meta(meta) : t.procedure if (command.params.length === 0) { @@ -315,25 +395,25 @@ const buildPositionalProcedure = ( positionalParams.forEach((param, i) => { const where = `Parameter ${i + 1} (${describeParam(param)}) of "${command.name}"` if (param.destructured) { - throw new Error( + throw new SkippedModuleCommandError( `${where} is a destructuring pattern, which isn't supported for positional arguments. Give the parameter a name, or move it into a trailing options object.`, ) } if (isObjectLikeSchema(paramSchemas[i])) { - throw new Error( + throw new SkippedModuleCommandError( `${where} is an object type, but only the *last* parameter can be an object - leading parameters become positional arguments and a trailing object parameter maps to flags. Move it to the end, or flatten it into the trailing options object.`, ) } if (isArrayOfPrimitives(paramSchemas[i])) { if (param.optional) { - throw new Error( + throw new SkippedModuleCommandError( `${where} is an optional array. Optional array parameters aren't supported as positional arguments - make it required, or move it into a trailing options object.`, ) } return // required array of primitives -> variadic positional, supported by the existing tuple handling } if (!isPrimitiveish(paramSchemas[i])) { - throw new Error( + throw new SkippedModuleCommandError( `${where} has type \`${param.typeText}\`, which can't be used as a positional argument. Positional parameters must be strings, numbers, booleans (or arrays of those) - put other values in a trailing options object.`, ) } @@ -349,9 +429,9 @@ const buildPositionalProcedure = ( return cliOptional[i] ? `(${param.typeText}) | undefined` : param.typeText }) .join(', ')}]` - const schema = Type.Script(context as never, tupleScript) as {items?: unknown[]; minItems?: number} + const schema = parseTypeScriptSchema(context, tupleScript) as {items?: unknown[]; minItems?: number} if (isNeverSchema(schema) || !Array.isArray(schema.items) || schema.items.length !== params.length) { - throw new Error( + throw new SkippedModuleCommandError( `Could not parse the parameter list of "${command.name}" as a tuple: \`${tupleScript}\`. This is likely a bug in trpc-cli's module-commands extractor - please report it.`, ) } @@ -361,7 +441,8 @@ const buildPositionalProcedure = ( positionalParams.forEach((param, i) => { const item = schema.items![i] as Record item.title = kebabCase(param.name!) - if (param.description) item.description = param.description + const paramDoc = parseCliJsdoc(param.description) + if (paramDoc.description) item.description = paramDoc.description if (cliOptional[i]) item.optional = true }) if (lastIsFlagsObject) { @@ -371,6 +452,7 @@ const buildPositionalProcedure = ( } schema.minItems = cliOptional.includes(true) ? cliOptional.indexOf(true) : params.length + applySchemaJsdocMetadata(schema) return builder.input(schema as never).handler(({input}) => fn(...(input as unknown[]))) } @@ -380,21 +462,29 @@ const buildPositionalProcedure = ( * (where it's used for object-vs-scalar analysis before the combined tuple script is synthesized). */ const parseParamSchema = (commandName: string, param: ExtractedParam, context: Record): unknown => { - const schema = Type.Script(context as never, param.typeText) + const schema = parseTypeScriptSchema(context, param.typeText) if (isNeverSchema(schema)) { - throw new Error( + throw new SkippedModuleCommandError( `Could not parse the type of parameter ${describeParam(param)} of "${commandName}": \`${param.typeText}\`. ` + - `Use a string/number/boolean type, an inline object type literal like \`{foo: string}\`, or a reference to a \`type X = {...}\`/\`interface X {...}\` declared in the same file.`, + `Use a string/number/boolean type, an inline object type literal like \`{foo: string}\`, or a reference to a \`type X = {...}\`/\`interface X {...}\` declared in the same file or imported from a relative file-backed module.`, ) } const danglingRefs = collectRefs(schema) if (danglingRefs.length > 0) { - throw new Error( + throw new SkippedModuleCommandError( `The type of parameter ${describeParam(param)} of "${commandName}" references ${danglingRefs.map(r => JSON.stringify(r)).join(', ')}, which couldn't be resolved. ` + - `Declare it as \`type X = {...}\` or \`interface X {...}\` in the same file, or inline the type.`, + `Declare it as \`type X = {...}\` or \`interface X {...}\` in the same file, import it from a relative file-backed module, or inline the type.`, ) } - return flattenIntersection(schema) + return applySchemaJsdocMetadata(flattenIntersection(schema)) +} + +const parseTypeScriptSchema = (context: Record, script: string): unknown => { + try { + return Type.Script(context as never, script) + } catch (error) { + throw new SkippedModuleCommandError(error instanceof Error ? error.message : String(error)) + } } /** @@ -611,6 +701,38 @@ const cleanJsdoc = (text: string): string | undefined => { return cleaned || undefined } +const parseCliJsdoc = (description: string | undefined): {description: string | undefined; aliases: string[]} => { + if (!description) return {description: undefined, aliases: []} + const aliases: string[] = [] + const lines: string[] = [] + for (const line of description.split('\n')) { + const match = line.trim().match(/^@alias\s+(.+)$/) + if (match) { + aliases.push(match[1].trim()) + continue + } + lines.push(line) + } + const cleaned = lines.join('\n').trim() + return {description: cleaned || undefined, aliases} +} + +const applySchemaJsdocMetadata = (schema: unknown): unknown => { + if (!schema || typeof schema !== 'object') return schema + const record = schema as Record + if (typeof record.description === 'string') { + const doc = parseCliJsdoc(record.description) + if (doc.description) record.description = doc.description + else delete record.description + if (doc.aliases.length > 0) record.alias = doc.aliases[0] + } + for (const value of Object.values(record)) { + if (Array.isArray(value)) value.forEach(applySchemaJsdocMetadata) + else applySchemaJsdocMetadata(value) + } + return schema +} + // ------------------------------------------------------------------ // Command extraction // ------------------------------------------------------------------ @@ -629,10 +751,36 @@ const extractModuleReexports = (source: string): ModuleReexport[] => { reexports.push({kind: 'all', name: undefined, specifier: match[2], position: match.index}) } + for (const match of source.matchAll(/(? !specifier.typeOnly) + .map(({imported, exported}) => ({imported, exported})) + if (names.length > 0) { + reexports.push({kind: 'named', name: undefined, names, specifier: match[4], position: match.index}) + } + } + reexports.sort((a, b) => a.position - b.position) - return reexports.map(({kind, name, specifier}) => ({kind, name, specifier})) + return reexports.map(({kind, name, names, specifier}) => ({kind, name, names, specifier})) } +const parseNamedSpecifiers = ( + raw: string, + typeOnlyExport: boolean, +): Array<{imported: string; exported: string; typeOnly: boolean}> => + raw + .split(',') + .map(part => part.trim()) + .filter(Boolean) + .flatMap(part => { + const typeOnly = typeOnlyExport || part.startsWith('type ') + const text = part.replace(/^type\s+/, '').trim() + const match = text.match(/^([A-Za-z_$][\w$]*|default)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/) + if (!match) return [] + return [{imported: match[1], exported: match[2] || match[1], typeOnly}] + }) + /** * @experimental Extract exported function declarations from module source text: name, preceding jsdoc, and the full * parameter list (names, optionality, type annotation text, inline jsdoc). Supports `export function f(...)`, @@ -710,13 +858,154 @@ export const extractModuleCommands = (source: string): ExtractedCommand[] => { const existing = winners.get(declaration.name) if (!existing || (existing.hasBody && !declaration.hasBody)) winners.set(declaration.name, declaration) } - return [...winners.values()].map(({name, exportName, default: defaultExport, description, paramList}) => ({ - name, - exportName, - default: defaultExport, - description, - params: parseParams(name, paramList), - })) + return [...winners.values()].flatMap(({name, exportName, default: defaultExport, description, paramList}) => { + const params = tryParseParams(name, paramList) + if (!params) return [] + return [{name, exportName, default: defaultExport, description, params}] + }) +} + +export const extractModuleClasses = (source: string): ExtractedClass[] => { + const scan = scanSource(source) + const classes: ExtractedClass[] = [] + + const patterns = [ + {pattern: /(?') + const braceIndex = findNextUnmasked(source, scan, headerIndex, '{') + const header = source.slice(headerIndex, braceIndex) + const hasBaseClass = /\bextends\b/.test(header) + + const classEnd = findBalancedEnd(source, scan, braceIndex, '{', '}') + const {methodDeclarations, hasConstructorParameters, hasZeroArgConstructor} = extractClassMethodDeclarations( + source, + scan, + braceIndex + 1, + classEnd - 1, + ) + if (hasConstructorParameters) continue + if (hasBaseClass && !hasZeroArgConstructor) continue + if (methodDeclarations.length === 0) continue + const methods = methodDeclarations.flatMap(({methodName, description, paramList}) => { + const params = tryParseParams(`${name}.${methodName}`, paramList) + if (!params) return [] + return [{name: methodName, exportName: methodName, default: false, description, params}] + }) + if (methods.length === 0) continue + classes.push({name, exportName, default: defaultExport, methods}) + } + } + + return classes +} + +const findNextUnmasked = (source: string, scan: SourceScan, start: number, char: string): number => { + for (let i = start; i < source.length; i++) { + if (!scan.masked[i] && source[i] === char) return i + } + throw new Error(`Could not find \`${char}\` after index ${start} of module source`) +} + +const extractClassMethodDeclarations = ( + source: string, + scan: SourceScan, + bodyStart: number, + bodyEnd: number, +): { + methodDeclarations: Array<{methodName: string; description: string | undefined; paramList: string}> + hasConstructorParameters: boolean + hasZeroArgConstructor: boolean +} => { + const body = source.slice(bodyStart, bodyEnd) + const declarations: Array<{ + name: string + position: number + hasBody: boolean + paramList: string + description: string | undefined + }> = [] + let hasConstructorParameters = false + let hasZeroArgConstructor = false + + const pattern = /(?') + while (parenIndex < source.length && /\s/.test(source[parenIndex])) parenIndex++ + if (source[parenIndex] !== '(') continue + + const parenEnd = findBalancedEnd(source, scan, parenIndex, '(', ')') + const paramList = source.slice(parenIndex + 1, parenEnd - 1) + if (name === 'constructor') { + if (paramList.trim()) hasConstructorParameters = true + else hasZeroArgConstructor = true + continue + } + + declarations.push({ + name, + position: absoluteIndex, + hasBody: hasFunctionBody(source, scan, parenEnd), + paramList, + description: jsdocBefore(source, scan, absoluteIndex), + }) + } + + declarations.sort((a, b) => a.position - b.position) + const winners = new Map() + for (const declaration of declarations) { + const existing = winners.get(declaration.name) + if (!existing || (existing.hasBody && !declaration.hasBody)) winners.set(declaration.name, declaration) + } + + return { + hasConstructorParameters, + hasZeroArgConstructor, + methodDeclarations: [...winners.values()].map(({name, description, paramList}) => ({ + methodName: name, + description, + paramList, + })), + } +} + +const isTopLevelClassMember = (source: string, scan: SourceScan, bodyStart: number, index: number): boolean => { + let depth = 0 + for (let i = bodyStart; i < index; i++) { + if (scan.masked[i]) continue + if (source[i] === '{') depth++ + else if (source[i] === '}') depth-- + } + return depth === 0 } /** @@ -778,6 +1067,15 @@ const hasFunctionBody = (source: string, scan: SourceScan, parenEnd: number): bo * jsdoc (`/** doc *\/ left: number`). `<`/`>` are tracked as brackets (so `Map` survives the * top-level-comma check) except the `>` of `=>`. */ +const tryParseParams = (functionName: string, paramList: string): ExtractedParam[] | undefined => { + try { + return parseParams(functionName, paramList) + } catch (error) { + if (error instanceof SkippedModuleCommandError) return undefined + throw error + } +} + const parseParams = (functionName: string, paramList: string): ExtractedParam[] => { const scan = scanSource(paramList) @@ -827,7 +1125,7 @@ const parseParams = (functionName: string, paramList: string): ExtractedParam[] if (nameText.startsWith('...')) { const annotation = colon === -1 ? 'string[]' : paramList.slice(colon + 1, segment.end).trim() - throw new Error( + throw new SkippedModuleCommandError( `Parameter "${nameText}" of "${functionName}" is a rest parameter, which isn't supported. Use an explicitly-typed array parameter (e.g. \`${nameText.slice(3)}: ${annotation}\`, which becomes a variadic positional argument), or move it into a trailing options object.`, ) } @@ -836,7 +1134,7 @@ const parseParams = (functionName: string, paramList: string): ExtractedParam[] const name = destructured ? undefined : nameText.replace(/\?$/, '').trim() if (colon === -1) { - throw new Error( + throw new SkippedModuleCommandError( `Parameter "${nameText}" of "${functionName}" has no type annotation. Annotate it, e.g. \`(${nameText}: string)\` or \`(${nameText}: {someFlag: string})\`.`, ) } @@ -849,6 +1147,69 @@ const parseParams = (functionName: string, paramList: string): ExtractedParam[] // Type declaration context // ------------------------------------------------------------------ +const buildFileDeclarationContext = async ( + resolved: FileSourceModule, + loader: ModuleFileLoader, +): Promise> => { + const parts = await collectTypeContextParts(resolved, loader, new Set()) + return buildDeclarationContext([...parts.sources, ...parts.aliases].join('\n')) +} + +const collectTypeContextParts = async ( + resolved: FileSourceModule, + loader: ModuleFileLoader, + seen: Set, +): Promise<{sources: string[]; aliases: string[]}> => { + if (seen.has(resolved.filepath)) return {sources: [], aliases: []} + seen.add(resolved.filepath) + + const sources: string[] = [] + const aliases: string[] = [] + for (const typeImport of extractModuleTypeImports(resolved.source)) { + if (!typeImport.specifier.startsWith('./') && !typeImport.specifier.startsWith('../')) continue + const child = await loader.loadSourceSpecifier(resolved.filepath, typeImport.specifier) + const childParts = await collectTypeContextParts(child, loader, seen) + sources.push(...childParts.sources) + aliases.push(...childParts.aliases) + for (const {imported, local} of typeImport.imports) { + if (imported !== local) aliases.push(`type ${local} = ${imported}`) + } + } + sources.push(resolved.source) + + return {sources, aliases} +} + +const extractModuleTypeImports = ( + source: string, +): Array<{specifier: string; imports: Array<{imported: string; local: string}>}> => { + const scan = scanSource(source) + const imports: Array<{specifier: string; imports: Array<{imported: string; local: string}>; position: number}> = [] + + for (const match of source.matchAll(/(? ({imported, local: exported})), + position: match.index, + }) + } + + for (const match of source.matchAll(/(? specifier.typeOnly) + if (typeSpecifiers.length === 0) continue + imports.push({ + specifier: match[3], + imports: typeSpecifiers.map(({imported, exported}) => ({imported, local: exported})), + position: match.index, + }) + } + + imports.sort((a, b) => a.position - b.position) + return imports.map(({specifier, imports: importedNames}) => ({specifier, imports: importedNames})) +} + /** * Extract `type X = ...` and `interface X {...}` declarations from the source and parse them into a record of * schemas, used as the `Type.Script` context so function parameters can reference named types. Tries a single diff --git a/src/types.ts b/src/types.ts index b545b549..26e23a9b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -34,14 +34,20 @@ export interface TrpcCliParams extends Dependencies { } /** - * @experimental Derive a CLI from a plain TypeScript module of exported functions instead of a router. + * @experimental Derive a CLI from a plain TypeScript module of exported functions/classes instead of a router. * Exported functions become commands: the jsdoc above each function becomes the command description, and the first * parameter's object type annotation (parsed from the module's *source text* via the vendored `trpc-cli/typebox` * `Type.Script`) becomes the input schema - property jsdoc comments become flag descriptions, and inputs are - * validated against the schema before the function runs. A default-exported function becomes the default command, - * equivalent to `{default: true}` in procedure meta. In file-backed module mode, `export * as foo from './foo'` - * becomes a nested sub-router named `foo`, and `export * from './foo'` merges that module's named commands into the - * current router level. + * validated against the schema before the function runs. Exported functions whose signatures cannot be converted + * into CLI inputs are ignored as ordinary non-command exports. `@alias` tags in command/property jsdoc become + * command and option aliases. A default-exported function becomes the default command, equivalent to `{default: + * true}` in procedure meta. Exported classes become nested command groups when they have no constructor arguments + * and at least one public command method; default-exported classes put their methods at the current router level. + * Classes with `extends` must declare an explicit zero-argument constructor. Unsupported class shapes are ignored + * as ordinary non-command exports. Their public instance methods are lazily invoked on a fresh class instance. In + * file-backed module mode, `export * as foo from './foo'` becomes a nested sub-router named `foo`, `export * from './foo'` + * merges that module's named commands into the current router level, and `export {foo} from './foo'` re-exports + * selected commands. * * `import.meta` satisfies this shape (it carries `filename`/`url`), so the simplest setup is to call `createCli` * from the bottom of the commands file itself: @@ -282,7 +288,7 @@ export type TrpcCliRunParams = { argv?: string[] logger?: Logger completion?: OmeletteInstanceLike | (() => Promise) - prompts?: Promptable | true | null + prompts?: Promptable | boolean | null /** Format an error thrown by the root procedure before logging to `logger.error` */ formatError?: (error: unknown) => string process?: { diff --git a/tasks/complete/2026-06-19-module-mode-next.md b/tasks/complete/2026-06-19-module-mode-next.md new file mode 100644 index 00000000..4e9e57f9 --- /dev/null +++ b/tasks/complete/2026-06-19-module-mode-next.md @@ -0,0 +1,193 @@ +--- +status: complete +size: medium +--- + +# Module Mode Next + +Status summary: implemented and verified. The scoped work documents current type/overload behavior, adds JSDoc aliases for module-mode commands/options, and adds lazy-instantiated class subcommand groups. Exported norpc procedures remain a separate follow-up. + +## User Ask + +Push module mode further and resolve these design questions: + +- Do extended interfaces work? Do intersected types work? +- How should aliases be supported? +- How should overloaded functions behave? +- Can subcommands be supported without `export * as whatever from './whatever.ts'`, perhaps via exported classes? +- Can some functions opt into explicit zod/standard-schema/trpc/orpc/norpc procedure definitions while other module exports remain plain functions? + +## Proposal + +### Current Behavior To Document + +Module mode already supports more of the type story than the release notes implied: + +- Inline parameter types and same-file `type`/`interface` declarations work. +- `interface Options extends Base { ... }` works. +- `interface Options extends A, B { ... }` works. +- Object intersections such as `type Opts = {a: string} & {b: string}` work and are flattened into one flag set when possible. +- Trailing intersection-alias options objects work in positional tuple mode. +- Union-of-object option shapes work. +- Overloaded functions already use the first overload signature as the CLI contract. + +The boundary should be explicit: plain-function source scanning is same-file and `Type.Script`-parseable only. Imported type references, arbitrary TypeScript compiler resolution, type-annotated const function aliases, and complex declarations that need external type context should keep failing loudly. Users who need richer typing should use explicit runtime schemas/procedures. + +### Aliases + +Plain-function module mode should support aliases through JSDoc tags: + +```ts +/** + * install dependencies + * @alias i + */ +export function install(options: { + /** fail if the lockfile changed + * @alias f + */ + frozenLockfile?: boolean +}) {} +``` + +Rules: + +- Repeated command-level `@alias ` tags map to `meta.aliases.command`. +- A property-level `@alias ` tag maps to that option's alias. +- Alias tags are stripped from help descriptions. +- Invalid or conflicting aliases fail with the same strictness as existing router/procedure aliases. +- No wrapper helper or static metadata side channel for plain functions in this pass. + +### Overloads + +Keep the current first-signature behavior. A CLI command has one help shape and one validation schema; merging overloads would advertise invalid combinations. Users who want a different CLI shape should reorder overloads, export a CLI-specific wrapper, or export an explicit schema/procedure command. + +### Same-File Subcommands + +Support exported classes as the only new same-file subcommand grouping syntax. The class is a command group; its public instance methods are subcommands. + +Example: + +```ts +export class Users { + /** invite a user */ + invite(options: {email: string}) { + return options.email + } + + async deactivate(options: {id: string}) { + return options.id + } + + #audit(action: string) { + // private implementation detail, not a command + } +} +``` + +This maps to `mycli users invite` and `mycli users deactivate`. + +Rules: + +- Exported functions whose signatures cannot be converted into CLI inputs are ignored as ordinary non-command exports. A `NoInfer<...>` parameter is a convenient opt-out because Type.Script cannot resolve it. +- Only direct `export class Users { ... }` declarations are candidates. +- `export default class Commands { ... }` exposes public methods at the current router level instead of adding a class-name subcommand. +- Classes without a base class may omit the constructor; classes with `extends` must declare an explicit zero-argument constructor. +- Classes with constructor parameters, unsupported inheritance, or no public command methods are ignored as ordinary exports. +- Public instance method declarations directly in the class body become commands. +- Private/protected methods and private fields are internal implementation details, not commands. +- Static methods are not commands in the first slice. +- Method parameter parsing, JSDoc descriptions, aliases, and overload behavior follow the same rules as exported functions. +- Help/schema generation must not instantiate the class. +- Instantiate lazily inside the command handler, and create a fresh instance per command invocation. +- If a public instance method is command-shaped but cannot be converted into a CLI input, ignore that method. +- Do not add object-literal command groups in this proposal. Ordinary exported object constants stay ignored. + +### Explicit Schema/Procedure Exports + +This belongs in a separate change, not this first follow-up. Keep the design note, but do not implement it in this proposal's scope. + +The likely separate change should support trpc-cli's own norpc values first: + +```ts +import {os} from 'trpc-cli' +import {z} from 'zod/v4' + +export const explicit = os + .input(z.object({name: z.string()})) + .meta({aliases: {command: ['x']}}) + .handler(({input}) => input.name) + +export const users = os.router({ + invite: os.input(z.object({email: z.string()})).handler(({input}) => input.email), +}) +``` + +Rules: + +- `isNorpcProcedure` runtime exports become commands named after their export. +- `isNorpcRouter` runtime exports become subcommand groups named after their export. +- Explicit norpc exports can coexist with source-scanned plain functions and class groups in that later change. +- Conflicts fail loudly. +- Exported consts whose runtime values are norpc procedures/routers should not require parseable function declarations. +- Actual tRPC/oRPC procedure or router exports are future work; they have different root parsing/calling requirements and should not be half-supported in this pass. + +### Default Commands + +Preserve existing `export default function` behavior for plain functions only. Do not add a magic `default()` method convention inside class groups. + +Default behavior for explicit norpc exports should be handled in the separate explicit-procedure change, using existing `meta.default`. + +## Suggested Implementation Slices + +1. Documentation and test pins for current type/overload behavior. +2. JSDoc metadata parsing for command and property `@alias`, including stripping tags from descriptions. +3. Lazy-instantiated class command groups. +4. Documentation for non-goals and follow-ups: imported type resolution, object-literal command groups, explicit norpc exports, tRPC/oRPC mixed exports, overload merging, and default-method magic. + +## Guesses And Assumptions + +- Same-file `Type.Script`-parseable support is the right boundary because it matches the project's preference for loud errors and small pragmatic mechanisms over building a TypeScript compiler. +- JSDoc is the least-bad metadata channel for aliases because module mode already treats source comments as CLI documentation. +- First-overload-only behavior is preferable because Commander help and validation need one concrete public invocation shape. +- Ignoring unconvertible function exports is preferable to forcing custom metadata for helper exports; `NoInfer<...>` becomes one possible opt-out without becoming a trpc-cli feature. +- Class groups are acceptable when constrained to no constructor arguments, public instance methods only, and lazy per-invocation instantiation. Inheritance is acceptable when the class explicitly declares a zero-argument constructor; unsupported class shapes should be ignored rather than hard errors. +- Explicit norpc exports are probably the right schema escape hatch, but they belong in a separate change because they introduce runtime-export composition beyond plain source-scanned commands. +- Object-literal command groups should be skipped for now so `export const config = {...}` remains unambiguously ordinary data. +- Default command behavior should avoid competing conventions and preserve only the existing plain-function shortcut in this proposal. + +## Out Of Scope For This Proposal PR + +- Implementing the proposal. +- Switching module mode to the TypeScript compiler API. +- Object-literal command groups. +- Class groups with constructor arguments. +- Explicit norpc procedure/router exports. +- Mixed tRPC/oRPC/norpc export trees. +- Overload merging or overload-selection metadata. +- Top-level-awaited `createCli(import.meta).run()`. + +## Checklist + +- [x] Run a grill-you interview against the existing module-mode implementation. _Completed via platform sub-agent fallback after the local `claude --print` path failed with a 401; transcript lives in `tasks/module-mode-next.interview.md`._ +- [x] Document factual current behavior for extended interfaces, intersections, aliases, and overloads. _Captured above under "Current Behavior To Document"._ +- [x] Propose support rules for aliases, subcommands, and procedure-like exports. _Captured above under the feature-specific proposal sections._ +- [x] Capture open risks, tradeoffs, and follow-up implementation slices. _Captured in "Suggested Implementation Slices", "Guesses And Assumptions", and "Out Of Scope"._ +- [x] Open a draft PR for review. _Opened as #211._ +- [x] Implement JSDoc `@alias` support. _Implemented in `src/module-commands.ts` by stripping `@alias` from JSDoc descriptions and mapping tags onto existing command/option alias metadata._ +- [x] Implement lazy class command groups. _Implemented in `src/module-commands.ts`; direct exported classes with no constructor args become nested routers, inherited classes require an explicit zero-argument constructor, unsupported class shapes are ignored, and method handlers instantiate a fresh class instance only when invoked._ +- [x] Handle default class exports, named command re-exports, and relative imported type declarations. _Default class methods are root commands; `export {CommandOrGroup} from './module'` re-exports selected file-backed commands/groups; relative type imports are read as source-only declaration context._ +- [x] Update docs and tests. _README module-mode docs updated; behavior covered in `test/typebox-module-commands.test.ts`._ + +## Implementation Notes + +- 2026-06-19: Created branch `module-mode-next` from `main`, committed this task stub first, pushed, and opened draft PR #211 before filling in the proposal. +- 2026-06-19: Local `claude --print` sub-agent invocation failed with `401 Invalid authentication credentials`; continued the grill with the platform multi-agent tool. +- 2026-06-19: Quick local probe against built `dist` confirmed same-file extended interfaces, multiple interface extends, and alias-to-alias intersections currently derive flags as expected. +- 2026-06-19: Follow-up user decision replaced object-literal groups with class groups only, scoped to no base class/no constructor args and lazy instantiation. +- 2026-06-19: Follow-up user decision moved support for exported norpc procedures/routers out of scope for this proposal and into a separate change. +- 2026-06-19: Implemented the scoped feature set. `pnpm exec vitest run test/typebox-module-commands.test.ts`, `pnpm compile`, and `pnpm test` pass. `pnpm lint` is blocked only by the pre-existing unstaged `test/zod4.test.ts` unused-disable warning. +- 2026-06-19: Follow-up user decision allowed `extends` when the class declares an explicit zero-argument constructor, and added coverage that TypeScript `private` methods are not commands. +- 2026-06-19: Follow-up user decision changed unsupported class shapes, including constructor parameters, from startup errors to ignored non-command exports. +- 2026-06-19: Follow-up user decision added support for `export default class`, named file-backed re-exports like `export {Users} from './users.ts'`, and relative imported type/interface declarations for file-backed modules. +- 2026-06-19: Follow-up user decision changed unconvertible function exports from startup errors to ignored non-command exports, making `NoInfer<...>` parameters usable as an opt-out. diff --git a/test/prompts.test.ts b/test/prompts.test.ts index d2ecb67c..e65995ba 100644 --- a/test/prompts.test.ts +++ b/test/prompts.test.ts @@ -38,7 +38,10 @@ test('prompts package types', async () => { }) test('built-in prompt types', () => { + const enablePrompts: boolean = false expectTypeOf(createCli({router: promptTypeRouter}).run).toBeCallableWith({prompts: true}) + expectTypeOf(createCli({router: promptTypeRouter}).run).toBeCallableWith({prompts: false}) + expectTypeOf(createCli({router: promptTypeRouter}).run).toBeCallableWith({prompts: enablePrompts}) expectTypeOf(createCli({router: promptTypeRouter}).run).toBeCallableWith({prompts: builtInPrompts}) expectTypeOf(createCli({router: promptTypeRouter}).run).toBeCallableWith({prompts: createBuiltInPrompts()}) }) diff --git a/test/typebox-module-commands.test.ts b/test/typebox-module-commands.test.ts index 5a4c5823..cac6a88c 100644 --- a/test/typebox-module-commands.test.ts +++ b/test/typebox-module-commands.test.ts @@ -159,35 +159,60 @@ test('module commands: {source, exports} escape hatch works without file reading expect(await runWith(params, ['add', '--help'])).toContain('the name of the package to add') }) -test('module commands: missing type annotation errors clearly', async () => { +test('module commands: unparseable exported functions are ignored', async () => { const params = { - source: `export function greet(name) { return 'hi ' + name }`, - exports: {greet: (name: string) => 'hi ' + name}, - } - await expect(runWith(params, ['--help'])).rejects.toThrowError( - 'Parameter "name" of "greet" has no type annotation. Annotate it, e.g. `(name: string)` or `(name: {someFlag: string})`.', - ) -}) + source: ` + export function missingAnnotation(name) { + return 'hi ' + name + } -test('module commands: unresolvable named type errors clearly', async () => { - const params = { - source: `export function deploy(options: ImportedFromElsewhere) {}`, - exports: {deploy: () => {}}, + export function unresolved(options: ImportedFromElsewhere) { + return options.name + } + + const localExportList = () => 'local' + export {localExportList} + + export const loadSomeInternalThing = (params: NoInfer<{foo: string}>) => { + return params.foo + } + + export function status() { + return 'ok' + } + `, + exports: { + loadSomeInternalThing: (input: any) => input.foo, + localExportList: () => 'local', + missingAnnotation: (name: string) => 'hi ' + name, + status: () => 'ok', + unresolved: (options: any) => options.name, + }, } - await expect(runWith(params, ['--help'])).rejects.toThrowError( - 'The type of parameter "options" of "deploy" references "ImportedFromElsewhere", which couldn\'t be resolved. Declare it as `type X = {...}` or `interface X {...}` in the same file, or inline the type.', - ) + + const help = await runWith(params, ['--help']) + expect(help).toContain('status') + expect(help).not.toContain('missing-annotation') + expect(help).not.toContain('unresolved') + expect(help).not.toContain('local-export-list') + expect(help).not.toContain('load-some-internal-thing') + expect(await runWith(params, ['status'])).toMatchInlineSnapshot(`"ok"`) }) -test('module commands: exported function with no parseable declaration errors clearly', async () => { +test('module commands: module with only ignored function exports errors with no commands found', async () => { const params = { - // `export {fn}` statements aren't supported by the extractor - the error should say so - source: `const start = () => 'started'\nexport {start}`, - exports: {start: () => 'started'}, + source: ` + export function greet(name) { + return 'hi ' + name + } + + export const loadSomeInternalThing = (params: NoInfer<{foo: string}>) => { + return params.foo + } + `, + exports: {greet: (name: string) => 'hi ' + name, loadSomeInternalThing: (input: any) => input.foo}, } - await expect(runWith(params, ['--help'])).rejects.toThrowError( - /Could not find a parseable declaration for exported function\(s\) "start"/, - ) + await expect(runWith(params, ['--help'])).rejects.toThrowError(/No commands found in module/) }) test('module commands: export * merges child module commands at the root', async () => { @@ -226,6 +251,35 @@ test('module commands: re-exported module resolution supports exact well-known e ) }) +test('module commands: named re-exports expose selected child command classes', async () => { + using fixture = createReexportFixture() + + expect(await runWith({filename: fixture.barrelPath}, ['users', 'invite', '--email', 'ada@example.com'])) + .toMatchInlineSnapshot(` + "user ada@example.com" + `) + + const help = await runWith({filename: fixture.barrelPath}, ['--help']) + expect(help).toContain('users') +}) + +test('module commands: file-backed modules resolve parameter types imported from relative files', async () => { + using fixture = createImportedTypesFixture() + + expect(await runWith({filename: fixture.commandsPath}, ['invite', '--email', 'ada@example.com', '--role', 'admin'])) + .toMatchInlineSnapshot(` + "invite ada@example.com as admin" + `) + expect(await runWith({filename: fixture.commandsPath}, ['assign', '--id', 'u_123', '--group', 'staff'])) + .toMatchInlineSnapshot(` + "assign u_123 to staff" + `) + + const help = await runWith({filename: fixture.commandsPath}, ['invite', '--help']) + expect(help).toContain('email to invite') + expect(help).toContain('role to grant') +}) + test('module commands: {source, exports} rejects re-export module composition', async () => { const params = { source: ` @@ -405,54 +459,51 @@ test('module positionals: missing and invalid positionals fail before the functi `) }) -test('module positionals: rest parameters error clearly', async () => { +test('module positionals: unsupported signatures are ignored', async () => { const params = { - source: `export function sum(...numbers: number[]) { return 0 }`, - exports: {sum: () => 0}, - } - await expect(runWith(params, ['--help'])).rejects.toThrowError( - 'Parameter "...numbers" of "sum" is a rest parameter, which isn\'t supported. Use an explicitly-typed array parameter (e.g. `numbers: number[]`, which becomes a variadic positional argument), or move it into a trailing options object.', - ) -}) + source: ` + export function sum(...numbers: number[]) { + return 0 + } -test('module positionals: destructured positional parameters error clearly', async () => { - const params = { - source: `export function move([x, y]: [number, number], options: {fast?: boolean}) {}`, - exports: {move: () => {}}, - } - await expect(runWith(params, ['--help'])).rejects.toThrowError( - 'Parameter 1 ("[number, number]") of "move" is a destructuring pattern, which isn\'t supported for positional arguments. Give the parameter a name, or move it into a trailing options object.', - ) -}) + export function move([x, y]: [number, number], options: {fast?: boolean}) { + return x + y + } -test('module positionals: object parameter in non-final position errors clearly', async () => { - const params = { - source: `export function deploy(options: {env: string}, target: string) {}`, - exports: {deploy: () => {}}, - } - await expect(runWith(params, ['--help'])).rejects.toThrowError( - 'Parameter 1 ("options") of "deploy" is an object type, but only the *last* parameter can be an object - leading parameters become positional arguments and a trailing object parameter maps to flags. Move it to the end, or flatten it into the trailing options object.', - ) -}) + export function deploy(options: {env: string}, target: string) { + return target + } -test('module positionals: optional array parameter errors clearly', async () => { - const params = { - source: `export function lint(files?: string[]) {}`, - exports: {lint: () => {}}, - } - await expect(runWith(params, ['--help'])).rejects.toThrowError( - 'Parameter 1 ("files") of "lint" is an optional array. Optional array parameters aren\'t supported as positional arguments - make it required, or move it into a trailing options object.', - ) -}) + export function lint(files?: string[]) { + return files?.join(',') || '' + } -test('module positionals: default value without a type annotation errors clearly', async () => { - const params = { - source: `export function pad(text: string, width = 10) { return text }`, - exports: {pad: (text: string) => text}, + export function pad(text: string, width = 10) { + return text + width + } + + export function status() { + return 'ok' + } + `, + exports: { + deploy: (_options: any, target: string) => target, + lint: (files?: string[]) => files?.join(',') || '', + move: ([x, y]: [number, number]) => x + y, + pad: (text: string, width = 10) => text + width, + status: () => 'ok', + sum: () => 0, + }, } - await expect(runWith(params, ['--help'])).rejects.toThrowError( - 'Parameter "width" of "pad" has no type annotation. Annotate it, e.g. `(width: string)` or `(width: {someFlag: string})`.', - ) + + const help = await runWith(params, ['--help']) + expect(help).toContain('status') + expect(help).not.toContain('sum') + expect(help).not.toContain('move') + expect(help).not.toContain('deploy') + expect(help).not.toContain('lint') + expect(help).not.toContain('pad') + expect(await runWith(params, ['status'])).toMatchInlineSnapshot(`"ok"`) }) test('module commands: intersection and multi-line union type aliases keep their tails', async () => { @@ -487,11 +538,46 @@ test('module commands: intersection and multi-line union type aliases keep their await expect(runWith(params, ['configure', '--mode', 'fast'])).rejects.toThrowError(/extra/) }) +test('module commands: same-file extended interfaces and alias intersections derive flags', async () => { + const params = { + source: ` + interface Common { + root: string + } + interface Named { + name: string + } + interface Options extends Common, Named { + tag: string + } + type Extra = Options & { + verbose?: boolean + } + + export function deploy(options: Extra) { + return options.root + ':' + options.name + ':' + options.tag + ':' + String(options.verbose || false) + } + `, + exports: { + deploy: (options: any) => `${options.root}:${options.name}:${options.tag}:${String(options.verbose || false)}`, + }, + } + + const help = await runWith(params, ['deploy', '--help']) + expect(help).toContain('--root ') + expect(help).toContain('--name ') + expect(help).toContain('--tag ') + expect(help).toContain('--verbose [boolean]') + expect( + await runWith(params, ['deploy', '--root', 'prod', '--name', 'api', '--tag', 'v1', '--verbose']), + ).toMatchInlineSnapshot(`"prod:api:v1:true"`) +}) + test('module commands: generic type parameters containing => are skipped correctly', async () => { const params = { // without the => exception in findBalancedEnd, the `>` of `() => void` would close the generic // bracket early and the whole declaration would mis-slice. (A *parameter* typed as a generic like - // `callback?: T` is a different story - it errors as an unresolvable reference, by design.) + // `callback?: T` is a different story - it is ignored unless the type can resolve into a CLI input.) source: ` export async function run void>(options: {name: string}) { return 'ran ' + options.name @@ -516,6 +602,36 @@ test('module commands: jsdoc still attaches when a line comment sits between it expect(await runWith(params, ['--help'])).toContain('does the thing') }) +test('module commands: jsdoc aliases create command and option aliases without leaking into help', async () => { + const params = { + source: ` + /** + * install dependencies + * @alias i + */ + export function install(options: { + /** fail if the lockfile changed + * @alias f + */ + frozenLockfile?: boolean + }) { + return options.frozenLockfile ? 'frozen' : 'normal' + } + `, + exports: {install: (options: any) => (options.frozenLockfile ? 'frozen' : 'normal')}, + } + + const rootHelp = await runWith(params, ['--help']) + expect(rootHelp).toContain('install dependencies') + expect(rootHelp).not.toContain('@alias') + expect(await runWith(params, ['i', '-f'])).toMatchInlineSnapshot(`"frozen"`) + + const installHelp = await runWith(params, ['install', '--help']) + expect(installHelp).toContain('-f, --frozen-lockfile') + expect(installHelp).toContain('fail if the lockfile changed') + expect(installHelp).not.toContain('@alias') +}) + test('module commands: union-of-objects parameter derives union flags', async () => { const params = { // regression (caught in review): the flags-object decision briefly only accepted plain objects, @@ -651,19 +767,273 @@ test('module commands: async const arrow with destructured param', async () => { ) }) -test('module commands: type-annotated const declarations error with parseable-declaration guidance', async () => { +test('module commands: type-annotated const function exports are ignored', async () => { const params = { - // `export const f: SomeType = ...` isn't parsed (the annotation would be the source of truth, and it can - // reference imported types the extractor can't see) - the existing actionable error applies + // `export const f: SomeType = ...` isn't parsed; the annotation would be the source of truth, and it can + // reference imported types the extractor can't see. source: ` type Cmd = (options: {name: string}) => string export const greet: Cmd = (options) => 'hi ' + options.name + export function status() { + return 'ok' + } `, - exports: {greet: (options: any) => 'hi ' + options.name}, + exports: {greet: (options: any) => 'hi ' + options.name, status: () => 'ok'}, } - await expect(runWith(params, ['--help'])).rejects.toThrowError( - /Could not find a parseable declaration for exported function\(s\) "greet"/, + const help = await runWith(params, ['--help']) + expect(help).toContain('status') + expect(help).not.toContain('greet') + expect(await runWith(params, ['status'])).toMatchInlineSnapshot(`"ok"`) +}) + +test('module commands: exported classes create lazily-instantiated command groups', async () => { + let constructed = 0 + class Users { + constructor() { + constructed++ + } + + invite(options: {email: string}) { + this.#audit() + return `invite ${options.email}` + } + + private getOrCreate(email: string) { + return {email} + } + + login(email: string, password: string) { + const user = this.getOrCreate(email) + return `login ${user.email} with ${password.length} chars` + } + + #audit() { + return 'audited' + } + } + + const params = { + source: ` + export class Users { + constructor() {} + + /** invite a user + * @alias i + */ + invite(options: { + /** address to invite */ + email: string + }) { + this.#audit() + return 'invite ' + options.email + } + + private getOrCreate(email: string) { + return {email} + } + + login(email: string, password: string) { + const user = this.getOrCreate(email) + return 'login ' + user.email + ' with ' + password.length + ' chars' + } + + #audit() { + return 'audited' + } + } + `, + exports: {Users}, + } + + const rootHelp = await runWith(params, ['--help']) + expect(rootHelp).toContain('users') + expect(constructed).toBe(0) + + const inviteHelp = await runWith(params, ['users', 'invite', '--help']) + expect(inviteHelp).toContain('invite a user') + expect(inviteHelp).toContain('--email ') + expect(inviteHelp).toContain('address to invite') + expect(inviteHelp).not.toContain('@alias') + expect(inviteHelp).not.toContain('audit') + expect(inviteHelp).not.toContain('get-or-create') + expect(constructed).toBe(0) + + expect(await runWith(params, ['users', 'invite', '--email', 'ada@example.com'])).toMatchInlineSnapshot( + `"invite ada@example.com"`, ) + expect(constructed).toBe(1) + expect(await runWith(params, ['users', 'i', '--email', 'grace@example.com'])).toMatchInlineSnapshot( + `"invite grace@example.com"`, + ) + expect(constructed).toBe(2) + expect(await runWith(params, ['users', 'login', 'ada@example.com', 's3cr3t'])).toMatchInlineSnapshot( + `"login ada@example.com with 6 chars"`, + ) + expect(constructed).toBe(3) +}) + +test('module commands: default exported class methods become root commands', async () => { + let constructed = 0 + class Commands { + constructor() { + constructed++ + } + + invite(options: {email: string}) { + return `invite ${options.email}` + } + } + + const params = { + source: ` + export default class Commands { + constructor() {} + + /** invite from the root */ + invite(options: { + /** target email */ + email: string + }) { + return 'invite ' + options.email + } + + private helper() { + return 'not a command' + } + } + `, + exports: {default: Commands}, + } + + const rootHelp = await runWith(params, ['--help']) + expect(rootHelp).toContain('invite') + expect(rootHelp).toContain('invite from the root') + expect(rootHelp).not.toContain('helper') + expect(constructed).toBe(0) + expect(await runWith(params, ['invite', '--email', 'ada@example.com'])).toMatchInlineSnapshot( + `"invite ada@example.com"`, + ) + expect(constructed).toBe(1) +}) + +test('module commands: inherited class groups require an explicit zero-arg constructor', async () => { + class Base { + protected prefix = 'base' + } + class Users extends Base { + constructor() { + super() + } + + invite(options: {email: string}) { + return `${this.prefix}:${options.email}` + } + } + expect( + await runWith( + { + source: ` + class Base { + protected prefix = 'base' + } + export class Users extends Base { + constructor() { + super() + } + + invite(options: {email: string}) { + return this.prefix + ':' + options.email + } + } + `, + exports: {Users}, + }, + ['users', 'invite', '--email', 'ada@example.com'], + ), + ).toMatchInlineSnapshot(`"base:ada@example.com"`) +}) + +test('module commands: unsupported exported classes are ignored instead of throwing', async () => { + class Base {} + class NeedsConfig { + constructor(config: {dryRun?: boolean}) { + void config + } + + invite(options: {email: string}) { + return options.email + } + } + class InheritedWithoutConstructor extends Base { + invite(options: {email: string}) { + return options.email + } + } + class StaticOnly {} + class AccessorOnly { + get invite() { + return 'not a command' + } + } + class PrivateOnly {} + + const params = { + source: ` + export class NeedsConfig { + constructor(config: {dryRun?: boolean}) {} + + invite(options: {email: string}) { + return options.email + } + } + + class Base {} + export class InheritedWithoutConstructor extends Base { + invite(options: {email: string}) { + return options.email + } + } + + export class StaticOnly { + static invite(options: {email: string}) { + return options.email + } + } + + export class AccessorOnly { + get invite() { + return 'not a command' + } + } + + export class PrivateOnly { + private invite(options: {email: string}) { + return options.email + } + } + + export function status() { + return 'ok' + } + `, + exports: { + AccessorOnly, + InheritedWithoutConstructor, + NeedsConfig, + PrivateOnly, + StaticOnly, + status: () => 'ok', + }, + } + + const help = await runWith(params, ['--help']) + expect(help).toContain('status') + expect(help).not.toContain('needs-config') + expect(help).not.toContain('inherited-without-constructor') + expect(help).not.toContain('static-only') + expect(help).not.toContain('accessor-only') + expect(help).not.toContain('private-only') + expect(await runWith(params, ['status'])).toMatchInlineSnapshot(`"ok"`) }) test('module positionals: destructured trailing options object works', async () => { @@ -689,6 +1059,7 @@ const createReexportFixture = () => { export * from './root' export * as admin from './admin' export * as extra from './extra.mts' + export {Users} from './users' export function localThing(options: {name: string}) { return \`local \${options.name}\` @@ -733,6 +1104,16 @@ const createReexportFixture = () => { } `, ) + write( + 'users.ts', + ` + export class Users { + invite(options: {email: string}) { + return \`user \${options.email}\` + } + } + `, + ) return { barrelPath: path.join(dir, 'barrel.ts'), @@ -741,3 +1122,52 @@ const createReexportFixture = () => { }, } } + +const createImportedTypesFixture = () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trpc-cli-imported-types-')) + const write = (filename: string, source: string) => fs.writeFileSync(path.join(dir, filename), source, 'utf8') + + write( + 'commands.ts', + ` + import type {InviteOptions as Options} from './types.js' + import {type AssignOptions} from './types.js' + + export function invite(options: Options) { + return \`invite \${options.email} as \${options.role || 'member'}\` + } + + export default class Commands { + assign(options: AssignOptions) { + return \`assign \${options.id} to \${options.group}\` + } + } + `, + ) + write( + 'types.ts', + ` + export interface InviteOptions { + /** email to invite */ + email: string + /** role to grant */ + role?: 'admin' | 'member' + } + + export type AssignOptions = { + /** user id */ + id: string + } & { + /** destination group */ + group: string + } + `, + ) + + return { + commandsPath: path.join(dir, 'commands.ts'), + [Symbol.dispose]() { + fs.rmSync(dir, {recursive: true, force: true}) + }, + } +} diff --git a/test/types.test.ts b/test/types.test.ts index 5ecdab22..e2a6d547 100644 --- a/test/types.test.ts +++ b/test/types.test.ts @@ -14,6 +14,7 @@ test('prompt types', async () => { test('agent-aware prompt disabling type', async () => { expectTypeOf({prompts: isAgent({}) ? null : ({} as Promptable)}).toMatchTypeOf() + expectTypeOf({prompts: !isAgent({})}).toMatchTypeOf() }) test('jsonInput createCli param type', async () => {