Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
- **`z.output<>` over `z.infer<>`** — use `z.output<schema>` for types after transforms/defaults are applied (what `schema.parse()` returns at runtime). Use `z.input<schema>` only when representing pre-validation types.
- **`const` generics on definitions** — any function that accepts Zod schemas and passes them to callbacks must use `const` generic parameters to preserve literal types (e.g. `<const args extends z.ZodObject<any>>`).
- **Streaming commands use `async *run`** — typed client/typegen stream detection is based on the handler being an async generator function. Do not hide streaming behind `run() { return stream() }` when generated client types should be streaming-aware.
- **CLI-value stream inference is structural** — TypeScript cannot reliably distinguish an actual `async *run` handler from `run() { return stream() }` after contextual typing. `Cli` value command maps treat handlers returning `AsyncGenerator` as streaming; runtime/typegen still detect actual async generator functions.
- **Flow schemas through generics** — when a factory function accepts Zod schemas, use generics to flow `z.output<>` through to callbacks (`run`, `next`), return types, and constraint types (`alias`). Never fall back to `any` in callback signatures.
- **Type tests in `.test-d.ts`** — use vitest's `expectTypeOf` in colocated `.test-d.ts` files to assert generic inference works. Type tests are first-class — write them alongside implementation, not as an afterthought.
- **Avoid global declaration merging in type tests** — module augmentation in `.test-d.ts` affects the full `tsc -b` project, so prefer explicit generics/local helper types unless the test is specifically about global registration behavior.
Expand Down
109 changes: 108 additions & 1 deletion src/Cli.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,115 @@ test('command() accumulates command types through chaining', () => {
expectTypeOf<Commands['get']>().toEqualTypeOf<{
args: { id: number }
options: { verbose: boolean }
output: unknown
}>()
expectTypeOf<Commands['list']>().toEqualTypeOf<{
args: {}
options: { limit: number }
output: unknown
}>()
})

test('command() accumulates output and structural stream metadata', () => {
const cli = Cli.create('test')
.command('inspect', {
output: z.object({ id: z.string(), ok: z.boolean() }),
run: () => ({ id: 'p1', ok: true }),
})
.command('logs', {
output: z.object({ line: z.string() }),
run: async function* () {
yield { line: 'one' }
},
})

type Commands = typeof cli extends Cli.Cli<infer C> ? C : never
expectTypeOf<Commands['inspect']>().toEqualTypeOf<{
args: {}
options: {}
output: { id: string; ok: boolean }
}>()
expectTypeOf<Commands['logs']>().toMatchTypeOf<{
args: {}
options: {}
output: { line: string }
stream: true
}>()
})

test('run() returning a generator keeps output metadata', () => {
async function* logs() {
yield { line: 'one' }
}
const cli = Cli.create('test').command('logs', {
output: z.object({ line: z.string() }),
run() {
return logs()
},
})

type Commands = typeof cli extends Cli.Cli<infer C> ? C : never
expectTypeOf<Commands['logs']>().toMatchTypeOf<{
args: {}
options: {}
output: { line: string }
stream: true
}>()
})

test('root CLIs preserve output and structural stream metadata when mounted', () => {
const status = Cli.create('status', {
output: z.object({ ok: z.boolean() }),
run: () => ({ ok: true }),
})
const logs = Cli.create('logs', {
output: z.object({ line: z.string() }),
run: async function* () {
yield { line: 'one' }
},
})
const cli = Cli.create('test').command(status).command(logs)

type Commands = typeof cli extends Cli.Cli<infer C> ? C : never
expectTypeOf<Commands['status']>().toEqualTypeOf<{
args: {}
options: {}
output: { ok: boolean }
}>()
expectTypeOf<Commands['logs']>().toMatchTypeOf<{
args: {}
options: {}
output: { line: string }
stream: true
}>()
})

test('mounted sub-CLIs preserve output and structural stream metadata', () => {
const project = Cli.create('project')
.command('inspect', {
output: z.object({ id: z.string() }),
run: () => ({ id: 'p1' }),
})
.command('logs', {
output: z.object({ line: z.string() }),
run: async function* () {
yield { line: 'one' }
},
})
const cli = Cli.create('test').command(project)

type Commands = typeof cli extends Cli.Cli<infer C> ? C : never
expectTypeOf<Commands['project inspect']>().toEqualTypeOf<{
args: {}
options: {}
output: { id: string }
}>()
expectTypeOf<Commands['project logs']>().toMatchTypeOf<{
args: {}
options: {}
output: { line: string }
stream: true
}>()
expectTypeOf<Commands['list']>().toEqualTypeOf<{ args: {}; options: { limit: number } }>()
})

test('OpenAPI mounted fetch accumulates operation command types', () => {
Expand Down
62 changes: 62 additions & 0 deletions src/Cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4372,6 +4372,34 @@ describe('fetch', () => {
])
})

test('POST /_incur/rpc executes root command', async () => {
const cli = Cli.create('test', {
args: z.object({ name: z.string() }),
run: (c) => ({ message: `hello ${c.args.name}` }),
})
const req = new Request('http://localhost/_incur/rpc', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ command: 'test', args: { name: 'Ada' } }),
})

expect(await fetchJson(cli, req)).toMatchInlineSnapshot(`
{
"body": {
"data": {
"message": "hello Ada",
},
"meta": {
"command": "test",
"duration": "<stripped>",
},
"ok": true,
},
"status": 200,
}
`)
})

test('POST /_incur/rpc streams async generator output as NDJSON', async () => {
const cli = Cli.create('test')
cli.command('stream', {
Expand Down Expand Up @@ -4426,6 +4454,40 @@ describe('fetch', () => {
`)
})

test('POST /_incur/rpc cancels command stream when response body is cancelled', async () => {
let closed = false
let resume: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
resume = resolve
})
const cli = Cli.create('test').command('stream', {
async *run() {
try {
yield { progress: 1 }
await gate
yield { progress: 2 }
} finally {
closed = true
}
},
})
const res = await cli.fetch(
new Request('http://localhost/_incur/rpc', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ command: 'stream', args: {}, options: {} }),
}),
)
const reader = res.body!.getReader()

await reader.read()
const cancelled = reader.cancel()
resume!()
await cancelled

expect(closed).toBe(true)
})

test('POST /_incur/rpc stream preserves returned ok CTA', async () => {
const cli = Cli.create('test')
cli.command('stream', {
Expand Down
Loading