diff --git a/.changeset/quiet-walls-share.md b/.changeset/quiet-walls-share.md new file mode 100644 index 0000000..c69413d --- /dev/null +++ b/.changeset/quiet-walls-share.md @@ -0,0 +1,5 @@ +--- +"incur": patch +--- + +Fixed HTTP and MCP command input validation to return standard validation field errors for object-shaped inputs. diff --git a/.changeset/sharp-rivers-notice.md b/.changeset/sharp-rivers-notice.md new file mode 100644 index 0000000..a0d82c4 --- /dev/null +++ b/.changeset/sharp-rivers-notice.md @@ -0,0 +1,5 @@ +--- +'incur': patch +--- + +Added a structured RPC endpoint for command execution over HTTP. diff --git a/AGENTS.md b/AGENTS.md index 593e71e..660f9b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,8 +24,10 @@ - **`z.output<>` over `z.infer<>`** — use `z.output` for types after transforms/defaults are applied (what `schema.parse()` returns at runtime). Use `z.input` 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. `>`). +- **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. - **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. - **No `any` leakage** — Zod schemas may use `z.ZodObject` as a generic bound, but inferred types flowing to user-facing callbacks must be narrowed via `z.output`. The user should never see `any` in their IDE. - **Type inference after every feature** — after implementing any feature, check if new types can be narrowed. If a new property, callback, or return type touches a Zod schema, add generics to flow the inferred type through. Add or update `.test-d.ts` type tests alongside. diff --git a/README.md b/README.md index af1f717..18d9e9f 100644 --- a/README.md +++ b/README.md @@ -380,6 +380,27 @@ Responses use the same JSON envelope as `--full-output --format json`: Async generator commands stream as NDJSON (`application/x-ndjson`). Middleware runs the same as in `serve()`. +#### RPC + +The `fetch` handler also exposes `POST /_incur/rpc` for structured command calls. Use it when a client already has separated `args` and `options` objects and should not encode positional args into the URL path: + +```http +POST /_incur/rpc +content-type: application/json + +{ + "command": "users", + "args": { "id": 42 }, + "options": { "limit": 5 } +} +``` + +The response uses the same JSON envelope as command API responses. + +Raw fetch gateways mounted with `command('api', { fetch })` are intentionally not supported by +structured RPC because they accept arbitrary HTTP requests instead of a known command schema. Mount +the gateway with an OpenAPI spec to generate typed operations, or call the HTTP route directly. + #### MCP over HTTP The `fetch` handler automatically exposes an MCP endpoint at `/mcp`. Agents can discover and call your CLI's commands as MCP tools over HTTP — no stdio required: @@ -555,7 +576,7 @@ cli.command('deploy', { ### Streaming -Use `async *run` to stream chunks incrementally. Yield objects for structured data or plain strings for text: +Use `async *run` to stream chunks incrementally. Yield objects for structured data or plain strings for text. Generated client types recognize streaming commands from this `async *run` shape: ```ts cli.command('logs', { diff --git a/SKILL.md b/SKILL.md index 6bb33bf..3fe7615 100644 --- a/SKILL.md +++ b/SKILL.md @@ -931,7 +931,7 @@ Bun.serve(cli) ## Streaming -Use `async *run` to stream chunks incrementally. Yield objects for structured data or plain strings for text: +Use `async *run` to stream chunks incrementally. Yield objects for structured data or plain strings for text. Generated client types recognize streaming commands from this `async *run` shape: ```ts cli.command('logs', { @@ -971,7 +971,14 @@ Generate type definitions for your CLI's command map to get typed CTAs: incur gen ``` -This creates a `incur.generated.ts` file that registers your commands on the `Cli.Commands` type, enabling autocomplete on CTA command names, args, and options. +This creates a `incur.generated.ts` file that registers your commands on the `Cli.Commands` type, enabling autocomplete on CTA command names, args, and options. It also exports a `Commands` type you can import and pass to `createClient` when calling the CLI over RPC: + +```ts +import { createClient } from 'incur' +import type { Commands } from './incur.generated.js' + +const client = createClient({ baseUrl: 'https://api.example.com' }) +``` ## Full Example diff --git a/src/Cli.test-d.ts b/src/Cli.test-d.ts index 1679727..f9a5f77 100644 --- a/src/Cli.test-d.ts +++ b/src/Cli.test-d.ts @@ -167,6 +167,147 @@ test('command() accumulates command types through chaining', () => { expectTypeOf().toEqualTypeOf<{ args: {}; options: { limit: number } }>() }) +test('OpenAPI mounted fetch accumulates operation command types', () => { + const spec = { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/users': { + get: { + operationId: 'listUsers', + parameters: [ + { + name: 'limit', + in: 'query', + schema: { type: 'number' }, + }, + ], + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + users: { + type: 'array', + items: { + type: 'object', + properties: { id: { type: 'number' }, name: { type: 'string' } }, + required: ['id', 'name'], + }, + }, + }, + required: ['users'], + }, + }, + }, + }, + }, + }, + post: { + operationId: 'createUser', + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { name: { type: 'string' }, active: { type: 'boolean' } }, + required: ['name'], + }, + }, + }, + }, + responses: { + 201: { + description: 'Created', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { id: { type: 'number' }, name: { type: 'string' } }, + required: ['id', 'name'], + }, + }, + }, + }, + }, + }, + }, + '/users/{id}': { + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'number' }, + }, + ], + get: { + operationId: 'getUser', + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { id: { type: 'number' }, name: { type: 'string' } }, + required: ['id', 'name'], + }, + }, + }, + }, + }, + }, + }, + }, + } as const + + const cli = Cli.create('test').command('api', { + fetch: () => new Response(), + openapi: spec, + }) + + type Commands = typeof cli extends Cli.Cli ? commands : never + expectTypeOf().toExtend<{ + args: {} + options: { limit?: number | undefined } + output: { users: { id: number; name: string }[] } + }>() + expectTypeOf().toExtend<{ + args: {} + options: { name?: string | undefined; active?: boolean | undefined } + output: { id: number; name: string } + }>() + expectTypeOf().toExtend<{ + args: { id: number } + options: {} + output: { id: number; name: string } + }>() + + // @ts-expect-error raw gateway name is not a generated command + type RawGateway = Commands['api'] + void (undefined as unknown as RawGateway) +}) + +test('mounted root CLI preserves output type in command map', () => { + const deploy = Cli.create('deploy', { + output: z.object({ id: z.string(), status: z.literal('queued') }), + run: () => ({ id: 'dep_123', status: 'queued' as const }), + }) + + const cli = Cli.create('test').command(deploy) + + type Commands = typeof cli extends Cli.Cli ? commands : never + expectTypeOf().toExtend<{ + args: {} + options: {} + output: { id: string; status: 'queued' } + }>() +}) + test('middleware() infers vars types', () => { const cli = Cli.create('test', { vars: z.object({ user: z.string(), count: z.number() }), diff --git a/src/Cli.test.ts b/src/Cli.test.ts index bd55fc6..3dbf1fa 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -3,6 +3,8 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' +import * as Command from './internal/command.js' + const originalIsTTY = process.stdout.isTTY beforeAll(() => { ;(process.stdout as any).isTTY = false @@ -4051,6 +4053,83 @@ describe('--filter-output', () => { }) }) +describe('Command.execute', () => { + test.each([ + { + name: 'split', + command: { options: z.object({ name: z.string() }), run: () => ({ ok: true }) }, + inputOptions: { name: 123 }, + path: 'name', + parseMode: 'split' as const, + }, + { + name: 'flat', + command: { args: z.object({ id: z.string() }), run: () => ({ ok: true }) }, + inputOptions: { id: 123 }, + path: 'id', + parseMode: 'flat' as const, + }, + { + name: 'structured', + command: { args: z.object({ id: z.string() }), run: () => ({ ok: true }) }, + inputArgs: { id: 123 }, + inputOptions: {}, + path: 'id', + parseMode: 'structured' as const, + }, + ])('$name mode returns validation fieldErrors for invalid command input', async (c) => { + const result = await Command.execute(c.command, { + agent: true, + argv: [], + format: 'json', + formatExplicit: false, + inputArgs: 'inputArgs' in c ? c.inputArgs : undefined, + inputOptions: c.inputOptions, + name: 'test', + parseMode: c.parseMode, + path: 'users', + version: undefined, + }) + + expect(result).toMatchObject({ + ok: false, + error: { + code: 'VALIDATION_ERROR', + fieldErrors: [ + { + code: 'invalid_type', + missing: false, + path: c.path, + }, + ], + }, + }) + }) + + test('does not normalize handler-thrown Zod errors as command input', async () => { + const result = await Command.execute( + { + run() { + z.object({ name: z.string() }).parse({ name: 123 }) + }, + }, + { + agent: true, + argv: [], + format: 'json', + formatExplicit: false, + inputOptions: {}, + name: 'test', + path: 'users', + version: undefined, + }, + ) + + expect(result).toMatchObject({ ok: false, error: { code: 'UNKNOWN' } }) + expect(result).not.toHaveProperty('error.fieldErrors') + }) +}) + async function fetchJson(cli: Cli.Cli, req: Request) { const res = await cli.fetch(req) const body = await res.json() @@ -4201,6 +4280,239 @@ describe('fetch', () => { `) }) + test('POST /_incur/rpc executes command with structured args and options', async () => { + const cli = Cli.create('test') + cli.command('math/sum', { + args: z.object({ left: z.number() }), + options: z.object({ right: z.number() }), + run: (c) => ({ value: c.args.left + c.options.right }), + }) + + const req = new Request('http://localhost/_incur/rpc', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + command: 'math/sum', + args: { left: 1 }, + options: { right: 2 }, + }), + }) + + expect(await fetchJson(cli, req)).toMatchInlineSnapshot(` + { + "body": { + "data": { + "value": 3, + }, + "meta": { + "command": "math/sum", + "duration": "", + }, + "ok": true, + }, + "status": 200, + } + `) + }) + + test('POST /_incur/rpc rejects non-object args and options', async () => { + const cli = Cli.create('test').command('ping', { run: () => ({ ok: true }) }) + const req = new Request('http://localhost/_incur/rpc', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ command: 'ping', args: [] }), + }) + + const { status, body } = await fetchJson(cli, req) + expect(status).toBe(400) + expect(body.error).toEqual({ + code: 'VALIDATION_ERROR', + message: '`args` and `options` must be objects.', + }) + }) + + test('POST /_incur/rpc rejects raw fetch gateways', async () => { + const cli = Cli.create('test').command('api', { + fetch: () => new Response(JSON.stringify({ ok: true })), + }) + const req = new Request('http://localhost/_incur/rpc', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ command: 'api' }), + }) + + const { status, body } = await fetchJson(cli, req) + expect(status).toBe(400) + expect(body.error).toEqual({ + code: 'FETCH_GATEWAY_UNSUPPORTED', + message: + 'Raw fetch gateways cannot be called through structured RPC. Mount the gateway with an OpenAPI spec to generate typed commands, or call the HTTP route directly.', + }) + }) + + test('POST /_incur/rpc returns validation field errors', async () => { + const cli = Cli.create('test').command('sum', { + args: z.object({ left: z.number() }), + run: (c) => ({ value: c.args.left }), + }) + const req = new Request('http://localhost/_incur/rpc', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ command: 'sum', args: {} }), + }) + + const { status, body } = await fetchJson(cli, req) + expect(status).toBe(400) + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(body.error.fieldErrors).toMatchObject([ + { + missing: true, + path: 'left', + }, + ]) + }) + + test('POST /_incur/rpc streams async generator output as NDJSON', async () => { + const cli = Cli.create('test') + cli.command('stream', { + args: z.object({ start: z.number() }), + options: z.object({ step: z.number() }), + async *run(c) { + yield { progress: c.args.start } + yield { progress: c.args.start + c.options.step } + return { done: c.args.start + c.options.step * 2 } + }, + }) + const res = await cli.fetch( + new Request('http://localhost/_incur/rpc', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + command: 'stream', + args: { start: 2 }, + options: { step: 3 }, + }), + }), + ) + + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('application/x-ndjson') + const lines = (await res.text()) + .trim() + .split('\n') + .map((l) => JSON.parse(l)) + expect(lines).toMatchInlineSnapshot(` + [ + { + "data": { + "progress": 2, + }, + "type": "chunk", + }, + { + "data": { + "progress": 5, + }, + "type": "chunk", + }, + { + "meta": { + "command": "stream", + }, + "ok": true, + "type": "done", + }, + ] + `) + }) + + test('POST /_incur/rpc stream preserves returned ok CTA', async () => { + const cli = Cli.create('test') + cli.command('stream', { + async *run(c) { + yield { progress: 1 } + return c.ok(undefined, { cta: { commands: ['status'] } }) + }, + }) + 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 lines = (await res.text()) + .trim() + .split('\n') + .map((l) => JSON.parse(l)) + expect(lines).toMatchInlineSnapshot(` + [ + { + "data": { + "progress": 1, + }, + "type": "chunk", + }, + { + "meta": { + "command": "stream", + "cta": { + "commands": [ + { + "command": "test status", + }, + ], + "description": "Suggested command:", + }, + }, + "ok": true, + "type": "done", + }, + ] + `) + }) + + test('POST /_incur/rpc stream preserves returned errors', async () => { + const cli = Cli.create('test') + cli.command('stream', { + async *run(c) { + yield { progress: 1 } + return c.error({ code: 'STREAM_FAIL', message: 'broke' }) + }, + }) + 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 lines = (await res.text()) + .trim() + .split('\n') + .map((l) => JSON.parse(l)) + expect(lines).toMatchInlineSnapshot(` + [ + { + "data": { + "progress": 1, + }, + "type": "chunk", + }, + { + "error": { + "code": "STREAM_FAIL", + "message": "broke", + }, + "ok": false, + "type": "error", + }, + ] + `) + }) + test('trailing path segments → positional args', async () => { const cli = Cli.create('test') cli.command('users', { @@ -4325,6 +4637,79 @@ describe('fetch', () => { `) }) + test('async generator RPC stream preserves returned ok CTA', async () => { + const cli = Cli.create('test') + cli.command('stream', { + async *run(c) { + yield { progress: 1 } + return c.ok(undefined, { cta: { commands: ['status'] } }) + }, + }) + const res = await cli.fetch(new Request('http://localhost/stream')) + const lines = (await res.text()) + .trim() + .split('\n') + .map((l) => JSON.parse(l)) + expect(lines).toMatchInlineSnapshot(` + [ + { + "data": { + "progress": 1, + }, + "type": "chunk", + }, + { + "meta": { + "command": "stream", + "cta": { + "commands": [ + { + "command": "test status", + }, + ], + "description": "Suggested command:", + }, + }, + "ok": true, + "type": "done", + }, + ] + `) + }) + + test('async generator RPC stream preserves returned errors', async () => { + const cli = Cli.create('test') + cli.command('stream', { + async *run(c) { + yield { progress: 1 } + return c.error({ code: 'STREAM_FAIL', message: 'broke' }) + }, + }) + const res = await cli.fetch(new Request('http://localhost/stream')) + const lines = (await res.text()) + .trim() + .split('\n') + .map((l) => JSON.parse(l)) + expect(lines).toMatchInlineSnapshot(` + [ + { + "data": { + "progress": 1, + }, + "type": "chunk", + }, + { + "error": { + "code": "STREAM_FAIL", + "message": "broke", + }, + "ok": false, + "type": "error", + }, + ] + `) + }) + test('middleware sets var → command sees it', async () => { const cli = Cli.create('test', { vars: z.object({ user: z.string().default('anonymous') }), diff --git a/src/Cli.ts b/src/Cli.ts index 8af1e64..b658aea 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -35,6 +35,8 @@ import * as Skill from './Skill.js' import * as SyncMcp from './SyncMcp.js' import * as SyncSkills from './SyncSkills.js' +declare const rootType: unique symbol + /** A CLI application instance. Also used as a command group when mounted on a parent CLI. */ export type Cli< commands extends CommandsMap = {}, @@ -53,35 +55,39 @@ export type Cli< >( name: name, definition: CommandDefinition, - ): Cli< - commands & { [key in name]: { args: InferOutput; options: InferOutput } }, - vars, - env - > - /** Mounts a sub-CLI as a command group. */ - ( - cli: Cli & { name: name }, - ): Cli + ): Cli }, vars, env> /** Mounts a root CLI as a single command. */ < const name extends string, const args extends z.ZodObject | undefined, const opts extends z.ZodObject | undefined, + const output extends z.ZodType | undefined, >( - cli: Root & { name: name }, - ): Cli< - commands & { [key in name]: { args: InferOutput; options: InferOutput } }, - vars, - env - > - /** Mounts a fetch handler as a command, optionally with OpenAPI spec for typed subcommands. */ + cli: Root & { name: name }, + ): Cli }, vars, env> + /** Mounts a sub-CLI as a command group. */ + ( + cli: Cli & { name: name }, + ): Cli + /** Mounts a fetch handler with an OpenAPI spec as a typed command group. */ + ( + name: name, + definition: { + basePath?: string | undefined + description?: string | undefined + fetch: FetchHandler + openapi: spec + outputPolicy?: OutputPolicy | undefined + }, + ): Cli, vars, env> + /** Mounts a raw fetch handler as an untyped command gateway. */ ( name: name, definition: { basePath?: string | undefined description?: string | undefined fetch: FetchHandler - openapi?: Openapi.OpenAPISpec | undefined + openapi?: undefined outputPolicy?: OutputPolicy | undefined }, ): Cli @@ -106,7 +112,11 @@ export type Cli< export type Root< _args extends z.ZodObject | undefined = undefined, _options extends z.ZodObject | undefined = undefined, -> = Omit + _output extends z.ZodType | undefined = undefined, +> = Omit & { + /** @internal Carries root command schemas for mount inference. */ + [rootType]: { args: _args; options: _options; output: _output } +} /** Extracts the commands map from the registered type. */ export type Commands = Register extends { commands: infer commands extends CommandsMap } @@ -149,10 +159,14 @@ export type Cta = } }[keyof commands & string] | { + /** Positional arguments appended as bare values. */ + args?: Record | undefined /** The command name to run. */ command: string & {} /** A short description of what the command does. */ description?: string | undefined + /** Named options formatted as `--key value` flags. */ + options?: Record | undefined }) /** Creates a CLI with a root handler. Can still register subcommands which take precedence. */ @@ -165,7 +179,8 @@ export function create< >( name: string, definition: create.Options & { run: Function }, -): Cli<{ [key in typeof name]: { args: InferOutput; options: InferOutput } }, vars, env> +): Cli<{ [key in typeof name]: CommandMapEntry }, vars, env> & + Root /** Creates a router CLI that registers subcommands. */ export function create< const args extends z.ZodObject | undefined = undefined, @@ -185,11 +200,12 @@ export function create< definition: create.Options & { name: string; run: Function }, ): Cli< { - [key in (typeof definition)['name']]: { args: InferOutput; options: InferOutput } + [key in (typeof definition)['name']]: CommandMapEntry }, vars, env -> +> & + Root /** Creates a router CLI from a single options object (e.g. package.json). */ export function create< const args extends z.ZodObject | undefined = undefined, @@ -204,12 +220,12 @@ export function create( ): Cli | Root { const name = typeof nameOrDefinition === 'string' ? nameOrDefinition : nameOrDefinition.name const def = typeof nameOrDefinition === 'string' ? (definition ?? {}) : nameOrDefinition - const rootDef = 'run' in def ? (def as CommandDefinition) : undefined + const rootDef = + 'run' in def ? annotateStreamingCommand(def as CommandDefinition) : undefined const rootFetch = 'fetch' in def ? (def.fetch as FetchHandler) : undefined const commands = new Map() const middlewares: MiddlewareHandler[] = [] - const pending: Promise[] = [] const mcpHandler = createMcpHttpHandler(name, def.version ?? '0.0.0') const cli: Cli = { @@ -221,20 +237,17 @@ export function create( command(nameOrCli: any, def?: any): any { if (typeof nameOrCli === 'string') { if (def && 'fetch' in def && typeof def.fetch === 'function') { - // OpenAPI + fetch → generate typed command group (async, resolved before serve) + // OpenAPI + fetch → generate typed command group. if (def.openapi) { - pending.push( - Openapi.generateCommands(def.openapi, def.fetch, { basePath: def.basePath }).then( - (generated) => { - commands.set(nameOrCli, { - _group: true, - description: def.description, - commands: generated as Map, - ...(def.outputPolicy ? { outputPolicy: def.outputPolicy } : undefined), - } as InternalGroup) - }, - ), - ) + const generated = Openapi.generateCommands(def.openapi, def.fetch, { + basePath: def.basePath, + }) + commands.set(nameOrCli, { + _group: true, + description: def.description, + commands: generated as Map, + ...(def.outputPolicy ? { outputPolicy: def.outputPolicy } : undefined), + } as InternalGroup) return cli } commands.set(nameOrCli, { @@ -246,7 +259,7 @@ export function create( } as InternalFetchGateway) return cli } - commands.set(nameOrCli, def) + commands.set(nameOrCli, annotateStreamingCommand(def)) if (def.aliases) for (const a of def.aliases) commands.set(a, { _alias: true, target: nameOrCli }) return cli @@ -274,7 +287,6 @@ export function create( }, async fetch(req: Request) { - if (pending.length > 0) await Promise.all(pending) return fetchImpl(name, commands, req, { description: def.description, envSchema: def.env, @@ -288,7 +300,6 @@ export function create( }, async serve(argv = process.argv.slice(2), serveOptions: serve.Options = {}) { - if (pending.length > 0) await Promise.all(pending) return serveImpl(name, commands, argv, { ...serveOptions, aliases: def.aliases, @@ -1523,6 +1534,8 @@ declare namespace fetchImpl { envSchema?: z.ZodObject | undefined /** Group-level middleware collected during command resolution. */ groupMiddlewares?: MiddlewareHandler[] | undefined + /** Structured args received from the RPC route. */ + structuredArgs?: Record | undefined mcpHandler?: | (( req: Request, @@ -1657,6 +1670,14 @@ async function fetchImpl( vars: options.vars, }) + if ( + req.method === 'POST' && + segments[0] === '_incur' && + segments[1] === 'rpc' && + segments.length === 2 + ) + return executeRpcCommand(commands, req, start, options) + // .well-known/skills/ — Agent Skills Discovery (RFC) if ( segments[0] === '.well-known' && @@ -1776,6 +1797,69 @@ async function fetchImpl( }) } +/** @internal Executes an RPC client request. */ +async function executeRpcCommand( + commands: Map, + req: Request, + start: number, + options: fetchImpl.Options, +): Promise { + function jsonResponse(body: unknown, status: number) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) + } + + function error(code: string, message: string, status: number, command = '/_incur/rpc') { + return jsonResponse( + { + ok: false, + error: { code, message }, + meta: { command, duration: `${Math.round(performance.now() - start)}ms` }, + }, + status, + ) + } + + let body: unknown + try { + body = await req.json() + } catch { + return error('VALIDATION_ERROR', 'Request body must be JSON.', 400) + } + + if (!isRecord(body)) return error('VALIDATION_ERROR', 'Request body must be an object.', 400) + + if (typeof body.command !== 'string') + return error('VALIDATION_ERROR', '`command` must be a string.', 400) + const command = body.command.trim() + if (!command) return error('VALIDATION_ERROR', '`command` must be a non-empty string.', 400) + + const args = body.args ?? {} + const rpcOptions = body.options ?? {} + if (!isRecord(args) || !isRecord(rpcOptions)) + return error('VALIDATION_ERROR', '`args` and `options` must be objects.', 400) + + const tokens = command.split(/\s+/) + const resolved = resolveCommand(commands, tokens) + if ('fetchGateway' in resolved) + return error( + 'FETCH_GATEWAY_UNSUPPORTED', + 'Raw fetch gateways cannot be called through structured RPC. Mount the gateway with an OpenAPI spec to generate typed commands, or call the HTTP route directly.', + 400, + command, + ) + if (!('command' in resolved) || resolved.rest.length > 0) + return error('COMMAND_NOT_FOUND', 'Command not found.', 404, command) + + return executeCommand(resolved.path, resolved.command, [], rpcOptions, start, { + ...options, + groupMiddlewares: resolved.middlewares, + structuredArgs: args, + }) +} + /** @internal Executes a resolved command for the fetch handler and returns a JSON Response. */ async function executeCommand( path: string, @@ -1804,10 +1888,11 @@ async function executeCommand( env: options.envSchema, format: 'json', formatExplicit: true, + inputArgs: options.structuredArgs, inputOptions, middlewares: allMiddleware, name: options.name ?? path, - parseMode: 'split', + parseMode: options.structuredArgs === undefined ? 'split' : 'structured', path, vars: options.vars, version: options.version, @@ -1820,34 +1905,68 @@ async function executeCommand( const stream = new ReadableStream({ async start(controller) { const encoder = new TextEncoder() + const write = (value: unknown) => { + controller.enqueue(encoder.encode(JSON.stringify(value) + '\n')) + } try { - for await (const value of result.stream) { - controller.enqueue( - encoder.encode(JSON.stringify({ type: 'chunk', data: value }) + '\n'), - ) - } - controller.enqueue( - encoder.encode( - JSON.stringify({ - type: 'done', - ok: true, - meta: { command: path }, - }) + '\n', - ), - ) - } catch (error) { - controller.enqueue( - encoder.encode( - JSON.stringify({ + let returnValue: unknown + while (true) { + const { value, done } = await result.stream.next() + if (done) { + returnValue = value + break + } + if (isSentinel(value) && value[sentinel] === 'error') { + const err = value as ErrorResult + write({ type: 'error', ok: false, error: { - code: 'UNKNOWN', - message: error instanceof Error ? error.message : String(error), + code: err.code, + message: err.message, + ...(err.retryable !== undefined ? { retryable: err.retryable } : undefined), }, - }) + '\n', - ), - ) + }) + controller.close() + return + } + write({ type: 'chunk', data: value }) + } + if (isSentinel(returnValue) && returnValue[sentinel] === 'error') { + const err = returnValue as ErrorResult + write({ + type: 'error', + ok: false, + error: { + code: err.code, + message: err.message, + ...(err.retryable !== undefined ? { retryable: err.retryable } : undefined), + }, + }) + controller.close() + return + } + const cta = + isSentinel(returnValue) && returnValue[sentinel] === 'ok' + ? formatCtaBlock(options.name ?? path, (returnValue as OkResult).cta) + : undefined + write({ + type: 'done', + ok: true, + meta: { command: path, ...(cta ? { cta } : undefined) }, + }) + } catch (error) { + write({ + type: 'error', + ok: false, + error: { + code: error instanceof IncurError ? error.code : 'UNKNOWN', + message: error instanceof Error ? error.message : String(error), + ...(error instanceof IncurError && error.retryable !== undefined + ? { retryable: error.retryable } + : undefined), + }, + }) } controller.close() }, @@ -1869,6 +1988,7 @@ async function executeCommand( ...(result.error.retryable !== undefined ? { retryable: result.error.retryable } : undefined), + ...(result.error.fieldErrors ? { fieldErrors: result.error.fieldErrors } : undefined), }, meta: { command: path, @@ -2406,7 +2526,16 @@ function formatFetchHelp(name: string, description?: string): string { /** Shape of the commands map accumulated through `.command()` chains. */ export type CommandsMap = Record< string, - { args: Record; options: Record } + { + /** Command positional argument shape. */ + args: Record + /** Command named option shape. */ + options: Record + /** Command output shape. */ + output?: unknown | undefined + /** Whether the command streams output chunks. */ + stream?: true | undefined + } > /** @internal Entry stored in a command map — either a leaf definition, a group, or a fetch gateway. */ @@ -2462,6 +2591,19 @@ function isAlias(entry: CommandEntry): entry is InternalAlias { return '_alias' in entry } +const AsyncGeneratorFunction = async function* () {}.constructor + +function isAsyncGeneratorFunction(value: unknown): boolean { + return typeof value === 'function' && value.constructor === AsyncGeneratorFunction +} + +function annotateStreamingCommand>( + command: command, +): command { + if (isAsyncGeneratorFunction(command.run)) return { ...command, _stream: true } as command + return command +} + /** @internal Follows an alias entry to its canonical target. Returns the entry unchanged if not an alias. */ function resolveAlias( commands: Map, @@ -3001,6 +3143,16 @@ type InferReturn = output extends z.ZodTyp ? z.output : unknown +/** @internal Shape of a command entry inferred from command schemas. */ +type CommandMapEntry< + args extends z.ZodObject | undefined = undefined, + options extends z.ZodObject | undefined = undefined, + output extends z.ZodType | undefined = undefined, +> = { + args: InferOutput + options: InferOutput +} & (output extends z.ZodType ? { output: InferReturn } : {}) + /** @internal Inferred vars type from a Zod schema, or `{}` when no schema is provided. */ type InferVars | undefined> = vars extends z.ZodObject ? z.output : {} @@ -3058,6 +3210,8 @@ type CommandDefinition< vars extends z.ZodObject | undefined = undefined, cliEnv extends z.ZodObject | undefined = undefined, > = CommandMeta & { + /** @internal Whether this command's handler is an async generator function. */ + _stream?: true | undefined /** Alternative names for this command (e.g. `['extensions', 'ext']` for an `extension` command). */ aliases?: string[] | undefined /** Zod schema for positional arguments. */ @@ -3086,42 +3240,51 @@ type CommandDefinition< /** Alternative usage patterns shown in help output. */ usage?: Usage[] | undefined /** The command handler. Return a value for single-return, or use `async *run` to stream chunks. */ - run(context: { - /** Whether the consumer is an agent (stdout is not a TTY). */ - agent: boolean - /** Positional arguments. */ - args: InferOutput - /** The binary name the user invoked (e.g. an alias). Falls back to `name` when not resolvable. */ - displayName: string - /** Parsed environment variables. */ - env: InferOutput - /** Return an error result with optional CTAs. */ - error: (options: { - code: string - cta?: CtaBlock | undefined - exitCode?: number | undefined - message: string - retryable?: boolean | undefined - }) => never - /** The resolved output format (e.g. `'toon'`, `'json'`, `'jsonl'`). */ - format: Formatter.Format - /** Whether the user explicitly passed `--format` or `--json`. */ - formatExplicit: boolean - /** The CLI name. */ - name: string - /** Return a success result with optional metadata (e.g. CTAs). */ - ok: (data: InferReturn, meta?: { cta?: CtaBlock | undefined }) => never - options: InferOutput - /** Variables set by middleware. */ - var: InferVars - /** The CLI version string. */ - version: string | undefined - }): - | InferReturn - | Promise> - | AsyncGenerator, unknown, unknown> + run: CommandRun } +type CommandRun< + args extends z.ZodObject | undefined = undefined, + env extends z.ZodObject | undefined = undefined, + options extends z.ZodObject | undefined = undefined, + output extends z.ZodType | undefined = undefined, + vars extends z.ZodObject | undefined = undefined, + cliEnv extends z.ZodObject | undefined = undefined, +> = (context: { + /** Whether the consumer is an agent (stdout is not a TTY). */ + agent: boolean + /** Positional arguments. */ + args: InferOutput + /** The binary name the user invoked (e.g. an alias). Falls back to `name` when not resolvable. */ + displayName: string + /** Parsed environment variables. */ + env: InferOutput + /** Return an error result with optional CTAs. */ + error: (options: { + code: string + cta?: CtaBlock | undefined + exitCode?: number | undefined + message: string + retryable?: boolean | undefined + }) => never + /** The resolved output format (e.g. `'toon'`, `'jsonl'`). */ + format: Formatter.Format + /** Whether the user explicitly passed `--format` or `--json`. */ + formatExplicit: boolean + /** The CLI name. */ + name: string + /** Return a success result with optional metadata (e.g. CTAs). */ + ok: (data: InferReturn, meta?: { cta?: CtaBlock | undefined }) => never + options: InferOutput + /** Variables set by middleware. */ + var: InferVars + /** The CLI version string. */ + version: string | undefined +}) => + | InferReturn + | Promise> + | AsyncGenerator, unknown, unknown> + /** @internal A formatted CTA block as it appears in the output envelope. */ type FormattedCtaBlock = { /** Formatted command suggestions. */ diff --git a/src/Client.test-d.ts b/src/Client.test-d.ts new file mode 100644 index 0000000..3af033f --- /dev/null +++ b/src/Client.test-d.ts @@ -0,0 +1,375 @@ +import { Cli, ClientError, createClient, isClientRpcError, isClientRpcErrorEnvelope } from 'incur' +import type { ClientRpcError, ClientRpcErrorEnvelope } from 'incur' +import { expectTypeOf, test } from 'vitest' + +// BEGIN generated client round-trip fixture +/** Command map generated from your incur CLI. */ +export type Commands = { + /** Generated command "admin users get". */ + 'admin users get': { + args: { id: number } + options: { verbose?: boolean | undefined } + output: { id: number } + } + /** Generated command "api getUser". */ + 'api getUser': { + args: { id: number } + options: {} + output: { id: number; name: string; [key: string]: unknown } + } + /** Generated command "auth". */ + auth: { args: {}; options: { token: string }; output: void } + /** Generated command "logs". */ + logs: { + args: {} + options: {} + output: { line: string } + stream: true + } + /** Generated command "project deploy". */ + 'project deploy': { + args: { id: string } + options: { dryRun: boolean } + output: { deployId: string; status: 'queued' | 'done' } + } + /** Generated command "project inspect". */ + 'project inspect': { + args: { id: string; includeLogs?: boolean | undefined } + options: {} + output: { id: string; logs?: string[] | undefined } + } + /** Generated command "project list". */ + 'project list': { + args: {} + options: { cursor?: string | undefined; limit?: number | undefined } + output: { items: string[]; nextCursor?: string | undefined } + } + /** Generated command "status". */ + status: { args: {}; options: {}; output: { ok: boolean } } +} + +declare module 'incur' { + interface Register { + commands: Commands + } +} +// END generated client round-trip fixture + +type GeneratedCommands = Commands + +test('createClient selects args, options, and output by command string', () => { + const client = createClient({ baseUrl: 'https://api.example.com' }) + const deploy = client('project deploy') + + expectTypeOf[0]>().toExtend<{ + args: { id: string } + options: { dryRun: boolean } + }>() + expectTypeOf<{ + args: { id: string } + options: { dryRun: boolean } + }>().toExtend[0]>() + expectTypeOf>>().toEqualTypeOf<{ + deployId: string + status: 'queued' | 'done' + }>() + + deploy({ args: { id: 'p1' }, options: { dryRun: true } }) + + // @ts-expect-error missing required args + deploy({ options: { dryRun: true } }) + + // @ts-expect-error missing required options + deploy({ args: { id: 'p1' } }) + + // @ts-expect-error arg property has the wrong type + deploy({ args: { id: 1 }, options: { dryRun: true } }) + + // @ts-expect-error unknown option property + deploy({ args: { id: 'p1' }, options: { dryRun: true, verbose: true } }) + + // @ts-expect-error unknown command + client('project destroy') +}) + +test('createClient allows omitted input when args and options are empty', () => { + const client = createClient({ baseUrl: 'https://api.example.com' }) + const status = client('status') + + expectTypeOf>>().toEqualTypeOf<{ ok: boolean }>() + status() + status({}) + status({ args: {}, options: {} }) +}) + +test('createClient requires input when args are required and options are empty', () => { + const client = createClient({ baseUrl: 'https://api.example.com' }) + const inspect = client('project inspect') + + type Input = { + args: { id: string; includeLogs?: boolean | undefined } + options?: {} | undefined + } + expectTypeOf[0]>().toExtend() + expectTypeOf().toExtend[0]>() + expectTypeOf>>().toEqualTypeOf<{ + id: string + logs?: string[] | undefined + }>() + + inspect({ args: { id: 'p1' } }) + inspect({ args: { id: 'p1', includeLogs: true }, options: {} }) + + // @ts-expect-error input is required when args has a required key + inspect() + + // @ts-expect-error required arg key is missing + inspect({ args: {} }) +}) + +test('createClient allows optional input when args and options have no required keys', () => { + const client = createClient({ baseUrl: 'https://api.example.com' }) + const list = client('project list') + + type Input = + | { + args?: {} | undefined + options?: { cursor?: string | undefined; limit?: number | undefined } | undefined + } + | undefined + expectTypeOf[0]>().toExtend() + expectTypeOf().toExtend[0]>() + + list() + list({}) + list({ options: { limit: 10 } }) + list({ args: {}, options: { cursor: 'next' } }) + + // @ts-expect-error optional option has the wrong type + list({ options: { limit: '10' } }) +}) + +test('createClient requires input when only options are required', () => { + const client = createClient({ baseUrl: 'https://api.example.com' }) + const auth = client('auth') + + type Input = { + args?: {} | undefined + options: { token: string } + } + expectTypeOf[0]>().toExtend() + expectTypeOf().toExtend[0]>() + + auth({ options: { token: 'secret' } }) + auth({ args: {}, options: { token: 'secret' } }) + + // @ts-expect-error options are required + auth() + + // @ts-expect-error required option key is missing + auth({ options: {} }) +}) + +test('createClient preserves mounted sub-CLI groups', () => { + const client = createClient({ baseUrl: 'https://api.example.com' }) + const get = client('admin users get') + + type Input = { + args: { id: number } + options?: { verbose?: boolean | undefined } | undefined + } + expectTypeOf[0]>().toExtend() + expectTypeOf().toExtend[0]>() + expectTypeOf>>().toEqualTypeOf<{ id: number }>() + + get({ args: { id: 1 } }) + get({ args: { id: 1 }, options: { verbose: true } }) + + // @ts-expect-error mounted sub-CLI args keep their generated types + get({ args: { id: '1' } }) +}) + +test('createClient keeps unknown args, options, and output unknown', () => { + type RuntimeCommands = Record + const client = createClient({ baseUrl: 'https://api.example.com' }) + const raw = client('raw') + + type Input = { args?: unknown; options?: unknown } | undefined + expectTypeOf[0]>().toExtend() + expectTypeOf().toExtend[0]>() + expectTypeOf>>().toEqualTypeOf() + + raw() + raw({ args: 'anything', options: 123 }) +}) + +test('createClient can be made permissive with an explicit unknown command map', () => { + type RuntimeCommands = Record + const client = createClient({ baseUrl: 'https://api.example.com' }) + const call = client('anything') + + type Input = { args?: unknown; options?: unknown } | undefined + expectTypeOf[0]>().toExtend() + expectTypeOf().toExtend[0]>() + expectTypeOf>>().toEqualTypeOf() + + call() + call({ args: { any: 'value' }, options: ['also accepted'] }) +}) + +test('createClient returns async iterables for streaming commands', () => { + const client = createClient({ baseUrl: 'https://api.example.com' }) + const logs = client('logs') + + expectTypeOf>>().toEqualTypeOf>() + + async function read() { + const stream = await logs() + for await (const chunk of stream) expectTypeOf(chunk).toEqualTypeOf<{ line: string }>() + } + void read +}) + +test('ClientError can be imported and RPC payloads can be narrowed', () => { + const error = new ClientError('Invalid input', { + error: { + code: 'VALIDATION_ERROR', + message: 'Invalid input', + retryable: false, + fieldErrors: [ + { + path: 'id', + expected: 'string', + received: 'number', + message: 'Expected string, received number', + }, + ], + } satisfies ClientRpcError, + status: 400, + }) + const caught: unknown = error + + if (caught instanceof ClientError) { + expectTypeOf(caught.data).toEqualTypeOf() + expectTypeOf(caught.error).toEqualTypeOf() + expectTypeOf(caught.status).toEqualTypeOf() + + if (isClientRpcError(caught.error)) { + expectTypeOf(caught.error).toEqualTypeOf() + expectTypeOf(caught.error.code).toEqualTypeOf() + expectTypeOf(caught.error.retryable).toEqualTypeOf() + expectTypeOf(caught.error.fieldErrors?.[0]?.path).toEqualTypeOf() + } + + if (isClientRpcErrorEnvelope(caught.data)) { + expectTypeOf(caught.data).toEqualTypeOf() + expectTypeOf(caught.data.error.code).toEqualTypeOf() + expectTypeOf(caught.data.meta?.command).toEqualTypeOf() + } + } +}) + +test('generated command map preserves exact optional properties', () => { + expectTypeOf().toEqualTypeOf<{ + cursor?: string | undefined + limit?: number | undefined + }>() + expectTypeOf().toEqualTypeOf<{ + items: string[] + nextCursor?: string | undefined + }>() +}) + +test('generated command map works with required input and output inference', () => { + const client = createClient({ baseUrl: 'https://api.example.com' }) + const deploy = client('project deploy') + + type Input = { + args: { id: string } + options: { dryRun: boolean } + } + expectTypeOf[0]>().toExtend() + expectTypeOf().toExtend[0]>() + expectTypeOf>>().toEqualTypeOf<{ + deployId: string + status: 'queued' | 'done' + }>() +}) + +test('generated command map allows optional input with explicit undefined values', () => { + const client = createClient({ baseUrl: 'https://api.example.com' }) + const list = client('project list') + + list({ options: { cursor: undefined, limit: undefined } }) +}) + +test('generated command map includes mounted root CLIs and omits aliases', () => { + const client = createClient({ baseUrl: 'https://api.example.com' }) + const status = client('status') + + expectTypeOf>>().toEqualTypeOf<{ ok: boolean }>() + status() + + // @ts-expect-error aliases are intentionally absent from generated command maps + client('ship') +}) + +test('createClient consumes OpenAPI mounted command maps inferred from Cli instances', () => { + const spec = { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/users/{id}': { + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'number' }, + }, + ], + get: { + operationId: 'getUser', + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { id: { type: 'number' }, name: { type: 'string' } }, + required: ['id', 'name'], + }, + }, + }, + }, + }, + }, + }, + }, + } as const + const cli = Cli.create('test').command('api', { + fetch: () => new Response(), + openapi: spec, + }) + type Commands = typeof cli extends Cli.Cli ? commands : never + const client = createClient({ baseUrl: 'https://api.example.com' }) + const getUser = client('api getUser') + + expectTypeOf[0]>().toExtend<{ + args: { id: number } + options?: {} | undefined + }>() + expectTypeOf>>().toExtend<{ + id: number + name: string + }>() + + getUser({ args: { id: 1 } }) + + // @ts-expect-error OpenAPI path args are required + getUser() + + // @ts-expect-error raw fetch gateway is not generated as a command + client('api') +}) diff --git a/src/Client.test.ts b/src/Client.test.ts new file mode 100644 index 0000000..a4e5213 --- /dev/null +++ b/src/Client.test.ts @@ -0,0 +1,511 @@ +import { + Cli, + ClientError, + createClient, + isClientRpcError, + isClientRpcErrorEnvelope, + z, +} from 'incur' + +type RuntimeCommands = Record + +describe('createClient', () => { + test('posts command calls to the RPC route and unwraps ok data', async () => { + const calls: { init: RequestInit | undefined; url: string }[] = [] + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: async (input, init) => { + calls.push({ init, url: String(input) }) + return Response.json({ + ok: true, + data: { deployId: 'd1', status: 'queued' }, + meta: { command: 'project deploy', duration: '1ms' }, + }) + }, + }) + + await expect( + client('project deploy')({ + args: { id: 'p1' }, + options: { dryRun: true }, + }), + ).resolves.toEqual({ deployId: 'd1', status: 'queued' }) + + expect(calls).toHaveLength(1) + expect(calls[0]?.url).toBe('https://api.example.com/_incur/rpc') + expect(calls[0]?.init?.method).toBe('POST') + expect(calls[0]?.init?.headers).toEqual({ + accept: 'application/json, application/x-ndjson', + 'content-type': 'application/json', + }) + expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ + command: 'project deploy', + args: { id: 'p1' }, + options: { dryRun: true }, + }) + }) + + test('normalizes base URL paths and URL instances', async () => { + const urls: string[] = [] + const fetch = async (input: RequestInfo | URL) => { + urls.push(String(input)) + return Response.json({ ok: true, data: null }) + } + + await createClient({ baseUrl: 'https://api.example.com/v1', fetch })('ping')() + await createClient({ baseUrl: 'https://api.example.com/v1/', fetch })('ping')() + await createClient({ baseUrl: new URL('https://api.example.com/v2'), fetch })( + 'ping', + )() + + expect(urls).toEqual([ + 'https://api.example.com/v1/_incur/rpc', + 'https://api.example.com/v1/_incur/rpc', + 'https://api.example.com/v2/_incur/rpc', + ]) + }) + + test('defaults omitted args and options to empty objects', async () => { + const bodies: unknown[] = [] + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: async (_input, init) => { + bodies.push(JSON.parse(String(init?.body))) + return Response.json({ ok: true, data: 'pong' }) + }, + }) + + await client('ping')() + await client('ping')({ args: { id: 'p1' } }) + await client('ping')({ options: { dryRun: true } }) + + expect(bodies).toEqual([ + { command: 'ping', args: {}, options: {} }, + { command: 'ping', args: { id: 'p1' }, options: {} }, + { command: 'ping', args: {}, options: { dryRun: true } }, + ]) + }) + + test('throws failed RPC envelopes', async () => { + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: async () => + Response.json( + { + ok: false, + error: { code: 'NOPE', message: 'Nope' }, + meta: { command: 'project deploy', duration: '1ms' }, + }, + { status: 500 }, + ), + }) + + await expect( + client('project deploy')({ + args: { id: 'p1' }, + options: { dryRun: true }, + }), + ).rejects.toMatchObject({ + name: 'Incur.ClientError', + message: 'Nope', + error: { code: 'NOPE', message: 'Nope' }, + status: 500, + }) + }) + + test('throws exported ClientError with narrowable RPC fields', async () => { + const fieldErrors = [ + { + code: 'invalid_type', + missing: false, + path: 'id', + expected: 'string', + received: 'number', + message: 'Expected string, received number', + }, + ] + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: async () => + Response.json( + { + ok: false, + error: { + code: 'VALIDATION_ERROR', + message: 'Invalid input', + retryable: false, + fieldErrors, + }, + meta: { command: 'project deploy', duration: '2ms' }, + }, + { status: 400 }, + ), + }) + + await expect(client('project deploy')()).rejects.toBeInstanceOf(ClientError) + + try { + await client('project deploy')() + throw new Error('Expected client call to fail') + } catch (error) { + expect(error).toBeInstanceOf(ClientError) + if (!(error instanceof ClientError)) throw error + + expect(error.status).toBe(400) + expect(isClientRpcError(error.error)).toBe(true) + if (isClientRpcError(error.error)) { + expect(error.error.code).toBe('VALIDATION_ERROR') + expect(error.error.retryable).toBe(false) + expect(error.error.fieldErrors).toEqual(fieldErrors) + } + expect(isClientRpcErrorEnvelope(error.data)).toBe(true) + if (isClientRpcErrorEnvelope(error.data)) { + expect(error.data.error.code).toBe('VALIDATION_ERROR') + expect(error.data.meta?.command).toBe('project deploy') + } + } + }) + + test('returns async iterable for streaming RPC responses', async () => { + const client = createClient<{ + logs: { args: {}; options: {}; output: { line: string }; stream: true } + }>({ + baseUrl: 'https://api.example.com', + fetch: async () => + new Response( + [ + JSON.stringify({ type: 'chunk', data: { line: 'one' } }), + JSON.stringify({ type: 'chunk', data: { line: 'two' } }), + JSON.stringify({ type: 'done', ok: true, meta: { command: 'logs' } }), + ].join('\n') + '\n', + { headers: { 'content-type': 'application/x-ndjson' } }, + ), + }) + + const stream = await client('logs')() + const chunks = [] + for await (const chunk of stream) chunks.push(chunk) + + expect(chunks).toEqual([{ line: 'one' }, { line: 'two' }]) + }) + + test('calls a real CLI RPC server and unwraps non-streaming responses', async () => { + const cli = Cli.create('test').command('sum', { + args: z.object({ left: z.number() }), + options: z.object({ right: z.number() }), + run: (c) => ({ value: c.args.left + c.options.right }), + }) + const client = createClient<{ + sum: { args: { left: number }; options: { right: number }; output: { value: number } } + }>({ + baseUrl: 'http://localhost', + fetch: (input, init) => cli.fetch(new Request(input, init)), + }) + + await expect( + client('sum')({ + args: { left: 1 }, + options: { right: 2 }, + }), + ).resolves.toEqual({ value: 3 }) + }) + + test('calls a real CLI RPC server and iterates streaming responses', async () => { + const cli = Cli.create('test').command('logs', { + args: z.object({ prefix: z.string() }), + options: z.object({ count: z.number() }), + output: z.object({ line: z.string() }), + async *run(c) { + yield { line: `${c.args.prefix}-1` } + yield { line: `${c.args.prefix}-${c.options.count}` } + }, + }) + const client = createClient<{ + logs: { + args: { prefix: string } + options: { count: number } + output: { line: string } + stream: true + } + }>({ + baseUrl: 'http://localhost', + fetch: (input, init) => cli.fetch(new Request(input, init)), + }) + + const stream = await client('logs')({ + args: { prefix: 'line' }, + options: { count: 2 }, + }) + const chunks = [] + for await (const chunk of stream) chunks.push(chunk) + + expect(chunks).toEqual([{ line: 'line-1' }, { line: 'line-2' }]) + }) + + test('throws failed streaming RPC records', async () => { + const client = createClient<{ + logs: { args: {}; options: {}; output: { line: string }; stream: true } + }>({ + baseUrl: 'https://api.example.com', + fetch: async () => + new Response( + JSON.stringify({ + type: 'error', + ok: false, + error: { code: 'NOPE', message: 'Nope' }, + }) + '\n', + { headers: { 'content-type': 'application/x-ndjson' }, status: 500 }, + ), + }) + + const stream = await client('logs')() + await expect(async () => { + for await (const chunk of stream) void chunk + }).rejects.toMatchObject({ + name: 'Incur.ClientError', + message: 'Nope', + error: { code: 'NOPE', message: 'Nope' }, + status: 500, + }) + }) + + test('throws invalid JSON streaming RPC records', async () => { + const client = createClient<{ + logs: { args: {}; options: {}; output: { line: string }; stream: true } + }>({ + baseUrl: 'https://api.example.com', + fetch: async () => + new Response('{bad json}\n', { + headers: { 'content-type': 'application/x-ndjson' }, + status: 502, + }), + }) + + const stream = await client('logs')() + await expect(async () => { + for await (const chunk of stream) void chunk + }).rejects.toMatchObject({ + name: 'Incur.ClientError', + message: 'Expected a JSON RPC stream record', + data: '{bad json}', + status: 502, + }) + }) + + test('parses streaming RPC records split across chunks', async () => { + const encoder = new TextEncoder() + const body = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('{"type":"chunk","data":{"line":"')) + controller.enqueue(encoder.encode('one"}}\n{"type":"chunk","data":{"line":"two"}}\n')) + controller.enqueue(encoder.encode('{"type":"done","ok":true}\n')) + controller.close() + }, + }) + const client = createClient<{ + logs: { args: {}; options: {}; output: { line: string }; stream: true } + }>({ + baseUrl: 'https://api.example.com', + fetch: async () => + new Response(body, { + headers: { 'content-type': 'application/x-ndjson' }, + }), + }) + + const stream = await client('logs')() + const chunks = [] + for await (const chunk of stream) chunks.push(chunk) + + expect(chunks).toEqual([{ line: 'one' }, { line: 'two' }]) + }) + + test('ignores blank lines in streaming RPC responses', async () => { + const client = createClient<{ + logs: { args: {}; options: {}; output: { line: string }; stream: true } + }>({ + baseUrl: 'https://api.example.com', + fetch: async () => + new Response( + [ + '', + ' ', + JSON.stringify({ type: 'chunk', data: { line: 'one' } }), + '', + JSON.stringify({ type: 'done', ok: true }), + ].join('\n') + '\n', + { headers: { 'content-type': 'application/x-ndjson' } }, + ), + }) + + const stream = await client('logs')() + const chunks = [] + for await (const chunk of stream) chunks.push(chunk) + + expect(chunks).toEqual([{ line: 'one' }]) + }) + + test('throws when streaming RPC responses have no body', async () => { + const client = createClient<{ + logs: { args: {}; options: {}; output: { line: string }; stream: true } + }>({ + baseUrl: 'https://api.example.com', + fetch: async () => + new Response(null, { + headers: { 'content-type': 'application/x-ndjson' }, + status: 204, + }), + }) + + const stream = await client('logs')() + await expect(async () => { + for await (const chunk of stream) void chunk + }).rejects.toMatchObject({ + name: 'Incur.ClientError', + message: 'Expected an RPC stream body', + status: 204, + }) + }) + + test('throws malformed streaming RPC records', async () => { + const client = createClient<{ + logs: { args: {}; options: {}; output: { line: string }; stream: true } + }>({ + baseUrl: 'https://api.example.com', + fetch: async () => + new Response(JSON.stringify({ type: 'done', ok: false }) + '\n', { + headers: { 'content-type': 'application/x-ndjson' }, + }), + }) + + const stream = await client('logs')() + await expect(async () => { + for await (const chunk of stream) void chunk + }).rejects.toMatchObject({ + name: 'Incur.ClientError', + message: 'Malformed RPC stream record', + data: { type: 'done', ok: false }, + }) + }) + + test('throws when streaming RPC responses end before done', async () => { + const client = createClient<{ + logs: { args: {}; options: {}; output: { line: string }; stream: true } + }>({ + baseUrl: 'https://api.example.com', + fetch: async () => + new Response(JSON.stringify({ type: 'chunk', data: { line: 'one' } }) + '\n', { + headers: { 'content-type': 'application/x-ndjson' }, + }), + }) + + const stream = await client('logs')() + await expect(async () => { + for await (const chunk of stream) void chunk + }).rejects.toMatchObject({ + name: 'Incur.ClientError', + message: 'RPC stream ended before done', + }) + }) + + test('cancels streaming RPC responses when consumers stop early', async () => { + let cancelled = false + const body = new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode(JSON.stringify({ type: 'chunk', data: { line: 'one' } }) + '\n'), + ) + }, + cancel() { + cancelled = true + }, + }) + const client = createClient<{ + logs: { args: {}; options: {}; output: { line: string }; stream: true } + }>({ + baseUrl: 'https://api.example.com', + fetch: async () => + new Response(body, { + headers: { 'content-type': 'application/x-ndjson' }, + }), + }) + + const stream = await client('logs')() + for await (const chunk of stream) { + void chunk + break + } + + expect(cancelled).toBe(true) + }) + + test('uses a fallback message for failed RPC envelopes without error messages', async () => { + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: async () => Response.json({ ok: false, error: 'NOPE' }, { status: 400 }), + }) + + await expect(client('project deploy')()).rejects.toMatchObject({ + name: 'Incur.ClientError', + message: 'RPC command failed', + data: { ok: false, error: 'NOPE' }, + error: 'NOPE', + status: 400, + }) + }) + + test('wraps fetch failures', async () => { + const cause = new Error('network down') + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: async () => { + throw cause + }, + }) + + await expect(client('ping')()).rejects.toMatchObject({ + name: 'Incur.ClientError', + message: 'RPC request failed', + cause, + }) + }) + + test('throws for non-json responses', async () => { + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: async () => new Response('not json', { status: 502 }), + }) + + await expect(client('ping')()).rejects.toMatchObject({ + name: 'Incur.ClientError', + message: 'Expected a JSON RPC envelope', + data: 'not json', + status: 502, + }) + }) + + test('throws for malformed RPC envelopes', async () => { + const client = createClient({ + baseUrl: 'https://api.example.com', + fetch: async () => Response.json({ data: 'missing ok' }, { status: 200 }), + }) + + await expect(client('ping')()).rejects.toMatchObject({ + name: 'Incur.ClientError', + message: 'Malformed RPC envelope', + data: { data: 'missing ok' }, + status: 200, + }) + }) + + test('requires fetch to exist', () => { + const original = globalThis.fetch + vi.stubGlobal('fetch', undefined) + try { + expect(() => createClient({ baseUrl: 'https://api.example.com' })).toThrow( + 'Incur clients require a fetch implementation', + ) + } finally { + vi.stubGlobal('fetch', original) + } + }) +}) diff --git a/src/Client.ts b/src/Client.ts new file mode 100644 index 0000000..6ccff33 --- /dev/null +++ b/src/Client.ts @@ -0,0 +1,231 @@ +import { ClientError } from './Errors.js' +import { isRecord } from './internal/helpers.js' +import type { Register } from './Register.js' + +type DefaultCommand = { + args: unknown + options: unknown + output: unknown +} + +type Commands = Register extends { commands: infer commands } + ? commands + : Record + +type Args = command extends { args: infer args } ? args : unknown +type Options = command extends { options: infer options } ? options : unknown +type Output = command extends { output: infer output } ? output : unknown + +type RequiredKeys = value extends object + ? { + [key in keyof value]-?: {} extends Pick ? never : key + }[keyof value] + : never + +type Field = value extends object + ? RequiredKeys extends never + ? { [field in key]?: value | undefined } + : { [field in key]: value } + : { [field in key]?: value | undefined } + +type Input = Field<'args', Args> & Field<'options', Options> +type Result = command extends { stream: true } + ? AsyncIterable> + : Output + +type Caller = + RequiredKeys> extends never + ? (input?: Input) => Promise> + : (input: Input) => Promise> + +type RuntimeInput = { + args?: unknown | undefined + options?: unknown | undefined +} + +type Envelope = { + data?: unknown | undefined + error?: unknown | undefined + ok: boolean +} + +/** + * Typed incur RPC client backed by the commands registered through declaration merging. + */ +export type Client = >( + command: command, +) => Caller + +/** Options for creating an incur RPC client. */ +type ClientOptions = { + /** Base URL for the incur server. */ + baseUrl: string | URL + /** Fetch implementation. Defaults to `globalThis.fetch`. */ + fetch?: typeof globalThis.fetch | undefined +} + +/** Creates a typed incur RPC client. */ +export function createClient(options: ClientOptions): Client { + const fetch = options.fetch ?? globalThis.fetch + if (!fetch) throw new ClientError('Incur clients require a fetch implementation') + + return ((command: string) => + async (input: RuntimeInput = {}) => { + let response: Response + try { + response = await fetch(endpoint(options.baseUrl), { + body: JSON.stringify({ + command, + args: input.args ?? {}, + options: input.options ?? {}, + }), + headers: { + accept: 'application/json, application/x-ndjson', + 'content-type': 'application/json', + }, + method: 'POST', + }) + } catch (error) { + throw new ClientError('RPC request failed', { cause: error }) + } + + if (isStreamingResponse(response)) return parseStreamingResponse(response) + + const envelope = await parseResponse(response) + if (envelope.ok) return envelope.data + + const message = errorMessage(envelope.error, 'RPC command failed') + throw new ClientError(message, { + data: envelope, + error: envelope.error, + status: response.status, + }) + }) as Client +} + +function endpoint(base: string | URL): URL { + const url = new URL(base) + if (!url.pathname.endsWith('/')) url.pathname += '/' + return new URL('_incur/rpc', url) +} + +function isStreamingResponse(response: Response): boolean { + return response.headers.get('content-type')?.includes('application/x-ndjson') ?? false +} + +async function parseResponse(response: Response): Promise { + const text = await response.text() + let value: unknown + try { + value = JSON.parse(text) + } catch (error) { + throw new ClientError('Expected a JSON RPC envelope', { + cause: error, + data: text, + status: response.status, + }) + } + + if (!isRecord(value) || typeof value.ok !== 'boolean') + throw new ClientError('Malformed RPC envelope', { + data: value, + status: response.status, + }) + return value as Envelope +} + +async function* parseStreamingResponse(response: Response): AsyncGenerator { + if (!response.body) + throw new ClientError('Expected an RPC stream body', { + status: response.status, + }) + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + let completed = false + let eof = false + + try { + while (true) { + const { value, done } = await reader.read() + if (done) { + eof = true + break + } + buffer += decoder.decode(value, { stream: true }) + + let newline: number + while ((newline = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newline).trim() + buffer = buffer.slice(newline + 1) + if (line) { + const result = readStreamRecord(line, response.status) + if (result.done) { + completed = true + return + } + yield result.data + } + } + } + + const remaining = buffer.trim() + if (remaining) { + const result = readStreamRecord(remaining, response.status) + if (result.done) { + completed = true + return + } + yield result.data + } + } finally { + if (!completed && !eof) await reader.cancel() + reader.releaseLock() + } + + throw new ClientError('RPC stream ended before done', { + status: response.status, + }) +} + +function readStreamRecord( + line: string, + status: number, +): { data: unknown; done?: false | undefined } | { done: true } { + let value: unknown + try { + value = JSON.parse(line) + } catch (error) { + throw new ClientError('Expected a JSON RPC stream record', { + cause: error, + data: line, + status, + }) + } + + if (!isRecord(value) || typeof value.type !== 'string') + throw new ClientError('Malformed RPC stream record', { + data: value, + status, + }) + + if (value.type === 'chunk') return { data: value.data } + if (value.type === 'done' && value.ok === true) return { done: true } + if (value.type === 'error' && value.ok === false) { + throw new ClientError(errorMessage(value.error, 'RPC stream failed'), { + data: value, + error: value.error, + status, + }) + } + + throw new ClientError('Malformed RPC stream record', { + data: value, + status, + }) +} + +function errorMessage(error: unknown, fallback: string): string { + return isRecord(error) && typeof error.message === 'string' ? error.message : fallback +} diff --git a/src/Errors.ts b/src/Errors.ts index 84b0b99..8ebeae3 100644 --- a/src/Errors.ts +++ b/src/Errors.ts @@ -87,6 +87,106 @@ export type FieldError = { message: string } +/** Metadata returned with structured RPC envelopes. */ +export type ClientRpcMeta = { + /** Command path that handled the RPC request. */ + command?: string | undefined + /** Suggested next actions returned by the command. */ + cta?: unknown | undefined + /** Server-side command duration. */ + duration?: string | undefined +} + +/** Error payload returned by structured RPC commands. */ +export type ClientRpcError = { + /** Machine-readable error code. */ + code: string + /** Human-readable error message. */ + message: string + /** Whether the operation can be retried. */ + retryable?: boolean | undefined + /** Per-field validation errors. */ + fieldErrors?: FieldError[] | undefined +} + +/** Successful structured RPC response envelope. */ +export type ClientRpcSuccessEnvelope = { + /** Command output data. */ + data?: unknown | undefined + /** Response metadata. */ + meta?: ClientRpcMeta | undefined + /** Whether the command succeeded. */ + ok: true +} + +/** Failed structured RPC response envelope. */ +export type ClientRpcErrorEnvelope = { + /** Command error payload. */ + error: ClientRpcError + /** Response metadata. */ + meta?: ClientRpcMeta | undefined + /** Whether the command succeeded. */ + ok: false +} + +/** Structured RPC response envelope. */ +export type ClientRpcEnvelope = ClientRpcSuccessEnvelope | ClientRpcErrorEnvelope + +/** Error thrown by incur RPC clients. */ +export class ClientError extends Error { + /** Error class name. */ + override name = 'Incur.ClientError' + /** Malformed response payload or failed RPC envelope. */ + data: unknown + /** Failed RPC error payload. */ + error: unknown + /** HTTP status returned by the server. */ + status: number | undefined + + constructor(message: string, options: ClientError.Options = {}) { + super(message, 'cause' in options ? { cause: options.cause } : undefined) + this.data = options.data + this.error = options.error + this.status = options.status + } +} + +export declare namespace ClientError { + /** Options for constructing a ClientError. */ + type Options = { + /** The underlying cause. */ + cause?: unknown | undefined + /** Malformed response payload or failed RPC envelope. */ + data?: unknown | undefined + /** Failed RPC error payload. */ + error?: unknown | undefined + /** HTTP status returned by the server. */ + status?: number | undefined + } +} + +/** Narrows an unknown value to a structured RPC error payload. */ +export function isClientRpcError(value: unknown): value is ClientRpcError { + return ( + isErrorRecord(value) && + typeof value.code === 'string' && + typeof value.message === 'string' && + (value.retryable === undefined || typeof value.retryable === 'boolean') && + (value.fieldErrors === undefined || + (Array.isArray(value.fieldErrors) && value.fieldErrors.every(isFieldError))) + ) +} + +/** Narrows an unknown value to a failed structured RPC envelope. */ +export function isClientRpcErrorEnvelope(value: unknown): value is ClientRpcErrorEnvelope { + return ( + isErrorRecord(value) && + value.ok === false && + isClientRpcError(value.error) && + (value.meta === undefined || isErrorRecord(value.meta)) + ) +} + /** Validation error with per-field error details. */ export class ValidationError extends BaseError { override name = 'Incur.ValidationError' @@ -146,3 +246,19 @@ function walk(error: unknown, fn?: ((error: unknown) => boolean) | undefined): u while ((current as any)?.cause) current = (current as any).cause return current } + +function isErrorRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isFieldError(value: unknown): value is FieldError { + return ( + isErrorRecord(value) && + (value.code === undefined || typeof value.code === 'string') && + (value.missing === undefined || typeof value.missing === 'boolean') && + typeof value.path === 'string' && + typeof value.expected === 'string' && + typeof value.received === 'string' && + typeof value.message === 'string' + ) +} diff --git a/src/Openapi.test.ts b/src/Openapi.test.ts index 398da04..4ef96a7 100644 --- a/src/Openapi.test.ts +++ b/src/Openapi.test.ts @@ -139,6 +139,185 @@ describe('generateCommands', () => { const limitSchema = cmd.options!.shape.limit expect(limitSchema.description).toBe('Max results') }) + + test('command has output schema from success response', async () => { + const commands = await Openapi.generateCommands(openapiSpec, openapiApp.fetch) + const cmd = commands.get('getUser')! + expect(cmd.output).toBeDefined() + expect(z.toJSONSchema(cmd.output!)).toMatchObject({ + type: 'object', + required: ['id', 'name'], + properties: { + id: { type: 'number' }, + name: { type: 'string' }, + }, + }) + }) + + test('ignores path item metadata and applies path-level parameters', async () => { + const commands = await Openapi.generateCommands( + { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/users/{id}': { + summary: 'User routes', + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'number' }, + }, + ], + get: { + operationId: 'getUser', + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { id: { type: 'number' } }, + required: ['id'], + }, + }, + }, + }, + }, + }, + }, + }, + }, + app.fetch, + ) + + const cmd = commands.get('getUser')! + expect({ + commands: [...commands.keys()], + args: z.toJSONSchema(cmd.args!), + }).toMatchInlineSnapshot(` + { + "args": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "id": { + "type": "number", + }, + }, + "required": [ + "id", + ], + "type": "object", + }, + "commands": [ + "getUser", + ], + } + `) + }) + + test('supports OpenAPI 3.2 query operations', () => { + const commands = Openapi.generateCommands( + { + openapi: '3.2.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/search': { + query: { + operationId: 'searchUsers', + responses: { 200: { description: 'OK' } }, + }, + }, + }, + }, + app.fetch, + ) + + expect(commands.has('searchUsers')).toBe(true) + }) + + test('encodes path params when building requests', async () => { + let url = '' + const commands = Openapi.generateCommands( + { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/files/{id}': { + get: { + operationId: 'getFile', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { 200: { description: 'OK' } }, + }, + }, + }, + }, + (req) => { + url = req.url + return Response.json({ ok: true }) + }, + ) + + await commands.get('getFile')!.run({ args: { id: 'a/b c' } }) + expect(url).toBe('http://localhost/files/a%2Fb%20c') + }) + + test('coerces string false to boolean false for OpenAPI params', () => { + const commands = Openapi.generateCommands( + { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/users': { + get: { + operationId: 'listUsers', + parameters: [{ name: 'active', in: 'query', schema: { type: 'boolean' } }], + responses: { 200: { description: 'OK' } }, + }, + }, + }, + }, + app.fetch, + ) + + expect(commands.get('listUsers')!.options!.parse({ active: 'false' })).toEqual({ + active: false, + }) + }) + + test('optional request bodies do not require flattened body fields', () => { + const commands = Openapi.generateCommands( + { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/users': { + post: { + operationId: 'createUser', + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + }, + }, + }, + responses: { 200: { description: 'OK' } }, + }, + }, + }, + }, + app.fetch, + ) + + expect(commands.get('createUser')!.options!.parse({})).toEqual({}) + }) }) describe('cli integration', () => { diff --git a/src/Openapi.ts b/src/Openapi.ts index b13d327..206a34e 100644 --- a/src/Openapi.ts +++ b/src/Openapi.ts @@ -12,7 +12,25 @@ import { dereference } from './internal/dereference.js' import * as Schema from './Schema.js' /** A minimal OpenAPI 3.x spec shape. Accepts both hand-written specs and generated ones (e.g. from `@hono/zod-openapi`). */ -export type OpenAPISpec = { paths?: {} | undefined } +export type OpenAPISpec = { + /** OpenAPI document metadata. */ + info?: unknown + /** OpenAPI version string. */ + openapi?: string | undefined + /** OpenAPI path table keyed by URL path. */ + paths?: Record> | undefined +} + +/** Command map generated from an OpenAPI spec mounted at `name`. */ +export type CommandMap = spec extends { + paths: infer paths +} + ? EntriesToMap< + { + [path in keyof paths & string]: OperationEntries + }[keyof paths & string] + > + : {} /** Options for generating an OpenAPI document from an incur CLI. */ export type GenerateOptions = { @@ -76,6 +94,218 @@ type RequestBody = { required?: boolean | undefined } +const openApiMethodNames = [ + 'delete', + 'get', + 'head', + 'options', + 'patch', + 'post', + 'put', + 'query', + 'trace', +] as const + +type HttpOperationMethod = (typeof openApiMethodNames)[number] + +type OperationEntries = + methods extends Record + ? { + [method in keyof methods & HttpOperationMethod]: methods[method] extends Record< + string, + unknown + > + ? { + name: `${name} ${OperationName}` + entry: { + args: ParametersToObject< + PathItemParameters | OperationParameters, + 'path' + > + options: ParametersToObject< + PathItemParameters | OperationParameters, + 'query' + > & + RequestBodyToObject> + output: OperationOutput + } + } + : never + }[keyof methods & HttpOperationMethod] + : never + +type EntriesToMap = UnionToIntersection< + entries extends { entry: unknown; name: string } + ? { [key in entries['name']]: entries['entry'] } + : unknown +> + +type UnionToIntersection = (value extends unknown ? (arg: value) => void : never) extends ( + arg: infer result, +) => void + ? result + : never + +type OperationName = operation extends { + operationId: infer id extends string +} + ? id + : `${method}_${ReplacePathChars}` + +type ReplacePathChars = value extends `${infer head}/${infer tail}` + ? `${head}_${ReplacePathChars}` + : value extends `${infer head}{${infer tail}` + ? `${head}_${ReplacePathChars}` + : value extends `${infer head}}${infer tail}` + ? `${head}_${ReplacePathChars}` + : value + +type OperationParameters = operation extends { + parameters: infer parameters extends readonly unknown[] +} + ? parameters[number] + : never + +type PathItemParameters = pathItem extends { + parameters: infer parameters extends readonly unknown[] +} + ? parameters[number] + : never + +type OperationRequestBody = operation extends { requestBody: infer body } ? body : never + +type ParametersToObject = RequiredParameterProps< + parameter, + location +> & + OptionalParameterProps + +type RequiredParameterProps = { + [item in parameter as item extends { + in: location + name: infer name extends string + required: true + } + ? name + : never]: item extends { schema: infer schema } ? JsonSchemaToType : string +} + +type OptionalParameterProps = { + [item in parameter as item extends { + in: location + name: infer name extends string + } + ? item extends { required: true } + ? never + : name + : never]?: item extends { schema: infer schema } + ? JsonSchemaToType | undefined + : string | undefined +} + +type RequestBodyToObject = [body] extends [ + { + content: { 'application/json': { schema: infer schema } } + }, +] + ? body extends { required: true } + ? SchemaObjectToType + : OptionalSchemaObjectToType + : {} + +type OperationOutput = operation extends { responses: infer responses } + ? ResponseOutput + : unknown + +type ResponseOutput = + ResponseContent> extends infer schema + ? [schema] extends [never] + ? unknown + : JsonSchemaToType + : unknown + +type PickSuccessResponse = + responses extends Record + ? '200' extends keyof responses + ? responses['200'] + : 200 extends keyof responses + ? responses[200] + : SuccessResponse + : never + +type SuccessResponse = { + [status in keyof responses]: `${status & (number | string)}` extends `2${string}` + ? responses[status] + : never +}[keyof responses] + +type ResponseContent = response extends { + content: { 'application/json': { schema: infer schema } } +} + ? schema + : never + +type SchemaObjectToType = schema extends { + properties: infer properties extends Record +} + ? RequiredSchemaProps> & + OptionalSchemaProps> + : {} + +type RequiredKeys = schema extends { required: infer required extends readonly string[] } + ? required[number] + : never + +type RequiredSchemaProps, required extends string> = { + [key in keyof properties & string as key extends required ? key : never]: JsonSchemaToType< + properties[key] + > +} + +type OptionalSchemaProps, required extends string> = { + [key in keyof properties & string as key extends required ? never : key]?: + | JsonSchemaToType + | undefined +} + +type OptionalSchemaObjectToType = schema extends { + properties: infer properties extends Record +} + ? { [key in keyof properties & string]?: JsonSchemaToType | undefined } + : {} + +type JsonSchemaToType = schema extends { const: infer value } + ? value + : schema extends { enum: infer values extends readonly unknown[] } + ? values[number] + : schema extends { anyOf: infer values extends readonly unknown[] } + ? JsonSchemaToType + : schema extends { oneOf: infer values extends readonly unknown[] } + ? JsonSchemaToType + : schema extends { type: infer type extends readonly unknown[] } + ? JsonSchemaTypeToType + : schema extends { type: infer type } + ? JsonSchemaTypeToType + : unknown + +type JsonSchemaTypeToType = type extends 'string' + ? string + : type extends 'number' | 'integer' + ? number + : type extends 'boolean' + ? boolean + : type extends 'null' + ? null + : type extends 'array' + ? schema extends { items: infer item } + ? JsonSchemaToType[] + : unknown[] + : type extends 'object' + ? SchemaObjectToType + : unknown + +const openApiMethods = new Set(openApiMethodNames) + /** A fetch handler. */ type FetchHandler = (req: Request) => Response | Promise @@ -84,6 +314,7 @@ type GeneratedCommand = { args?: z.ZodObject | undefined description?: string | undefined options?: z.ZodObject | undefined + output?: z.ZodType | undefined run: (context: any) => any } @@ -266,28 +497,34 @@ function encodePathSegment(segment: string) { } /** Generates incur command entries from an OpenAPI spec. Resolves all `$ref` pointers. */ -export async function generateCommands( +export function generateCommands( spec: OpenAPISpec, fetch: FetchHandler, options: { basePath?: string | undefined } = {}, -): Promise> { +): Map { const resolved = dereference(structuredClone(spec)) as OpenAPISpec const commands = new Map() const paths = (resolved.paths ?? {}) as Record> for (const [path, methods] of Object.entries(paths)) { + const pathItem = methods as { parameters?: readonly Parameter[] | undefined } + const pathParameters = pathItem.parameters ?? [] for (const [method, operation] of Object.entries(methods)) { - if (method.startsWith('x-')) continue + if (!openApiMethods.has(method)) continue const op = operation as Operation const name = op.operationId ?? `${method}_${path.replace(/[/{}]/g, '_')}` const httpMethod = method.toUpperCase() - const pathParams = (op.parameters ?? []).filter((p) => p.in === 'path') - const queryParams = (op.parameters ?? []).filter((p) => p.in === 'query') + const parameters = [...pathParameters, ...(op.parameters ?? [])] + const pathParams = parameters.filter((p) => p.in === 'path') + const queryParams = parameters.filter((p) => p.in === 'query') const bodySchema = op.requestBody?.content?.['application/json']?.schema const bodyProps = (bodySchema?.properties ?? {}) as Record> - const bodyRequired = new Set((bodySchema?.required as string[]) ?? []) + const bodyRequired = new Set( + op.requestBody?.required ? ((bodySchema?.required as string[]) ?? []) : [], + ) + const outputSchema = successResponseSchema(op.responses) // Build args Zod schema from path params let argsSchema: z.ZodObject | undefined @@ -321,6 +558,7 @@ export async function generateCommands( description: op.summary ?? op.description, args: argsSchema, options: optionsSchema, + ...(outputSchema ? { output: toZod(outputSchema) } : undefined), run: createHandler({ basePath: options.basePath, fetch, @@ -337,6 +575,18 @@ export async function generateCommands( return commands } +function successResponseSchema(responses: Record | undefined) { + if (!responses) return undefined + const entries = Object.entries(responses) + const match = + entries.find(([status]) => status === '200') ?? + entries.find(([status]) => status.startsWith('2')) + const response = match?.[1] as + | { content?: Record | undefined }> | undefined } + | undefined + return response?.content?.['application/json']?.schema +} + function createHandler(config: { basePath?: string | undefined bodyProps: Record> @@ -353,7 +603,8 @@ function createHandler(config: { let urlPath = (config.basePath ?? '') + config.path for (const p of config.pathParams) { const value = args[p.name] - if (value !== undefined) urlPath = urlPath.replace(`{${p.name}}`, String(value)) + if (value !== undefined) + urlPath = urlPath.replace(`{${p.name}}`, encodeURIComponent(String(value))) } // Build query string from query params @@ -417,14 +668,14 @@ function coerceIfNeeded(schema: z.ZodType): z.ZodType { return isOptional ? z.coerce.number().optional() : z.coerce.number() // Direct boolean if (inner instanceof z.ZodBoolean) - return isOptional ? z.coerce.boolean().optional() : z.coerce.boolean() + return isOptional ? booleanParam(inner).optional() : booleanParam(inner) // Union containing number or boolean (e.g. type: ["number", "null"] from OpenAPI 3.1) if (inner instanceof z.ZodUnion) { const options = (inner as any)._zod?.def?.options as z.ZodType[] | undefined if (options?.some((o: z.ZodType) => o instanceof z.ZodNumber)) return isOptional ? z.coerce.number().optional() : z.coerce.number() if (options?.some((o: z.ZodType) => o instanceof z.ZodBoolean)) - return isOptional ? z.coerce.boolean().optional() : z.coerce.boolean() + return isOptional ? booleanParam(inner).optional() : booleanParam(inner) } // No coercion needed return undefined @@ -434,3 +685,12 @@ function coerceIfNeeded(schema: z.ZodType): z.ZodType { const desc = (schema as any).description ?? (inner as any).description return desc ? coerced.describe(desc) : coerced } + +function booleanParam(schema: z.ZodType) { + return z.preprocess((value) => { + if (typeof value !== 'string') return value + if (value === 'true') return true + if (value === 'false') return false + return value + }, schema) +} diff --git a/src/Parser.ts b/src/Parser.ts index ea21a75..3a601ad 100644 --- a/src/Parser.ts +++ b/src/Parser.ts @@ -257,7 +257,7 @@ function setOption( } /** Wraps zod schema.parse(), converting ZodError to ValidationError. */ -function zodParse(schema: z.ZodObject, data: Record) { +export function zodParse(schema: z.ZodObject, data: Record) { try { return schema.parse(data) } catch (err: any) { diff --git a/src/Register.ts b/src/Register.ts index 2ab451d..6a78796 100644 --- a/src/Register.ts +++ b/src/Register.ts @@ -7,7 +7,7 @@ * declare module 'incur' { * interface Register { * commands: { - * get: { args: { id: number }; options: {} } + * get: { args: { id: number }; options: {}; output: { name: string } } * list: { args: {}; options: { limit: number } } * } * } diff --git a/src/Typegen.test.ts b/src/Typegen.test.ts index e6402c0..86dadf3 100644 --- a/src/Typegen.test.ts +++ b/src/Typegen.test.ts @@ -1,4 +1,7 @@ import { Cli, Typegen, z } from 'incur' +import fs from 'node:fs/promises' + +import { app, spec } from '../test/fixtures/hono-openapi-app.js' describe('fromCli', () => { test('simple commands with args and options', () => { @@ -13,12 +16,17 @@ describe('fromCli', () => { }) expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` - "declare module 'incur' { + "/** Command map generated from your incur CLI. */ + export type Commands = { + /** Generated command "get". */ + "get": { args: { id: number }; options: {} } + /** Generated command "list". */ + "list": { args: {}; options: { limit: number } } + } + + declare module 'incur' { interface Register { - commands: { - 'get': { args: { id: number }; options: {} } - 'list': { args: {}; options: { limit: number } } - } + commands: Commands } } " @@ -29,11 +37,15 @@ describe('fromCli', () => { const cli = Cli.create('test').command('ping', { run: () => ({}) }) expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` - "declare module 'incur' { + "/** Command map generated from your incur CLI. */ + export type Commands = { + /** Generated command "ping". */ + "ping": { args: {}; options: {} } + } + + declare module 'incur' { interface Register { - commands: { - 'ping': { args: {}; options: {} } - } + commands: Commands } } " @@ -54,12 +66,17 @@ describe('fromCli', () => { cli.command(pr) expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` - "declare module 'incur' { + "/** Command map generated from your incur CLI. */ + export type Commands = { + /** Generated command "pr create". */ + "pr create": { args: { title: string }; options: {} } + /** Generated command "pr list". */ + "pr list": { args: {}; options: { state: string } } + } + + declare module 'incur' { interface Register { - commands: { - 'pr create': { args: { title: string }; options: {} } - 'pr list': { args: {}; options: { state: string } } - } + commands: Commands } } " @@ -77,11 +94,15 @@ describe('fromCli', () => { cli.command(pr) expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` - "declare module 'incur' { + "/** Command map generated from your incur CLI. */ + export type Commands = { + /** Generated command "pr review approve". */ + "pr review approve": { args: { id: number }; options: {} } + } + + declare module 'incur' { interface Register { - commands: { - 'pr review approve': { args: { id: number }; options: {} } - } + commands: Commands } } " @@ -125,7 +146,7 @@ describe('fromCli', () => { .command('middle', { run: () => ({}) }) const output = Typegen.fromCli(cli) - const commandOrder = [...output.matchAll(/^ {6}'(\w+)':/gm)].map((m) => m[1]) + const commandOrder = [...output.matchAll(/^ "(\w+)":/gm)].map((m) => m[1]) expect(commandOrder).toEqual(['alpha', 'middle', 'zebra']) }) @@ -169,21 +190,225 @@ describe('fromCli', () => { expect(output).toContain('config: { host: string; port: number }') }) - test('optional properties use optional modifier', () => { + test('optional properties include undefined for exactOptionalPropertyTypes', () => { const cli = Cli.create('test').command('create', { args: z.object({ name: z.string() }), options: z.object({ verbose: z.boolean().optional(), + nullable: z.string().nullable().optional(), output: z.string(), }), run: () => ({}), }) const output = Typegen.fromCli(cli) - expect(output).toContain('verbose?: boolean') + expect(output).toContain('verbose?: boolean | undefined') + expect(output).toContain('nullable?: string | null | undefined') expect(output).toContain('output: string') }) + test('dense object schema', () => { + const cli = Cli.create('test').command('build', { + options: z.object({ + name: z.string(), + count: z.number(), + active: z.boolean(), + mode: z.literal('strict'), + state: z.enum(['open', 'closed']), + target: z.union([z.string(), z.number()]), + values: z.array(z.union([z.literal('a'), z.literal(1), z.boolean()])), + nested: z.object({ + label: z.string().optional(), + flags: z.array(z.enum(['on', 'off'])), + }), + }), + run: () => ({}), + }) + + expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` + "/** Command map generated from your incur CLI. */ + export type Commands = { + /** Generated command "build". */ + "build": { args: {}; options: { name: string; count: number; active: boolean; mode: "strict"; state: "open" | "closed"; target: string | number; values: ("a" | 1 | boolean)[]; nested: { label?: string | undefined; flags: ("on" | "off")[] } } } + } + + declare module 'incur' { + interface Register { + commands: Commands + } + } + " + `) + }) + + test('command output schema', () => { + const cli = Cli.create('test').command('cmd', { + output: z.object({ ok: z.boolean() }), + run: () => ({ ok: true }), + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain('"cmd": { args: {}; options: {}; output: { ok: boolean } }') + }) + + test('output schemas for non-object top-level types', () => { + const cli = Cli.create('test') + .command('text', { + output: z.string(), + run: () => 'ok', + }) + .command('values', { + output: z.array(z.union([z.string(), z.number()])), + run: () => ['ok', 1], + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain('"text": { args: {}; options: {}; output: string }') + expect(output).toContain('"values": { args: {}; options: {}; output: (string | number)[] }') + }) + + test('output schemas for records and tuples', () => { + const cli = Cli.create('test') + .command('record', { + output: z.record(z.string(), z.number()), + run: () => ({ count: 1 }), + }) + .command('enum-record', { + output: z.record(z.enum(['left', 'right']), z.number()), + run: () => ({ left: 1, right: 2 }), + }) + .command('tuple', { + output: z.tuple([z.string(), z.number(), z.boolean()]), + run: () => ['ok', 1, true] as [string, number, boolean], + }) + .command('rest-tuple', { + output: z.tuple([z.string()]).rest(z.number()), + run: () => ['ok', 1, 2] as [string, ...number[]], + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain('"record": { args: {}; options: {}; output: Record }') + expect(output).toContain( + '"enum-record": { args: {}; options: {}; output: Record<"left" | "right", number> }', + ) + expect(output).toContain( + '"rest-tuple": { args: {}; options: {}; output: [string, ...number[]] }', + ) + expect(output).toContain( + '"tuple": { args: {}; options: {}; output: [string, number, boolean] }', + ) + }) + + test('unknown output schemas fall back to unknown', () => { + const cli = Cli.create('test').command('cmd', { + output: z.any(), + run: () => ({}), + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain('"cmd": { args: {}; options: {}; output: unknown }') + }) + + test('object keys that are not identifiers are quoted', () => { + const cli = Cli.create('test').command('cmd', { + options: z.object({ + '1x': z.number(), + 'foo-bar': z.string(), + }), + run: () => ({}), + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain('"1x": number') + expect(output).toContain('"foo-bar": string') + }) + + test('command keys are escaped', () => { + const cli = Cli.create('test').command('quote\'s "cmd" \\ slash */ end', { + run: () => ({}), + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain('Generated command "quote\'s \\"cmd\\" \\\\ slash *\\/ end"') + expect(output).toContain('"quote\'s \\"cmd\\" \\\\ slash */ end": { args: {}; options: {} }') + }) + + test('catchall output widens the index signature for known properties', () => { + const cli = Cli.create('test').command('cmd', { + output: z.object({ name: z.string() }).catchall(z.number()), + run: () => ({ name: 'test' }) as never, + }) + + expect(Typegen.fromCli(cli)).toContain( + '"cmd": { args: {}; options: {}; output: { name: string; [key: string]: number | string } }', + ) + }) + + test('unsupported schemas throw a clear typegen error', () => { + const cli = Cli.create('test').command('cmd', { + output: z.string().transform((value) => value.length), + run: () => 1, + }) + + expect(() => Typegen.fromCli(cli)).toThrow( + 'Cannot generate TypeScript type for schema unsupported by JSON Schema', + ) + }) + + test('streaming command', () => { + const cli = Cli.create('test').command('logs', { + output: z.object({ line: z.string() }), + async *run() { + yield { line: 'one' } + }, + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain( + '"logs": { args: {}; options: {}; output: { line: string }; stream: true }', + ) + }) + + test('skips commands that cannot be called by RPC client', () => { + const cli = Cli.create('test') + .command('deploy', { + aliases: ['ship'], + run: () => ({}), + }) + .command('api', { fetch: () => new Response('ok') }) + + expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` + "/** Command map generated from your incur CLI. */ + export type Commands = { + /** Generated command "deploy". */ + "deploy": { args: {}; options: {} } + } + + declare module 'incur' { + interface Register { + commands: Commands + } + } + " + `) + }) + + test('includes OpenAPI mounted operations without serving first', () => { + const cli = Cli.create('test').command('api', { + fetch: app.fetch, + openapi: spec, + }) + + const output = Typegen.fromCli(cli) + expect(output).toContain( + '"api getUser": { args: { id: number }; options: {}; output: { id: number; name: string; [key: string]: unknown } }', + ) + expect(output).toContain( + '"api createUser": { args: {}; options: { name: string }; output: { created: boolean; name: string; [key: string]: unknown } }', + ) + expect(output).not.toContain('"api": { args') + }) + test('mixed top-level and grouped commands', () => { const cli = Cli.create('test') cli.command('ping', { run: () => ({}) }) @@ -191,15 +416,171 @@ describe('fromCli', () => { cli.command(pr) expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` - "declare module 'incur' { + "/** Command map generated from your incur CLI. */ + export type Commands = { + /** Generated command "ping". */ + "ping": { args: {}; options: {} } + /** Generated command "pr list". */ + "pr list": { args: {}; options: {} } + } + + declare module 'incur' { interface Register { - commands: { - 'ping': { args: {}; options: {} } - 'pr list': { args: {}; options: {} } - } + commands: Commands } } " `) }) + + test('generated client type fixture matches fromCli output', async () => { + const output = await fs.readFile(new URL('./Client.test-d.ts', import.meta.url), 'utf8') + const fixture = extractGeneratedClientFixture(output) + expect(normalizeDeclaration(Typegen.fromCli(createClientRoundTripCli()))).toBe( + normalizeDeclaration(fixture), + ) + }) }) + +function extractGeneratedClientFixture(value: string): string { + const start = '// BEGIN generated client round-trip fixture' + const end = '// END generated client round-trip fixture' + return value.slice(value.indexOf(start) + start.length, value.indexOf(end)).trimStart() +} + +function normalizeDeclaration(value: string): string { + let output = '' + let quote = '' + let escaping = false + + for (const char of value) { + if (quote) { + if (escaping) { + output += char + escaping = false + continue + } + if (char === '\\') { + output += char + escaping = true + continue + } + if (char === quote) { + output += '"' + quote = '' + continue + } + output += char + continue + } + + if (char === "'" || char === '"') { + output += '"' + quote = char + continue + } + if (char === ';' || /\s/.test(char)) continue + output += char + } + + return output.replace(/"([A-Za-z_$][\w$]*)":/g, '$1:') +} + +function createClientRoundTripCli() { + const spec = { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/users/{id}': { + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'number' }, + }, + ], + get: { + operationId: 'getUser', + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { id: { type: 'number' }, name: { type: 'string' } }, + required: ['id', 'name'], + }, + }, + }, + }, + }, + }, + }, + }, + } as const + const status = Cli.create('status', { + output: z.object({ ok: z.boolean() }), + run: () => ({ ok: true }), + }) + const project = Cli.create('project') + .command('deploy', { + aliases: ['ship'], + args: z.object({ id: z.string() }), + options: z.object({ dryRun: z.boolean() }), + output: z.object({ + deployId: z.string(), + status: z.enum(['queued', 'done']), + }), + run: () => ({ deployId: 'dep_123', status: 'queued' as const }), + }) + .command('inspect', { + args: z.object({ + id: z.string(), + includeLogs: z.boolean().optional(), + }), + output: z.object({ + id: z.string(), + logs: z.array(z.string()).optional(), + }), + run: (c) => ({ id: c.args.id }), + }) + .command('list', { + options: z.object({ + cursor: z.string().optional(), + limit: z.number().optional(), + }), + output: z.object({ + items: z.array(z.string()), + nextCursor: z.string().optional(), + }), + run: () => ({ items: [] }), + }) + const users = Cli.create('users').command('get', { + args: z.object({ id: z.number() }), + options: z.object({ verbose: z.boolean().optional() }), + output: z.object({ id: z.number() }), + run: (c) => ({ id: c.args.id }), + }) + + return Cli.create('test') + .command(status) + .command(project) + .command(Cli.create('admin').command(users)) + .command('auth', { + options: z.object({ token: z.string() }), + output: z.void(), + run: () => undefined, + }) + .command('logs', { + output: z.object({ line: z.string() }), + async *run() { + yield { line: 'one' } + }, + }) + .command('api', { + fetch: () => new Response(), + openapi: spec, + }) +} diff --git a/src/Typegen.ts b/src/Typegen.ts index 2bed6a8..0113e2a 100644 --- a/src/Typegen.ts +++ b/src/Typegen.ts @@ -10,50 +10,95 @@ export async function generate(input: string, output: string): Promise { await fs.writeFile(output, fromCli(cli)) } -/** Generates a `.d.ts` declaration string for the `incur` module augmentation. */ +/** Generates a `.ts` declaration string exporting an incur command map type and Register augmentation. */ export function fromCli(cli: Cli.Cli): string { const commands = Cli.toCommands.get(cli) if (!commands) throw new Error('No commands registered on this CLI instance') const entries = collectEntries(commands, []) - const lines: string[] = ["declare module 'incur' {", ' interface Register {', ' commands: {'] + const lines: string[] = [ + '/** Command map generated from your incur CLI. */', + 'export type Commands = {', + ] - for (const { name, args, options } of entries) + for (const { name, args, options, output, stream } of entries) { + const outputType = output ? `; output: ${schemaToType(output, 'unknown')}` : '' + const streamType = stream ? '; stream: true' : '' lines.push( - ` '${name}': { args: ${schemaToType(args)}; options: ${schemaToType(options)} }`, + ` /** Generated command ${commentText(JSON.stringify(name))}. */`, + ` ${JSON.stringify(name)}: { args: ${schemaToType(args)}; options: ${schemaToType(options)}${outputType}${streamType} }`, ) + } - lines.push(' }', ' }', '}', '') + lines.push( + '}', + '', + "declare module 'incur' {", + ' interface Register {', + ' commands: Commands', + ' }', + '}', + '', + ) return lines.join('\n') } /** Recursively collects leaf commands with their full paths and schemas. */ -function collectEntries( - commands: Map, - prefix: string[], -): { name: string; args?: z.ZodObject; options?: z.ZodObject }[] { +function collectEntries(commands: Map, prefix: string[]): Entry[] { const result: ReturnType = [] for (const [name, entry] of commands) { + if ('_alias' in entry || '_fetch' in entry) continue const path = [...prefix, name] if ('_group' in entry && entry._group) result.push(...collectEntries(entry.commands, path)) - else result.push({ name: path.join(' '), args: entry.args, options: entry.options }) + else + result.push({ + name: path.join(' '), + args: entry.args, + options: entry.options, + output: entry.output, + stream: entry._stream, + }) } return result.sort((a, b) => a.name.localeCompare(b.name)) } -/** Converts a Zod object schema to a TypeScript type string. Returns `{}` for undefined schemas. */ -function schemaToType(schema: z.ZodObject | undefined): string { - if (!schema) return '{}' - const json = z.toJSONSchema(schema) as Record +type Entry = { + args?: z.ZodObject | undefined + name: string + options?: z.ZodObject | undefined + output?: z.ZodType | undefined + stream?: true | undefined +} + +/** Converts a Zod schema to a TypeScript type string. Returns `fallback` for undefined schemas. */ +function schemaToType(schema: z.ZodType | undefined, fallback = '{}'): string { + if (!schema) return fallback + + const kind = (schema as any)._def?.type + if (kind === 'void') return 'void' + if (kind === 'undefined') return 'undefined' + + let json: Record + try { + json = z.toJSONSchema(schema) as Record + } catch (error) { + throw new TypegenError( + 'Cannot generate TypeScript type for schema unsupported by JSON Schema', + { + cause: error, + }, + ) + } + const defs = (json.$defs ?? {}) as Record> - const properties = json.properties as Record> | undefined - if (!properties || Object.keys(properties).length === 0) return '{}' - const required = new Set((json.required as string[] | undefined) ?? []) - const entries = Object.entries(properties).map( - ([key, value]) => `${key}${required.has(key) ? '' : '?'}: ${resolveType(value, defs)}`, - ) - return `{ ${entries.join('; ')} }` + return resolveType(json, defs) +} + +/** Error thrown when type generation cannot represent a schema. */ +class TypegenError extends Error { + /** Error class name. */ + override name = 'Incur.TypegenError' } /** Recursively resolves a JSON Schema node to a TypeScript type string. */ @@ -90,20 +135,76 @@ function resolveType( case 'null': return 'null' case 'array': { + const prefixItems = schema.prefixItems as Record[] | undefined + if (prefixItems) { + const items = schema.items as Record | undefined + const entries = prefixItems.map((item) => resolveType(item, defs)) + if (items) entries.push(`...${resolveType(items, defs)}[]`) + return `[${entries.join(', ')}]` + } + const items = schema.items as Record | undefined const itemType = items ? resolveType(items, defs) : 'unknown' return itemType.includes(' | ') ? `(${itemType})[]` : `${itemType}[]` } case 'object': { const properties = schema.properties as Record> | undefined - if (!properties || Object.keys(properties).length === 0) return '{}' + const additional = schema.additionalProperties + if (!properties || Object.keys(properties).length === 0) { + if (isSchema(additional)) + return `Record<${propertyNamesType(schema.propertyNames, defs)}, ${resolveType( + additional, + defs, + )}>` + return '{}' + } + const required = new Set((schema.required as string[] | undefined) ?? []) - const entries = Object.entries(properties).map( - ([key, value]) => `${key}${required.has(key) ? '' : '?'}: ${resolveType(value, defs)}`, + const entries = Object.entries(properties).map(([key, value]) => ({ + key, + type: propertyType(resolveType(value, defs), required.has(key)), + })) + const props = entries.map( + ({ key, type }) => `${propertyKey(key)}${required.has(key) ? '' : '?'}: ${type}`, ) - return `{ ${entries.join('; ')} }` + if (isSchema(additional)) + props.push( + `[key: string]: ${unionType([ + resolveType(additional, defs), + ...entries.map((entry) => entry.type), + ])}`, + ) + return `{ ${props.join('; ')} }` } default: + if ('not' in schema) return 'never' return 'unknown' } } + +function isSchema(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function commentText(value: string): string { + return value.replaceAll('*/', '*\\/') +} + +function propertyKey(key: string): string { + return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key) +} + +function propertyNamesType(value: unknown, defs: Record>): string { + if (isSchema(value)) return resolveType(value, defs) + return 'string' +} + +function propertyType(type: string, required: boolean): string { + if (required || type.split(' | ').includes('undefined')) return type + return `${type} | undefined` +} + +function unionType(values: string[]): string { + const parts = [...new Set(values.flatMap((value) => value.split(' | ')))] + return parts.includes('unknown') ? 'unknown' : parts.join(' | ') +} diff --git a/src/e2e.test.ts b/src/e2e.test.ts index f44516a..ee65415 100644 --- a/src/e2e.test.ts +++ b/src/e2e.test.ts @@ -1598,36 +1598,61 @@ describe('--llms', () => { }) describe('typegen', () => { - test('generates correct .d.ts for entire CLI', () => { + test('generates correct command map type for entire CLI', () => { expect(Typegen.fromCli(createApp())).toMatchInlineSnapshot(` - "declare module 'incur' { + "/** Command map generated from your incur CLI. */ + export type Commands = { + /** Generated command "auth login". */ + "auth login": { args: {}; options: { hostname: string; web: boolean; scopes: string[] } } + /** Generated command "auth logout". */ + "auth logout": { args: {}; options: {} } + /** Generated command "auth status". */ + "auth status": { args: {}; options: {}; output: { loggedIn: boolean; hostname: string; user: string } } + /** Generated command "config". */ + "config": { args: { key?: string | undefined }; options: {} } + /** Generated command "echo". */ + "echo": { args: { message: string; repeat?: number | undefined }; options: { upper: boolean; prefix: string } } + /** Generated command "explode". */ + "explode": { args: {}; options: {} } + /** Generated command "explode-clac". */ + "explode-clac": { args: {}; options: {} } + /** Generated command "noop". */ + "noop": { args: {}; options: {} } + /** Generated command "ping". */ + "ping": { args: {}; options: {} } + /** Generated command "project create". */ + "project create": { args: { name: string }; options: { description: string; private: boolean }; output: { id: string; url: string } } + /** Generated command "project delete". */ + "project delete": { args: { id: string }; options: { force: boolean } } + /** Generated command "project deploy create". */ + "project deploy create": { args: { env: string }; options: { branch: string; dryRun: boolean }; output: { deployId: string; url: string; status: string } } + /** Generated command "project deploy rollback". */ + "project deploy rollback": { args: { deployId: string }; options: {} } + /** Generated command "project deploy status". */ + "project deploy status": { args: { deployId: string }; options: {}; output: { deployId: string; status: string; progress: number } } + /** Generated command "project get". */ + "project get": { args: { id: string }; options: {}; output: { id: string; name: string; description: string; members: { userId: string; role: string }[] } } + /** Generated command "project list". */ + "project list": { args: {}; options: { limit: number; sort: "name" | "created" | "updated"; archived: boolean }; output: { items: { id: string; name: string; archived: boolean }[]; total: number } } + /** Generated command "slow". */ + "slow": { args: {}; options: {} } + /** Generated command "stream". */ + "stream": { args: {}; options: {}; stream: true } + /** Generated command "stream-error". */ + "stream-error": { args: {}; options: {}; stream: true } + /** Generated command "stream-ok". */ + "stream-ok": { args: {}; options: {}; stream: true } + /** Generated command "stream-text". */ + "stream-text": { args: {}; options: {}; stream: true } + /** Generated command "stream-throw". */ + "stream-throw": { args: {}; options: {}; stream: true } + /** Generated command "validate-fail". */ + "validate-fail": { args: { email: string; age: number }; options: {} } + } + + declare module 'incur' { interface Register { - commands: { - 'api': { args: {}; options: {} } - 'auth login': { args: {}; options: { hostname: string; web: boolean; scopes: string[] } } - 'auth logout': { args: {}; options: {} } - 'auth status': { args: {}; options: {} } - 'config': { args: { key?: string }; options: {} } - 'echo': { args: { message: string; repeat?: number }; options: { upper: boolean; prefix: string } } - 'explode': { args: {}; options: {} } - 'explode-clac': { args: {}; options: {} } - 'noop': { args: {}; options: {} } - 'ping': { args: {}; options: {} } - 'project create': { args: { name: string }; options: { description: string; private: boolean } } - 'project delete': { args: { id: string }; options: { force: boolean } } - 'project deploy create': { args: { env: string }; options: { branch: string; dryRun: boolean } } - 'project deploy rollback': { args: { deployId: string }; options: {} } - 'project deploy status': { args: { deployId: string }; options: {} } - 'project get': { args: { id: string }; options: {} } - 'project list': { args: {}; options: { limit: number; sort: "name" | "created" | "updated"; archived: boolean } } - 'slow': { args: {}; options: {} } - 'stream': { args: {}; options: {} } - 'stream-error': { args: {}; options: {} } - 'stream-ok': { args: {}; options: {} } - 'stream-text': { args: {}; options: {} } - 'stream-throw': { args: {}; options: {} } - 'validate-fail': { args: { email: string; age: number }; options: {} } - } + commands: Commands } } " diff --git a/src/index.ts b/src/index.ts index c622838..34251a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,13 @@ export { z } from 'zod' +export { createClient } from './Client.js' +export { ClientError, isClientRpcError, isClientRpcErrorEnvelope } from './Errors.js' +export type { + ClientRpcEnvelope, + ClientRpcError, + ClientRpcErrorEnvelope, + ClientRpcMeta, + ClientRpcSuccessEnvelope, +} from './Errors.js' export * as Cli from './Cli.js' export * as Completions from './Completions.js' export { default as middleware } from './middleware.js' diff --git a/src/internal/command.ts b/src/internal/command.ts index 3dc0c6f..5ffad77 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -81,12 +81,16 @@ export async function execute(command: any, options: execute.Options): Promise | undefined /** Raw parsed options (from query params, JSON body, or MCP params). For CLI, pass `{}`. */ inputOptions: Record /** Middleware handlers (root + group + command, already collected). */ @@ -296,8 +302,9 @@ export declare namespace execute { * - `'argv'` (default): parse both args and options from argv tokens (CLI mode) * - `'split'`: args from argv, options from inputOptions (HTTP mode) * - `'flat'`: all params from inputOptions, split by schema shapes (MCP mode) + * - `'structured'`: args from inputArgs, options from inputOptions (RPC mode) */ - parseMode?: 'argv' | 'split' | 'flat' | undefined + parseMode?: 'argv' | 'split' | 'flat' | 'structured' | undefined /** The resolved command path. */ path: string /** Vars schema for middleware variables. */