Skip to content

Commit 05908e1

Browse files
committed
Expand module mode file-backed class support
1 parent e888909 commit 05908e1

5 files changed

Lines changed: 366 additions & 66 deletions

File tree

README.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -677,10 +677,11 @@ File-backed module mode can compose command modules too:
677677
```ts
678678
// commands.ts
679679
export * from './workspace.ts' // merges named commands into this level
680+
export {Users} from './users.ts' // re-exports one selected command/group
680681
export * as users from './users.ts' // creates a nested `users` sub-router
681682
```
682683

683-
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`.
684+
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`.
684685

685686
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.
686687

@@ -709,7 +710,15 @@ $ tsx commands.ts users invite --email [email protected]
709710
710711
```
711712

712-
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.
713+
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:
714+
715+
```ts
716+
export default class Commands {
717+
invite(options: {email: string}) {
718+
return `invite ${options.email}` // mycli invite --email [email protected]
719+
}
720+
}
721+
```
713722

714723
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):
715724

@@ -737,9 +746,9 @@ Details and limitations:
737746

738747
- `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).
739748
- `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.
740-
- Parameter types can be inline literals (`{foo: string}`, `'fast' | 'slow'`) or references to a `type X = {...}`/`interface X {...}` declared in the same file. 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.
749+
- 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.
741750
- 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.
742-
- 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 * 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.
751+
- 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.
743752
- 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.
744753
- 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.
745754
- 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.

0 commit comments

Comments
 (0)