diff --git a/package.json b/package.json index 3679490..3ca6109 100644 --- a/package.json +++ b/package.json @@ -49,20 +49,21 @@ "@anthropic-ai/sdk": "^0.78.0", "@clack/core": "^1.0.1", "@clack/prompts": "1.0.1", + "@hono/node-server": "^1", "@napi-rs/keyring": "^1.2.0", "@workos-inc/node": "^8.7.0", + "@workos/openapi-spec": "^0.1.0", "@workos/skills": "0.5.0", "chalk": "^5.6.2", "diff": "^8.0.3", "fast-glob": "^3.3.3", + "hono": "^4", "ink": "^6.8.0", "opn": "^5.4.0", "react": "^19.2.4", "semver": "^7.7.4", "uuid": "^13.0.0", "xstate": "^5.28.0", - "hono": "^4", - "@hono/node-server": "^1", "yaml": "^2.8.2", "yargs": "^18.0.0", "zod": "^4.3.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec9d8f9..ad4ec44 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@workos-inc/node': specifier: ^8.7.0 version: 8.7.0 + '@workos/openapi-spec': + specifier: ^0.1.0 + version: 0.1.0 '@workos/skills': specifier: 0.5.0 version: 0.5.0 @@ -1071,6 +1074,9 @@ packages: resolution: {integrity: sha512-43HfXSR2Ez7M4ixpebuYVZzZf3gauh5jvv9lYnePg/x0XZMN2hjpEV3FD1LQX1vfMbqQ5gON3DN+/gH2rITm3A==} engines: {node: '>=20.15.0'} + '@workos/openapi-spec@0.1.0': + resolution: {integrity: sha512-pVw8SPFsva6UR8Z2ovE1DC4Aq1fRdPXywT8JP7yN1G8Y02+/kDb6KgFXmpKiv76XinUXDOAmU9tr/G73UI8mHw==} + '@workos/skills@0.5.0': resolution: {integrity: sha512-fScc+usjjvpyIOqSDq3AVmRpgpB5+yhyNfiqlSI/NsENe9C7Ynmbzx+XYwMJzGXpKIdUQQBHbvYRMOA1L+mWyw==} @@ -2515,6 +2521,8 @@ snapshots: iron-webcrypto: 2.0.0 jose: 6.1.3 + '@workos/openapi-spec@0.1.0': {} + '@workos/skills@0.5.0': dependencies: yaml: 2.8.3 diff --git a/src/bin.ts b/src/bin.ts index bdbf96b..3950886 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -430,6 +430,88 @@ yargs(rawArgs) ); return yargs.demandCommand(1, 'Please specify an env subcommand').strict(); }) + .command( + 'api [endpoint] [filter]', + 'Make authenticated requests to the WorkOS API', + (yargs) => + yargs + .options(insecureStorageOption) + .positional('endpoint', { + type: 'string', + describe: "API endpoint path (e.g. /users), or 'ls' to list endpoints", + }) + .positional('filter', { + type: 'string', + describe: 'Filter keyword (used with ls)', + }) + .option('method', { + alias: 'X', + type: 'string', + describe: 'HTTP method (default: GET, or POST if body provided)', + }) + .option('data', { + alias: 'd', + type: 'string', + describe: 'JSON request body', + }) + .option('file', { + type: 'string', + describe: 'Read request body from a file (or - for stdin)', + }) + .option('include', { + alias: 'i', + type: 'boolean', + default: false, + describe: 'Show response headers', + }) + .option('api-key', { + type: 'string', + describe: 'Override the API key', + }) + .option('dry-run', { + type: 'boolean', + default: false, + describe: 'Show the request without executing it', + }) + .option('yes', { + alias: 'y', + type: 'boolean', + default: false, + describe: 'Skip confirmation for mutating requests', + }) + .example('workos api ls', 'List all available endpoints') + .example('workos api ls users', 'List endpoints matching "users"') + .example('workos api /user_management/users', 'GET /user_management/users') + .example('workos api /organizations -d \'{"name":"Acme"}\'', 'POST with a JSON body') + .example('workos api /organizations/org_123 -X DELETE', 'DELETE an organization'), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage as boolean | undefined); + const endpoint = argv.endpoint as string | undefined; + const filter = argv.filter as string | undefined; + + const { runApiLs, runApiRequest, runApiInteractive } = await import('./commands/api/index.js'); + + if (!endpoint) { + await runApiInteractive({ apiKey: argv.apiKey as string | undefined }); + return; + } + + if (endpoint === 'ls') { + await runApiLs(filter); + return; + } + + await runApiRequest(endpoint, { + method: argv.method, + data: argv.data, + file: argv.file, + include: argv.include, + apiKey: argv.apiKey, + dryRun: argv.dryRun, + yes: argv.yes, + }); + }, + ) .command(['organization', 'org'], 'Manage WorkOS organizations (create, update, get, list, delete)', (yargs) => { yargs.options({ ...insecureStorageOption, diff --git a/src/commands/api/catalog.spec.ts b/src/commands/api/catalog.spec.ts new file mode 100644 index 0000000..18709b5 --- /dev/null +++ b/src/commands/api/catalog.spec.ts @@ -0,0 +1,281 @@ +import { describe, it, expect } from 'vitest'; +import { parseSpec, endpointsByTag, type EndpointInfo } from './catalog.js'; + +const SAMPLE_SPEC = ` +openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +paths: + /organizations: + get: + operationId: listOrganizations + summary: List organizations + tags: [Organizations] + parameters: + - name: limit + in: query + required: false + description: Max items + post: + operationId: createOrganization + summary: Create organization + tags: [Organizations] + requestBody: + required: true + content: + application/json: + schema: + type: object + /organizations/{id}: + parameters: + - name: id + in: path + required: true + description: Organization id + get: + operationId: getOrganization + summary: Get organization + tags: [Organizations] + delete: + operationId: deleteOrganization + summary: Delete organization + tags: [Organizations] + /users: + get: + operationId: listUsers + summary: List users + tags: [Users] +`; + +describe('parseSpec', () => { + it('returns endpoints for each method on a path', () => { + const catalog = parseSpec(SAMPLE_SPEC); + const ops = catalog.endpoints.filter((e) => e.path === '/organizations').map((e) => e.method); + expect(ops.sort()).toEqual(['GET', 'POST']); + }); + + it('captures summary, tag, and operationId', () => { + const catalog = parseSpec(SAMPLE_SPEC); + const get = catalog.endpoints.find((e) => e.path === '/organizations' && e.method === 'GET'); + expect(get).toMatchObject({ + summary: 'List organizations', + tag: 'Organizations', + operationId: 'listOrganizations', + }); + }); + + it('extracts path parameters from shared parameters block', () => { + const catalog = parseSpec(SAMPLE_SPEC); + const get = catalog.endpoints.find((e) => e.path === '/organizations/{id}' && e.method === 'GET'); + expect(get?.pathParams).toEqual([{ name: 'id', description: 'Organization id', required: true }]); + expect(get?.queryParams).toEqual([]); + }); + + it('extracts query parameters from operation', () => { + const catalog = parseSpec(SAMPLE_SPEC); + const get = catalog.endpoints.find((e) => e.path === '/organizations' && e.method === 'GET'); + expect(get?.queryParams).toEqual([{ name: 'limit', description: 'Max items', required: false }]); + }); + + it('flags hasRequestBody when requestBody is present', () => { + const catalog = parseSpec(SAMPLE_SPEC); + const post = catalog.endpoints.find((e) => e.path === '/organizations' && e.method === 'POST'); + const get = catalog.endpoints.find((e) => e.path === '/organizations' && e.method === 'GET'); + expect(post?.hasRequestBody).toBe(true); + expect(get?.hasRequestBody).toBe(false); + }); + + it('captures requestBodyRequired from the spec', () => { + const catalog = parseSpec(SAMPLE_SPEC); + const post = catalog.endpoints.find((e) => e.path === '/organizations' && e.method === 'POST'); + const get = catalog.endpoints.find((e) => e.path === '/organizations' && e.method === 'GET'); + expect(post?.requestBodyRequired).toBe(true); + expect(get?.requestBodyRequired).toBe(false); + }); + + it('sets requestBodyRequired to false when requestBody exists but required is not set', () => { + const yaml = ` +openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +paths: + /widgets: + patch: + operationId: patchWidget + summary: Patch widget + tags: [Widgets] + requestBody: + content: + application/json: + schema: + type: object +`; + const catalog = parseSpec(yaml); + expect(catalog.endpoints[0]?.hasRequestBody).toBe(true); + expect(catalog.endpoints[0]?.requestBodyRequired).toBe(false); + }); + + it('produces a sorted unique tags list', () => { + const catalog = parseSpec(SAMPLE_SPEC); + expect(catalog.tags).toEqual(['Organizations', 'Users']); + }); + + it('returns an empty catalog when paths is missing', () => { + const catalog = parseSpec('openapi: 3.0.0\ninfo:\n title: t\n version: 1.0.0\n'); + expect(catalog.endpoints).toEqual([]); + expect(catalog.tags).toEqual([]); + }); + + it('resolves $ref parameters against components.parameters', () => { + const yaml = ` +openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +components: + parameters: + SharedId: + name: id + in: path + required: true + description: Shared id parameter + SharedLimit: + name: limit + in: query + required: false + description: Page size +paths: + /widgets/{id}: + parameters: + - $ref: '#/components/parameters/SharedId' + get: + operationId: getWidget + summary: Get widget + parameters: + - $ref: '#/components/parameters/SharedLimit' +`; + const catalog = parseSpec(yaml); + const ep = catalog.endpoints.find((e) => e.path === '/widgets/{id}' && e.method === 'GET'); + expect(ep?.pathParams).toEqual([{ name: 'id', description: 'Shared id parameter', required: true }]); + expect(ep?.queryParams).toEqual([{ name: 'limit', description: 'Page size', required: false }]); + }); + + it('skips $ref parameters that cannot be resolved instead of leaking placeholders', () => { + const yaml = ` +openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +paths: + /widgets/{id}: + parameters: + - $ref: '#/components/parameters/Missing' + - name: id + in: path + required: true + description: Inline id + get: + operationId: getWidget + summary: Get widget +`; + const catalog = parseSpec(yaml); + const ep = catalog.endpoints[0]; + // The unresolvable $ref should be silently dropped; the inline param survives. + expect(ep?.pathParams).toEqual([{ name: 'id', description: 'Inline id', required: true }]); + }); + + it('deduplicates params by (name, in) — operation-level overrides path-level', () => { + const yaml = ` +openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +paths: + /widgets/{id}: + parameters: + - name: id + in: path + required: true + description: From path-level + get: + operationId: getWidget + summary: Get widget + parameters: + - name: id + in: path + required: true + description: From operation-level (wins) +`; + const catalog = parseSpec(yaml); + const ep = catalog.endpoints[0]; + expect(ep?.pathParams).toEqual([{ name: 'id', description: 'From operation-level (wins)', required: true }]); + }); + + it('falls back to "other" tag when none is provided', () => { + const yaml = ` +openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +paths: + /noop: + get: + operationId: noop + summary: No tag +`; + const catalog = parseSpec(yaml); + expect(catalog.endpoints[0]?.tag).toBe('other'); + expect(catalog.tags).toEqual(['other']); + }); +}); + +describe('endpointsByTag', () => { + const endpoints: EndpointInfo[] = [ + { + method: 'GET', + path: '/users', + summary: '', + tag: 'Users', + operationId: 'listUsers', + pathParams: [], + queryParams: [], + hasRequestBody: false, + requestBodyRequired: false, + }, + { + method: 'POST', + path: '/organizations', + summary: '', + tag: 'Organizations', + operationId: 'createOrg', + pathParams: [], + queryParams: [], + hasRequestBody: true, + requestBodyRequired: true, + }, + { + method: 'DELETE', + path: '/users/{id}', + summary: '', + tag: 'Users', + operationId: 'deleteUser', + pathParams: [], + queryParams: [], + hasRequestBody: false, + requestBodyRequired: false, + }, + ]; + + it('groups endpoints by tag preserving insertion order', () => { + const grouped = endpointsByTag(endpoints); + expect([...grouped.keys()]).toEqual(['Users', 'Organizations']); + expect(grouped.get('Users')?.map((e) => e.operationId)).toEqual(['listUsers', 'deleteUser']); + expect(grouped.get('Organizations')?.map((e) => e.operationId)).toEqual(['createOrg']); + }); + + it('returns an empty map when no endpoints are provided', () => { + expect(endpointsByTag([]).size).toBe(0); + }); +}); diff --git a/src/commands/api/catalog.ts b/src/commands/api/catalog.ts new file mode 100644 index 0000000..c81dd57 --- /dev/null +++ b/src/commands/api/catalog.ts @@ -0,0 +1,140 @@ +import { parse as parseYaml } from 'yaml'; +import { createRequire } from 'node:module'; +import { readFile } from 'node:fs/promises'; + +export interface Param { + name: string; + description: string; + required: boolean; +} + +export interface EndpointInfo { + method: string; + path: string; + summary: string; + tag: string; + operationId: string; + pathParams: Param[]; + queryParams: Param[]; + hasRequestBody: boolean; + requestBodyRequired: boolean; +} + +export interface Catalog { + endpoints: EndpointInfo[]; + tags: string[]; +} + +const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete'] as const; + +interface RawParam { + name?: string; + in?: string; + description?: string; + required?: boolean; + $ref?: string; +} + +/** + * Resolve an OpenAPI 3.x parameter object that may itself be a $ref pointing + * into components.parameters. Returns undefined if the ref can't be resolved + * (so the parameter is skipped instead of producing a {param} placeholder + * that leaks into request URLs). + */ +function resolveParam(param: RawParam, componentParams: Record): RawParam | undefined { + if (!param || typeof param !== 'object') return undefined; + if (typeof param.$ref === 'string') { + const match = /^#\/components\/parameters\/(.+)$/.exec(param.$ref); + if (!match) return undefined; + const target = componentParams[match[1]!]; + if (!target) return undefined; + // Recurse so a chain of $refs still resolves to a concrete definition. + return resolveParam(target, componentParams); + } + return param; +} + +export function parseSpec(yamlText: string): Catalog { + const spec = parseYaml(yamlText) as { + paths?: Record; + components?: { parameters?: Record }; + }; + const endpoints: EndpointInfo[] = []; + const componentParams = spec.components?.parameters ?? {}; + + for (const [path, pathItem] of Object.entries(spec.paths ?? {})) { + const pathObj = pathItem as Record; + + for (const method of HTTP_METHODS) { + const operation = pathObj[method]; + if (!operation || typeof operation !== 'object') continue; + + const op = operation as Record; + const tag = ((op.tags as string[]) ?? ['other'])[0] ?? 'other'; + + // Resolve $ref and merge path-level + operation-level params. + // Operation-level params override path-level ones with the same (name, in) + // pair, per the OpenAPI 3.x spec. + const rawPathLevel = (pathObj.parameters as RawParam[] | undefined) ?? []; + const rawOpLevel = (op.parameters as RawParam[] | undefined) ?? []; + const merged = new Map(); + for (const raw of [...rawPathLevel, ...rawOpLevel]) { + const resolved = resolveParam(raw, componentParams); + if (!resolved || !resolved.name || !resolved.in) continue; + merged.set(`${resolved.in}:${resolved.name}`, resolved); + } + const allParams = [...merged.values()]; + + const pathParams: Param[] = allParams + .filter((p) => p.in === 'path') + .map((p) => ({ name: p.name!, description: p.description ?? '', required: p.required ?? true })); + + const queryParams: Param[] = allParams + .filter((p) => p.in === 'query') + .map((p) => ({ name: p.name!, description: p.description ?? '', required: p.required ?? false })); + + const reqBody = op.requestBody as Record | undefined; + endpoints.push({ + method: method.toUpperCase(), + path, + summary: (op.summary as string) ?? '', + tag, + operationId: (op.operationId as string) ?? '', + pathParams, + queryParams, + hasRequestBody: !!reqBody, + requestBodyRequired: !!reqBody?.required, + }); + } + } + + const tags = [...new Set(endpoints.map((e) => e.tag))].sort(); + return { endpoints, tags }; +} + +let cachedCatalog: Promise | undefined; + +export function loadCatalog(): Promise { + // Cache the in-flight Promise (not just the resolved value) so concurrent + // callers reuse the same readFile/parse pass — see request.ts callers. + if (cachedCatalog) return cachedCatalog; + + cachedCatalog = (async () => { + const require = createRequire(import.meta.url); + const specPath = require.resolve('@workos/openapi-spec/spec'); + const yamlText = await readFile(specPath, 'utf-8'); + return parseSpec(yamlText); + })(); + + return cachedCatalog; +} + +export function endpointsByTag(endpoints: EndpointInfo[]): Map { + const grouped = new Map(); + for (const ep of endpoints) { + const list = grouped.get(ep.tag) ?? []; + list.push(ep); + grouped.set(ep.tag, list); + } + return grouped; +} diff --git a/src/commands/api/format.spec.ts b/src/commands/api/format.spec.ts new file mode 100644 index 0000000..aff627b --- /dev/null +++ b/src/commands/api/format.spec.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import chalk from 'chalk'; +import { colorMethod, printResponse } from './format.js'; +import { setOutputMode } from '../../utils/output.js'; +import type { ApiResponse } from './request.js'; + +const previousChalkLevel = chalk.level; + +function buildResponse(overrides: Partial = {}): ApiResponse { + const headers = new Headers(); + headers.set('content-type', 'application/json'); + return { + status: 200, + headers, + body: { ok: true }, + rawBody: '{"ok":true}', + ...overrides, + }; +} + +describe('colorMethod', () => { + beforeEach(() => { + chalk.level = 1; + }); + + afterEach(() => { + chalk.level = previousChalkLevel; + }); + + it('returns the method string regardless of color level', () => { + chalk.level = 0; + expect(colorMethod('GET')).toBe('GET'); + expect(colorMethod('POST')).toBe('POST'); + expect(colorMethod('PUT')).toBe('PUT'); + expect(colorMethod('PATCH')).toBe('PATCH'); + expect(colorMethod('DELETE')).toBe('DELETE'); + expect(colorMethod('OPTIONS')).toBe('OPTIONS'); + }); + + it('matches chalk color helpers when colors are enabled', () => { + expect(colorMethod('GET')).toBe(chalk.green('GET')); + expect(colorMethod('POST')).toBe(chalk.blue('POST')); + expect(colorMethod('PUT')).toBe(chalk.yellow('PUT')); + expect(colorMethod('PATCH')).toBe(chalk.yellow('PATCH')); + expect(colorMethod('DELETE')).toBe(chalk.red('DELETE')); + }); + + it('passes through unknown verbs unchanged', () => { + expect(colorMethod('TRACE')).toBe('TRACE'); + }); +}); + +describe('printResponse', () => { + let consoleOutput: string[]; + + beforeEach(() => { + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + setOutputMode('human'); + }); + + it('prints a pretty JSON body in human mode', () => { + setOutputMode('human'); + printResponse(buildResponse({ body: { ok: true, count: 2 } })); + expect(consoleOutput.some((l) => l.includes('"ok": true'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('"count": 2'))).toBe(true); + }); + + it('prints raw body when human mode receives a non-object body', () => { + setOutputMode('human'); + printResponse(buildResponse({ body: 'plain text', rawBody: 'plain text' })); + expect(consoleOutput).toContain('plain text'); + }); + + it('prints status and headers when includeStatus is true in human mode', () => { + setOutputMode('human'); + const headers = new Headers({ 'x-request-id': 'abc' }); + printResponse(buildResponse({ status: 201, headers }), { includeStatus: true }); + const joined = consoleOutput.join('\n'); + expect(joined).toContain('HTTP 201'); + expect(joined).toContain('x-request-id: abc'); + }); + + it('emits a single JSON line in JSON mode', () => { + setOutputMode('json'); + printResponse(buildResponse({ body: { ok: true } })); + expect(consoleOutput).toEqual([JSON.stringify({ ok: true })]); + }); + + it('emits a single structured JSON line in JSON mode when includeStatus is true', () => { + setOutputMode('json'); + const headers = new Headers({ 'x-request-id': 'abc' }); + printResponse(buildResponse({ status: 201, headers, body: { ok: true } }), { includeStatus: true }); + + expect(consoleOutput).toHaveLength(1); + const parsed = JSON.parse(consoleOutput[0]!) as { + status: number; + headers: Record; + body: { ok: boolean }; + }; + expect(parsed.status).toBe(201); + expect(parsed.headers['x-request-id']).toBe('abc'); + expect(parsed.body).toEqual({ ok: true }); + }); + + it('does not emit any human-readable status/header lines in JSON mode', () => { + setOutputMode('json'); + const headers = new Headers({ 'x-request-id': 'abc' }); + printResponse(buildResponse({ status: 201, headers, body: { ok: true } }), { includeStatus: true }); + + for (const line of consoleOutput) { + expect(line).not.toMatch(/^HTTP \d/); + expect(line).not.toMatch(/^x-request-id:/); + } + }); +}); diff --git a/src/commands/api/format.ts b/src/commands/api/format.ts new file mode 100644 index 0000000..5f76041 --- /dev/null +++ b/src/commands/api/format.ts @@ -0,0 +1,51 @@ +import chalk from 'chalk'; +import { isJsonMode, outputJson } from '../../utils/output.js'; +import type { ApiResponse } from './request.js'; + +export function colorMethod(method: string): string { + switch (method) { + case 'GET': + return chalk.green(method); + case 'POST': + return chalk.blue(method); + case 'PUT': + case 'PATCH': + return chalk.yellow(method); + case 'DELETE': + return chalk.red(method); + default: + return method; + } +} + +export function printResponse( + response: ApiResponse, + { includeStatus = false }: { includeStatus?: boolean } = {}, +): void { + if (isJsonMode()) { + if (includeStatus) { + const headers: Record = {}; + response.headers.forEach((value, key) => { + headers[key] = value; + }); + outputJson({ status: response.status, headers, body: response.body }); + } else { + outputJson(response.body); + } + return; + } + + if (includeStatus) { + console.log(chalk.dim(`HTTP ${response.status}`)); + response.headers.forEach((value, key) => { + console.log(chalk.dim(`${key}: ${value}`)); + }); + console.log(); + } + + if (typeof response.body === 'object' && response.body !== null) { + console.log(JSON.stringify(response.body, null, 2)); + } else { + console.log(response.rawBody); + } +} diff --git a/src/commands/api/index.spec.ts b/src/commands/api/index.spec.ts new file mode 100644 index 0000000..8d16f84 --- /dev/null +++ b/src/commands/api/index.spec.ts @@ -0,0 +1,420 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { Catalog } from './catalog.js'; +import type { ApiResponse } from './request.js'; + +const mockCatalog: Catalog = { + endpoints: [ + { + method: 'GET', + path: '/users', + summary: 'List users', + tag: 'Users', + operationId: 'listUsers', + pathParams: [], + queryParams: [], + hasRequestBody: false, + requestBodyRequired: false, + }, + { + method: 'POST', + path: '/organizations', + summary: 'Create organization', + tag: 'Organizations', + operationId: 'createOrganization', + pathParams: [], + queryParams: [], + hasRequestBody: true, + requestBodyRequired: true, + }, + { + method: 'DELETE', + path: '/users/{id}', + summary: 'Delete user', + tag: 'Users', + operationId: 'deleteUser', + pathParams: [{ name: 'id', description: '', required: true }], + queryParams: [], + hasRequestBody: false, + requestBodyRequired: false, + }, + ], + tags: ['Organizations', 'Users'], +}; + +const mockApiRequest = vi.fn<(...args: unknown[]) => Promise>(); + +vi.mock('./catalog.js', async () => { + const actual = await vi.importActual('./catalog.js'); + return { + ...actual, + loadCatalog: async () => mockCatalog, + }; +}); + +vi.mock('./request.js', () => ({ + apiRequest: (...args: unknown[]) => mockApiRequest(...args), +})); + +vi.mock('../../lib/api-key.js', () => ({ + resolveApiKey: vi.fn(() => 'sk_test'), + resolveApiBaseUrl: vi.fn(() => 'https://api.example.com'), +})); + +const mockConfirm = vi.fn(); +const mockIsCancel = vi.fn(() => false); +vi.mock('../../utils/clack.js', () => ({ + default: { + confirm: (...args: unknown[]) => mockConfirm(...args), + isCancel: (...args: unknown[]) => mockIsCancel(...args), + }, +})); + +vi.mock('../../utils/environment.js', () => ({ + isNonInteractiveEnvironment: vi.fn(() => false), +})); + +const { setOutputMode } = await import('../../utils/output.js'); +const { isNonInteractiveEnvironment } = await import('../../utils/environment.js'); +const { runApiInteractive, runApiLs, runApiRequest } = await import('./index.js'); + +function buildResponse(overrides: Partial = {}): ApiResponse { + return { + status: 200, + headers: new Headers(), + body: { ok: true }, + rawBody: '{"ok":true}', + ...overrides, + }; +} + +describe('runApiInteractive', () => { + let consoleOutput: string[]; + let stderrOutput: string[]; + let exitSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + consoleOutput = []; + stderrOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + stderrOutput.push(args.map(String).join(' ')); + }); + exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`__exit__:${code ?? 0}`); + }) as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + setOutputMode('human'); + }); + + it('prints usage instructions when stdin/stdout is non-interactive', async () => { + setOutputMode('human'); + vi.mocked(isNonInteractiveEnvironment).mockReturnValueOnce(true); + await runApiInteractive(); + expect(consoleOutput.join('\n')).toContain('Interactive mode requires a TTY'); + }); + + it('emits a structured tty_required error in JSON mode when non-interactive', async () => { + setOutputMode('json'); + // JSON mode short-circuits before the TTY check, so the underlying environment doesn't matter. + await expect(runApiInteractive()).rejects.toThrow(/__exit__:1/); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(consoleOutput).toEqual([]); + const errorLine = stderrOutput.find((line) => { + try { + const parsed = JSON.parse(line) as { error?: { code?: string } }; + return parsed.error?.code === 'tty_required'; + } catch { + return false; + } + }); + expect(errorLine).toBeDefined(); + }); + + it('refuses to enter interactive mode in JSON mode even when a TTY is present', async () => { + setOutputMode('json'); + // Default mock returns false (TTY present); JSON mode must short-circuit + // before isNonInteractiveEnvironment() is even called. + await expect(runApiInteractive()).rejects.toThrow(/__exit__:1/); + expect(isNonInteractiveEnvironment).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(consoleOutput).toEqual([]); + const errorLine = stderrOutput.find((line) => { + try { + const parsed = JSON.parse(line) as { error?: { code?: string } }; + return parsed.error?.code === 'tty_required'; + } catch { + return false; + } + }); + expect(errorLine).toBeDefined(); + }); +}); + +describe('runApiLs', () => { + let consoleOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + setOutputMode('human'); + }); + + it('lists endpoints grouped by tag in human mode', async () => { + setOutputMode('human'); + await runApiLs(); + const joined = consoleOutput.join('\n'); + expect(joined).toContain('Users'); + expect(joined).toContain('/users'); + expect(joined).toContain('Organizations'); + expect(joined).toContain('/organizations'); + }); + + it('filters endpoints by substring (path/tag/summary/operationId)', async () => { + setOutputMode('human'); + await runApiLs('organization'); + const joined = consoleOutput.join('\n'); + expect(joined).toContain('/organizations'); + expect(joined).not.toContain('/users'); + }); + + it('prints a friendly message when no endpoint matches the filter', async () => { + setOutputMode('human'); + await runApiLs('does-not-exist'); + expect(consoleOutput.some((l) => l.includes('No endpoints matching "does-not-exist"'))).toBe(true); + }); + + it('emits structured JSON in JSON mode', async () => { + setOutputMode('json'); + await runApiLs('users'); + expect(consoleOutput).toHaveLength(1); + const parsed = JSON.parse(consoleOutput[0]!); + expect(parsed.data).toEqual([ + { method: 'GET', path: '/users', summary: 'List users', tag: 'Users' }, + { method: 'DELETE', path: '/users/{id}', summary: 'Delete user', tag: 'Users' }, + ]); + }); +}); + +describe('runApiRequest', () => { + let consoleOutput: string[]; + let stderrOutput: string[]; + let exitSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + consoleOutput = []; + stderrOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + stderrOutput.push(args.map(String).join(' ')); + }); + exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`__exit__:${code ?? 0}`); + }) as never); + mockConfirm.mockResolvedValue(true); + mockIsCancel.mockReturnValue(false); + }); + + afterEach(() => { + vi.restoreAllMocks(); + setOutputMode('human'); + }); + + it('prints a human-readable dry-run preview without executing the request', async () => { + setOutputMode('human'); + await runApiRequest('/users', { dryRun: true }); + expect(mockApiRequest).not.toHaveBeenCalled(); + const joined = consoleOutput.join('\n'); + expect(joined).toContain('[dry-run]'); + expect(joined).toContain('GET https://api.example.com/users'); + }); + + it('emits structured JSON for a dry-run in JSON mode', async () => { + setOutputMode('json'); + await runApiRequest('/users', { dryRun: true }); + expect(mockApiRequest).not.toHaveBeenCalled(); + const parsed = JSON.parse(consoleOutput[0]!); + expect(parsed).toEqual({ + dryRun: true, + method: 'GET', + url: 'https://api.example.com/users', + }); + }); + + it('parses --data into the JSON dry-run payload', async () => { + setOutputMode('json'); + await runApiRequest('/organizations', { dryRun: true, data: '{"name":"Acme"}' }); + const parsed = JSON.parse(consoleOutput[0]!); + expect(parsed).toEqual({ + dryRun: true, + method: 'POST', + url: 'https://api.example.com/organizations', + body: { name: 'Acme' }, + }); + }); + + it('exits with a structured error in JSON dry-run mode when --data is not valid JSON', async () => { + setOutputMode('json'); + await expect(runApiRequest('/organizations', { dryRun: true, data: 'not json' })).rejects.toThrow(/__exit__:1/); + expect(exitSpy).toHaveBeenCalledWith(1); + const errorLine = stderrOutput.find((line) => { + try { + const parsed = JSON.parse(line); + return parsed?.error?.code === 'invalid_json_body'; + } catch { + return false; + } + }); + expect(errorLine).toBeDefined(); + }); + + it('falls back to a raw human-mode preview when --data is not valid JSON', async () => { + setOutputMode('human'); + await runApiRequest('/organizations', { dryRun: true, data: 'not json' }); + expect(mockApiRequest).not.toHaveBeenCalled(); + const joined = consoleOutput.join('\n'); + expect(joined).toContain('[dry-run]'); + expect(joined).toContain('not json'); + }); + + it('infers POST when a body is provided without an explicit method', async () => { + mockApiRequest.mockResolvedValue(buildResponse()); + await runApiRequest('/organizations', { data: '{"name":"Acme"}', yes: true }); + expect(mockApiRequest).toHaveBeenCalledWith( + expect.objectContaining({ method: 'POST', path: '/organizations', body: '{"name":"Acme"}' }), + ); + }); + + it('skips confirmation when --yes is set for mutating methods', async () => { + mockApiRequest.mockResolvedValue(buildResponse()); + await runApiRequest('/organizations', { method: 'DELETE', yes: true }); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockApiRequest).toHaveBeenCalled(); + }); + + it('prompts for confirmation on mutating methods in TTY environments', async () => { + mockApiRequest.mockResolvedValue(buildResponse()); + mockConfirm.mockResolvedValueOnce(true); + await runApiRequest('/organizations', { method: 'POST', data: '{}' }); + expect(mockConfirm).toHaveBeenCalled(); + expect(mockApiRequest).toHaveBeenCalled(); + }); + + it('aborts when the user declines the confirmation prompt', async () => { + mockConfirm.mockResolvedValueOnce(false); + await expect(runApiRequest('/organizations', { method: 'POST', data: '{}' })).rejects.toThrow(/__exit__:0/); + expect(mockApiRequest).not.toHaveBeenCalled(); + }); + + it('exits with code 1 when the response status is >= 400', async () => { + mockApiRequest.mockResolvedValue(buildResponse({ status: 404, body: { error: 'not_found' } })); + await expect(runApiRequest('/users', { yes: true })).rejects.toThrow(/__exit__:1/); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('passes --include through to printResponse', async () => { + setOutputMode('human'); + const headers = new Headers({ 'x-request-id': 'abc' }); + mockApiRequest.mockResolvedValue(buildResponse({ status: 201, headers })); + await runApiRequest('/users', { include: true, yes: true }); + const joined = consoleOutput.join('\n'); + expect(joined).toContain('HTTP 201'); + expect(joined).toContain('x-request-id: abc'); + }); + + it('forwards --api-key to apiRequest', async () => { + mockApiRequest.mockResolvedValue(buildResponse()); + await runApiRequest('/users', { apiKey: 'sk_override', yes: true }); + expect(mockApiRequest).toHaveBeenCalledWith(expect.objectContaining({ apiKey: 'sk_override' })); + }); + + it('exits with a structured error when --file points at a missing path', async () => { + setOutputMode('json'); + await expect( + runApiRequest('/organizations', { file: '/tmp/__nonexistent_workos_api_body__.json', yes: true }), + ).rejects.toThrow(/__exit__:1/); + expect(exitSpy).toHaveBeenCalledWith(1); + const errorLine = stderrOutput.find((line) => { + try { + const parsed = JSON.parse(line) as { error?: { code?: string; message?: string } }; + return parsed.error?.code === 'file_read_error'; + } catch { + return false; + } + }); + expect(errorLine).toBeDefined(); + expect(mockApiRequest).not.toHaveBeenCalled(); + }); + + it('treats an empty --data string as a body so method inference does not flip to GET', async () => { + mockApiRequest.mockResolvedValue(buildResponse()); + await runApiRequest('/organizations', { data: '', yes: true }); + expect(mockApiRequest).toHaveBeenCalledWith(expect.objectContaining({ method: 'POST', body: '' })); + }); + + it('refuses mutating requests without --yes in non-interactive human mode', async () => { + setOutputMode('human'); + vi.mocked(isNonInteractiveEnvironment).mockReturnValueOnce(true); + await expect(runApiRequest('/organizations', { method: 'POST', data: '{}' })).rejects.toThrow(/__exit__:1/); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockApiRequest).not.toHaveBeenCalled(); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(stderrOutput.some((l) => l.includes('Refusing to POST'))).toBe(true); + }); + + it('exits with confirmation_required in JSON mode when a mutating request lacks --yes', async () => { + setOutputMode('json'); + await expect(runApiRequest('/organizations', { method: 'POST', data: '{}' })).rejects.toThrow(/__exit__:1/); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockApiRequest).not.toHaveBeenCalled(); + expect(mockConfirm).not.toHaveBeenCalled(); + const errorLine = stderrOutput.find((line) => { + try { + const parsed = JSON.parse(line) as { error?: { code?: string } }; + return parsed.error?.code === 'confirmation_required'; + } catch { + return false; + } + }); + expect(errorLine).toBeDefined(); + }); + + it('exits with empty_stdin_body when --file - is used and stdin is empty', async () => { + setOutputMode('json'); + // Replace process.stdin with an async iterator that yields no chunks (EOF immediately). + const emptyStdin = (async function* () {})(); + const originalStdin = process.stdin; + Object.defineProperty(process, 'stdin', { value: emptyStdin, configurable: true }); + try { + await expect(runApiRequest('/orgs', { file: '-', yes: true })).rejects.toThrow(/__exit__:1/); + } finally { + Object.defineProperty(process, 'stdin', { value: originalStdin, configurable: true }); + } + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockApiRequest).not.toHaveBeenCalled(); + const errorLine = stderrOutput.find((line) => { + try { + const parsed = JSON.parse(line) as { error?: { code?: string } }; + return parsed.error?.code === 'empty_stdin_body'; + } catch { + return false; + } + }); + expect(errorLine).toBeDefined(); + }); +}); diff --git a/src/commands/api/index.ts b/src/commands/api/index.ts new file mode 100644 index 0000000..a81675a --- /dev/null +++ b/src/commands/api/index.ts @@ -0,0 +1,205 @@ +import chalk from 'chalk'; +import { readFile } from 'node:fs/promises'; +import { loadCatalog, endpointsByTag } from './catalog.js'; +import { apiRequest } from './request.js'; +import { resolveApiBaseUrl } from '../../lib/api-key.js'; +import { exitWithError, isJsonMode, outputJson } from '../../utils/output.js'; +import { isNonInteractiveEnvironment } from '../../utils/environment.js'; +import { colorMethod, printResponse } from './format.js'; + +export { colorMethod } from './format.js'; + +export interface ApiCommandOptions { + method?: string; + data?: string; + file?: string; + include?: boolean; + apiKey?: string; + dryRun?: boolean; + yes?: boolean; +} + +const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +export async function runApiInteractive(options?: { apiKey?: string }): Promise { + // Interactive mode is inherently human-oriented (clack prompts, preview text, + // etc.). Refuse to enter it whenever JSON output was requested, regardless of + // TTY status, so stdout stays machine-readable. + if (isJsonMode()) { + exitWithError({ + code: 'tty_required', + message: 'Interactive mode is not available with --json. Provide an endpoint or use `workos api ls`.', + details: { + usage: ['workos api ', 'workos api ls [filter]'], + }, + }); + } + + if (isNonInteractiveEnvironment()) { + console.log( + 'Interactive mode requires a TTY.\n\n' + + 'Usage:\n' + + ' workos api Make an API request\n' + + ' workos api ls [filter] List available endpoints\n' + + '\nExample:\n' + + ' workos api /user_management/users\n' + + ' workos api ls users', + ); + return; + } + + const { apiInteractive } = await import('./interactive.js'); + await apiInteractive({ apiKey: options?.apiKey }); +} + +export async function runApiLs(filter?: string): Promise { + const catalog = await loadCatalog(); + let endpoints = catalog.endpoints; + + if (filter) { + const lower = filter.toLowerCase(); + endpoints = endpoints.filter( + (e) => + e.path.toLowerCase().includes(lower) || + e.tag.toLowerCase().includes(lower) || + e.summary.toLowerCase().includes(lower) || + e.operationId.toLowerCase().includes(lower), + ); + } + + if (isJsonMode()) { + outputJson({ + data: endpoints.map((e) => ({ + method: e.method, + path: e.path, + summary: e.summary, + tag: e.tag, + })), + }); + return; + } + + if (endpoints.length === 0) { + console.log(filter ? `No endpoints matching "${filter}".` : 'No endpoints found.'); + return; + } + + const grouped = endpointsByTag(endpoints); + + for (const [tag, eps] of grouped) { + console.log(`\n${chalk.bold(tag)}`); + for (const ep of eps) { + const method = colorMethod(ep.method).padEnd(18); + console.log(` ${method} ${ep.path} ${chalk.dim(ep.summary)}`); + } + } + console.log(); +} + +export async function runApiRequest(endpoint: string, options: ApiCommandOptions): Promise { + const body = await resolveBody(options); + const hasBody = body !== undefined; + const method = (options.method ?? (hasBody ? 'POST' : 'GET')).toUpperCase(); + const baseUrl = resolveApiBaseUrl(); + + if (options.dryRun) { + if (isJsonMode()) { + let parsedBody: unknown; + if (hasBody) { + try { + parsedBody = JSON.parse(body); + } catch { + exitWithError({ code: 'invalid_json_body', message: 'Request body is not valid JSON.' }); + } + } + outputJson({ + dryRun: true, + method, + url: `${baseUrl}${normalizePath(endpoint)}`, + body: parsedBody, + }); + } else { + console.log(`${chalk.dim('[dry-run]')} ${method} ${baseUrl}${normalizePath(endpoint)}`); + if (hasBody) prettyPrint(body); + } + return; + } + + if (MUTATING_METHODS.has(method) && !options.yes) { + if (isJsonMode()) { + exitWithError({ + code: 'confirmation_required', + message: 'Mutating requests in JSON mode require --yes to keep stdout machine-readable.', + }); + } + if (isNonInteractiveEnvironment()) { + console.error(`Refusing to ${method} ${endpoint} without --yes in a non-interactive environment.`); + process.exit(1); + } + const clack = (await import('../../utils/clack.js')).default; + console.log(`\n${chalk.yellow('About to')} ${method} ${endpoint}`); + if (hasBody) prettyPrint(body); + const ok = await clack.confirm({ message: 'Proceed?' }); + if (!ok || clack.isCancel(ok)) { + process.exit(0); + } + } + + const response = await apiRequest({ + method, + path: normalizePath(endpoint), + apiKey: options.apiKey, + body, + baseUrl, + }); + + printResponse(response, { includeStatus: options.include }); + + if (response.status >= 400) { + process.exit(1); + } +} + +function normalizePath(path: string): string { + if (!path.startsWith('/')) return `/${path}`; + return path; +} + +async function resolveBody(options: ApiCommandOptions): Promise { + if (options.data !== undefined) return options.data; + if (options.file) { + if (options.file === '-') { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk); + } + const stdinBody = Buffer.concat(chunks).toString('utf-8'); + if (stdinBody.length === 0) { + exitWithError({ + code: 'empty_stdin_body', + message: + 'Reading request body from stdin (--file -) yielded no data. Pipe data into the command or pass --data instead.', + }); + } + return stdinBody; + } + try { + return await readFile(options.file, 'utf-8'); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + exitWithError({ + code: 'file_read_error', + message: `Could not read request body file "${options.file}": ${message}`, + }); + } + } + return undefined; +} + +function prettyPrint(jsonString: string): void { + try { + console.log(JSON.stringify(JSON.parse(jsonString), null, 2)); + } catch { + console.log(jsonString); + } +} diff --git a/src/commands/api/interactive.spec.ts b/src/commands/api/interactive.spec.ts new file mode 100644 index 0000000..9469bc9 --- /dev/null +++ b/src/commands/api/interactive.spec.ts @@ -0,0 +1,314 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { Catalog } from './catalog.js'; +import type { ApiResponse } from './request.js'; + +const mockCatalog: Catalog = { + endpoints: [ + { + method: 'GET', + path: '/users', + summary: 'List users', + tag: 'Users', + operationId: 'listUsers', + pathParams: [], + queryParams: [], + hasRequestBody: false, + requestBodyRequired: false, + }, + { + method: 'GET', + path: '/users/{id}', + summary: 'Get user', + tag: 'Users', + operationId: 'getUser', + pathParams: [{ name: 'id', description: 'User ID', required: true }], + queryParams: [{ name: 'expand', description: 'Expand fields', required: false }], + hasRequestBody: false, + requestBodyRequired: false, + }, + { + method: 'POST', + path: '/organizations', + summary: 'Create organization', + tag: 'Organizations', + operationId: 'createOrganization', + pathParams: [], + queryParams: [], + hasRequestBody: true, + requestBodyRequired: true, + }, + { + method: 'GET', + path: '/users/{id}/links/{id}', + summary: 'Repeated path param (defensive)', + tag: 'Users', + operationId: 'getUserLink', + pathParams: [{ name: 'id', description: 'Identifier reused twice', required: true }], + queryParams: [], + hasRequestBody: false, + requestBodyRequired: false, + }, + { + method: 'PATCH', + path: '/users/{id}', + summary: 'Update user', + tag: 'Users', + operationId: 'updateUser', + pathParams: [{ name: 'id', description: 'User ID', required: true }], + queryParams: [], + hasRequestBody: true, + requestBodyRequired: false, + }, + { + method: 'GET', + path: '/authorize', + summary: 'Authorize', + tag: 'Auth', + operationId: 'authorize', + pathParams: [], + queryParams: [ + { name: 'response_type', description: 'Response type', required: true }, + { name: 'state', description: 'Optional state', required: false }, + ], + hasRequestBody: false, + requestBodyRequired: false, + }, + ], + tags: ['Auth', 'Organizations', 'Users'], +}; + +const mockApiRequest = vi.fn<(...args: unknown[]) => Promise>(); + +vi.mock('./catalog.js', async () => { + const actual = await vi.importActual('./catalog.js'); + return { + ...actual, + loadCatalog: async () => mockCatalog, + }; +}); + +vi.mock('./request.js', () => ({ + apiRequest: (...args: unknown[]) => mockApiRequest(...args), +})); + +vi.mock('../../lib/api-key.js', () => ({ + resolveApiKey: vi.fn(() => 'sk_test'), + resolveApiBaseUrl: vi.fn(() => 'https://api.example.com'), +})); + +const mockSelect = vi.fn(); +const mockText = vi.fn(); +const mockConfirm = vi.fn(); +const cancelSymbol = Symbol('cancel'); +const mockIsCancel = vi.fn((value: unknown) => value === cancelSymbol); + +vi.mock('../../utils/clack.js', () => ({ + default: { + select: (...args: unknown[]) => mockSelect(...args), + text: (...args: unknown[]) => mockText(...args), + confirm: (...args: unknown[]) => mockConfirm(...args), + isCancel: (value: unknown) => mockIsCancel(value), + }, +})); + +const { apiInteractive } = await import('./interactive.js'); + +function buildResponse(overrides: Partial = {}): ApiResponse { + return { + status: 200, + headers: new Headers(), + body: { ok: true }, + rawBody: '{"ok":true}', + ...overrides, + }; +} + +describe('apiInteractive', () => { + let consoleOutput: string[]; + let exitSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockIsCancel.mockImplementation((value: unknown) => value === cancelSymbol); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`__exit__:${code ?? 0}`); + }) as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('drives the happy path: select tag → endpoint → confirm → execute', async () => { + mockSelect.mockResolvedValueOnce('Users').mockResolvedValueOnce(mockCatalog.endpoints[0]); + mockConfirm.mockResolvedValueOnce(true); + mockApiRequest.mockResolvedValueOnce(buildResponse()); + + await apiInteractive(); + + expect(mockApiRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + path: '/users', + apiKey: 'sk_test', + baseUrl: 'https://api.example.com', + }), + ); + }); + + it('substitutes path params and prompts for them', async () => { + mockSelect.mockResolvedValueOnce('Users').mockResolvedValueOnce(mockCatalog.endpoints[1]); + mockText.mockResolvedValueOnce('user_42'); + // ep has only optional query params, decline adding them + mockConfirm.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mockApiRequest.mockResolvedValueOnce(buildResponse()); + + await apiInteractive(); + + expect(mockApiRequest).toHaveBeenCalledWith(expect.objectContaining({ path: '/users/user_42' })); + }); + + it('appends URL-encoded query params when the user opts in', async () => { + mockSelect.mockResolvedValueOnce('Users').mockResolvedValueOnce(mockCatalog.endpoints[1]); + mockText + .mockResolvedValueOnce('user 42') // path param + .mockResolvedValueOnce('first name'); // query param value + mockConfirm + .mockResolvedValueOnce(true) // wantsQuery + .mockResolvedValueOnce(true); // execute + mockApiRequest.mockResolvedValueOnce(buildResponse()); + + await apiInteractive(); + + // Both path and query values are URL-encoded so fetch() doesn't throw "Invalid URL" + // on values containing spaces or other URL-unsafe characters. + expect(mockApiRequest).toHaveBeenCalledWith( + expect.objectContaining({ path: '/users/user%2042?expand=first%20name' }), + ); + }); + + it('URL-encodes path param values containing reserved characters', async () => { + mockSelect.mockResolvedValueOnce('Users').mockResolvedValueOnce(mockCatalog.endpoints[1]); + // Value with characters that would break URL parsing if substituted verbatim. + mockText.mockResolvedValueOnce('a/b?c#d'); + // No query params, then execute. + mockConfirm.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mockApiRequest.mockResolvedValueOnce(buildResponse()); + + await apiInteractive(); + + expect(mockApiRequest).toHaveBeenCalledWith(expect.objectContaining({ path: '/users/a%2Fb%3Fc%23d' })); + }); + + it('collects a required JSON request body without asking to confirm', async () => { + mockSelect.mockResolvedValueOnce('Organizations').mockResolvedValueOnce(mockCatalog.endpoints[2]); + // No confirm for body (requestBodyRequired=true); only confirm for execute + mockConfirm.mockResolvedValueOnce(true); + mockText.mockResolvedValueOnce('{"name":"Acme"}'); + mockApiRequest.mockResolvedValueOnce(buildResponse({ status: 201 })); + + await apiInteractive(); + + expect(mockApiRequest).toHaveBeenCalledWith( + expect.objectContaining({ method: 'POST', path: '/organizations', body: '{"name":"Acme"}' }), + ); + }); + + it('prompts to confirm an optional request body and skips it when declined', async () => { + const patchUser = mockCatalog.endpoints[4]; // PATCH /users/{id}, requestBodyRequired: false + mockSelect.mockResolvedValueOnce('Users').mockResolvedValueOnce(patchUser); + mockText.mockResolvedValueOnce('user_42'); // path param + mockConfirm + .mockResolvedValueOnce(false) // decline optional body + .mockResolvedValueOnce(true); // execute + mockApiRequest.mockResolvedValueOnce(buildResponse()); + + await apiInteractive(); + + expect(mockApiRequest).toHaveBeenCalledWith(expect.objectContaining({ body: undefined })); + }); + + it('exits with code 0 when the user cancels at the category prompt', async () => { + mockSelect.mockResolvedValueOnce(cancelSymbol); + + await expect(apiInteractive()).rejects.toThrow(/__exit__:0/); + expect(exitSpy).toHaveBeenCalledWith(0); + expect(mockApiRequest).not.toHaveBeenCalled(); + }); + + it('exits with code 0 when the user declines the final confirmation', async () => { + mockSelect.mockResolvedValueOnce('Users').mockResolvedValueOnce(mockCatalog.endpoints[0]); + mockConfirm.mockResolvedValueOnce(false); + + await expect(apiInteractive()).rejects.toThrow(/__exit__:0/); + expect(exitSpy).toHaveBeenCalledWith(0); + expect(mockApiRequest).not.toHaveBeenCalled(); + }); + + it('replaces every occurrence of a repeated path placeholder', async () => { + const repeated = mockCatalog.endpoints[3]; + mockSelect.mockResolvedValueOnce('Users').mockResolvedValueOnce(repeated); + mockText.mockResolvedValueOnce('user_42'); + mockConfirm.mockResolvedValueOnce(true); + mockApiRequest.mockResolvedValueOnce(buildResponse()); + + await apiInteractive(); + + expect(mockApiRequest).toHaveBeenCalledWith(expect.objectContaining({ path: '/users/user_42/links/user_42' })); + }); + + it('always collects required query params without gating behind a confirm', async () => { + const authEp = mockCatalog.endpoints[5]; // GET /authorize with required response_type + optional state + mockSelect.mockResolvedValueOnce('Auth').mockResolvedValueOnce(authEp); + mockText.mockResolvedValueOnce('code'); // required: response_type + mockConfirm + .mockResolvedValueOnce(false) // decline optional query params + .mockResolvedValueOnce(true); // execute + mockApiRequest.mockResolvedValueOnce(buildResponse()); + + await apiInteractive(); + + expect(mockApiRequest).toHaveBeenCalledWith(expect.objectContaining({ path: '/authorize?response_type=code' })); + }); + + it('collects both required and optional query params when user opts in', async () => { + const authEp = mockCatalog.endpoints[5]; + mockSelect.mockResolvedValueOnce('Auth').mockResolvedValueOnce(authEp); + mockText + .mockResolvedValueOnce('code') // required: response_type + .mockResolvedValueOnce('abc123'); // optional: state + mockConfirm + .mockResolvedValueOnce(true) // accept optional query params + .mockResolvedValueOnce(true); // execute + mockApiRequest.mockResolvedValueOnce(buildResponse()); + + await apiInteractive(); + + expect(mockApiRequest).toHaveBeenCalledWith( + expect.objectContaining({ path: '/authorize?response_type=code&state=abc123' }), + ); + }); + + it('uses the provided apiKey override instead of resolveApiKey()', async () => { + mockSelect.mockResolvedValueOnce('Users').mockResolvedValueOnce(mockCatalog.endpoints[0]); + mockConfirm.mockResolvedValueOnce(true); + mockApiRequest.mockResolvedValueOnce(buildResponse()); + + await apiInteractive({ apiKey: 'sk_override' }); + + expect(mockApiRequest).toHaveBeenCalledWith(expect.objectContaining({ apiKey: 'sk_override' })); + }); + + it('exits with code 1 when the response status is >= 400', async () => { + mockSelect.mockResolvedValueOnce('Users').mockResolvedValueOnce(mockCatalog.endpoints[0]); + mockConfirm.mockResolvedValueOnce(true); + mockApiRequest.mockResolvedValueOnce(buildResponse({ status: 500, body: { error: 'boom' } })); + + await expect(apiInteractive()).rejects.toThrow(/__exit__:1/); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/commands/api/interactive.ts b/src/commands/api/interactive.ts new file mode 100644 index 0000000..8e7a12f --- /dev/null +++ b/src/commands/api/interactive.ts @@ -0,0 +1,154 @@ +import clack from '../../utils/clack.js'; +import { loadCatalog, endpointsByTag, type EndpointInfo } from './catalog.js'; +import { apiRequest } from './request.js'; +import { colorMethod, printResponse } from './format.js'; +import { resolveApiKey, resolveApiBaseUrl } from '../../lib/api-key.js'; + +function assertNotCancelled(value: T | symbol): T { + if (clack.isCancel(value)) process.exit(0); + return value as T; +} + +export async function apiInteractive(options?: { apiKey?: string }): Promise { + const catalog = await loadCatalog(); + const grouped = endpointsByTag(catalog.endpoints); + + const tag = assertNotCancelled( + await clack.select({ + message: 'Select a category:', + options: catalog.tags.map((t) => { + const count = grouped.get(t)?.length ?? 0; + return { value: t, label: `${t} (${count})` }; + }), + }), + ); + + const endpoints = grouped.get(tag)!; + const ep = assertNotCancelled( + await clack.select({ + message: 'Select an endpoint:', + options: endpoints.map((e) => ({ + value: e, + label: `${colorMethod(e.method).padEnd(18)} ${e.path}`, + hint: e.summary, + })), + }), + ); + + let resolvedPath = ep.path; + for (const param of ep.pathParams) { + const value = assertNotCancelled( + await clack.text({ + message: `${param.name}:`, + placeholder: param.description || undefined, + validate: (v) => { + if (!v?.trim()) return `${param.name} is required`; + }, + }), + ); + resolvedPath = resolvedPath.replaceAll(`{${param.name}}`, encodeURIComponent(value.trim())); + } + + let queryString = ''; + if (ep.queryParams.length > 0) { + const requiredParams = ep.queryParams.filter((qp) => qp.required); + const optionalParams = ep.queryParams.filter((qp) => !qp.required); + const params: string[] = []; + + for (const qp of requiredParams) { + const value = assertNotCancelled( + await clack.text({ + message: `${qp.name} (required):`, + placeholder: qp.description || undefined, + validate: (v) => { + if (!v?.trim()) return `${qp.name} is required`; + }, + }), + ); + params.push(`${encodeURIComponent(qp.name)}=${encodeURIComponent(value.trim())}`); + } + + if (optionalParams.length > 0) { + const wantsOptional = assertNotCancelled( + await clack.confirm({ + message: `Add optional query parameters? (${optionalParams.length} available)`, + initialValue: false, + }), + ); + + if (wantsOptional) { + for (const qp of optionalParams) { + const value = assertNotCancelled( + await clack.text({ + message: `${qp.name}:`, + placeholder: qp.description || undefined, + }), + ); + const trimmed = value.trim(); + if (trimmed) { + params.push(`${encodeURIComponent(qp.name)}=${encodeURIComponent(trimmed)}`); + } + } + } + } + + if (params.length > 0) { + queryString = `?${params.join('&')}`; + } + } + + let body: string | undefined; + if (ep.hasRequestBody) { + let collectBody = ep.requestBodyRequired; + if (!collectBody) { + collectBody = assertNotCancelled( + await clack.confirm({ + message: 'Provide a request body?', + initialValue: ep.method === 'POST' || ep.method === 'PUT', + }), + ); + } + + if (collectBody) { + body = assertNotCancelled( + await clack.text({ + message: 'Request body (JSON):', + placeholder: '{"key": "value"}', + validate: (v) => { + if (!v?.trim()) return 'Body cannot be empty'; + try { + JSON.parse(v); + } catch { + return 'Invalid JSON'; + } + }, + }), + ).trim(); + } + } + + const fullPath = `${resolvedPath}${queryString}`; + + console.log(`\n ${colorMethod(ep.method)} ${fullPath}`); + if (body) { + console.log(JSON.stringify(JSON.parse(body), null, 2)); + } + console.log(); + + const ok = assertNotCancelled(await clack.confirm({ message: 'Execute this request?' })); + if (!ok) process.exit(0); + + const response = await apiRequest({ + method: ep.method, + path: fullPath, + apiKey: options?.apiKey ?? resolveApiKey(), + baseUrl: resolveApiBaseUrl(), + body, + }); + + printResponse(response, { includeStatus: true }); + + if (response.status >= 400) { + process.exit(1); + } +} diff --git a/src/commands/api/request.spec.ts b/src/commands/api/request.spec.ts new file mode 100644 index 0000000..1e089ec --- /dev/null +++ b/src/commands/api/request.spec.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +vi.mock('../../lib/api-key.js', () => ({ + resolveApiKey: vi.fn(() => 'sk_test_default'), + resolveApiBaseUrl: vi.fn(() => 'https://api.example.com'), +})); + +const { apiRequest } = await import('./request.js'); +const { resolveApiKey, resolveApiBaseUrl } = await import('../../lib/api-key.js'); + +function buildResponse(body: string, init: ResponseInit = {}): Response { + return new Response(body, init); +} + +describe('apiRequest', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('uses provided apiKey and baseUrl over resolver fallbacks', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(buildResponse('{"ok":true}')); + await apiRequest({ + method: 'GET', + path: '/users', + apiKey: 'sk_explicit', + baseUrl: 'https://override.example.com', + }); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://override.example.com/users', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: 'Bearer sk_explicit', + Accept: 'application/json', + }), + }), + ); + expect(resolveApiKey).not.toHaveBeenCalled(); + expect(resolveApiBaseUrl).not.toHaveBeenCalled(); + }); + + it('falls back to resolveApiKey and resolveApiBaseUrl when not provided', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(buildResponse('{}')); + await apiRequest({ method: 'GET', path: '/users' }); + expect(resolveApiKey).toHaveBeenCalled(); + expect(resolveApiBaseUrl).toHaveBeenCalled(); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.example.com/users', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer sk_test_default' }), + }), + ); + }); + + it('prefixes path with a leading slash when missing', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(buildResponse('{}')); + await apiRequest({ method: 'GET', path: 'users' }); + expect(fetchSpy).toHaveBeenCalledWith('https://api.example.com/users', expect.any(Object)); + }); + + it('sends body and Content-Type when body is provided', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(buildResponse('{}')); + await apiRequest({ method: 'POST', path: '/orgs', body: '{"name":"Acme"}' }); + const init = fetchSpy.mock.calls[0]![1]!; + const headers = init.headers as Record; + expect(init.method).toBe('POST'); + expect(init.body).toBe('{"name":"Acme"}'); + expect(headers['Content-Type']).toBe('application/json'); + }); + + it('omits Content-Type when no body is provided', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(buildResponse('{}')); + await apiRequest({ method: 'GET', path: '/orgs' }); + const init = fetchSpy.mock.calls[0]![1]!; + const headers = init.headers as Record; + expect(headers['Content-Type']).toBeUndefined(); + }); + + it('still sets Content-Type when an explicit empty-string body is provided', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(buildResponse('{}')); + await apiRequest({ method: 'POST', path: '/orgs', body: '' }); + const init = fetchSpy.mock.calls[0]![1]!; + const headers = init.headers as Record; + expect(headers['Content-Type']).toBe('application/json'); + expect(init.body).toBe(''); + }); + + it('parses a JSON response body', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue(buildResponse('{"id":"org_123"}', { status: 200 })); + const response = await apiRequest({ method: 'GET', path: '/orgs/org_123' }); + expect(response.status).toBe(200); + expect(response.body).toEqual({ id: 'org_123' }); + expect(response.rawBody).toBe('{"id":"org_123"}'); + }); + + it('returns the raw string when response body is not JSON', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue(buildResponse('plain text', { status: 200 })); + const response = await apiRequest({ method: 'GET', path: '/health' }); + expect(response.body).toBe('plain text'); + expect(response.rawBody).toBe('plain text'); + }); + + it('preserves non-2xx status codes for the caller to inspect', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue(buildResponse('{"error":"unauthorized"}', { status: 401 })); + const response = await apiRequest({ method: 'GET', path: '/orgs' }); + expect(response.status).toBe(401); + expect(response.body).toEqual({ error: 'unauthorized' }); + }); + + it('throws a friendly error when the network request fails', async () => { + vi.spyOn(global, 'fetch').mockRejectedValue(new Error('ECONNREFUSED')); + await expect(apiRequest({ method: 'GET', path: '/orgs' })).rejects.toThrow(/Failed to connect to WorkOS API/); + }); + + it('preserves the original network error detail and cause for debugging', async () => { + const original = new Error('getaddrinfo ENOTFOUND api.workos.com'); + vi.spyOn(global, 'fetch').mockRejectedValue(original); + let caught: unknown; + try { + await apiRequest({ method: 'GET', path: '/orgs' }); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toContain('getaddrinfo ENOTFOUND api.workos.com'); + expect((caught as Error).cause).toBe(original); + }); +}); diff --git a/src/commands/api/request.ts b/src/commands/api/request.ts new file mode 100644 index 0000000..0636d92 --- /dev/null +++ b/src/commands/api/request.ts @@ -0,0 +1,58 @@ +import { resolveApiKey, resolveApiBaseUrl } from '../../lib/api-key.js'; + +export interface ApiRequestOptions { + method: string; + path: string; + apiKey?: string; + body?: string; + baseUrl?: string; +} + +export interface ApiResponse { + status: number; + headers: Headers; + body: unknown; + rawBody: string; +} + +export async function apiRequest(options: ApiRequestOptions): Promise { + const apiKey = options.apiKey ?? resolveApiKey(); + const baseUrl = options.baseUrl ?? resolveApiBaseUrl(); + + let path = options.path; + if (!path.startsWith('/')) path = `/${path}`; + + const url = `${baseUrl}${path}`; + + const headers: Record = { + Authorization: `Bearer ${apiKey}`, + Accept: 'application/json', + }; + + if (options.body !== undefined) { + headers['Content-Type'] = 'application/json'; + } + + let response: Response; + try { + response = await fetch(url, { + method: options.method, + headers, + body: options.body, + }); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to connect to WorkOS API: ${detail}`, err instanceof Error ? { cause: err } : undefined); + } + + const rawBody = await response.text(); + + let body: unknown; + try { + body = JSON.parse(rawBody); + } catch { + body = rawBody; + } + + return { status: response.status, headers: response.headers, body, rawBody }; +} diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index c8ef279..f35a6c9 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -293,6 +293,72 @@ const commands: CommandSchema[] = [ }, ], }, + { + name: 'api', + description: 'Make authenticated requests to the WorkOS API', + positionals: [ + { + name: 'endpoint', + type: 'string', + description: "API endpoint path (e.g. /users), or 'ls' to list endpoints", + required: false, + }, + { name: 'filter', type: 'string', description: 'Filter keyword (used with ls)', required: false }, + ], + options: [ + insecureStorageOpt, + { + name: 'method', + type: 'string', + description: 'HTTP method (default: GET, or POST if body provided)', + required: false, + alias: 'X', + hidden: false, + }, + { name: 'data', type: 'string', description: 'JSON request body', required: false, alias: 'd', hidden: false }, + { + name: 'file', + type: 'string', + description: 'Read request body from a file (or - for stdin)', + required: false, + hidden: false, + }, + { + name: 'include', + type: 'boolean', + description: 'Show response headers', + required: false, + default: false, + alias: 'i', + hidden: false, + }, + apiKeyOpt, + { + name: 'dry-run', + type: 'boolean', + description: 'Show the request without executing it', + required: false, + default: false, + hidden: false, + }, + { + name: 'yes', + type: 'boolean', + description: 'Skip confirmation for mutating requests', + required: false, + default: false, + alias: 'y', + hidden: false, + }, + ], + examples: [ + 'workos api ls', + 'workos api ls users', + 'workos api /user_management/users', + 'workos api /organizations -d \'{"name":"Acme"}\'', + 'workos api /organizations/org_123 -X DELETE', + ], + }, { name: 'organization', description: 'Manage WorkOS organizations (create, update, get, list, delete)',