diff --git a/README.md b/README.md index 3f51002..33a9573 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ Or run via `npx @kodycodes/cli` without a global install. | `kody auth bootstrap --code` | Redeems a one-shot `kody_bc_…` from MCP `cliCredentialBootstrap` and stores the resulting `kody_at_…` for `execute --local` (never prints the token). | | `kody whoami` | Confirms the CLI MCP connection and lists tools. With a scoped API token (and no login), shows token identity via the Open API. | | `kody search [query]` | Calls Kody `search` from the CLI (prefer the host MCP tool). Token-only auth uses Open API `GET /v1/search`. | +| `kody api ` | Thin Open API wrapper matching the MCP `api` tool: `operationId` + flat `--params` JSON. Auth: `--token` / `KODY_API_TOKEN` / stored `auth bootstrap` token. | | `kody execute` | Calls Kody `execute` from the CLI (`--invoke`, `--code`, `--file`, or stdin via `--file -`). With a scoped API token and no login (or with `--token`), cloud execute goes through CapabilityProxy → `kody.execute` — no `kody login`. Add `--local` to run the module (and static `kody:@…` package modules) on this machine instead. | `--json` prints structured MCP results. @@ -93,6 +94,7 @@ npx @kodycodes/cli execute --code 'export default async () => ({ ok: true })' npx @kodycodes/cli execute --local --file ./task.js --params '{"to":"me@example.com"}' npx @kodycodes/cli search "what can you do" npx @kodycodes/cli whoami +npx @kodycodes/cli api usageGet --params '{}' ``` - Neither bootstrap store, `kody login`, nor a token → the error prefers diff --git a/src/cli.ts b/src/cli.ts index 8d2d4c9..5a975b9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,7 +19,11 @@ import { runInstall } from './install.js' import { resolveLocalExecuteBearer } from './local-execute-auth.js' import { runLocalExecute } from './local-execute.js' import { assertLocalExecuteNodeEngine } from './node-engine.js' -import { searchWithApiToken, whoamiWithApiToken } from './open-api-client.js' +import { + callOpenApiOperation, + searchWithApiToken, + whoamiWithApiToken, +} from './open-api-client.js' import { runRemoteExecuteWithToken } from './remote-execute.js' import { installSkill } from './skill.js' import { readPackageVersion } from './package-info.js' @@ -40,6 +44,7 @@ export type CommandName = | 'auth' | 'whoami' | 'search' + | 'api' | 'execute' | 'install' | 'skill' @@ -107,6 +112,7 @@ export function resolveCommand(argv: Array): { case 'auth': case 'whoami': case 'search': + case 'api': case 'execute': case 'install': case 'skill': @@ -260,7 +266,7 @@ async function dispatch( const scopes = result.stored.scopes?.join(', ') || '(none)' write( [ - `Bootstrap API token stored for execute --local, search, whoami, and token-auth cloud execute.`, + `Bootstrap API token stored for execute --local, search, whoami, api, and token-auth cloud execute.`, `api: ${result.stored.apiUrl}`, `token id: ${result.stored.tokenId}`, `scopes: ${scopes}`, @@ -462,6 +468,33 @@ async function dispatch( write(formatToolResult(result, json)) return result.isError ? 1 : 0 } + case 'api': { + const operationId = parsed.positionals[0]?.trim() ?? '' + if (!operationId || parsed.positionals.length > 1) { + throw new Error( + 'Usage: kody api [--params ] [--token ] [--api-url ] [--json]', + ) + } + const apiUrl = apiUrlFrom({ + apiUrl: + typeof parsed.values['api-url'] === 'string' + ? parsed.values['api-url'] + : undefined, + }) + const tokenValues = tokenFlagValues(parsed.values) + const params = parseApiParamsJson( + typeof parsed.values.params === 'string' ? parsed.values.params : undefined, + ) + const result = await callOpenApiOperation({ + operationId, + params, + token: requireApiToken(tokenValues, process.env, 'api', { apiUrl }), + apiUrl, + }) + // Always JSON: mirrors MCP `api` structured results for agents/scripts. + write(`${JSON.stringify(result, null, 2)}\n`) + return 0 + } case 'install': { const result = await runInstall( { @@ -563,6 +596,21 @@ function tokenFlagValues(values: ReturnType['values']): { } } +/** Parse `--params` JSON for `kody api` (flat object, same as MCP `api`). */ +export function parseApiParamsJson(paramsJson?: string): Record { + if (paramsJson === undefined) return {} + let parsed: unknown + try { + parsed = JSON.parse(paramsJson) + } catch { + throw new Error('--params must be valid JSON (a flat object).') + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('--params must be a JSON object (e.g. \'{"query":"email"}\').') + } + return parsed as Record +} + /** * Prefer a scoped API token when the user passed `--token`, or when * `KODY_API_TOKEN` / a stored bootstrap token is available and there is no diff --git a/src/help.ts b/src/help.ts index 2d379e7..cc4ef13 100644 --- a/src/help.ts +++ b/src/help.ts @@ -13,6 +13,7 @@ Usage: kody auth bootstrap --code [--api-url ] kody whoami [--mcp-url ] [--token ] [--api-url ] [--json] kody search [query] [--entity ] [--domain ] [--limit ] [--token ] [--api-url ] [--json] + kody api [--params ] [--token ] [--api-url ] [--json] kody execute [--invoke | --code | --file ] [--params ] [--conversation-id ] [--json] [--token ] [--api-url ] [--local] kody install [--mcp-url ] [--clients ] [--yes] [--project] [--json] @@ -28,13 +29,24 @@ Usage: Redeem a one-shot \`kody_bc_…\` from MCP \`cliCredentialBootstrap\` (POST /v1/tokens/bootstrap/redeem, no Authorization header). Stores the resulting \`kody_at_…\` for \`execute --local\`, - search, whoami, and token-auth cloud execute without printing - the token. Prefer this over tokenCreate for agents already on - MCP. Interactive humans can use \`kody login\` instead. + search, whoami, api, and token-auth cloud execute without + printing the token. Prefer this over tokenCreate for agents + already on MCP. Interactive humans can use \`kody login\` + instead. Do not call \`kody api cliCredentialBootstrapRedeem\` + — that would print the token. + + api Call one Open API operation by operationId + flat --params + JSON (same shape as the MCP \`api\` tool). Uses scoped API + auth only (\`--token\` / ${apiTokenEnvVar} / stored bootstrap). + Prints JSON. Example: \`kody api usageGet --params '{}'\`. + Unknown operationIds error clearly; see + ${defaultApiUrl}/openapi.json. tokenCreate / tokenRotate + responses include a one-time token value — prefer env storage + over pasting into chat. --token / ${apiTokenEnvVar} Scoped API token (preferred via env). With no \`kody login\` - session, search / whoami / execute use the Open API and + session, search / whoami / api / execute use the Open API and CapabilityProxy — including cloud execute without --local. Auth priority matches \`execute --local\`: \`--token\` / ${apiTokenEnvVar}; stored bootstrap/API token from @@ -58,7 +70,7 @@ Usage: Environment: KODY_MCP_URL Override the default MCP URL (${defaultMcpUrl}) - ${apiTokenEnvVar} Scoped API token for token-auth search / whoami / execute + ${apiTokenEnvVar} Scoped API token for token-auth search / whoami / api / execute KODY_API_URL Override the Kody API URL (${defaultApiUrl}) KODY_CACHE_DIR Where execute --local caches workerd (default: user cache dir) KODY_WORKERD_PATH Use this workerd binary instead of the pinned download diff --git a/src/open-api-client.ts b/src/open-api-client.ts index aa9634b..cf4ea43 100644 --- a/src/open-api-client.ts +++ b/src/open-api-client.ts @@ -26,18 +26,192 @@ export class OpenApiError extends Error { } } +/** Path to the live OpenAPI document on the Kody API origin. */ +export const openApiDocumentPath = 'openapi.json' + +/** + * Bootstrap redeem returns a `kody_at_…` once. Agents must use + * `kody auth bootstrap --code` so the token is stored and never printed. + */ +export const cliCredentialBootstrapRedeemOperationId = + 'cliCredentialBootstrapRedeem' + +export type OpenApiOperationRoute = { + operationId: string + method: string + pathTemplate: string + pathParamNames: Array +} + +const openApiRouteCache = new Map>() + export async function openApiGet( input: OpenApiClientInput & { path: string; query?: Record }, +): Promise { + return openApiRequest({ + ...input, + method: 'GET', + path: input.path, + query: input.query, + }) +} + +/** + * Authenticated Open API request (Bearer `kody_at_…`). Shared by search/whoami + * helpers and `kody api `. + */ +export async function openApiRequest( + input: OpenApiClientInput & { + method: string + path: string + query?: Record + body?: Record + }, ): Promise { const apiUrl = input.apiUrl || defaultApiUrl assertTokenSafeApiUrl(apiUrl) - const url = capabilityProxyUrl(apiUrl, input.path) + const url = capabilityProxyUrl(apiUrl, input.path.replace(/^\//, '')) if (input.query) { - for (const [key, value] of Object.entries(input.query)) { - if (value === undefined || value === '') continue - url.searchParams.set(key, String(value)) + appendQueryParams(url.searchParams, input.query) + } + const method = input.method.toUpperCase() + const headers: Record = { + accept: 'application/json', + authorization: `Bearer ${input.token}`, + 'user-agent': `${cliName}/${readPackageVersion()}`, + } + let body: string | undefined + if (input.body !== undefined && method !== 'GET' && method !== 'HEAD') { + headers['content-type'] = 'application/json' + body = JSON.stringify(input.body) + } + const fetchFn = input.fetchFn ?? fetch + let response: Response + try { + response = await fetchFn(url, { method, headers, body }) + } catch (error) { + const reason = describeNetworkError(error) + throw new OpenApiError( + `Could not reach the Kody API at ${url.origin} (${reason}). Check --api-url / KODY_API_URL and your network.`, + ) + } + const responseBody = await readJson(response) + if (!response.ok) { + throw describeOpenApiFailure(response.status, responseBody, url) + } + return responseBody +} + +/** + * Call one Open API operation by `operationId` + flat `params`, matching the + * MCP `api` tool shape. Resolves method/path from `/openapi.json`. + */ +export async function callOpenApiOperation( + input: OpenApiClientInput & { + operationId: string + params?: Record + /** Test seam: skip /openapi.json fetch. */ + operations?: Map + }, +): Promise { + const operationId = input.operationId.trim() + if (!operationId) { + throw new OpenApiError('Provide an Open API operationId (e.g. usageGet, metaGetCurrentUser).') + } + assertApiOperationAllowed(operationId) + const apiUrl = input.apiUrl || defaultApiUrl + const operations = + input.operations ?? (await loadOpenApiOperations({ apiUrl, fetchFn: input.fetchFn })) + const route = operations.get(operationId) + if (!route) { + throw new OpenApiError( + `Unknown operationId "${operationId}". Operation ids are listed in ${capabilityProxyUrl(apiUrl, openApiDocumentPath).href} and match Kody capability names (plus token operations such as tokenCreate).`, + { status: 404, code: 'not_found' }, + ) + } + const built = buildOpenApiHttpRequest({ + route, + params: input.params ?? {}, + }) + return openApiRequest({ + token: input.token, + apiUrl, + fetchFn: input.fetchFn, + method: built.method, + path: built.path, + query: built.query, + body: built.body, + }) +} + +export function assertApiOperationAllowed(operationId: string): void { + if (operationId === cliCredentialBootstrapRedeemOperationId) { + throw new OpenApiError( + `Refusing to call ${cliCredentialBootstrapRedeemOperationId}: redeem returns a kody_at_… token that must not print to stdout. Use \`kody auth bootstrap --code \` instead (stores the token; never prints it).`, + { status: null, code: 'refused' }, + ) + } +} + +/** + * Split flat MCP-style params into path / query / body for an Open API route. + * GET and DELETE send non-path fields as query; other methods send them as JSON body. + */ +export function buildOpenApiHttpRequest(input: { + route: OpenApiOperationRoute + params: Record +}): { + method: string + path: string + query?: Record + body?: Record +} { + const remaining: Record = { ...input.params } + const pathParams: Record = {} + for (const name of input.route.pathParamNames) { + if (!(name in remaining) || remaining[name] === undefined || remaining[name] === null) { + throw new OpenApiError( + `Missing required path parameter "${name}" for ${input.route.operationId} (${input.route.pathTemplate}).`, + ) + } + pathParams[name] = String(remaining[name]) + delete remaining[name] + } + const path = fillPathTemplate(input.route.pathTemplate, pathParams) + const method = input.route.method.toUpperCase() + if (apiOperationUsesQueryInputs(method)) { + return { + method, + path, + ...(Object.keys(remaining).length > 0 ? { query: remaining } : {}), } } + return { + method, + path, + body: remaining, + } +} + +export function apiOperationUsesQueryInputs(method: string): boolean { + const upper = method.toUpperCase() + return upper === 'GET' || upper === 'DELETE' +} + +export async function loadOpenApiOperations(input: { + apiUrl?: string + fetchFn?: typeof fetch + /** Bypass cache (tests). */ + bustCache?: boolean +}): Promise> { + const apiUrl = input.apiUrl || defaultApiUrl + assertTokenSafeApiUrl(apiUrl) + const cacheKey = new URL(apiUrl).origin + if (!input.bustCache) { + const cached = openApiRouteCache.get(cacheKey) + if (cached) return cached + } + const url = capabilityProxyUrl(apiUrl, openApiDocumentPath) const fetchFn = input.fetchFn ?? fetch let response: Response try { @@ -45,21 +219,99 @@ export async function openApiGet( method: 'GET', headers: { accept: 'application/json', - authorization: `Bearer ${input.token}`, 'user-agent': `${cliName}/${readPackageVersion()}`, }, }) } catch (error) { const reason = describeNetworkError(error) throw new OpenApiError( - `Could not reach the Kody API at ${url.origin} (${reason}). Check --api-url / KODY_API_URL and your network.`, + `Could not load OpenAPI from ${url.href} (${reason}). Check --api-url / KODY_API_URL and your network.`, ) } const body = await readJson(response) if (!response.ok) { - throw describeOpenApiFailure(response.status, body, url) + throw new OpenApiError( + `Could not load OpenAPI from ${url.href} (HTTP ${response.status}).`, + { status: response.status }, + ) } - return body + const operations = indexOpenApiOperations(body) + openApiRouteCache.set(cacheKey, operations) + return operations +} + +/** Visible for tests. */ +export function indexOpenApiOperations(document: unknown): Map { + if (!isRecord(document) || !isRecord(document.paths)) { + throw new OpenApiError('OpenAPI document is missing a paths object.') + } + const operations = new Map() + for (const [pathTemplate, methods] of Object.entries(document.paths)) { + if (!isRecord(methods)) continue + for (const [method, operation] of Object.entries(methods)) { + if (!isRecord(operation)) continue + const operationId = + typeof operation.operationId === 'string' ? operation.operationId : '' + if (!operationId) continue + operations.set(operationId, { + operationId, + method: method.toUpperCase(), + pathTemplate, + pathParamNames: pathParamNamesFromTemplate(pathTemplate), + }) + } + } + return operations +} + +/** Clear the in-memory OpenAPI route cache (tests). */ +export function clearOpenApiRouteCache(): void { + openApiRouteCache.clear() +} + +function pathParamNamesFromTemplate(pathTemplate: string): Array { + const names: Array = [] + for (const match of pathTemplate.matchAll(/\{([^{}/]+)\}/g)) { + const name = match[1] + if (name) names.push(name) + } + return names +} + +function fillPathTemplate( + pathTemplate: string, + pathParams: Record, +): string { + return pathTemplate.replace(/\{([^{}/]+)\}/g, (_full, name: string) => { + const value = pathParams[name] + if (value === undefined) { + throw new OpenApiError(`Missing path parameter "${name}" for ${pathTemplate}.`) + } + return encodeURIComponent(value) + }) +} + +function appendQueryParams( + searchParams: URLSearchParams, + query: Record, +): void { + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null || value === '') continue + if (Array.isArray(value)) { + for (const entry of value) { + if (entry === undefined || entry === null) continue + searchParams.append(key, stringifyQueryValue(entry)) + } + continue + } + searchParams.set(key, stringifyQueryValue(value)) + } +} + +function stringifyQueryValue(value: unknown): string { + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + return JSON.stringify(value) } export async function searchWithApiToken(input: OpenApiClientInput & { diff --git a/test/cli.test.ts b/test/cli.test.ts index 1c7280b..402b384 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -7,6 +7,7 @@ import { apiUrlFrom, buildExecuteToolArgs, executeSourcesConflict, + parseApiParamsJson, resolveApiToken, resolveCommand, resolveLocalExecuteBearer, @@ -16,6 +17,7 @@ import { import type { StoredApiToken } from '../src/api-token-store.js' import { modernMcpProtocolVersion } from '../src/defaults.js' import { formatToolResult, listKodyTools } from '../src/mcp.js' +import { clearOpenApiRouteCache } from '../src/open-api-client.js' import { redact } from '../src/redact.js' import { createFileBackend, @@ -44,6 +46,8 @@ function sampleLoginCredentials( test('resolveCommand maps subcommands and flags', () => { assert.equal(resolveCommand(['search', 'what can you do']).command, 'search') + assert.equal(resolveCommand(['api', 'usageGet']).command, 'api') + assert.equal(resolveCommand(['api', 'usageGet']).positionals[0], 'usageGet') assert.equal(resolveCommand(['install', '--yes']).command, 'install') assert.equal(resolveCommand(['auth', 'bootstrap', '--code', 'kody_bc_x']).command, 'auth') assert.equal(resolveCommand(['auth', 'bootstrap']).positionals[0], 'bootstrap') @@ -60,6 +64,14 @@ test('resolveCommand maps subcommands and flags', () => { assert.throws(() => resolveCommand(['explode']), /Unknown command/) }) +test('parseApiParamsJson requires a flat object', () => { + assert.deepEqual(parseApiParamsJson(undefined), {}) + assert.deepEqual(parseApiParamsJson('{"query":"email"}'), { query: 'email' }) + assert.throws(() => parseApiParamsJson('['), /valid JSON/) + assert.throws(() => parseApiParamsJson('[]'), /JSON object/) + assert.throws(() => parseApiParamsJson('"x"'), /JSON object/) +}) + test('resolveCommand parses execute --local flags', () => { const { values } = resolveCommand([ 'execute', @@ -216,6 +228,94 @@ test('resolveApiToken falls back to a stored bootstrap token', () => { ) }) +test('api command calls Open API by operationId with scoped token', async () => { + clearOpenApiRouteCache() + const previousFetch = globalThis.fetch + const calls: Array = [] + globalThis.fetch = (async (input: Parameters[0], init?: RequestInit) => { + const url = new URL(String(input)) + calls.push(`${(init?.method ?? 'GET').toUpperCase()} ${url.pathname}`) + if (url.pathname === '/openapi.json') { + return new Response( + JSON.stringify({ + paths: { + '/v1/account/usage': { get: { operationId: 'usageGet' } }, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ) + } + if (url.pathname === '/v1/account/usage') { + assert.equal( + init?.headers && (init.headers as Record).authorization, + 'Bearer kody_at_test', + ) + return new Response(JSON.stringify({ plan: 'pro' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + return new Response('nope', { status: 404 }) + }) as typeof fetch + let stdout = '' + let stderr = '' + try { + const code = await runCli( + [ + 'api', + 'usageGet', + '--params', + '{}', + '--token', + 'kody_at_test', + '--api-url', + 'https://api.kody.codes', + ], + { + stdout: (text) => { + stdout += text + }, + stderr: (text) => { + stderr += text + }, + }, + ) + assert.equal(code, 0, stderr) + assert.match(stdout, /"plan": "pro"/) + assert.deepEqual(calls, ['GET /openapi.json', 'GET /v1/account/usage']) + } finally { + globalThis.fetch = previousFetch + clearOpenApiRouteCache() + } +}) + +test('api command refuses bootstrap redeem and documents auth bootstrap', async () => { + let stderr = '' + const code = await runCli( + ['api', 'cliCredentialBootstrapRedeem', '--params', '{"code":"kody_bc_x"}', '--token', 'tok'], + { + stdout: () => undefined, + stderr: (text) => { + stderr += text + }, + }, + ) + assert.equal(code, 1) + assert.match(stderr, /auth bootstrap --code/) +}) + +test('help documents the api command', async () => { + let stdout = '' + const code = await runCli(['help'], { + stdout: (text) => { + stdout += text + }, + }) + assert.equal(code, 0) + assert.match(stdout, /kody api /) + assert.match(stdout, /usageGet/) +}) + test('execute with --token (no --local) uses CapabilityProxy and never requires login', async () => { const previousMcpUrl = process.env.KODY_MCP_URL process.env.KODY_MCP_URL = 'http://127.0.0.1:9/unreachable-mcp' diff --git a/test/open-api-client.test.ts b/test/open-api-client.test.ts index f13108e..0076867 100644 --- a/test/open-api-client.test.ts +++ b/test/open-api-client.test.ts @@ -2,6 +2,12 @@ import assert from 'node:assert/strict' import { test } from 'node:test' import { OpenApiError, + assertApiOperationAllowed, + buildOpenApiHttpRequest, + callOpenApiOperation, + clearOpenApiRouteCache, + cliCredentialBootstrapRedeemOperationId, + indexOpenApiOperations, searchWithApiToken, whoamiWithApiToken, } from '../src/open-api-client.js' @@ -134,3 +140,204 @@ test('whoamiWithApiToken tolerates missing account:read on /me', async () => { assert.equal(identity.user, null) assert.equal(identity.token.id, 'tok_1') }) + +test('indexOpenApiOperations maps operationId to method and path params', () => { + const ops = indexOpenApiOperations({ + paths: { + '/v1/account/usage': { + get: { operationId: 'usageGet' }, + }, + '/v1/secrets/{scope}/{name}': { + put: { operationId: 'secretSet' }, + }, + '/v1/search': { + get: { operationId: 'search' }, + }, + }, + }) + assert.deepEqual(ops.get('usageGet'), { + operationId: 'usageGet', + method: 'GET', + pathTemplate: '/v1/account/usage', + pathParamNames: [], + }) + assert.deepEqual(ops.get('secretSet'), { + operationId: 'secretSet', + method: 'PUT', + pathTemplate: '/v1/secrets/{scope}/{name}', + pathParamNames: ['scope', 'name'], + }) +}) + +test('buildOpenApiHttpRequest splits path / query / body like MCP api params', () => { + assert.deepEqual( + buildOpenApiHttpRequest({ + route: { + operationId: 'usageGet', + method: 'GET', + pathTemplate: '/v1/account/usage', + pathParamNames: [], + }, + params: {}, + }), + { method: 'GET', path: '/v1/account/usage' }, + ) + assert.deepEqual( + buildOpenApiHttpRequest({ + route: { + operationId: 'search', + method: 'GET', + pathTemplate: '/v1/search', + pathParamNames: [], + }, + params: { query: 'email', limit: 5 }, + }), + { method: 'GET', path: '/v1/search', query: { query: 'email', limit: 5 } }, + ) + assert.deepEqual( + buildOpenApiHttpRequest({ + route: { + operationId: 'secretSet', + method: 'PUT', + pathTemplate: '/v1/secrets/{scope}/{name}', + pathParamNames: ['scope', 'name'], + }, + params: { scope: 'user', name: 'api-key', value: 'secret' }, + }), + { + method: 'PUT', + path: '/v1/secrets/user/api-key', + body: { value: 'secret' }, + }, + ) + assert.throws( + () => + buildOpenApiHttpRequest({ + route: { + operationId: 'secretSet', + method: 'PUT', + pathTemplate: '/v1/secrets/{scope}/{name}', + pathParamNames: ['scope', 'name'], + }, + params: { name: 'only-name' }, + }), + /Missing required path parameter "scope"/, + ) +}) + +test('assertApiOperationAllowed refuses bootstrap redeem', () => { + assert.throws( + () => assertApiOperationAllowed(cliCredentialBootstrapRedeemOperationId), + /auth bootstrap --code/, + ) +}) + +test('callOpenApiOperation loads openapi.json then calls the route', async () => { + clearOpenApiRouteCache() + const calls: Array = [] + const fetchFn = (async (input: Parameters[0], init?: RequestInit) => { + const url = new URL(String(input)) + calls.push(`${(init?.method ?? 'GET').toUpperCase()} ${url.pathname}${url.search}`) + if (url.pathname === '/openapi.json') { + return new Response( + JSON.stringify({ + paths: { + '/v1/account/usage': { get: { operationId: 'usageGet' } }, + '/v1/tokens': { post: { operationId: 'tokenCreate' } }, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ) + } + if (url.pathname === '/v1/account/usage') { + assert.equal( + init?.headers && (init.headers as Record).authorization, + 'Bearer tok', + ) + return new Response(JSON.stringify({ plan: 'pro', resources: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + return new Response('nope', { status: 404 }) + }) as typeof fetch + + const result = await callOpenApiOperation({ + token: 'tok', + apiUrl: 'https://api.kody.codes', + operationId: 'usageGet', + params: {}, + fetchFn, + }) + assert.deepEqual(result, { plan: 'pro', resources: [] }) + assert.deepEqual(calls, ['GET /openapi.json', 'GET /v1/account/usage']) +}) + +test('callOpenApiOperation errors on unknown operationId', async () => { + await assert.rejects( + () => + callOpenApiOperation({ + token: 'tok', + apiUrl: 'https://api.kody.codes', + operationId: 'notARealOp', + operations: new Map([ + [ + 'usageGet', + { + operationId: 'usageGet', + method: 'GET', + pathTemplate: '/v1/account/usage', + pathParamNames: [], + }, + ], + ]), + }), + /Unknown operationId "notARealOp"/, + ) +}) + +test('callOpenApiOperation refuses cliCredentialBootstrapRedeem', async () => { + await assert.rejects( + () => + callOpenApiOperation({ + token: 'tok', + operationId: cliCredentialBootstrapRedeemOperationId, + params: { code: 'kody_bc_x' }, + operations: new Map(), + }), + /auth bootstrap --code/, + ) +}) + +test('callOpenApiOperation POSTs body params for write ops', async () => { + const fetchFn = (async (input: Parameters[0], init?: RequestInit) => { + const url = new URL(String(input)) + assert.equal(url.pathname, '/v1/tokens') + assert.equal(init?.method, 'POST') + assert.equal(init?.body, JSON.stringify({ name: 'cli', scopes: ['search:read'] })) + return new Response(JSON.stringify({ id: 'tok_1', name: 'cli' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }) as typeof fetch + + const result = await callOpenApiOperation({ + token: 'tok', + apiUrl: 'https://api.kody.codes', + operationId: 'tokenCreate', + params: { name: 'cli', scopes: ['search:read'] }, + operations: new Map([ + [ + 'tokenCreate', + { + operationId: 'tokenCreate', + method: 'POST', + pathTemplate: '/v1/tokens', + pathParamNames: [], + }, + ], + ]), + fetchFn, + }) + assert.deepEqual(result, { id: 'tok_1', name: 'cli' }) +})