diff --git a/AGENTS.md b/AGENTS.md index 660f9b0..44350b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ - **`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. diff --git a/src/Cli.test-d.ts b/src/Cli.test-d.ts index f9a5f77..1416d21 100644 --- a/src/Cli.test-d.ts +++ b/src/Cli.test-d.ts @@ -163,8 +163,115 @@ 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 }>() - expectTypeOf().toEqualTypeOf<{ args: {}; options: { limit: number } }>() }) test('OpenAPI mounted fetch accumulates operation command types', () => { diff --git a/src/Cli.test.ts b/src/Cli.test.ts index 3dbf1fa..2eb17f2 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -4372,6 +4372,34 @@ describe('fetch', () => { ]) }) + test('POST /_incur/rpc executes root command', async () => { + const cli = Cli.create('test', { + args: z.object({ name: z.string() }), + run: (c) => ({ message: `hello ${c.args.name}` }), + }) + const req = new Request('http://localhost/_incur/rpc', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ command: 'test', args: { name: 'Ada' } }), + }) + + expect(await fetchJson(cli, req)).toMatchInlineSnapshot(` + { + "body": { + "data": { + "message": "hello Ada", + }, + "meta": { + "command": "test", + "duration": "", + }, + "ok": true, + }, + "status": 200, + } + `) + }) + test('POST /_incur/rpc streams async generator output as NDJSON', async () => { const cli = Cli.create('test') cli.command('stream', { @@ -4426,6 +4454,40 @@ describe('fetch', () => { `) }) + test('POST /_incur/rpc cancels command stream when response body is cancelled', async () => { + let closed = false + let resume: (() => void) | undefined + const gate = new Promise((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', { diff --git a/src/Cli.ts b/src/Cli.ts index b658aea..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' @@ -35,43 +36,57 @@ import * as Skill from './Skill.js' import * as SyncMcp from './SyncMcp.js' import * as SyncSkills from './SyncSkills.js' -declare const rootType: unique symbol - /** A CLI application instance. Also used as a command group when mounted on a parent CLI. */ export type Cli< commands extends CommandsMap = {}, 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, - ): Cli }, vars, env> + name: commandName, + definition: CommandDefinitionWithRun, + ): Cli< + commands & { + [key in commandName]: CommandMapEntry> + }, + vars, + env, + name + > /** Mounts a root CLI as a single command. */ - < - const name extends string, - const args extends z.ZodObject | undefined, - const opts extends z.ZodObject | undefined, - const output extends z.ZodType | undefined, - >( - cli: Root & { name: name }, - ): Cli }, vars, env> + ( + cli: Root, + ): Cli /** Mounts a sub-CLI as a command group. */ - ( - cli: Cli & { name: name }, - ): Cli + ( + cli: Cli, + ): Cli< + commands & { [key in keyof sub & string as `${childName} ${key}`]: sub[key] }, + vars, + env, + name + > /** Mounts a fetch handler with an OpenAPI spec as a typed command group. */ - ( - name: name, + ( + name: commandName, definition: { basePath?: string | undefined description?: string | undefined @@ -79,10 +94,10 @@ export type Cli< openapi: spec outputPolicy?: OutputPolicy | undefined }, - ): Cli, vars, env> + ): Cli, vars, env, name> /** Mounts a raw fetch handler as an untyped command gateway. */ - ( - name: name, + ( + name: commandName, definition: { basePath?: string | undefined description?: string | undefined @@ -90,32 +105,34 @@ export type Cli< 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, - _output extends z.ZodType | undefined = undefined, -> = Omit & { - /** @internal Carries root command schemas for mount inference. */ - [rootType]: { args: _args; options: _options; output: _output } + 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. */ @@ -171,49 +188,78 @@ export type Cta = /** 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]: CommandMapEntry }, vars, env> & - Root + 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']]: CommandMapEntry + [key in name]: CommandMapEntry> }, vars, - env + env, + name > & - Root + 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, @@ -324,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) @@ -335,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, @@ -397,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?: | { @@ -1534,8 +1567,6 @@ declare namespace fetchImpl { envSchema?: z.ZodObject | undefined /** Group-level middleware collected during command resolution. */ groupMiddlewares?: MiddlewareHandler[] | undefined - /** Structured args received from the RPC route. */ - structuredArgs?: Record | undefined mcpHandler?: | (( req: Request, @@ -1804,59 +1835,56 @@ async function executeRpcCommand( start: number, options: fetchImpl.Options, ): Promise { - function jsonResponse(body: unknown, status: number) { - return new Response(JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json' }, - }) - } - - function error(code: string, message: string, status: number, command = '/_incur/rpc') { - return jsonResponse( - { - ok: false, - error: { code, message }, - meta: { command, duration: `${Math.round(performance.now() - start)}ms` }, - }, - status, - ) - } - let body: unknown try { body = await req.json() } catch { - return error('VALIDATION_ERROR', 'Request body must be JSON.', 400) + 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' } }, + ) } - if (!isRecord(body)) return error('VALIDATION_ERROR', 'Request body must be an object.', 400) - - if (typeof body.command !== 'string') - return error('VALIDATION_ERROR', '`command` must be a string.', 400) - const command = body.command.trim() - if (!command) return error('VALIDATION_ERROR', '`command` must be a non-empty string.', 400) - - const args = body.args ?? {} - const rpcOptions = body.options ?? {} - if (!isRecord(args) || !isRecord(rpcOptions)) - return error('VALIDATION_ERROR', '`args` and `options` must be objects.', 400) + const 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, + }) - const tokens = command.split(/\s+/) - const resolved = resolveCommand(commands, tokens) - if ('fetchGateway' in resolved) - return error( - 'FETCH_GATEWAY_UNSUPPORTED', - 'Raw fetch gateways cannot be called through structured RPC. Mount the gateway with an OpenAPI spec to generate typed commands, or call the HTTP route directly.', - 400, - command, - ) - if (!('command' in resolved) || resolved.rest.length > 0) - return error('COMMAND_NOT_FOUND', 'Command not found.', 404, command) + if (result.kind === 'json') + return new Response(JSON.stringify(result.body), { + status: result.status, + headers: { 'content-type': 'application/json' }, + }) - return executeCommand(resolved.path, resolved.command, [], rpcOptions, start, { - ...options, - groupMiddlewares: resolved.middlewares, - structuredArgs: args, + 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' }, }) } @@ -1888,11 +1916,10 @@ async function executeCommand( env: options.envSchema, format: 'json', formatExplicit: true, - inputArgs: options.structuredArgs, inputOptions, middlewares: allMiddleware, name: options.name ?? path, - parseMode: options.structuredArgs === undefined ? 'split' : 'structured', + parseMode: 'split', path, vars: options.vars, version: options.version, @@ -2538,6 +2565,41 @@ export type CommandsMap = Record< } > +/** @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 @@ -3139,19 +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 - -/** @internal Shape of a command entry inferred from command schemas. */ -type CommandMapEntry< - args extends z.ZodObject | undefined = undefined, - options extends z.ZodObject | undefined = undefined, - output extends z.ZodType | undefined = undefined, -> = { - args: InferOutput - options: InferOutput -} & (output extends z.ZodType ? { output: InferReturn } : {}) +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> = diff --git a/src/Client.test-d.ts b/src/Client.test-d.ts index 3af033f..5eba1ae 100644 --- a/src/Client.test-d.ts +++ b/src/Client.test-d.ts @@ -1,4 +1,12 @@ -import { Cli, ClientError, createClient, isClientRpcError, isClientRpcErrorEnvelope } from 'incur' +import { + Cli, + ClientError, + createClient, + createMemoryClient, + isClientRpcError, + isClientRpcErrorEnvelope, + z, +} from 'incur' import type { ClientRpcError, ClientRpcErrorEnvelope } from 'incur' import { expectTypeOf, test } from 'vitest' @@ -230,6 +238,29 @@ test('createClient returns async iterables for streaming commands', () => { 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: { @@ -373,3 +404,253 @@ test('createClient consumes OpenAPI mounted command maps inferred from Cli insta // @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 index a4e5213..d073d01 100644 --- a/src/Client.test.ts +++ b/src/Client.test.ts @@ -2,8 +2,10 @@ import { Cli, ClientError, createClient, + createMemoryClient, isClientRpcError, isClientRpcErrorEnvelope, + middleware, z, } from 'incur' @@ -183,7 +185,7 @@ describe('createClient', () => { }) const stream = await client('logs')() - const chunks = [] + const chunks: { line: string }[] = [] for await (const chunk of stream) chunks.push(chunk) expect(chunks).toEqual([{ line: 'one' }, { line: 'two' }]) @@ -210,6 +212,27 @@ describe('createClient', () => { ).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() }), @@ -236,7 +259,7 @@ describe('createClient', () => { args: { prefix: 'line' }, options: { count: 2 }, }) - const chunks = [] + const chunks: { line: string }[] = [] for await (const chunk of stream) chunks.push(chunk) expect(chunks).toEqual([{ line: 'line-1' }, { line: 'line-2' }]) @@ -509,3 +532,399 @@ describe('createClient', () => { } }) }) + +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 index 6ccff33..46e3e40 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -1,5 +1,7 @@ +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 = { @@ -43,6 +45,8 @@ type RuntimeInput = { options?: unknown | undefined } +type Executor = (command: string, input: RuntimeInput) => Promise + type Envelope = { data?: unknown | undefined error?: unknown | undefined @@ -64,43 +68,77 @@ type ClientOptions = { 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 ((command: string) => - async (input: RuntimeInput = {}) => { - let response: Response - try { - response = await fetch(endpoint(options.baseUrl), { - body: JSON.stringify({ - command, - args: input.args ?? {}, - options: input.options ?? {}, - }), - headers: { - accept: 'application/json, application/x-ndjson', - 'content-type': 'application/json', - }, - method: 'POST', - }) - } catch (error) { - throw new ClientError('RPC request failed', { cause: error }) - } + 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) + if (isStreamingResponse(response)) return parseStreamingResponse(response) - const envelope = await parseResponse(response) - if (envelope.ok) return envelope.data + const envelope = await parseResponse(response) + return unwrapEnvelope(envelope, response.status) + }) +} - const message = errorMessage(envelope.error, 'RPC command failed') - throw new ClientError(message, { - data: envelope, - error: envelope.error, - status: response.status, - }) - }) as 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 +/** 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 { @@ -134,6 +172,17 @@ async function parseResponse(response: Response): Promise { 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', { @@ -189,6 +238,21 @@ async function* parseStreamingResponse(response: Response): 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, @@ -204,6 +268,19 @@ function readStreamRecord( }) } + 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, diff --git a/src/index.ts b/src/index.ts index 34251a6..645b1e4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export { z } from 'zod' -export { createClient } from './Client.js' +export { createClient, createMemoryClient } from './Client.js' export { ClientError, isClientRpcError, isClientRpcErrorEnvelope } from './Errors.js' export type { ClientRpcEnvelope, 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` +}