diff --git a/packages/ts-cli/package.json b/packages/ts-cli/package.json index 3e16c393..f656134d 100644 --- a/packages/ts-cli/package.json +++ b/packages/ts-cli/package.json @@ -23,6 +23,11 @@ "bun": "./src/openapi/index.ts", "types": "./dist/openapi/index.d.ts", "import": "./dist/openapi/index.js" + }, + "./openapi/ergonomic": { + "bun": "./src/openapi/ergonomic/index.ts", + "types": "./dist/openapi/ergonomic/index.d.ts", + "import": "./dist/openapi/ergonomic/index.js" } }, "scripts": { diff --git a/packages/ts-cli/src/openapi/ergonomic/__tests__/assemble.test.ts b/packages/ts-cli/src/openapi/ergonomic/__tests__/assemble.test.ts new file mode 100644 index 00000000..79cee867 --- /dev/null +++ b/packages/ts-cli/src/openapi/ergonomic/__tests__/assemble.test.ts @@ -0,0 +1,256 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { DecomposedFlag } from '../decompose.js' +import { assembleJsonHeader } from '../assemble.js' + +// Helper to create a minimal flag +function flag( + overrides: Partial & { name: string; role: DecomposedFlag['role'] } +): DecomposedFlag { + return { + cliFlag: '--' + overrides.name, + type: 'string', + required: false, + description: '', + path: [], + ...overrides, + } +} + +describe('assembleJsonHeader', () => { + it('assembles name+config flags into nested object', () => { + const flags: DecomposedFlag[] = [ + flag({ name: 'source', role: 'name', path: ['source', 'name'], parentProp: 'source' }), + flag({ name: 'sourceConfig', role: 'config', path: ['source'], parentProp: 'source' }), + flag({ + name: 'destination', + role: 'name', + path: ['destination', 'name'], + parentProp: 'destination', + }), + flag({ + name: 'destinationConfig', + role: 'config', + path: ['destination'], + parentProp: 'destination', + }), + flag({ name: 'config', role: 'base-config', path: [] }), + ] + + const result = assembleJsonHeader({ + flags, + args: { + source: 'stripe', + sourceConfig: '{"api_key":"sk_test_123"}', + destination: 'postgres', + destinationConfig: '{"connection_string":"postgresql://..."}', + }, + }) + + const parsed = JSON.parse(result!) + expect(parsed.source).toEqual({ name: 'stripe', api_key: 'sk_test_123' }) + expect(parsed.destination).toEqual({ name: 'postgres', connection_string: 'postgresql://...' }) + }) + + it('assembles list flags as array of objects', () => { + const flags: DecomposedFlag[] = [ + flag({ name: 'streams', role: 'list', path: ['streams'] }), + flag({ name: 'config', role: 'base-config', path: [] }), + ] + + const result = assembleJsonHeader({ + flags, + args: { streams: 'accounts,customers,products' }, + }) + + const parsed = JSON.parse(result!) + expect(parsed.streams).toEqual([ + { name: 'accounts' }, + { name: 'customers' }, + { name: 'products' }, + ]) + }) + + it('name flag overrides name from config', () => { + const flags: DecomposedFlag[] = [ + flag({ name: 'source', role: 'name', path: ['source', 'name'], parentProp: 'source' }), + flag({ name: 'sourceConfig', role: 'config', path: ['source'], parentProp: 'source' }), + flag({ name: 'config', role: 'base-config', path: [] }), + ] + + const result = assembleJsonHeader({ + flags, + args: { + source: 'stripe', + sourceConfig: '{"name":"should-be-overridden","api_key":"sk_test"}', + }, + }) + + const parsed = JSON.parse(result!) + expect(parsed.source.name).toBe('stripe') + expect(parsed.source.api_key).toBe('sk_test') + }) + + it('returns undefined when nothing is set', () => { + const flags: DecomposedFlag[] = [ + flag({ name: 'source', role: 'name', path: ['source', 'name'], parentProp: 'source' }), + flag({ name: 'config', role: 'base-config', path: [] }), + ] + + const result = assembleJsonHeader({ + flags, + args: {}, + }) + + expect(result).toBeUndefined() + }) + + it('reads config from file via --config', () => { + const dir = mkdtempSync(join(tmpdir(), 'assemble-test-')) + const configPath = join(dir, 'pipeline.json') + writeFileSync( + configPath, + JSON.stringify({ + source: { name: 'stripe', api_key: 'sk_from_file' }, + destination: { name: 'postgres' }, + streams: [{ name: 'accounts' }], + }) + ) + + const flags: DecomposedFlag[] = [ + flag({ name: 'source', role: 'name', path: ['source', 'name'], parentProp: 'source' }), + flag({ name: 'sourceConfig', role: 'config', path: ['source'], parentProp: 'source' }), + flag({ + name: 'destination', + role: 'name', + path: ['destination', 'name'], + parentProp: 'destination', + }), + flag({ + name: 'destinationConfig', + role: 'config', + path: ['destination'], + parentProp: 'destination', + }), + flag({ name: 'streams', role: 'list', path: ['streams'] }), + flag({ name: 'config', role: 'base-config', path: [] }), + ] + + const result = assembleJsonHeader({ + flags, + args: { config: configPath }, + }) + + const parsed = JSON.parse(result!) + expect(parsed.source).toEqual({ name: 'stripe', api_key: 'sk_from_file' }) + expect(parsed.destination).toEqual({ name: 'postgres' }) + expect(parsed.streams).toEqual([{ name: 'accounts' }]) + }) + + it('cascade: flags > env > file', () => { + const dir = mkdtempSync(join(tmpdir(), 'assemble-cascade-')) + const configPath = join(dir, 'base.json') + writeFileSync( + configPath, + JSON.stringify({ + source: { name: 'from-file', api_key: 'from-file', base_url: 'from-file' }, + }) + ) + + const flags: DecomposedFlag[] = [ + flag({ name: 'source', role: 'name', path: ['source', 'name'], parentProp: 'source' }), + flag({ name: 'sourceConfig', role: 'config', path: ['source'], parentProp: 'source' }), + flag({ name: 'config', role: 'base-config', path: [] }), + ] + + // Set env vars + const saved: Record = {} + saved['SRCTEST_NAME'] = process.env['SRCTEST_NAME'] + saved['SRCTEST_API_KEY'] = process.env['SRCTEST_API_KEY'] + process.env['SRCTEST_NAME'] = 'from-env' + process.env['SRCTEST_API_KEY'] = 'from-env' + + try { + const result = assembleJsonHeader({ + flags, + args: { + source: 'from-flag', // flag wins over env and file + config: configPath, + }, + envPrefixes: { source: 'SRCTEST' }, + }) + + const parsed = JSON.parse(result!) + expect(parsed.source.name).toBe('from-flag') // flag wins + expect(parsed.source.api_key).toBe('from-env') // env wins over file + expect(parsed.source.base_url).toBe('from-file') // file fills in remaining + } finally { + // Restore env + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + } + }) + + describe('env var integration', () => { + const saved: Record = {} + + beforeEach(() => { + for (const key of Object.keys(process.env)) { + if (key.startsWith('ERGTEST_')) { + saved[key] = process.env[key] + } + } + }) + + afterEach(() => { + for (const key of Object.keys(process.env)) { + if (key.startsWith('ERGTEST_')) { + delete process.env[key] + } + } + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + }) + + it('picks up env vars for a property group', () => { + process.env['ERGTEST_NAME'] = 'stripe' + process.env['ERGTEST_API_KEY'] = 'sk_test_env' + + const flags: DecomposedFlag[] = [ + flag({ name: 'source', role: 'name', path: ['source', 'name'], parentProp: 'source' }), + flag({ name: 'sourceConfig', role: 'config', path: ['source'], parentProp: 'source' }), + flag({ name: 'config', role: 'base-config', path: [] }), + ] + + const result = assembleJsonHeader({ + flags, + args: {}, + envPrefixes: { source: 'ERGTEST' }, + }) + + const parsed = JSON.parse(result!) + expect(parsed.source).toEqual({ name: 'stripe', api_key: 'sk_test_env' }) + }) + }) + + it('handles scalar flags', () => { + const flags: DecomposedFlag[] = [ + flag({ name: 'limit', role: 'scalar', path: ['limit'] }), + flag({ name: 'config', role: 'base-config', path: [] }), + ] + + const result = assembleJsonHeader({ + flags, + args: { limit: '42' }, + }) + + const parsed = JSON.parse(result!) + expect(parsed.limit).toBe(42) + }) +}) diff --git a/packages/ts-cli/src/openapi/ergonomic/__tests__/decompose.test.ts b/packages/ts-cli/src/openapi/ergonomic/__tests__/decompose.test.ts new file mode 100644 index 00000000..58503a66 --- /dev/null +++ b/packages/ts-cli/src/openapi/ergonomic/__tests__/decompose.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest' +import type { OpenAPIParameter, OpenAPISpec } from '../../types.js' +import { decomposeHeaderParam } from '../decompose.js' + +const pipelineSpec: OpenAPISpec = { + paths: {}, + components: { + schemas: { + PipelineConfig: { + type: 'object', + required: ['source', 'destination'], + properties: { + source: { + type: 'object', + required: ['name'], + properties: { name: { type: 'string' } }, + additionalProperties: true, + }, + destination: { + type: 'object', + required: ['name'], + properties: { name: { type: 'string' } }, + additionalProperties: true, + }, + streams: { + type: 'array', + items: { + type: 'object', + required: ['name'], + properties: { name: { type: 'string' } }, + }, + }, + }, + }, + }, + }, +} + +describe('decomposeHeaderParam', () => { + it('decomposes a JSON header with $ref to PipelineConfig', () => { + const param: OpenAPIParameter = { + name: 'x-pipeline', + in: 'header', + schema: { + type: 'string', + contentMediaType: 'application/json', + contentSchema: { $ref: '#/components/schemas/PipelineConfig' }, + } as never, + } + + const result = decomposeHeaderParam(param, pipelineSpec) + + expect(result.headerName).toBe('x-pipeline') + expect(result.isJsonHeader).toBe(true) + + const flagNames = result.flags.map((f) => f.cliFlag) + expect(flagNames).toContain('--source') + expect(flagNames).toContain('--source-config') + expect(flagNames).toContain('--destination') + expect(flagNames).toContain('--destination-config') + expect(flagNames).toContain('--streams') + expect(flagNames).toContain('--config') + }) + + it('assigns correct roles to PipelineConfig flags', () => { + const param: OpenAPIParameter = { + name: 'x-pipeline', + in: 'header', + schema: { + type: 'string', + contentMediaType: 'application/json', + contentSchema: { $ref: '#/components/schemas/PipelineConfig' }, + } as never, + } + + const result = decomposeHeaderParam(param, pipelineSpec) + const byCliFlag = new Map(result.flags.map((f) => [f.cliFlag, f])) + + expect(byCliFlag.get('--source')!.role).toBe('name') + expect(byCliFlag.get('--source-config')!.role).toBe('config') + expect(byCliFlag.get('--destination')!.role).toBe('name') + expect(byCliFlag.get('--destination-config')!.role).toBe('config') + expect(byCliFlag.get('--streams')!.role).toBe('list') + expect(byCliFlag.get('--config')!.role).toBe('base-config') + }) + + it('sets parentProp for name and config flags', () => { + const param: OpenAPIParameter = { + name: 'x-pipeline', + in: 'header', + schema: { + type: 'string', + contentMediaType: 'application/json', + contentSchema: { $ref: '#/components/schemas/PipelineConfig' }, + } as never, + } + + const result = decomposeHeaderParam(param, pipelineSpec) + const sourceFlag = result.flags.find((f) => f.cliFlag === '--source')! + const sourceConfigFlag = result.flags.find((f) => f.cliFlag === '--source-config')! + + expect(sourceFlag.parentProp).toBe('source') + expect(sourceFlag.path).toEqual(['source', 'name']) + expect(sourceConfigFlag.parentProp).toBe('source') + expect(sourceConfigFlag.path).toEqual(['source']) + }) + + it('handles non-JSON header param (integer)', () => { + const param: OpenAPIParameter = { + name: 'x-state-checkpoint-limit', + in: 'header', + schema: { type: 'integer' }, + description: 'Stop after N checkpoints', + } + + const result = decomposeHeaderParam(param, pipelineSpec) + + expect(result.isJsonHeader).toBe(false) + expect(result.flags).toHaveLength(1) + expect(result.flags[0]!.cliFlag).toBe('--state-checkpoint-limit') + expect(result.flags[0]!.role).toBe('scalar') + expect(result.flags[0]!.description).toBe('Stop after N checkpoints') + }) + + it('handles non-JSON header param with JSON content (x-state with no contentSchema)', () => { + const param: OpenAPIParameter = { + name: 'x-state', + in: 'header', + schema: { type: 'string' }, + description: 'Per-stream cursor state', + } + + const result = decomposeHeaderParam(param, pipelineSpec) + + expect(result.isJsonHeader).toBe(false) + expect(result.flags).toHaveLength(1) + expect(result.flags[0]!.cliFlag).toBe('--state') + expect(result.flags[0]!.role).toBe('json') + }) + + it('handles JSON header with inline schema (open object)', () => { + const param: OpenAPIParameter = { + name: 'x-state', + in: 'header', + schema: { + type: 'string', + contentMediaType: 'application/json', + contentSchema: { + type: 'object', + additionalProperties: true, + description: 'Per-stream cursor state', + }, + } as never, + } + + const result = decomposeHeaderParam(param, pipelineSpec) + + expect(result.isJsonHeader).toBe(true) + // Open object with no properties → only a base-config flag + const configFlag = result.flags.find((f) => f.role === 'base-config') + expect(configFlag).toBeDefined() + }) + + it('marks source as required when schema says so', () => { + const param: OpenAPIParameter = { + name: 'x-pipeline', + in: 'header', + schema: { + type: 'string', + contentMediaType: 'application/json', + contentSchema: { $ref: '#/components/schemas/PipelineConfig' }, + } as never, + } + + const result = decomposeHeaderParam(param, pipelineSpec) + const sourceFlag = result.flags.find((f) => f.cliFlag === '--source')! + const streamsFlag = result.flags.find((f) => f.cliFlag === '--streams')! + + // source is in required: ['source', 'destination'] + expect(sourceFlag.required).toBe(true) + // streams is not in required + expect(streamsFlag.required).toBe(false) + }) +}) diff --git a/packages/ts-cli/src/openapi/ergonomic/__tests__/ergonomic.test.ts b/packages/ts-cli/src/openapi/ergonomic/__tests__/ergonomic.test.ts new file mode 100644 index 00000000..a2ef49e2 --- /dev/null +++ b/packages/ts-cli/src/openapi/ergonomic/__tests__/ergonomic.test.ts @@ -0,0 +1,266 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { runCommand } from 'citty' +import type { CommandDef } from 'citty' +import { toCliFlag } from '../../parse.js' +import { createErgonomicCli } from '../index.js' +import type { OpenAPISpec } from '../../types.js' + +// Load the real engine spec +const specPath = join( + import.meta.dirname, + '..', + '..', + '..', + '..', + '..', + '..', + 'docs', + 'openapi', + 'engine.json' +) +const engineSpec: OpenAPISpec = JSON.parse(readFileSync(specPath, 'utf-8')) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function optionFlags(cmd: CommandDef): string[] { + return Object.entries(cmd.args ?? {}) + .filter(([, def]) => def.type !== 'positional') + .map(([key]) => '--' + toCliFlag(key)) +} + +function subCommandNames(cmd: CommandDef): string[] { + return Object.keys(cmd.subCommands ?? {}) +} + +// --------------------------------------------------------------------------- +// Structure tests: verify decomposed flags appear on the right commands +// --------------------------------------------------------------------------- + +describe('createErgonomicCli with engine.json', () => { + const handler = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + + it('creates subcommands for all operations', () => { + const root = createErgonomicCli({ spec: engineSpec, handler }) + const names = subCommandNames(root) + expect(names).toContain('health') + expect(names).toContain('setup') + expect(names).toContain('teardown') + expect(names).toContain('check') + expect(names).toContain('read') + expect(names).toContain('write') + expect(names).toContain('sync') + expect(names).toContain('list-connectors') + }) + + it('read command has decomposed pipeline flags + state flags', () => { + const root = createErgonomicCli({ spec: engineSpec, handler }) + const readCmd = root.subCommands!['read'] as CommandDef + const flags = optionFlags(readCmd) + + // Decomposed from x-pipeline + expect(flags).toContain('--source') + expect(flags).toContain('--source-config') + expect(flags).toContain('--destination') + expect(flags).toContain('--destination-config') + expect(flags).toContain('--streams') + expect(flags).toContain('--config') + + // From x-state-checkpoint-limit (non-JSON scalar) + expect(flags).toContain('--state-checkpoint-limit') + }) + + it('check command has decomposed pipeline flags but no state flags', () => { + const root = createErgonomicCli({ spec: engineSpec, handler }) + const checkCmd = root.subCommands!['check'] as CommandDef + const flags = optionFlags(checkCmd) + + expect(flags).toContain('--source') + expect(flags).toContain('--source-config') + expect(flags).toContain('--destination') + expect(flags).toContain('--destination-config') + expect(flags).toContain('--config') + + // check doesn't have x-state or x-state-checkpoint-limit + expect(flags).not.toContain('--state-checkpoint-limit') + }) + + it('health command has no decomposed flags', () => { + const root = createErgonomicCli({ spec: engineSpec, handler }) + const healthCmd = root.subCommands!['health'] as CommandDef + const flags = optionFlags(healthCmd) + + expect(flags).not.toContain('--source') + expect(flags).not.toContain('--config') + }) + + it('list-connectors has no decomposed flags', () => { + const root = createErgonomicCli({ spec: engineSpec, handler }) + const cmd = root.subCommands!['list-connectors'] as CommandDef + const flags = optionFlags(cmd) + + expect(flags).not.toContain('--source') + expect(flags).not.toContain('--config') + }) + + it('write command has pipeline flags plus body', () => { + const root = createErgonomicCli({ spec: engineSpec, handler }) + const writeCmd = root.subCommands!['write'] as CommandDef + const flags = optionFlags(writeCmd) + + expect(flags).toContain('--source') + expect(flags).toContain('--destination') + expect(flags).toContain('--config') + expect(flags).toContain('--body') + }) +}) + +// --------------------------------------------------------------------------- +// Integration: mock handler, verify assembled headers +// --------------------------------------------------------------------------- + +describe('ergonomic CLI handler integration', () => { + it('assembles x-pipeline header from decomposed flags', async () => { + const capturedRequests: Request[] = [] + const handler = vi.fn().mockImplementation((req: Request) => { + capturedRequests.push(req) + return Promise.resolve(new Response(null, { status: 204 })) + }) + + const root = createErgonomicCli({ spec: engineSpec, handler }) + + await runCommand(root, { + rawArgs: [ + 'setup', + '--source', + 'stripe', + '--source-config', + '{"api_key":"sk_test_123","api_version":"2024-12-18.acacia"}', + '--destination', + 'postgres', + '--destination-config', + '{"connection_string":"postgresql://localhost/test"}', + ], + }) + + expect(capturedRequests).toHaveLength(1) + const req = capturedRequests[0]! + const pipelineHeader = req.headers.get('x-pipeline') + expect(pipelineHeader).toBeTruthy() + + const pipeline = JSON.parse(pipelineHeader!) + expect(pipeline.source.name).toBe('stripe') + expect(pipeline.source.api_key).toBe('sk_test_123') + expect(pipeline.destination.name).toBe('postgres') + expect(pipeline.destination.connection_string).toBe('postgresql://localhost/test') + }) + + it('assembles streams from comma-separated flag', async () => { + const capturedRequests: Request[] = [] + const handler = vi.fn().mockImplementation((req: Request) => { + capturedRequests.push(req) + return Promise.resolve( + new Response('{"type":"state","stream":"a","data":{}}\n', { + headers: { 'content-type': 'application/x-ndjson' }, + }) + ) + }) + + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + const root = createErgonomicCli({ spec: engineSpec, handler }) + + await runCommand(root, { + rawArgs: [ + 'read', + '--source', + 'stripe', + '--source-config', + '{"api_key":"sk_test"}', + '--destination', + 'postgres', + '--streams', + 'accounts,customers', + '--state-checkpoint-limit', + '1', + ], + }) + + writeSpy.mockRestore() + + expect(capturedRequests).toHaveLength(1) + const req = capturedRequests[0]! + + const pipeline = JSON.parse(req.headers.get('x-pipeline')!) + expect(pipeline.streams).toEqual([{ name: 'accounts' }, { name: 'customers' }]) + + // x-state-checkpoint-limit is a non-JSON header + expect(req.headers.get('x-state-checkpoint-limit')).toBe('1') + }) + + it('uses env var prefixes for source config', async () => { + // Set env vars + const savedName = process.env['ERGSRC_NAME'] + const savedKey = process.env['ERGSRC_API_KEY'] + process.env['ERGSRC_NAME'] = 'stripe' + process.env['ERGSRC_API_KEY'] = 'sk_from_env' + + const capturedRequests: Request[] = [] + const handler = vi.fn().mockImplementation((req: Request) => { + capturedRequests.push(req) + return Promise.resolve(new Response(null, { status: 204 })) + }) + + try { + const root = createErgonomicCli({ + spec: engineSpec, + handler, + envPrefixes: { source: 'ERGSRC' }, + }) + + await runCommand(root, { + rawArgs: [ + 'setup', + '--destination', + 'postgres', + '--destination-config', + '{"connection_string":"postgresql://localhost/test"}', + ], + }) + + expect(capturedRequests).toHaveLength(1) + const pipeline = JSON.parse(capturedRequests[0]!.headers.get('x-pipeline')!) + expect(pipeline.source.name).toBe('stripe') + expect(pipeline.source.api_key).toBe('sk_from_env') + } finally { + if (savedName === undefined) delete process.env['ERGSRC_NAME'] + else process.env['ERGSRC_NAME'] = savedName + if (savedKey === undefined) delete process.env['ERGSRC_API_KEY'] + else process.env['ERGSRC_API_KEY'] = savedKey + } + }) + + it('supports groupByTag', () => { + const handler = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + const root = createErgonomicCli({ + spec: engineSpec, + handler, + groupByTag: true, + }) + + const groups = subCommandNames(root) + // toCliFlag doesn't handle spaces — "Stateless Sync API" becomes "stateless sync api" + expect(groups).toContain('stateless sync api') + expect(groups).toContain('status') + expect(groups).toContain('connectors') + + const syncGroup = root.subCommands!['stateless sync api'] as CommandDef + const syncOps = subCommandNames(syncGroup) + expect(syncOps).toContain('read') + expect(syncOps).toContain('sync') + expect(syncOps).toContain('setup') + }) +}) diff --git a/packages/ts-cli/src/openapi/ergonomic/assemble.ts b/packages/ts-cli/src/openapi/ergonomic/assemble.ts new file mode 100644 index 00000000..a930de3e --- /dev/null +++ b/packages/ts-cli/src/openapi/ergonomic/assemble.ts @@ -0,0 +1,108 @@ +import { envPrefix, mergeConfig, parseJsonOrFile, parseStreams } from '../../config.js' +import type { DecomposedFlag } from './decompose.js' + +export interface AssembleContext { + flags: DecomposedFlag[] + args: Record + /** Map of schema property names to env var prefixes. + * e.g. { source: 'SOURCE', destination: 'DESTINATION' } */ + envPrefixes?: Record +} + +/** + * Assemble decomposed flag values + env vars + config file into a JSON string + * suitable for setting as a header value. + * + * Cascade priority: flags > env vars > config file (first wins per key). + * Returns undefined if nothing was set. + */ +export function assembleJsonHeader(ctx: AssembleContext): string | undefined { + const { flags, args, envPrefixes = {} } = ctx + + // 1. Load base config from --config flag + const baseConfigFlag = flags.find((f) => f.role === 'base-config') + const baseConfig = baseConfigFlag ? parseJsonOrFile(args[baseConfigFlag.name]) : {} + + const assembled: Record = {} + + // Group flags by parentProp to handle name+config pairs + const parentProps = new Set() + for (const flag of flags) { + if (flag.parentProp) parentProps.add(flag.parentProp) + } + + // 2. For each property group that has name+config pattern + for (const prop of parentProps) { + const nameFlag = flags.find((f) => f.parentProp === prop && f.role === 'name') + const configFlag = flags.find((f) => f.parentProp === prop && f.role === 'config') + + // Flag values + const flagObj: Record = {} + if (nameFlag && args[nameFlag.name] !== undefined) { + flagObj['name'] = args[nameFlag.name] + } + const configObj = configFlag ? parseJsonOrFile(args[configFlag.name]) : {} + const flagValues = { ...configObj, ...flagObj } // name takes precedence over config + + // Env values + const prefix = envPrefixes[prop] + const envValues = prefix ? envPrefix(prefix) : {} + + // Base config values for this property + const fileValues = + baseConfig[prop] != null && typeof baseConfig[prop] === 'object' + ? (baseConfig[prop] as Record) + : {} + + const merged = mergeConfig(flagValues, envValues, fileValues) + if (Object.keys(merged).length > 0) { + assembled[prop] = merged + } + } + + // 3. Handle list flags (e.g. --streams) + for (const flag of flags) { + if (flag.role !== 'list') continue + const prop = flag.path[0]! + const value = args[flag.name] + if (value !== undefined) { + assembled[prop] = parseStreams(value) + } else if (baseConfig[prop] !== undefined) { + assembled[prop] = baseConfig[prop] + } + } + + // 4. Handle json flags (open objects without named properties) + for (const flag of flags) { + if (flag.role !== 'json' || flag.path.length === 0) continue + const prop = flag.path[0]! + if (prop in assembled) continue // already handled by name+config pattern + const value = args[flag.name] + if (value !== undefined) { + assembled[prop] = parseJsonOrFile(value) + } else if (baseConfig[prop] !== undefined) { + assembled[prop] = baseConfig[prop] + } + } + + // 5. Handle scalar flags + for (const flag of flags) { + if (flag.role !== 'scalar' || flag.path.length === 0) continue + const prop = flag.path[0]! + if (prop in assembled) continue + const value = args[flag.name] + if (value !== undefined) { + assembled[prop] = tryNumeric(value) + } else if (baseConfig[prop] !== undefined) { + assembled[prop] = baseConfig[prop] + } + } + + if (Object.keys(assembled).length === 0) return undefined + return JSON.stringify(assembled) +} + +function tryNumeric(value: string): unknown { + const n = Number(value) + return Number.isFinite(n) ? n : value +} diff --git a/packages/ts-cli/src/openapi/ergonomic/decompose.ts b/packages/ts-cli/src/openapi/ergonomic/decompose.ts new file mode 100644 index 00000000..4c0b1d0c --- /dev/null +++ b/packages/ts-cli/src/openapi/ergonomic/decompose.ts @@ -0,0 +1,188 @@ +import type { OpenAPIParameter, OpenAPISpec } from '../types.js' +import { toCliFlag } from '../parse.js' +import { toOptName } from '../dispatch.js' +import type { ExtendedSchema } from './types.js' +import { isJsonHeaderParam, resolveRef } from './types.js' + +export type FlagRole = + | 'name' // shorthand for obj.name: --source stripe + | 'config' // JSON/file for rest of object: --source-config '{...}' + | 'list' // comma-separated names: --streams a,b + | 'json' // full JSON/file: --state '{...}' + | 'scalar' // plain value: --state-checkpoint-limit 5 + | 'base-config' // entire parent object: --config pipeline.json + +export interface DecomposedFlag { + /** citty arg key (camelCase): "source", "sourceConfig", "streams" */ + name: string + /** Display flag: "--source", "--source-config", "--streams" */ + cliFlag: string + type: 'string' | 'boolean' + required: boolean + description: string + role: FlagRole + /** JSON path this flag maps to in the parent object */ + path: string[] + /** For 'name'/'config' roles: the property name on the parent object */ + parentProp?: string +} + +export interface DecomposedParam { + /** Original header name (e.g. "x-pipeline") */ + headerName: string + /** Whether this header has JSON content */ + isJsonHeader: boolean + /** Generated flags from decomposing the content schema */ + flags: DecomposedFlag[] +} + +/** Decompose a single header parameter into ergonomic CLI flags. */ +export function decomposeHeaderParam(param: OpenAPIParameter, spec: OpenAPISpec): DecomposedParam { + if (!isJsonHeaderParam(param)) { + // Non-JSON header: single flag, strip x- prefix + const flagCliName = stripXPrefix(param.name) + const schema = param.schema as ExtendedSchema | undefined + const isInteger = schema?.type === 'integer' || schema?.type === 'number' + return { + headerName: param.name, + isJsonHeader: false, + flags: [ + { + name: toOptName(flagCliName), + cliFlag: '--' + toCliFlag(flagCliName), + type: 'string', + required: param.required === true, + description: param.description ?? schema?.description ?? '', + role: isInteger ? 'scalar' : 'json', + path: [], + }, + ], + } + } + + const schema = param.schema as ExtendedSchema + let contentSchema = schema.contentSchema! + if (contentSchema.$ref) { + contentSchema = resolveRef(spec, contentSchema.$ref) + } + + const flags = decomposeSchema(contentSchema, param) + return { + headerName: param.name, + isJsonHeader: true, + flags, + } +} + +/** Decompose a resolved content schema into individual CLI flags. */ +function decomposeSchema(schema: ExtendedSchema, param: OpenAPIParameter): DecomposedFlag[] { + const flags: DecomposedFlag[] = [] + const requiredProps = schema.required ?? [] + + for (const [propName, propSchema] of Object.entries(schema.properties ?? {})) { + const resolved = propSchema as ExtendedSchema + + if (isNamedObject(resolved)) { + // Object with properties.name + additionalProperties → two flags: name + config + flags.push({ + name: toOptName(propName), + cliFlag: '--' + toCliFlag(propName), + type: 'string', + required: requiredProps.includes(propName), + description: `${capitalize(propName)} connector name`, + role: 'name', + path: [propName, 'name'], + parentProp: propName, + }) + flags.push({ + name: toOptName(propName + '_config'), + cliFlag: '--' + toCliFlag(propName + '_config'), + type: 'string', + required: false, + description: `Additional ${propName} config (JSON or @file)`, + role: 'config', + path: [propName], + parentProp: propName, + }) + } else if (isNamedArray(resolved)) { + // Array of objects with required name → comma-separated list + flags.push({ + name: toOptName(propName), + cliFlag: '--' + toCliFlag(propName), + type: 'string', + required: requiredProps.includes(propName), + description: `${capitalize(propName)} names, comma-separated`, + role: 'list', + path: [propName], + }) + } else if (isOpenObject(resolved)) { + // Open object (additionalProperties, no named properties) → json + flags.push({ + name: toOptName(propName), + cliFlag: '--' + toCliFlag(propName), + type: 'string', + required: requiredProps.includes(propName), + description: resolved.description ?? `${capitalize(propName)} (JSON or @file)`, + role: 'json', + path: [propName], + }) + } else { + // Simple scalar + flags.push({ + name: toOptName(propName), + cliFlag: '--' + toCliFlag(propName), + type: 'string', + required: requiredProps.includes(propName), + description: resolved.description ?? capitalize(propName), + role: 'scalar', + path: [propName], + }) + } + } + + // Always add a base-config flag for the entire JSON header + const headerLabel = stripXPrefix(param.name) + flags.push({ + name: 'config', + cliFlag: '--config', + type: 'string', + required: false, + description: `Full ${headerLabel} config (JSON or @file, flags override)`, + role: 'base-config', + path: [], + }) + + return flags +} + +/** Object with { properties: { name: string }, additionalProperties: true } */ +function isNamedObject(schema: ExtendedSchema): boolean { + if (schema.type !== 'object') return false + const nameField = schema.properties?.['name'] + if (!nameField) return false + if (nameField.type !== 'string') return false + return schema.additionalProperties === true +} + +/** Array whose items are objects with a required `name` property. */ +function isNamedArray(schema: ExtendedSchema): boolean { + if (schema.type !== 'array') return false + const items = schema.items + if (!items || items.type !== 'object') return false + return (items.required ?? []).includes('name') +} + +/** Object with additionalProperties but no named properties. */ +function isOpenObject(schema: ExtendedSchema): boolean { + if (schema.type !== 'object') return false + const hasProps = schema.properties && Object.keys(schema.properties).length > 0 + return !hasProps && schema.additionalProperties != null && schema.additionalProperties !== false +} + +function stripXPrefix(name: string): string { + return name.replace(/^x-/, '') +} + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1) +} diff --git a/packages/ts-cli/src/openapi/ergonomic/index.ts b/packages/ts-cli/src/openapi/ergonomic/index.ts new file mode 100644 index 00000000..2b0ffa71 --- /dev/null +++ b/packages/ts-cli/src/openapi/ergonomic/index.ts @@ -0,0 +1,301 @@ +import { defineCommand } from 'citty' +import type { ArgDef, CommandDef } from 'citty' +import { buildRequest, handleResponse, toOptName } from '../dispatch.js' +import type { Handler } from '../dispatch.js' +import { defaultOperationName, parseSpec, toCliFlag } from '../parse.js' +import type { ParsedOperation } from '../parse.js' +import type { OpenAPIOperation, OpenAPISpec } from '../types.js' +import { decomposeHeaderParam } from './decompose.js' +import type { DecomposedParam } from './decompose.js' +import { assembleJsonHeader } from './assemble.js' + +export type { Handler } +export { decomposeHeaderParam } from './decompose.js' +export { assembleJsonHeader } from './assemble.js' +export type { DecomposedFlag, DecomposedParam, FlagRole } from './decompose.js' +export type { AssembleContext } from './assemble.js' +export type { ExtendedSchema } from './types.js' +export { resolveRef, isJsonHeaderParam } from './types.js' + +export interface CreateErgonomicCliOptions { + /** OpenAPI 3.1 spec object */ + spec: OpenAPISpec + /** Web-standard request handler */ + handler: Handler + /** Override command name derivation */ + nameOperation?: (method: string, path: string, operation: OpenAPIOperation) => string + /** Exclude specific operationIds */ + exclude?: string[] + /** Group commands under subcommands by OpenAPI tag */ + groupByTag?: boolean + /** Base URL for constructing Request objects (default: 'http://localhost') */ + baseUrl?: string + /** Provider for NDJSON request body stream */ + ndjsonBodyStream?: () => ReadableStream | null | undefined + /** CLI metadata for the root command */ + meta?: { name?: string; description?: string; version?: string } + /** Extra args to declare on the root command */ + rootArgs?: Record + /** Map of schema property names to env var prefixes. + * e.g. { source: 'SOURCE', destination: 'DESTINATION' } */ + envPrefixes?: Record +} + +/** Returns a citty CommandDef with ergonomic, decomposed flags for JSON-in-header params. */ +export function createErgonomicCli(opts: CreateErgonomicCliOptions): CommandDef { + const { + spec, + handler, + nameOperation, + exclude = [], + groupByTag = false, + baseUrl = 'http://localhost', + ndjsonBodyStream, + meta, + rootArgs, + envPrefixes = {}, + } = opts + + const operations = parseSpec(spec).filter( + (op) => !op.operationId || !exclude.includes(op.operationId) + ) + + const subCommands: Record = {} + + if (groupByTag) { + const groups = new Map() + const ungrouped: ParsedOperation[] = [] + + for (const op of operations) { + const tag = op.tags[0] + if (tag) { + const list = groups.get(tag) ?? [] + list.push(op) + groups.set(tag, list) + } else { + ungrouped.push(op) + } + } + + for (const [tag, ops] of groups) { + const groupSubCommands: Record = {} + for (const op of ops) { + const name = getOpName(op, nameOperation) + groupSubCommands[name] = buildErgonomicCommand( + op, + spec, + handler, + baseUrl, + nameOperation, + ndjsonBodyStream, + envPrefixes + ) + } + subCommands[toCliFlag(tag)] = defineCommand({ + meta: { name: toCliFlag(tag) }, + subCommands: groupSubCommands, + }) + } + + for (const op of ungrouped) { + const name = getOpName(op, nameOperation) + subCommands[name] = buildErgonomicCommand( + op, + spec, + handler, + baseUrl, + nameOperation, + ndjsonBodyStream, + envPrefixes + ) + } + } else { + for (const op of operations) { + const name = getOpName(op, nameOperation) + subCommands[name] = buildErgonomicCommand( + op, + spec, + handler, + baseUrl, + nameOperation, + ndjsonBodyStream, + envPrefixes + ) + } + } + + return defineCommand({ + meta: meta + ? { name: meta.name, description: meta.description, version: meta.version } + : undefined, + args: rootArgs, + subCommands, + }) +} + +function getOpName( + op: ParsedOperation, + nameOverride?: (method: string, path: string, op: OpenAPIOperation) => string +): string { + const rawOp = toRawOp(op) + return nameOverride + ? nameOverride(op.method, op.path, rawOp) + : defaultOperationName(op.method, op.path, rawOp) +} + +function toRawOp(op: ParsedOperation): OpenAPIOperation { + return { + operationId: op.operationId, + tags: op.tags, + parameters: [...op.pathParams, ...op.queryParams, ...op.headerParams], + requestBody: op.bodySchema + ? { + required: op.bodyRequired, + content: { 'application/json': { schema: op.bodySchema } }, + } + : undefined, + } +} + +/** Build a single ergonomic command, decomposing JSON-in-header params. */ +function buildErgonomicCommand( + operation: ParsedOperation, + spec: OpenAPISpec, + handler: Handler, + baseUrl: string, + nameOverride: CreateErgonomicCliOptions['nameOperation'], + ndjsonBodyStream: CreateErgonomicCliOptions['ndjsonBodyStream'], + envPrefixes: Record +): CommandDef { + const rawOp = toRawOp(operation) + const name = nameOverride + ? nameOverride(operation.method, operation.path, rawOp) + : defaultOperationName(operation.method, operation.path, rawOp) + + const args: Record = {} + + // Path params → positional args + for (const param of operation.pathParams) { + args[param.name] = { + type: 'positional', + required: param.required !== false, + description: param.description ?? '', + } + } + + // Query params → --flags + for (const param of operation.queryParams) { + const key = toOptName(param.name) + args[key] = { + type: 'string', + required: param.required === true, + description: param.description ?? '', + } + } + + // Header params: decompose JSON headers, pass through non-JSON + const decomposed: DecomposedParam[] = [] + for (const param of operation.headerParams) { + const dp = decomposeHeaderParam(param, spec) + decomposed.push(dp) + for (const flag of dp.flags) { + args[flag.name] = { + type: flag.type, + required: false, // Ergonomic flags are never individually required by citty + description: flag.description, + } + } + } + + // Body: same logic as original command.ts + if (operation.bodySchema) { + const props = operation.bodySchema.properties + if (props && !operation.ndjsonRequest) { + const requiredFields = operation.bodySchema.required ?? [] + for (const [propName, propSchema] of Object.entries(props)) { + const key = toOptName(propName) + args[key] = { + type: 'string', + required: requiredFields.includes(propName), + description: propSchema.description ?? '', + } + } + } else { + const bodyOptional = operation.ndjsonRequest && ndjsonBodyStream !== undefined + args['body'] = { + type: 'string', + required: operation.bodyRequired === true && !bodyOptional, + description: 'Request body as JSON string', + } + } + } + + return defineCommand({ + meta: { name }, + args, + async run({ args: cmdArgs }) { + const positionals = operation.pathParams.map( + (p) => (cmdArgs as Record)[p.name] + ) + const opts = cmdArgs as Record + + // Build the base request using buildRequest for path/query/body handling. + // We pass an operation with empty headerParams so buildRequest doesn't set raw headers. + const opForBuild: ParsedOperation = { + ...operation, + headerParams: [], + } + let request = buildRequest(opForBuild, positionals, opts, baseUrl) + + // Assemble JSON headers from decomposed flags + const headers = new Headers(request.headers) + for (const dp of decomposed) { + if (dp.isJsonHeader) { + const value = assembleJsonHeader({ + flags: dp.flags, + args: opts, + envPrefixes, + }) + if (value) { + headers.set(dp.headerName, value) + } + } else { + // Non-JSON header: get value from the single flag + const flag = dp.flags[0] + if (flag) { + const value = opts[flag.name] + if (value !== undefined) { + headers.set(dp.headerName, value) + } + } + } + } + + request = new Request(request.url, { + method: request.method, + headers, + body: request.body, + ...(request.body ? { duplex: 'half' } : {}), + } as RequestInit) + + // Handle NDJSON body stream override + if (operation.ndjsonRequest && ndjsonBodyStream) { + const stream = ndjsonBodyStream() + if (stream) { + const ndjsonHeaders = new Headers(request.headers) + ndjsonHeaders.set('Content-Type', 'application/x-ndjson') + ndjsonHeaders.set('Transfer-Encoding', 'chunked') + request = new Request(request.url, { + method: request.method, + headers: ndjsonHeaders, + body: stream, + duplex: 'half', + } as RequestInit) + } + } + + const response = await handler(request) + await handleResponse(response, operation) + }, + }) +} diff --git a/packages/ts-cli/src/openapi/ergonomic/types.ts b/packages/ts-cli/src/openapi/ergonomic/types.ts new file mode 100644 index 00000000..c10ce922 --- /dev/null +++ b/packages/ts-cli/src/openapi/ergonomic/types.ts @@ -0,0 +1,44 @@ +import type { OpenAPIParameter, OpenAPISpec } from '../types.js' + +/** Extended schema with OAS 3.1 fields needed for JSON-in-header detection. */ +export interface ExtendedSchema { + type?: string | string[] + $ref?: string + properties?: Record + required?: string[] + items?: ExtendedSchema + enum?: unknown[] + description?: string + format?: string + const?: unknown + additionalProperties?: boolean | ExtendedSchema + contentMediaType?: string + contentSchema?: ExtendedSchema + oneOf?: ExtendedSchema[] + discriminator?: { propertyName: string; mapping?: Record } + exclusiveMinimum?: number + maximum?: number + example?: unknown +} + +/** Resolve a $ref string (e.g. "#/components/schemas/Foo") to its target schema. */ +export function resolveRef(spec: OpenAPISpec, ref: string): ExtendedSchema { + // Only handle local JSON Pointer refs: #/components/schemas/Name + const prefix = '#/components/schemas/' + if (!ref.startsWith(prefix)) { + throw new Error(`Unsupported $ref: ${ref} (only local component refs supported)`) + } + const name = ref.slice(prefix.length) + const schema = (spec.components?.schemas as Record | undefined)?.[name] + if (!schema) { + throw new Error(`$ref target not found: ${ref}`) + } + return schema +} + +/** Check if a header param carries JSON content via OAS 3.1 contentMediaType. */ +export function isJsonHeaderParam(param: OpenAPIParameter): boolean { + const schema = param.schema as ExtendedSchema | undefined + if (!schema) return false + return schema.contentMediaType === 'application/json' && schema.contentSchema != null +}