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..44350b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,8 +24,11 @@ - **`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. +- **CLI-value stream inference is structural** — TypeScript cannot reliably distinguish an actual `async *run` handler from `run() { return stream() }` after contextual typing. `Cli` value command maps treat handlers returning `AsyncGenerator` as streaming; runtime/typegen still detect actual async generator functions. - **Flow schemas through generics** — when a factory function accepts Zod schemas, use generics to flow `z.output<>` through to callbacks (`run`, `next`), return types, and constraint types (`alias`). Never fall back to `any` in callback signatures. - **Type tests in `.test-d.ts`** — use vitest's `expectTypeOf` in colocated `.test-d.ts` files to assert generic inference works. Type tests are first-class — write them alongside implementation, not as an afterthought. +- **Avoid global declaration merging in type tests** — module augmentation in `.test-d.ts` affects the full `tsc -b` project, so prefer explicit generics/local helper types unless the test is specifically about global registration behavior. - **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..1416d21 100644 --- a/src/Cli.test-d.ts +++ b/src/Cli.test-d.ts @@ -163,8 +163,256 @@ test('command() accumulates command types through chaining', () => { expectTypeOf().toEqualTypeOf<{ args: { id: number } options: { verbose: boolean } + output: unknown + }>() + expectTypeOf().toEqualTypeOf<{ + args: {} + options: { limit: number } + output: unknown + }>() +}) + +test('command() accumulates output and structural stream metadata', () => { + const cli = Cli.create('test') + .command('inspect', { + output: z.object({ id: z.string(), ok: z.boolean() }), + run: () => ({ id: 'p1', ok: true }), + }) + .command('logs', { + output: z.object({ line: z.string() }), + run: async function* () { + yield { line: 'one' } + }, + }) + + type Commands = typeof cli extends Cli.Cli ? C : never + expectTypeOf().toEqualTypeOf<{ + args: {} + options: {} + output: { id: string; ok: boolean } + }>() + expectTypeOf().toMatchTypeOf<{ + args: {} + options: {} + output: { line: string } + stream: true + }>() +}) + +test('run() returning a generator keeps output metadata', () => { + async function* logs() { + yield { line: 'one' } + } + const cli = Cli.create('test').command('logs', { + output: z.object({ line: z.string() }), + run() { + return logs() + }, + }) + + type Commands = typeof cli extends Cli.Cli ? C : never + expectTypeOf().toMatchTypeOf<{ + args: {} + options: {} + output: { line: string } + stream: true + }>() +}) + +test('root CLIs preserve output and structural stream metadata when mounted', () => { + const status = Cli.create('status', { + output: z.object({ ok: z.boolean() }), + run: () => ({ ok: true }), + }) + const logs = Cli.create('logs', { + output: z.object({ line: z.string() }), + run: async function* () { + yield { line: 'one' } + }, + }) + const cli = Cli.create('test').command(status).command(logs) + + type Commands = typeof cli extends Cli.Cli ? C : never + expectTypeOf().toEqualTypeOf<{ + args: {} + options: {} + output: { ok: boolean } + }>() + expectTypeOf().toMatchTypeOf<{ + args: {} + options: {} + output: { line: string } + stream: true + }>() +}) + +test('mounted sub-CLIs preserve output and structural stream metadata', () => { + const project = Cli.create('project') + .command('inspect', { + output: z.object({ id: z.string() }), + run: () => ({ id: 'p1' }), + }) + .command('logs', { + output: z.object({ line: z.string() }), + run: async function* () { + yield { line: 'one' } + }, + }) + const cli = Cli.create('test').command(project) + + type Commands = typeof cli extends Cli.Cli ? C : never + expectTypeOf().toEqualTypeOf<{ + args: {} + options: {} + output: { id: string } + }>() + expectTypeOf().toMatchTypeOf<{ + args: {} + options: {} + output: { line: string } + stream: true + }>() +}) + +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' } }>() - expectTypeOf().toEqualTypeOf<{ args: {}; options: { limit: number } }>() }) test('middleware() infers vars types', () => { diff --git a/src/Cli.test.ts b/src/Cli.test.ts index bd55fc6..2eb17f2 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,301 @@ 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 executes root command', async () => { + const cli = Cli.create('test', { + args: z.object({ name: z.string() }), + run: (c) => ({ message: `hello ${c.args.name}` }), + }) + const req = new Request('http://localhost/_incur/rpc', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ command: 'test', args: { name: 'Ada' } }), + }) + + expect(await fetchJson(cli, req)).toMatchInlineSnapshot(` + { + "body": { + "data": { + "message": "hello Ada", + }, + "meta": { + "command": "test", + "duration": "", + }, + "ok": true, + }, + "status": 200, + } + `) + }) + + 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 cancels command stream when response body is cancelled', async () => { + let closed = false + let resume: (() => void) | undefined + const gate = new Promise((resolve) => { + resume = resolve + }) + const cli = Cli.create('test').command('stream', { + async *run() { + try { + yield { progress: 1 } + await gate + yield { progress: 2 } + } finally { + closed = true + } + }, + }) + const res = await cli.fetch( + new Request('http://localhost/_incur/rpc', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ command: 'stream', args: {}, options: {} }), + }), + ) + const reader = res.body!.getReader() + + await reader.read() + const cancelled = reader.cancel() + resume!() + await cancelled + + expect(closed).toBe(true) + }) + + test('POST /_incur/rpc stream preserves returned ok CTA', async () => { + const cli = Cli.create('test') + cli.command('stream', { + 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 +4699,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..960efaa 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -23,6 +23,7 @@ import { import * as Command from './internal/command.js' import { isRecord, suggest, toKebab } from './internal/helpers.js' import { detectRunner } from './internal/pm.js' +import * as Rpc from './internal/rpc.js' import type { OneOf } from './internal/types.js' import * as Mcp from './Mcp.js' import type { Context as MiddlewareContext, Handler as MiddlewareHandler } from './middleware.js' @@ -40,73 +41,99 @@ export type Cli< commands extends CommandsMap = {}, vars extends z.ZodObject | undefined = undefined, env extends z.ZodObject | undefined = undefined, + name extends string = string, > = { /** Registers a root command or mounts a sub-CLI as a command group. */ command: { /** Registers a command. Returns the CLI instance for chaining. */ < - const name extends string, + const commandName extends string, const args extends z.ZodObject | undefined = undefined, const cmdEnv extends z.ZodObject | undefined = undefined, const options extends z.ZodObject | undefined = undefined, const output extends z.ZodType | undefined = undefined, + const run extends CommandRun = CommandRun< + args, + cmdEnv, + options, + output, + vars, + env + >, >( - name: name, - definition: CommandDefinition, + name: commandName, + definition: CommandDefinitionWithRun, ): Cli< - commands & { [key in name]: { args: InferOutput; options: InferOutput } }, + commands & { + [key in commandName]: CommandMapEntry> + }, vars, - env + env, + name > - /** Mounts a sub-CLI as a command group. */ - ( - cli: Cli & { name: name }, - ): Cli /** Mounts a root CLI as a single command. */ - < - const name extends string, - const args extends z.ZodObject | undefined, - const opts extends z.ZodObject | undefined, - >( - cli: Root & { name: name }, + ( + cli: Root, + ): Cli + /** Mounts a sub-CLI as a command group. */ + ( + cli: Cli, ): Cli< - commands & { [key in name]: { args: InferOutput; options: InferOutput } }, + commands & { [key in keyof sub & string as `${childName} ${key}`]: sub[key] }, vars, - env + env, + name > - /** Mounts a fetch handler as a command, optionally with OpenAPI spec for typed subcommands. */ - ( - name: name, + /** Mounts a fetch handler with an OpenAPI spec as a typed command group. */ + ( + name: commandName, + definition: { + basePath?: string | undefined + description?: string | undefined + fetch: FetchHandler + openapi: spec + outputPolicy?: OutputPolicy | undefined + }, + ): Cli, vars, env, name> + /** Mounts a raw fetch handler as an untyped command gateway. */ + ( + name: commandName, definition: { basePath?: string | undefined description?: string | undefined fetch: FetchHandler - openapi?: Openapi.OpenAPISpec | undefined + openapi?: undefined outputPolicy?: OutputPolicy | undefined }, - ): Cli + ): Cli } /** A short description of the CLI. */ description?: string | undefined /** The env schema, if declared. Use `typeof cli.env` with `middleware()` for typed middleware. */ env: env /** The name of the CLI application. */ - name: string + name: name /** Handles an incoming HTTP request, resolves the matching command, and returns a JSON Response. */ fetch(req: Request): Promise /** Parses argv, runs the matched command, and writes the output envelope to stdout. */ serve(argv?: string[], options?: serve.Options): Promise /** Registers middleware that runs around every command. */ - use(handler: MiddlewareHandler): Cli + use(handler: MiddlewareHandler): Cli /** The vars schema, if declared. Use `typeof cli.vars` with `middleware()` for typed middleware. */ vars: vars } -/** Root CLI — a single command with no subcommands. Carries phantom generics for mounting inference. */ +/** @internal Phantom key carrying a root CLI's single-command map entry. */ +declare const rootEntry: unique symbol + +/** Root CLI — a single command with no subcommands. Carries phantom command metadata for mounting inference. */ export type Root< - _args extends z.ZodObject | undefined = undefined, - _options extends z.ZodObject | undefined = undefined, -> = Omit + entry extends CommandsMap[string] = CommandMapEntry, + name extends string = string, +> = Omit, 'command'> & { + /** @internal The root command map entry used when mounting this CLI on a parent. */ + [rootEntry]: entry +} /** Extracts the commands map from the registered type. */ export type Commands = Register extends { commands: infer commands extends CommandsMap } @@ -149,67 +176,102 @@ 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. */ export function create< + const name extends string, const args extends z.ZodObject | undefined = undefined, const env extends z.ZodObject | undefined = undefined, const opts extends z.ZodObject | undefined = undefined, const output extends z.ZodType | undefined = undefined, const vars extends z.ZodObject | undefined = undefined, + const run extends create.RootRun = create.RootRun< + args, + env, + opts, + output, + vars + >, >( - name: string, - definition: create.Options & { run: Function }, -): Cli<{ [key in typeof name]: { args: InferOutput; options: InferOutput } }, vars, env> + name: name, + definition: Omit, 'run'> & { run: run }, +): Cli< + { [key in name]: CommandMapEntry> }, + vars, + env, + name +> & + Root>, name> /** Creates a router CLI that registers subcommands. */ export function create< + const name extends string, const args extends z.ZodObject | undefined = undefined, const env extends z.ZodObject | undefined = undefined, const opts extends z.ZodObject | undefined = undefined, const output extends z.ZodType | undefined = undefined, const vars extends z.ZodObject | undefined = undefined, ->(name: string, definition?: create.Options): Cli<{}, vars, env> +>(name: name, definition?: create.Options): Cli<{}, vars, env, name> /** Creates a CLI with a root handler from a single options object. Can still register subcommands. */ export function create< + const name extends string, const args extends z.ZodObject | undefined = undefined, const env extends z.ZodObject | undefined = undefined, const opts extends z.ZodObject | undefined = undefined, const output extends z.ZodType | undefined = undefined, const vars extends z.ZodObject | undefined = undefined, + const run extends create.RootRun = create.RootRun< + args, + env, + opts, + output, + vars + >, >( - definition: create.Options & { name: string; run: Function }, + definition: Omit, 'run'> & { + name: name + run: run + }, ): Cli< { - [key in (typeof definition)['name']]: { args: InferOutput; options: InferOutput } + [key in name]: CommandMapEntry> }, vars, - env -> + env, + name +> & + Root>, name> /** Creates a router CLI from a single options object (e.g. package.json). */ export function create< + const name extends string, const args extends z.ZodObject | undefined = undefined, const env extends z.ZodObject | undefined = undefined, const opts extends z.ZodObject | undefined = undefined, const output extends z.ZodType | undefined = undefined, const vars extends z.ZodObject | undefined = undefined, ->(definition: create.Options & { name: string }): Cli<{}, vars, env> +>( + definition: create.Options & { name: name }, +): Cli<{}, vars, env, name> export function create( nameOrDefinition: string | (any & { name: string }), definition?: any, ): 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 +283,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 +305,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 +333,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 +346,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, @@ -313,6 +370,17 @@ export function create( }, } + Rpc.registerCliExecutor(cli, async (input, options = {}) => { + return Rpc.executeRpc(commands as Map, input, { + env: def.env, + envSource: options.env, + middlewares, + name, + rootCommand: rootDef, + vars: def.vars, + version: def.version, + }) + }) if (rootDef) toRootDefinition.set(cli as unknown as Root, rootDef) if (rootDef && def.aliases) toRootAliases.set(cli as unknown as Root, def.aliases) if (def.options) toRootOptions.set(cli, def.options) @@ -324,6 +392,15 @@ export function create( } export declare namespace create { + /** Root command handler used when creating a leaf CLI. */ + type RootRun< + 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, + > = CommandRun + /** Options for creating a CLI. Provide `run` for a leaf CLI, omit it for a router. */ type Options< args extends z.ZodObject | undefined = undefined, @@ -386,40 +463,7 @@ export declare namespace create { /** Zod schema for middleware variables. Keys define variable names, schemas define types and defaults. */ vars?: vars | undefined /** The root command handler. When provided, creates a leaf CLI with no subcommands. */ - 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 - }) => - | InferReturn - | Promise> - | AsyncGenerator, unknown, unknown>) - | undefined + run?: RootRun | undefined /** Options for the built-in `mcp add` command. */ mcp?: | { @@ -1657,6 +1701,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 +1828,66 @@ async function fetchImpl( }) } +/** @internal Executes an RPC client request. */ +async function executeRpcCommand( + commands: Map, + req: Request, + start: number, + options: fetchImpl.Options, +): Promise { + let body: unknown + try { + body = await req.json() + } catch { + return new Response( + JSON.stringify({ + ok: false, + error: { code: 'VALIDATION_ERROR', message: 'Request body must be JSON.' }, + meta: { + command: '/_incur/rpc', + duration: `${Math.round(performance.now() - start)}ms`, + }, + }), + { status: 400, headers: { 'content-type': 'application/json' } }, + ) + } + + const result = await Rpc.executeRpc(commands as Map, body, { + env: options.envSchema, + middlewares: options.middlewares, + name: options.name, + rootCommand: options.rootCommand, + start, + vars: options.vars, + version: options.version, + }) + + if (result.kind === 'json') + return new Response(JSON.stringify(result.body), { + status: result.status, + headers: { 'content-type': 'application/json' }, + }) + + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder() + try { + for await (const record of result.stream) + controller.enqueue(encoder.encode(JSON.stringify(record) + '\n')) + } finally { + controller.close() + } + }, + async cancel() { + await result.stream.return(undefined) + }, + }) + return new Response(stream, { + status: result.status, + headers: { 'content-type': 'application/x-ndjson' }, + }) +} + /** @internal Executes a resolved command for the fetch handler and returns a JSON Response. */ async function executeCommand( path: string, @@ -1820,34 +1932,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 +2015,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,9 +2553,53 @@ 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 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, + stream extends boolean = false, +> = { + args: InferOutput + options: InferOutput + output: InferReturn +} & (stream extends true ? { stream: true } : {}) + +/** @internal A command definition with its concrete handler preserved for command map inference. */ +type CommandDefinitionWithRun< + 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, + run extends CommandRun = CommandRun< + args, + env, + options, + output, + vars, + cliEnv + >, +> = Omit, 'run'> & { run: run } + +/** @internal Whether a command handler is typed as returning async generator chunks. */ +type IsStreamingRun = run extends (...args: any[]) => AsyncGenerator + ? true + : false + /** @internal Entry stored in a command map — either a leaf definition, a group, or a fetch gateway. */ type CommandEntry = | CommandDefinition @@ -2462,6 +2653,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, @@ -2997,9 +3201,11 @@ type InferOutput | undefined> = schema extends z.ZodObject ? z.output : {} /** @internal Inferred return type for a command handler. */ -type InferReturn = output extends z.ZodType - ? z.output - : unknown +type InferReturn = [output] extends [never] + ? unknown + : output extends z.ZodType + ? z.output + : unknown /** @internal Inferred vars type from a Zod schema, or `{}` when no schema is provided. */ type InferVars | undefined> = @@ -3058,6 +3264,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 +3294,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..5eba1ae --- /dev/null +++ b/src/Client.test-d.ts @@ -0,0 +1,656 @@ +import { + Cli, + ClientError, + createClient, + createMemoryClient, + isClientRpcError, + isClientRpcErrorEnvelope, + z, +} 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('createClient consumes command maps inferred from Cli instances', () => { + const cli = Cli.create('test') + .command('sum', { + args: z.object({ left: z.number() }), + options: z.object({ right: z.number() }), + output: z.object({ value: z.number() }), + run: (c) => ({ value: c.args.left + c.options.right }), + }) + .command('logs', { + output: z.object({ line: z.string() }), + run: async function* () { + yield { line: 'one' } + }, + }) + type Commands = typeof cli extends Cli.Cli ? commands : never + const client = createClient({ baseUrl: 'https://api.example.com' }) + const sum = client('sum') + const logs = client('logs') + + expectTypeOf>>().toEqualTypeOf<{ value: number }>() + expectTypeOf>>().toEqualTypeOf>() +}) + +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') +}) + +test('createMemoryClient allows omitted input when args and options are empty', () => { + const cli = Cli.create('test').command('ping', { + output: z.object({ ok: z.boolean() }), + run: () => ({ ok: true }), + }) + const client = createMemoryClient(cli) + const ping = client('ping') + + expectTypeOf>>().toEqualTypeOf<{ ok: boolean }>() + ping() + ping({}) + ping({ args: {}, options: {} }) + + // @ts-expect-error unknown command + client('missing') +}) + +test('createMemoryClient requires input when args are required and options are empty', () => { + const cli = Cli.create('test').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, + logs: c.args.includeLogs ? ['one'] : undefined, + }), + }) + const client = createMemoryClient(cli) + const inspect = client('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('createMemoryClient requires input when only options are required', () => { + const cli = Cli.create('test').command('login', { + options: z.object({ token: z.string() }), + output: z.void(), + run: () => undefined, + }) + const client = createMemoryClient(cli) + const login = client('login') + + type Input = { + args?: {} | undefined + options: { token: string } + } + expectTypeOf[0]>().toExtend() + expectTypeOf().toExtend[0]>() + expectTypeOf>>().toEqualTypeOf() + + login({ options: { token: 'secret' } }) + login({ args: {}, options: { token: 'secret' } }) + + // @ts-expect-error options are required + login() + + // @ts-expect-error required option key is missing + login({ options: {} }) +}) + +test('createMemoryClient allows optional input when args and options have no required keys', () => { + const cli = Cli.create('test').command('list', { + args: z.object({ archived: z.boolean().optional() }), + 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 client = createMemoryClient(cli) + const list = client('list') + + type Input = + | { + args?: { archived?: boolean | undefined } | undefined + options?: { cursor?: string | undefined; limit?: number | undefined } | undefined + } + | undefined + expectTypeOf[0]>().toExtend() + expectTypeOf().toExtend[0]>() + expectTypeOf>>().toEqualTypeOf<{ + items: string[] + nextCursor?: string | undefined + }>() + + list() + list({}) + list({ args: { archived: true } }) + list({ options: { limit: 10 } }) + list({ args: {}, options: { cursor: 'next' } }) + + // @ts-expect-error optional arg has the wrong type + list({ args: { archived: 'true' } }) + + // @ts-expect-error optional option has the wrong type + list({ options: { limit: '10' } }) +}) + +test('createMemoryClient preserves mixed required and optional fields', () => { + const cli = Cli.create('test').command('config set', { + args: z.object({ key: z.string(), value: z.union([z.number(), z.string()]) }), + options: z.object({ + force: z.boolean(), + scope: z.union([z.literal('project'), z.literal('user')]).optional(), + }), + output: z.object({ saved: z.literal(true) }), + run: () => ({ saved: true as const }), + }) + const client = createMemoryClient(cli) + const set = client('config set') + + type Input = { + args: { key: string; value: number | string } + options: { force: boolean; scope?: 'project' | 'user' | undefined } + } + expectTypeOf[0]>().toExtend() + expectTypeOf().toExtend[0]>() + expectTypeOf>>().toEqualTypeOf<{ saved: true }>() + + set({ args: { key: 'theme', value: 'dark' }, options: { force: false } }) + set({ args: { key: 'retries', value: 3 }, options: { force: true, scope: 'user' } }) + + // @ts-expect-error optional option still narrows to known values + set({ args: { key: 'theme', value: 'dark' }, options: { force: true, scope: 'org' } }) +}) + +test('createMemoryClient infers root CLI commands', () => { + const cli = Cli.create('status', { + output: z.object({ ok: z.boolean() }), + run: () => ({ ok: true }), + }) + const client = createMemoryClient(cli) + const status = client('status') + + expectTypeOf>>().toEqualTypeOf<{ ok: boolean }>() + status() + + // @ts-expect-error unknown command + client('ping') +}) + +test('createMemoryClient infers mounted root CLI commands', () => { + const status = Cli.create('status', { + args: z.object({ service: z.string() }), + options: z.object({ verbose: z.boolean().optional() }), + output: z.object({ service: z.string(), ok: z.boolean() }), + run: (c) => ({ service: c.args.service, ok: true }), + }) + const cli = Cli.create('app').command(status) + const client = createMemoryClient(cli) + const call = client('status') + + type Input = { + args: { service: string } + options?: { verbose?: boolean | undefined } | undefined + } + expectTypeOf[0]>().toExtend() + expectTypeOf().toExtend[0]>() + expectTypeOf>>().toEqualTypeOf<{ + service: string + ok: boolean + }>() + + call({ args: { service: 'api' } }) + call({ args: { service: 'api' }, options: { verbose: true } }) + + // @ts-expect-error required arg key is missing + call({ args: {} }) + + // @ts-expect-error unknown command + client('app status') +}) + +test('createMemoryClient infers mounted sub-CLI command groups', () => { + const project = Cli.create('project') + .command('inspect', { + args: z.object({ id: z.string() }), + output: z.object({ id: z.string() }), + run: (c) => ({ id: c.args.id }), + }) + .command('list', { + options: z.object({ limit: z.number().optional() }), + output: z.object({ items: z.array(z.string()) }), + run: () => ({ items: [] }), + }) + const cli = Cli.create('test').command(project) + const client = createMemoryClient(cli) + const inspect = client('project inspect') + const list = client('project list') + + expectTypeOf[0]>().toExtend<{ + args: { id: string } + options?: {} | undefined + }>() + expectTypeOf>>().toEqualTypeOf<{ id: string }>() + expectTypeOf[0]>().toExtend< + | { + args?: {} | undefined + options?: { limit?: number | undefined } | undefined + } + | undefined + >() + expectTypeOf>>().toEqualTypeOf<{ items: string[] }>() + + inspect({ args: { id: 'p1' } }) + list() + list({ options: { limit: 10 } }) + + // @ts-expect-error group name is required + client('inspect') +}) + +test('createMemoryClient returns async iterables for streaming CLI commands', () => { + const cli = Cli.create('test').command('logs', { + output: z.object({ line: z.string() }), + run: async function* () { + yield { line: 'one' } + }, + }) + const client = createMemoryClient(cli) + const logs = client('logs') + + expectTypeOf>>().toEqualTypeOf>() +}) + +test('createMemoryClient supports explicit generated command maps', () => { + const cli = Cli.create('test').command('raw', { + run: () => ({ ok: true }), + }) + const client = createMemoryClient(cli) + const status = client('status') + + expectTypeOf>>().toEqualTypeOf<{ ok: boolean }>() +}) diff --git a/src/Client.test.ts b/src/Client.test.ts new file mode 100644 index 0000000..d073d01 --- /dev/null +++ b/src/Client.test.ts @@ -0,0 +1,930 @@ +import { + Cli, + ClientError, + createClient, + createMemoryClient, + isClientRpcError, + isClientRpcErrorEnvelope, + middleware, + 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: { line: string }[] = [] + 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 command aliases and root aliases through a real CLI RPC server', async () => { + const update = Cli.create('update', { + aliases: ['upgrade'], + run: () => ({ result: 'updated' }), + }) + const cli = Cli.create('pkg') + .command(update) + .command('extension', { + aliases: ['extensions', 'ext'], + run: () => ({ result: 'extended' }), + }) + const client = createClient({ + baseUrl: 'http://localhost', + fetch: (input, init) => cli.fetch(new Request(input, init)), + }) + + await expect(client('extensions')()).resolves.toEqual({ result: 'extended' }) + await expect(client('ext')()).resolves.toEqual({ result: 'extended' }) + await expect(client('upgrade')()).resolves.toEqual({ result: 'updated' }) + }) + + 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: { line: string }[] = [] + 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) + } + }) +}) + +describe('createMemoryClient', () => { + test('unwraps non-streaming command data without fetch', async () => { + let fetched = false + 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 }), + }) + cli.fetch = async () => { + fetched = true + return Response.json({ ok: false }) + } + const client = createMemoryClient(cli) + + await expect( + client('sum')({ + args: { left: 1 }, + options: { right: 2 }, + }), + ).resolves.toEqual({ value: 3 }) + expect(fetched).toBe(false) + }) + + test('throws validation errors', async () => { + const cli = Cli.create('test').command('sum', { + args: z.object({ left: z.number() }), + run: (c) => ({ value: c.args.left }), + }) + const client = createMemoryClient(cli) + + await expect(client('sum')({ args: {} })).rejects.toMatchObject({ + name: 'Incur.ClientError', + message: expect.stringContaining('Invalid input'), + error: { + code: 'VALIDATION_ERROR', + fieldErrors: expect.any(Array), + }, + status: 400, + }) + }) + + test('executes root CLI commands', async () => { + const cli = Cli.create('test', { + args: z.object({ name: z.string() }), + output: z.object({ message: z.string() }), + run: (c) => ({ message: `hello ${c.args.name}` }), + }) + const client = createMemoryClient(cli) + + await expect(client('test')({ args: { name: 'Ada' } })).resolves.toEqual({ + message: 'hello Ada', + }) + }) + + test('throws unknown command errors', async () => { + const cli = Cli.create('test').command('ping', { + run: () => 'pong', + }) + const client = createMemoryClient(cli) + + await expect(client('pong' as 'ping')()).rejects.toMatchObject({ + name: 'Incur.ClientError', + message: 'Command not found.', + error: { code: 'COMMAND_NOT_FOUND', message: 'Command not found.' }, + status: 404, + }) + }) + + test('throws c.error and thrown command errors', async () => { + const cli = Cli.create('test') + .command('blocked', { + run: (c) => c.error({ code: 'BLOCKED', message: 'Blocked' }), + }) + .command('explode', { + run: () => { + throw new Error('Boom') + }, + }) + const client = createMemoryClient(cli) + + await expect(client('blocked')()).rejects.toMatchObject({ + name: 'Incur.ClientError', + message: 'Blocked', + error: { code: 'BLOCKED', message: 'Blocked' }, + status: 500, + }) + await expect(client('explode')()).rejects.toMatchObject({ + name: 'Incur.ClientError', + message: 'Boom', + error: { code: 'UNKNOWN', message: 'Boom' }, + status: 500, + }) + }) + + test('runs root, group, and command middleware in order', async () => { + const order: string[] = [] + const root = middleware(async (_c, next) => { + order.push('root before') + await next() + order.push('root after') + }) + const group = middleware(async (_c, next) => { + order.push('group before') + await next() + order.push('group after') + }) + const command = middleware(async (_c, next) => { + order.push('command before') + await next() + order.push('command after') + }) + const admin = Cli.create('admin') + .use(group) + .command('ping', { + middleware: [command], + run: () => { + order.push('run') + return 'pong' + }, + }) + const cli = Cli.create('test').use(root).command(admin) + const client = createMemoryClient(cli) + + await expect(client('admin ping')()).resolves.toBe('pong') + expect(order).toEqual([ + 'root before', + 'group before', + 'command before', + 'run', + 'command after', + 'group after', + 'root after', + ]) + }) + + test('passes env through CLI, command, and middleware contexts', async () => { + const seen: unknown[] = [] + const env = z.object({ + API_TOKEN: z.string(), + API_URL: z.string().default('https://api.example.com'), + }) + const root = middleware(async (c, next) => { + seen.push({ root: c.env }) + await next() + }) + const command = middleware(async (c, next) => { + seen.push({ command: c.env }) + await next() + }) + const cli = Cli.create('test', { env }) + .use(root) + .command('deploy', { + env: z.object({ DEPLOY_ENV: z.enum(['staging', 'production']) }), + middleware: [command], + run: (c) => ({ env: c.env.DEPLOY_ENV }), + }) + const client = createMemoryClient(cli, { + env: { + API_TOKEN: 'secret-123', + DEPLOY_ENV: 'staging', + }, + }) + + await expect(client('deploy')()).resolves.toEqual({ env: 'staging' }) + expect(seen).toEqual([ + { root: { API_TOKEN: 'secret-123', API_URL: 'https://api.example.com' } }, + { command: { API_TOKEN: 'secret-123', API_URL: 'https://api.example.com' } }, + ]) + }) + + test('throws env validation errors', async () => { + let ran = false + const cli = Cli.create('test', { + env: z.object({ API_TOKEN: z.string() }), + }) + .use(async (_c, next) => { + ran = true + await next() + }) + .command('deploy', { run: () => ({ ok: true }) }) + const client = createMemoryClient(cli, { env: {} }) + + await expect(client('deploy')()).rejects.toMatchObject({ + name: 'Incur.ClientError', + message: expect.stringContaining('Invalid input'), + error: { + code: 'VALIDATION_ERROR', + fieldErrors: expect.any(Array), + }, + status: 400, + }) + expect(ran).toBe(false) + }) + + test('throws command env validation errors before running handler', async () => { + let ran = false + const cli = Cli.create('test').command('deploy', { + env: z.object({ DEPLOY_ENV: z.enum(['staging', 'production']) }), + run: () => { + ran = true + return { ok: true } + }, + }) + const client = createMemoryClient(cli, { env: { DEPLOY_ENV: 'preview' } }) + + await expect(client('deploy')()).rejects.toMatchObject({ + name: 'Incur.ClientError', + message: expect.stringContaining('Invalid option'), + error: { + code: 'VALIDATION_ERROR', + fieldErrors: expect.any(Array), + }, + status: 400, + }) + expect(ran).toBe(false) + }) + + test('rejects non-object args and options', async () => { + const cli = Cli.create('test').command('ping', { run: () => ({ ok: true }) }) + const client = createMemoryClient(cli) + + await expect(client('ping')({ args: [] })).rejects.toMatchObject({ + name: 'Incur.ClientError', + message: '`args` and `options` must be objects.', + error: { + code: 'VALIDATION_ERROR', + message: '`args` and `options` must be objects.', + }, + status: 400, + }) + await expect(client('ping')({ options: [] })).rejects.toMatchObject({ + name: 'Incur.ClientError', + message: '`args` and `options` must be objects.', + error: { + code: 'VALIDATION_ERROR', + message: '`args` and `options` must be objects.', + }, + status: 400, + }) + }) + + test('resolves command aliases and root command aliases', async () => { + const update = Cli.create('update', { + aliases: ['upgrade'], + run: () => ({ result: 'updated' }), + }) + const cli = Cli.create('pkg') + .command(update) + .command('extension', { + aliases: ['extensions', 'ext'], + run: () => ({ result: 'extended' }), + }) + const client = createMemoryClient(cli) + + await expect(client('extensions')()).resolves.toEqual({ result: 'extended' }) + await expect(client('ext')()).resolves.toEqual({ result: 'extended' }) + await expect(client('upgrade')()).resolves.toEqual({ result: 'updated' }) + }) + + test('resolves command aliases inside mounted groups', async () => { + const admin = Cli.create('admin').command('list', { + aliases: ['ls'], + run: () => ({ items: ['one'] }), + }) + const cli = Cli.create('app').command(admin) + const client = createMemoryClient(cli) + + await expect(client('admin ls')()).resolves.toEqual({ items: ['one'] }) + }) + + test('executes mounted leaf CLIs and grouped commands', async () => { + const greet = Cli.create('greet', { + args: z.object({ name: z.string() }), + options: z.object({ loud: z.boolean().default(false) }), + run: (c) => ({ message: c.options.loud ? `HELLO ${c.args.name}` : `hello ${c.args.name}` }), + }) + const admin = Cli.create('admin').command('reset', { run: () => ({ reset: true }) }) + const cli = Cli.create('app').command(greet).command(admin) + const client = createMemoryClient(cli) + + await expect( + client('greet')({ + args: { name: 'Ada' }, + options: { loud: true }, + }), + ).resolves.toEqual({ message: 'HELLO Ada' }) + await expect(client('admin reset')()).resolves.toEqual({ reset: true }) + }) + + test('trims command names and rejects blank commands', async () => { + const cli = Cli.create('test').command('ping', { run: () => 'pong' }) + const client = createMemoryClient(cli) + + await expect(client(' ping ')()).resolves.toBe('pong') + await expect(client(' ')()).rejects.toMatchObject({ + name: 'Incur.ClientError', + message: '`command` must be a non-empty string.', + error: { + code: 'VALIDATION_ERROR', + message: '`command` must be a non-empty string.', + }, + status: 400, + }) + }) + + test('returns async iterable streaming chunks', async () => { + const cli = Cli.create('test').command('logs', { + args: z.object({ prefix: z.string() }), + output: z.object({ line: z.string() }), + async *run(c) { + yield { line: `${c.args.prefix}-1` } + yield { line: `${c.args.prefix}-2` } + }, + }) + const client = createMemoryClient(cli) + + const stream = await client('logs')({ args: { prefix: 'line' } }) + const chunks: { line: string }[] = [] + for await (const chunk of stream) chunks.push(chunk) + + expect(chunks).toEqual([{ line: 'line-1' }, { line: 'line-2' }]) + }) + + test('throws streaming c.error records', async () => { + const cli = Cli.create('test').command('logs', { + output: z.object({ line: z.string() }), + async *run(c) { + yield { line: 'one' } + return c.error({ code: 'NOPE', message: 'Nope' }) + }, + }) + const client = createMemoryClient(cli) + + const stream = await client('logs')() + const chunks: { line: string }[] = [] + await expect(async () => { + for await (const chunk of stream) chunks.push(chunk) + }).rejects.toMatchObject({ + name: 'Incur.ClientError', + message: 'Nope', + error: { code: 'NOPE', message: 'Nope' }, + status: 200, + }) + expect(chunks).toEqual([{ line: 'one' }]) + }) + + test('throws streaming thrown errors', async () => { + const cli = Cli.create('test').command('logs', { + output: z.object({ line: z.string() }), + async *run() { + yield { line: 'one' } + throw new Error('Boom') + }, + }) + const client = createMemoryClient(cli) + + const stream = await client('logs')() + const chunks: { line: string }[] = [] + await expect(async () => { + for await (const chunk of stream) chunks.push(chunk) + }).rejects.toMatchObject({ + name: 'Incur.ClientError', + message: 'Boom', + error: { code: 'UNKNOWN', message: 'Boom' }, + status: 200, + }) + expect(chunks).toEqual([{ line: 'one' }]) + }) + + test('closes streaming commands when consumers stop early', async () => { + let closed = false + const cli = Cli.create('test').command('logs', { + output: z.object({ line: z.string() }), + async *run() { + try { + yield { line: 'one' } + yield { line: 'two' } + } finally { + closed = true + } + }, + }) + const client = createMemoryClient(cli) + + const stream = await client('logs')() + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + break + } + + expect(chunks).toEqual([{ line: 'one' }]) + expect(closed).toBe(true) + }) +}) diff --git a/src/Client.ts b/src/Client.ts new file mode 100644 index 0000000..46e3e40 --- /dev/null +++ b/src/Client.ts @@ -0,0 +1,308 @@ +import * as Cli from './Cli.js' +import { ClientError } from './Errors.js' +import { isRecord } from './internal/helpers.js' +import * as Rpc from './internal/rpc.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 Executor = (command: string, input: RuntimeInput) => Promise + +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 +} + +/** Options for creating an in-memory incur RPC client. */ +type MemoryClientOptions = { + /** Environment source used for CLI-level and command-level env parsing. */ + env?: Record | 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 createCurriedClient(async (command, input) => { + 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) + return unwrapEnvelope(envelope, response.status) + }) +} + +/** Creates a typed incur RPC client that executes commands against a CLI instance in memory. */ +export function createMemoryClient( + cli: Cli.Cli, + options?: MemoryClientOptions | undefined, +): Client +/** Creates a typed incur RPC client that executes commands against a CLI instance in memory. */ +export function createMemoryClient( + cli: Cli.Cli, + options?: MemoryClientOptions | undefined, +): Client +export function createMemoryClient( + cli: Cli.Cli, + options: MemoryClientOptions = {}, +): Client { + return createCurriedClient(async (command, input) => { + const result = await Rpc.executeCli( + cli, + { + command, + args: input.args ?? {}, + options: input.options ?? {}, + }, + { env: options.env }, + ) + + if (result.kind === 'stream') return parseMemoryStream(result.stream, result.status) + return unwrapEnvelope(result.body, result.status) + }) +} + +function createCurriedClient(execute: Executor): Client { + return ((command: string) => + async (input: RuntimeInput = {}) => + execute(command, input)) 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 +} + +function unwrapEnvelope(envelope: Envelope, status: number | undefined): unknown { + if (envelope.ok) return envelope.data + + const message = errorMessage(envelope.error, 'RPC command failed') + throw new ClientError(message, { + data: envelope, + error: envelope.error, + status, + }) +} + +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, + }) +} + +async function* parseMemoryStream( + stream: AsyncGenerator, + status: number, +): AsyncGenerator { + for await (const record of stream) { + const result = readStreamValue(record, status) + if (result.done) return + yield result.data + } + + throw new ClientError('RPC stream ended before done', { + 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, + }) + + return readStreamValue(value, status) +} + +function readStreamValue( + value: unknown, + status: number, +): { data: unknown; done?: false | undefined } | { done: true } { + 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..645b1e4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,13 @@ export { z } from 'zod' +export { createClient, createMemoryClient } 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. */ diff --git a/src/internal/rpc.ts b/src/internal/rpc.ts new file mode 100644 index 0000000..e98972c --- /dev/null +++ b/src/internal/rpc.ts @@ -0,0 +1,508 @@ +import { z } from 'zod' + +import type { FieldError } from '../Errors.js' +import { IncurError } from '../Errors.js' +import type { Handler as MiddlewareHandler } from '../middleware.js' +import * as Command from './command.js' +import { isRecord } from './helpers.js' + +/** @internal Registers the structured RPC executor for a CLI instance. */ +export function registerCliExecutor(cli: object, executor: CliExecutor): void { + cliExecutors.set(cli, executor) +} + +/** @internal Executes structured RPC against a registered CLI instance. */ +export async function executeCli( + cli: object, + input: CliInput, + options: CliOptions = {}, +): Promise { + const executor = cliExecutors.get(cli) + if (!executor) throw new Error('Cannot execute RPC for an unknown CLI instance.') + return executor(input, options) +} + +/** @internal Executes a structured incur RPC request without binding it to a transport. */ +export async function executeRpc( + commands: Map, + input: unknown, + options: executeRpc.Options = {}, +): Promise { + const start = options.start ?? performance.now() + + function error(code: string, message: string, status: number, command = '/_incur/rpc') { + return { + kind: 'json', + status, + body: { + ok: false, + error: { code, message }, + meta: { command, duration: duration(start) }, + }, + } satisfies executeRpc.JsonResult + } + + if (!isRecord(input)) return error('VALIDATION_ERROR', 'Request body must be an object.', 400) + + if (typeof input.command !== 'string') + return error('VALIDATION_ERROR', '`command` must be a string.', 400) + const command = input.command.trim() + if (!command) return error('VALIDATION_ERROR', '`command` must be a non-empty string.', 400) + + const args = input.args ?? {} + const inputOptions = input.options ?? {} + if (!isRecord(args) || !isRecord(inputOptions)) + return error('VALIDATION_ERROR', '`args` and `options` must be objects.', 400) + + const resolved = + options.rootCommand && command === options.name + ? { command: options.rootCommand, middlewares: [], path: command, rest: [] } + : resolveCommand(commands, command.split(/\s+/)) + 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) + + const allMiddleware = [ + ...(options.middlewares ?? []), + ...resolved.middlewares, + ...(((resolved.command as RpcCommand).middleware as MiddlewareHandler[] | undefined) ?? []), + ] + + const result = await Command.execute(resolved.command, { + agent: true, + argv: [], + env: options.env, + envSource: options.envSource, + format: 'json', + formatExplicit: true, + inputArgs: args, + inputOptions, + middlewares: allMiddleware, + name: options.name ?? resolved.path, + parseMode: 'structured', + path: resolved.path, + vars: options.vars, + version: options.version, + }) + + if ('stream' in result) + return { + kind: 'stream', + status: 200, + stream: streamRecords(result.stream, { + name: options.name ?? resolved.path, + path: resolved.path, + }), + } + + const meta = { command: resolved.path, duration: duration(start) } + + if (!result.ok) { + const cta = formatCtaBlock(options.name ?? resolved.path, result.cta as CtaBlock | undefined) + return { + kind: 'json', + status: result.error.code === 'VALIDATION_ERROR' ? 400 : 500, + body: { + ok: false, + error: { + code: result.error.code, + message: result.error.message, + ...(result.error.retryable !== undefined + ? { retryable: result.error.retryable } + : undefined), + ...(result.error.fieldErrors ? { fieldErrors: result.error.fieldErrors } : undefined), + }, + meta: { + ...meta, + ...(cta ? { cta } : undefined), + }, + }, + } + } + + const cta = formatCtaBlock(options.name ?? resolved.path, result.cta as CtaBlock | undefined) + return { + kind: 'json', + status: 200, + body: { + ok: true, + data: result.data, + meta: { + ...meta, + ...(cta ? { cta } : undefined), + }, + }, + } +} + +/** @internal Structured RPC command input for a registered CLI instance. */ +export type CliInput = { + /** Command path, separated by spaces for nested commands. */ + command: string + /** Structured positional arguments. */ + args?: unknown | undefined + /** Structured named options. */ + options?: unknown | undefined +} + +/** @internal Options for structured RPC command execution against a CLI instance. */ +export type CliOptions = { + /** Environment source used for CLI-level and command-level env parsing. */ + env?: Record | undefined +} + +type CliExecutor = (input: CliInput, options?: CliOptions | undefined) => Promise + +const cliExecutors = new WeakMap() + +export declare namespace executeRpc { + /** Options for structured RPC execution. */ + type Options = { + /** CLI-level env schema. */ + env?: z.ZodObject | undefined + /** Source for environment variables. Defaults to `process.env`. */ + envSource?: Record | undefined + /** Root CLI middleware. */ + middlewares?: MiddlewareHandler[] | undefined + /** CLI name used for command context and CTA formatting. */ + name?: string | undefined + /** Root command definition for leaf CLIs. */ + rootCommand?: unknown | undefined + /** Start time used for envelope duration metadata. */ + start?: number | undefined + /** Vars schema for middleware variables. */ + vars?: z.ZodObject | undefined + /** CLI version string. */ + version?: string | undefined + } + + /** Structured RPC execution result. */ + type Result = JsonResult | StreamResult + + /** Non-streaming structured RPC result. */ + type JsonResult = { + /** Result discriminator for JSON envelope responses. */ + kind: 'json' + /** Normalized response envelope. */ + body: Envelope + /** HTTP-compatible status code for adapters that expose RPC over HTTP. */ + status: number + } + + /** Streaming structured RPC result. */ + type StreamResult = { + /** Result discriminator for streaming record responses. */ + kind: 'stream' + /** Normalized stream records. */ + stream: AsyncGenerator + /** HTTP-compatible status code for adapters that expose RPC over HTTP. */ + status: number + } +} + +/** @internal Structured RPC response envelope. */ +export type Envelope = + | { + /** Command output data. */ + data: unknown + /** Response metadata. */ + meta: Meta + /** Whether the command succeeded. */ + ok: true + } + | { + /** Command error. */ + error: RpcError + /** Response metadata. */ + meta: Meta + /** Whether the command succeeded. */ + ok: false + } + +/** @internal Structured RPC stream record. */ +export type StreamRecord = + | { + /** Stream chunk data. */ + data: unknown + /** Stream record discriminator. */ + type: 'chunk' + } + | { + /** Response metadata. */ + meta: Omit + /** Whether the command succeeded. */ + ok: true + /** Stream record discriminator. */ + type: 'done' + } + | { + /** Command error. */ + error: Omit + /** Response metadata. */ + meta?: Pick | undefined + /** Whether the command succeeded. */ + ok: false + /** Stream record discriminator. */ + type: 'error' + } + +/** @internal Formats a CTA block into the RPC envelope shape. */ +export function formatCtaBlock( + name: string, + block: CtaBlock | undefined, +): FormattedCtaBlock | undefined { + if (!block || block.commands.length === 0) return undefined + return { + description: + block.description ?? + (block.commands.length === 1 ? 'Suggested command:' : 'Suggested commands:'), + commands: block.commands.map((cta) => formatCta(name, cta)), + } +} + +type Meta = { + command: string + cta?: FormattedCtaBlock | undefined + duration: string +} + +type RpcError = { + code: string + fieldErrors?: FieldError[] | undefined + message: string + retryable?: boolean | undefined +} + +type RpcCommand = { + middleware?: MiddlewareHandler[] | undefined + outputPolicy?: unknown | undefined + run: (...args: any[]) => unknown +} + +type RpcGroup = { + _group: true + commands: Map + description?: string | undefined + middlewares?: MiddlewareHandler[] | undefined + outputPolicy?: unknown | undefined +} + +type RpcFetchGateway = { + _fetch: true +} + +type RpcAlias = { + _alias: true + target: string +} + +type ResolvedCommand = + | { + command: RpcCommand + middlewares: MiddlewareHandler[] + path: string + rest: string[] + } + | { + fetchGateway: RpcFetchGateway + middlewares: MiddlewareHandler[] + path: string + rest: string[] + } + | { + help: true + path: string + } + | { error: string; path: string; rest: string[] } + +type CtaBlock = { + commands: unknown[] + description?: string | undefined +} + +type FormattedCtaBlock = { + commands: FormattedCta[] + description: string +} + +type FormattedCta = { + command: string + description?: string | undefined +} + +type Cta = + | string + | { + args?: Record | undefined + command: string + description?: string | undefined + options?: Record | undefined + } + +type OkResult = { + [sentinel]: 'ok' + cta?: CtaBlock | undefined + data: unknown +} + +type ErrorResult = { + [sentinel]: 'error' + code: string + cta?: CtaBlock | undefined + exitCode?: number | undefined + message: string + retryable?: boolean | undefined +} + +const sentinel = Symbol.for('incur.sentinel') + +async function* streamRecords( + stream: AsyncGenerator, + options: { name: string; path: string }, +): AsyncGenerator { + let completed = false + try { + let returnValue: unknown + while (true) { + const { value, done } = await stream.next() + if (done) { + returnValue = value + break + } + if (isSentinel(value) && value[sentinel] === 'error') { + yield errorRecord(value, options.name) + completed = true + return + } + yield { type: 'chunk', data: value } + } + if (isSentinel(returnValue) && returnValue[sentinel] === 'error') { + yield errorRecord(returnValue, options.name) + completed = true + return + } + const cta = + isSentinel(returnValue) && returnValue[sentinel] === 'ok' + ? formatCtaBlock(options.name, returnValue.cta) + : undefined + yield { + type: 'done', + ok: true, + meta: { + command: options.path, + ...(cta ? { cta } : undefined), + }, + } + completed = true + } catch (error) { + yield { + 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), + }, + } + completed = true + } finally { + if (!completed) await stream.return(undefined) + } +} + +function errorRecord(error: ErrorResult, name: string): StreamRecord { + const cta = formatCtaBlock(name, error.cta) + return { + type: 'error', + ok: false, + error: { + code: error.code, + message: error.message, + ...(error.retryable !== undefined ? { retryable: error.retryable } : undefined), + }, + ...(cta ? { meta: { cta } } : undefined), + } +} + +function resolveCommand(commands: Map, tokens: string[]): ResolvedCommand { + const [first, ...rest] = tokens + + if (!first || !commands.has(first)) return { error: first ?? '(none)', path: '', rest } + + let entry = resolveAlias(commands, commands.get(first)!) + const path = [first] + let remaining = rest + const middlewares: MiddlewareHandler[] = [] + + if (isFetchGateway(entry)) + return { fetchGateway: entry, middlewares, path: path.join(' '), rest: remaining } + + while (isGroup(entry)) { + if (entry.middlewares) middlewares.push(...entry.middlewares) + const next = remaining[0] + if (!next) return { help: true, path: path.join(' ') } + + const rawChild = entry.commands.get(next) + if (!rawChild) return { error: next, path: path.join(' '), rest: remaining.slice(1) } + + entry = resolveAlias(entry.commands, rawChild) + path.push(next) + remaining = remaining.slice(1) + + if (isFetchGateway(entry)) + return { fetchGateway: entry, middlewares, path: path.join(' '), rest: remaining } + } + + return { command: entry as RpcCommand, middlewares, path: path.join(' '), rest: remaining } +} + +function resolveAlias(commands: Map, entry: unknown): unknown { + if (isAlias(entry)) return commands.get(entry.target)! + return entry +} + +function isAlias(entry: unknown): entry is RpcAlias { + return isRecord(entry) && entry._alias === true && typeof entry.target === 'string' +} + +function isGroup(entry: unknown): entry is RpcGroup { + return isRecord(entry) && entry._group === true && entry.commands instanceof Map +} + +function isFetchGateway(entry: unknown): entry is RpcFetchGateway { + return isRecord(entry) && entry._fetch === true +} + +function isSentinel(value: unknown): value is OkResult | ErrorResult { + return typeof value === 'object' && value !== null && sentinel in value +} + +function formatCta(name: string, cta: unknown): FormattedCta { + if (typeof cta === 'string') return { command: `${name} ${cta}` } + if (!isRpcCta(cta)) return { command: `${name} ${String(cta)}` } + const prefix = cta.command === name || cta.command.startsWith(`${name} `) ? '' : `${name} ` + let command = `${prefix}${cta.command}` + if (cta.args) + for (const [key, value] of Object.entries(cta.args)) + command += value === true ? ` <${key}>` : ` ${value}` + if (cta.options) + for (const [key, value] of Object.entries(cta.options)) + command += value === true ? ` --${key} <${key}>` : ` --${key} ${value}` + return { command, ...(cta.description ? { description: cta.description } : undefined) } +} + +function isRpcCta(value: unknown): value is Exclude { + return isRecord(value) && typeof value.command === 'string' +} + +function duration(start: number) { + return `${Math.round(performance.now() - start)}ms` +}