From eaec6f9a8aac3323bc4238a1170181d634d106cf Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Wed, 27 May 2026 14:01:30 +1000 Subject: [PATCH 1/2] feat: support hosted openapi cli sources --- .changeset/wise-hosts-fetch.md | 5 ++ src/Cli.test-d.ts | 45 ++++++++++- src/Cli.ts | 89 ++++++++++++++++----- src/Fetch.test.ts | 64 ++++++++++++++- src/Fetch.ts | 49 ++++++++++++ src/Openapi.test.ts | 54 +++++++++++++ src/Openapi.ts | 38 +++++++++ src/e2e.test.ts | 142 ++++++++++++++++++++++++++++++++- 8 files changed, 464 insertions(+), 22 deletions(-) create mode 100644 .changeset/wise-hosts-fetch.md diff --git a/.changeset/wise-hosts-fetch.md b/.changeset/wise-hosts-fetch.md new file mode 100644 index 0000000..5671a56 --- /dev/null +++ b/.changeset/wise-hosts-fetch.md @@ -0,0 +1,5 @@ +--- +"incur": minor +--- + +Added hosted OpenAPI command generation from `Fetch.fromRequest` sources. diff --git a/src/Cli.test-d.ts b/src/Cli.test-d.ts index 1679727..e9451c9 100644 --- a/src/Cli.test-d.ts +++ b/src/Cli.test-d.ts @@ -1,4 +1,4 @@ -import { Cli, middleware, z } from 'incur' +import { Cli, Fetch, middleware, z } from 'incur' import type { MiddlewareHandler } from 'incur' import { expectTypeOf, test } from 'vitest' @@ -38,6 +38,49 @@ test('without schemas, run receives empty objects', () => { }) }) +test('fetch command accepts OpenAPI object and URL sources', () => { + const cli = Cli.create('test') + const fetch = () => new Response() + + cli.command('apiJson', { + fetch, + openapi: { paths: {} }, + }) + + cli.command('apiString', { + fetch, + openapi: 'https://api.example.com/openapi.json', + }) + + cli.command('apiUrl', { + fetch, + openapi: new URL('https://api.example.com/openapi.json'), + }) + + cli.command('hostedApi', { + fetch: Fetch.fromRequest('https://api.example.com'), + openapi: 'openapi.json', + }) +}) + +test('root fetch accepts hosted request sources with OpenAPI paths', () => { + Cli.create('test', { + fetch: Fetch.fromRequest('https://api.example.com'), + openapi: '/openapi.json', + }) + + Cli.create('test', { + fetch: Fetch.fromRequest(new URL('https://api.example.com')), + openapi: new URL('https://api.example.com/openapi.json'), + }) + + Cli.create('test', { + // @ts-expect-error -- hosted fetches must use Fetch.fromRequest. + fetch: 'https://api.example.com', + openapi: '/openapi.json', + }) +}) + test('output constrains run return type', () => { const cli = Cli.create('test') cli.command('greet', { diff --git a/src/Cli.ts b/src/Cli.ts index 8af1e64..da7821f 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -80,8 +80,8 @@ export type Cli< definition: { basePath?: string | undefined description?: string | undefined - fetch: FetchHandler - openapi?: Openapi.OpenAPISpec | undefined + fetch: FetchSource + openapi?: Openapi.OpenAPISource | undefined outputPolicy?: OutputPolicy | undefined }, ): Cli @@ -205,13 +205,26 @@ export function create( 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 rootFetch = 'fetch' in def ? (def.fetch as FetchHandler) : undefined + const rootFetchSource = + 'fetch' in def && def.fetch !== undefined ? (def.fetch as FetchSource) : undefined + const rootFetch = rootFetchSource === undefined ? undefined : resolveFetch(rootFetchSource) + const rootFetchBaseUrl = rootFetchSource === undefined ? undefined : fetchBaseUrl(rootFetchSource) const commands = new Map() const middlewares: MiddlewareHandler[] = [] const pending: Promise[] = [] const mcpHandler = createMcpHttpHandler(name, def.version ?? '0.0.0') + if (def.openapi && rootFetch) { + pending.push( + (async () => { + const spec = await Openapi.resolve(def.openapi, { baseUrl: rootFetchBaseUrl }) + const generated = await Openapi.generateCommands(spec, rootFetch) + for (const [name, command] of generated) commands.set(name, command) + })(), + ) + } + const cli: Cli = { name, description: def.description, @@ -220,20 +233,25 @@ export function create( command(nameOrCli: any, def?: any): any { if (typeof nameOrCli === 'string') { - if (def && 'fetch' in def && typeof def.fetch === 'function') { + if (def && 'fetch' in def && isFetchSource(def.fetch)) { + const fetch = resolveFetch(def.fetch) // OpenAPI + fetch → generate typed command group (async, resolved before serve) 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) - }, - ), + (async () => { + const spec = await Openapi.resolve(def.openapi, { + baseUrl: fetchBaseUrl(def.fetch), + }) + const generated = await Openapi.generateCommands(spec, 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 } @@ -241,7 +259,7 @@ export function create( _fetch: true, basePath: def.basePath, description: def.description, - fetch: def.fetch, + fetch, ...(def.outputPolicy ? { outputPolicy: def.outputPolicy } : undefined), } as InternalFetchGateway) return cli @@ -364,8 +382,10 @@ export declare namespace create { env?: env | undefined /** Usage examples for this command. */ examples?: Example[] | undefined - /** A fetch handler to use as the root command. All argv tokens are interpreted as path segments and curl-style flags. */ - fetch?: FetchHandler | undefined + /** A fetch handler or hosted fetch source to use as the root command. All argv tokens are interpreted as path segments and curl-style flags. */ + fetch?: FetchSource | undefined + /** OpenAPI spec source used to generate typed root commands for the root fetch handler. */ + openapi?: Openapi.OpenAPISource | undefined /** Default output format. Overridden by `--format` or `--json`. */ format?: Formatter.Format | undefined /** Zod schema for named options/flags. */ @@ -960,7 +980,18 @@ async function serveImpl( // --help on a fetch gateway → show fetch-specific help if (help && 'fetchGateway' in resolved) { const commandName = resolved.path === name ? name : `${name} ${resolved.path}` - writeln(formatFetchHelp(commandName, resolved.fetchGateway.description)) + if (resolved.path === name && commands.size > 0) + writeln( + Help.formatRoot(name, { + aliases: options.aliases, + configFlag, + description: options.description, + version: options.version, + commands: collectHelpCommands(commands), + root: true, + }), + ) + else writeln(formatFetchHelp(commandName, resolved.fetchGateway.description)) return } @@ -2420,7 +2451,10 @@ type CommandEntry = export type OutputPolicy = 'agent-only' | 'all' /** A standard Fetch API handler. */ -type FetchHandler = (req: Request) => Response | Promise +export type FetchHandler = Fetch.Handler + +/** Fetch handler or hosted source used by fetch-backed commands. */ +export type FetchSource = Fetch.Source /** @internal A command group's internal storage. */ type InternalGroup = { @@ -2440,6 +2474,23 @@ type InternalFetchGateway = { outputPolicy?: OutputPolicy | undefined } +function isFetchSource(value: unknown): value is FetchSource { + if (typeof value === 'function') return true + if (typeof value !== 'object' || value === null) return false + + const source = value as { fetch?: unknown; url?: unknown } + return typeof source.fetch === 'function' && source.url instanceof URL +} + +function resolveFetch(source: FetchSource): FetchHandler { + if (typeof source === 'function') return source + return source.fetch +} + +function fetchBaseUrl(source: FetchSource) { + return typeof source === 'function' ? undefined : source.url +} + /** @internal Type guard for command groups. */ function isGroup(entry: CommandEntry): entry is InternalGroup { return '_group' in entry diff --git a/src/Fetch.test.ts b/src/Fetch.test.ts index b003a6e..7b74404 100644 --- a/src/Fetch.test.ts +++ b/src/Fetch.test.ts @@ -1,8 +1,70 @@ -import { describe, expect, test } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import { app } from '../test/fixtures/hono-api.js' import * as Fetch from './Fetch.js' +describe('fromRequest', () => { + test('forwards requests to the hosted base URL', async () => { + const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + expect(request.url).toMatchInlineSnapshot(`"https://api.example.com/api/users?limit=5"`) + expect(request.method).toMatchInlineSnapshot(`"GET"`) + expect(request.headers.get('authorization')).toMatchInlineSnapshot(`"Bearer token"`) + expect(request.headers.get('x-api-key')).toMatchInlineSnapshot(`"key_test"`) + return Response.json({ ok: true }) + }) + + try { + const source = Fetch.fromRequest('https://api.example.com/api', { + headers: { authorization: 'Bearer token' }, + }) + const response = await source.fetch( + new Request('http://localhost/users?limit=5', { + headers: { 'x-api-key': 'key_test' }, + }), + ) + + expect(await response.json()).toMatchInlineSnapshot(` + { + "ok": true, + } + `) + } finally { + fetch.mockRestore() + } + }) + + test('preserves request method and body', async () => { + const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + expect(request.url).toMatchInlineSnapshot(`"https://api.example.com/api/users"`) + expect(request.method).toMatchInlineSnapshot(`"POST"`) + expect(request.headers.get('content-type')).toMatchInlineSnapshot(`"application/json"`) + expect(await request.text()).toMatchInlineSnapshot(`"{"name":"Ada"}"`) + return Response.json({ created: true }) + }) + + try { + const source = Fetch.fromRequest(new URL('https://api.example.com/api')) + const response = await source.fetch( + new Request('http://localhost/users', { + body: '{"name":"Ada"}', + headers: { 'content-type': 'application/json' }, + method: 'POST', + }), + ) + + expect(await response.json()).toMatchInlineSnapshot(` + { + "created": true, + } + `) + } finally { + fetch.mockRestore() + } + }) +}) + describe('parseArgv', () => { test('bare tokens → path segments', () => { const input = Fetch.parseArgv(['users', 'list']) diff --git a/src/Fetch.ts b/src/Fetch.ts index 33311a3..2f9b5b7 100644 --- a/src/Fetch.ts +++ b/src/Fetch.ts @@ -15,10 +15,59 @@ export type FetchOutput = { status: number } +/** A standard Fetch API handler. */ +export type Handler = (req: Request) => Response | Promise + +/** Fetch source accepted by fetch-backed CLI commands. */ +export type Source = Handler | RequestSource + +/** Hosted request source created from a base URL. */ +export type RequestSource = { + /** Handles a forwarded request. */ + fetch: Handler + /** Base URL used to resolve relative OpenAPI documents. */ + url: URL +} + /** Reserved flags consumed by the fetch gateway (not forwarded as query params). */ const reservedFlags = new Set(['method', 'body', 'data', 'header']) const reservedShort: Record = { X: 'method', d: 'data', H: 'header' } +/** Creates a hosted fetch source from a base request URL and shared request options. */ +export function fromRequest(url: string | URL, options: fromRequest.Options = {}): RequestSource { + const base = new URL(url) + const { headers: defaultHeaders, ...init } = options + + return { + url: base, + fetch(request) { + const incoming = new URL(request.url) + const target = new URL(base) + target.pathname = joinPath(base.pathname, incoming.pathname) + target.search = incoming.search + + const headers = new Headers(defaultHeaders) + request.headers.forEach((value, key) => headers.set(key, value)) + + return fetch(new Request(new Request(target, request), { ...init, headers })) + }, + } +} + +export declare namespace fromRequest { + /** Request options applied to every forwarded request. */ + type Options = Omit & { + /** Headers merged into every forwarded request. Per-request headers take precedence. */ + headers?: HeadersInit | undefined + } +} + +function joinPath(basePath: string, path: string) { + const prefix = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath + const suffix = path.startsWith('/') ? path : `/${path}` + return `${prefix}${suffix}` || '/' +} + /** Parses curl-style argv into a structured fetch input. */ export function parseArgv(argv: string[]): FetchInput { const segments: string[] = [] diff --git a/src/Openapi.test.ts b/src/Openapi.test.ts index 398da04..c563d60 100644 --- a/src/Openapi.test.ts +++ b/src/Openapi.test.ts @@ -12,6 +12,7 @@ import { app } from '../test/fixtures/hono-api.js' import { app as openapiApp, spec as openapiSpec } from '../test/fixtures/hono-openapi-app.js' import { spec } from '../test/fixtures/openapi-spec.js' import * as Cli from './Cli.js' +import * as Fetch from './Fetch.js' import * as Openapi from './Openapi.js' function serve(cli: { serve: Cli.Cli['serve'] }, argv: string[]) { @@ -34,6 +35,25 @@ function json(output: string) { return JSON.parse(output.replace(/"duration": "[^"]+"/g, '"duration": ""')) } +function openapiUrl() { + return `data:application/json,${encodeURIComponent(JSON.stringify(spec))}` +} + +function hostedApiFetch() { + return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + const url = new URL(request.url) + + if (url.href === 'https://api.example.com/api/openapi.json') return Response.json(spec) + if (url.pathname === '/api/users') { + url.pathname = '/users' + return app.fetch(new Request(url, request)) + } + + return new Response('Not Found', { status: 404 }) + }) +} + describe('fromCli', () => { test('generates OpenAPI 3.2 paths with inferred methods', () => { const cli = Cli.create('api', { description: 'API', version: '1.2.3' }) @@ -166,6 +186,40 @@ describe('cli integration', () => { expect(json(output).limit).toBe(5) }) + test('loads OpenAPI commands from a spec URL string', async () => { + const cli = Cli.create('test', { description: 'test' }).command('api', { + fetch: app.fetch, + openapi: openapiUrl(), + }) + const { output } = await serve(cli, ['api', 'listUsers']) + expect(output).toContain('Alice') + }) + + test('loads OpenAPI commands from a spec URL object', async () => { + const cli = Cli.create('test', { description: 'test' }).command('api', { + fetch: app.fetch, + openapi: new URL(openapiUrl()), + }) + const { output } = await serve(cli, ['api', 'listUsers']) + expect(output).toContain('Alice') + }) + + test('generates root commands from hosted fetch and OpenAPI URLs', async () => { + const fetch = hostedApiFetch() + const cli = Cli.create('test', { + description: 'test', + fetch: Fetch.fromRequest('https://api.example.com/api'), + openapi: 'openapi.json', + }) + + try { + const { output } = await serve(cli, ['listUsers', '--limit', '5', '--format', 'json']) + expect(json(output).limit).toBe(5) + } finally { + fetch.mockRestore() + } + }) + test('GET /users/:id via positional arg', async () => { const { output } = await serve(createCli(), ['api', 'getUser', '42']) expect(output).toMatchInlineSnapshot(` diff --git a/src/Openapi.ts b/src/Openapi.ts index b13d327..252a5f3 100644 --- a/src/Openapi.ts +++ b/src/Openapi.ts @@ -14,6 +14,9 @@ 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 } +/** OpenAPI document source accepted by fetch-backed CLI commands. */ +export type OpenAPISource = OpenAPISpec | string | URL + /** Options for generating an OpenAPI document from an incur CLI. */ export type GenerateOptions = { /** API description. Defaults to the CLI description. */ @@ -265,6 +268,41 @@ function encodePathSegment(segment: string) { return encodeURIComponent(segment) } +/** Resolves an OpenAPI document from a JSON object or JSON URL. */ +export async function resolve( + source: OpenAPISource, + options: resolve.Options = {}, +): Promise { + if (typeof source !== 'string' && !(source instanceof URL)) return source + + const response = await fetch(resolveUrl(source, options.baseUrl)) + if (!response.ok) + throw new Error(`Failed to fetch OpenAPI spec from ${source}: ${response.status}`) + return (await response.json()) as OpenAPISpec +} + +export declare namespace resolve { + /** Options for resolving an OpenAPI document source. */ + type Options = { + /** Base URL used to resolve relative OpenAPI document paths. */ + baseUrl?: string | URL | undefined + } +} + +function resolveUrl(source: string | URL, baseUrl: string | URL | undefined) { + if (source instanceof URL) return source + + try { + return new URL(source) + } catch { + if (baseUrl === undefined) + throw new Error(`Relative OpenAPI spec URL requires a fetch URL base: ${source}`) + const base = new URL(baseUrl) + if (!base.pathname.endsWith('/')) base.pathname = `${base.pathname}/` + return new URL(source, base) + } +} + /** Generates incur command entries from an OpenAPI spec. Resolves all `$ref` pointers. */ export async function generateCommands( spec: OpenAPISpec, diff --git a/src/e2e.test.ts b/src/e2e.test.ts index f44516a..cf5c8e6 100644 --- a/src/e2e.test.ts +++ b/src/e2e.test.ts @@ -1,6 +1,7 @@ -import { Cli, Errors, Skill, Typegen, z } from 'incur' +import { Cli, Errors, Fetch, Skill, Typegen, z } from 'incur' import { app as honoApp } from '../test/fixtures/hono-api.js' +import { spec as openapiSpec } from '../test/fixtures/openapi-spec.js' let __mockSkillsHash: string | undefined let __mockSkillsInstalled = true @@ -2372,6 +2373,130 @@ describe('fetch gateway', () => { }) }) +describe('hosted OpenAPI CLI', () => { + test('runs root commands from a hosted fetch source and relative OpenAPI path', async () => { + const fetch = hostedOpenapiFetch() + const cli = Cli.create('test', { + fetch: Fetch.fromRequest('https://api.example.com/api'), + openapi: 'openapi.json', + }) + + try { + const { output } = await serve(cli, ['listUsers', '--limit', '5']) + expect(output).toMatchInlineSnapshot(` + "users[1]{id,name}: + 1,Alice + limit: 5 + " + `) + } finally { + fetch.mockRestore() + } + }) + + test('runs mounted commands from a hosted fetch source and URL OpenAPI spec', async () => { + const fetch = hostedOpenapiFetch() + const cli = Cli.create('test').command('api', { + fetch: Fetch.fromRequest('https://api.example.com/api'), + openapi: new URL('https://api.example.com/api/openapi.json'), + }) + + try { + const { output } = await serve(cli, ['api', 'getUser', '42']) + expect(output).toMatchInlineSnapshot(` + "id: 42 + name: Alice + " + `) + } finally { + fetch.mockRestore() + } + }) + + test('root help renders generated commands', async () => { + const fetch = hostedOpenapiFetch() + const cli = Cli.create('test', { + fetch: Fetch.fromRequest('https://api.example.com/api'), + openapi: 'openapi.json', + }) + + try { + const { output } = await serve(cli, ['--help']) + expect(output).toMatchInlineSnapshot(` + "test + + Usage: test + + Commands: + createUser Create a user + deleteUser Delete a user + getUser Get a user by ID + healthCheck Health check + listUsers List users + + Integrations: + completions Generate shell completion script + mcp add Register as MCP server + skills Sync skill files to agents (add, list) + + Global Options: + --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) + --format Output format + --full-output Show full output envelope + --help Show help + --llms, --llms-full Print LLM-readable manifest + --mcp Start as MCP stdio server + --schema Show JSON Schema for command + --token-count Print token count of output (instead of output) + --token-limit Limit output to n tokens + --token-offset Skip first n tokens of output + --version Show version + " + `) + } finally { + fetch.mockRestore() + } + }) + + test('mounted help renders generated commands', async () => { + const fetch = hostedOpenapiFetch() + const cli = Cli.create('test').command('api', { + fetch: Fetch.fromRequest('https://api.example.com/api'), + openapi: new URL('https://api.example.com/api/openapi.json'), + }) + + try { + const { output } = await serve(cli, ['api', '--help']) + expect(output).toMatchInlineSnapshot(` + "test api + + Usage: test api + + Commands: + createUser Create a user + deleteUser Delete a user + getUser Get a user by ID + healthCheck Health check + listUsers List users + + Global Options: + --filter-output Filter output by key paths (e.g. foo,bar.baz,a[0,3]) + --format Output format + --full-output Show full output envelope + --help Show help + --llms, --llms-full Print LLM-readable manifest + --schema Show JSON Schema for command + --token-count Print token count of output (instead of output) + --token-limit Limit output to n tokens + --token-offset Skip first n tokens of output + " + `) + } finally { + fetch.mockRestore() + } + }) +}) + async function fetchJson(cli: Cli.Cli, req: Request) { const res = await cli.fetch(req) const body = await res.json() @@ -3053,6 +3178,21 @@ function json(raw: string) { return JSON.parse(raw) } +function hostedOpenapiFetch() { + return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + const url = new URL(request.url) + + if (url.href === 'https://api.example.com/api/openapi.json') return Response.json(openapiSpec) + if (url.pathname.startsWith('/api/')) { + url.pathname = url.pathname.slice('/api'.length) + return honoApp.fetch(new Request(url, request)) + } + + return new Response('Not Found', { status: 404 }) + }) +} + function createMiddlewareApp() { const order: string[] = [] From 88426b7317d2714fe492edce22f323003ae932c3 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Wed, 27 May 2026 14:02:52 +1000 Subject: [PATCH 2/2] Update wise-hosts-fetch.md --- .changeset/wise-hosts-fetch.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/wise-hosts-fetch.md b/.changeset/wise-hosts-fetch.md index 5671a56..0d1f013 100644 --- a/.changeset/wise-hosts-fetch.md +++ b/.changeset/wise-hosts-fetch.md @@ -1,5 +1,5 @@ --- -"incur": minor +"incur": patch --- Added hosted OpenAPI command generation from `Fetch.fromRequest` sources.