From cc76a4eb187cf5bcf8f615cf233d9ebe7b69a408 Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Sun, 29 Mar 2026 13:26:57 -0700 Subject: [PATCH 1/5] feat(ts-cli): add ergonomic OpenAPI CLI generator that decomposes JSON-in-header params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `createErgonomicCli()` that detects OAS 3.1 `contentMediaType` + `contentSchema` on header params and decomposes them into human-friendly flags (--source, --source-config, --destination, --streams, --config) with config cascade support (flags > env vars > file). Strictly additive — no existing files modified. Co-Authored-By: Claude Opus 4.6 (1M context) Committed-By-Agent: claude --- packages/ts-cli/package.json | 5 + .../ergonomic/__tests__/assemble.test.ts | 256 +++++++++++++++ .../ergonomic/__tests__/decompose.test.ts | 184 +++++++++++ .../ergonomic/__tests__/ergonomic.test.ts | 266 ++++++++++++++++ .../ts-cli/src/openapi/ergonomic/assemble.ts | 108 +++++++ .../ts-cli/src/openapi/ergonomic/decompose.ts | 194 +++++++++++ .../ts-cli/src/openapi/ergonomic/index.ts | 301 ++++++++++++++++++ .../ts-cli/src/openapi/ergonomic/types.ts | 44 +++ 8 files changed, 1358 insertions(+) create mode 100644 packages/ts-cli/src/openapi/ergonomic/__tests__/assemble.test.ts create mode 100644 packages/ts-cli/src/openapi/ergonomic/__tests__/decompose.test.ts create mode 100644 packages/ts-cli/src/openapi/ergonomic/__tests__/ergonomic.test.ts create mode 100644 packages/ts-cli/src/openapi/ergonomic/assemble.ts create mode 100644 packages/ts-cli/src/openapi/ergonomic/decompose.ts create mode 100644 packages/ts-cli/src/openapi/ergonomic/index.ts create mode 100644 packages/ts-cli/src/openapi/ergonomic/types.ts 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..909909ed --- /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..aedb9ae5 --- /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..e95bde8a --- /dev/null +++ b/packages/ts-cli/src/openapi/ergonomic/decompose.ts @@ -0,0 +1,194 @@ +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..b4fae123 --- /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 +} From 3746e78055f244f7b12e3ac55460155514b9ebc7 Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Sun, 29 Mar 2026 13:34:12 -0700 Subject: [PATCH 2/5] style: fix prettier formatting in ergonomic CLI files Co-Authored-By: Claude Opus 4.6 (1M context) Committed-By-Agent: claude --- docs/openapi/engine.json | 206 +++++++++++++++++- .../ergonomic/__tests__/assemble.test.ts | 6 +- .../ergonomic/__tests__/ergonomic.test.ts | 4 +- .../ts-cli/src/openapi/ergonomic/decompose.ts | 10 +- .../ts-cli/src/openapi/ergonomic/index.ts | 14 +- 5 files changed, 212 insertions(+), 28 deletions(-) diff --git a/docs/openapi/engine.json b/docs/openapi/engine.json index cbf4dac2..c06180d9 100644 --- a/docs/openapi/engine.json +++ b/docs/openapi/engine.json @@ -250,7 +250,7 @@ "content": { "application/x-ndjson": { "schema": { - "$ref": "#/components/schemas/Message" + "$ref": "#/components/schemas/MessageOutput" } } } @@ -615,7 +615,12 @@ }, "failure_type": { "type": "string", - "enum": ["config_error", "system_error", "transient_error", "auth_error"] + "enum": [ + "config_error", + "system_error", + "transient_error", + "auth_error" + ] }, "message": { "type": "string" @@ -649,25 +654,210 @@ "DestinationOutput": { "oneOf": [ { - "$ref": "#/components/schemas/StateMessage" + "$ref": "#/components/schemas/StateMessageOutput" }, { - "$ref": "#/components/schemas/ErrorMessage" + "$ref": "#/components/schemas/ErrorMessageOutput" }, { - "$ref": "#/components/schemas/LogMessage" + "$ref": "#/components/schemas/LogMessageOutput" } ], "type": "object", "discriminator": { "propertyName": "type", "mapping": { - "state": "#/components/schemas/StateMessage", - "error": "#/components/schemas/ErrorMessage", - "log": "#/components/schemas/LogMessage" + "state": "#/components/schemas/StateMessageOutput", + "error": "#/components/schemas/ErrorMessageOutput", + "log": "#/components/schemas/LogMessageOutput" + } + } + }, + "MessageOutput": { + "oneOf": [ + { + "$ref": "#/components/schemas/RecordMessageOutput" + }, + { + "$ref": "#/components/schemas/StateMessageOutput" + }, + { + "$ref": "#/components/schemas/CatalogMessageOutput" + }, + { + "$ref": "#/components/schemas/LogMessageOutput" + }, + { + "$ref": "#/components/schemas/ErrorMessageOutput" + }, + { + "$ref": "#/components/schemas/StreamStatusMessageOutput" + } + ], + "type": "object", + "discriminator": { + "propertyName": "type", + "mapping": { + "record": "#/components/schemas/RecordMessageOutput", + "state": "#/components/schemas/StateMessageOutput", + "catalog": "#/components/schemas/CatalogMessageOutput", + "log": "#/components/schemas/LogMessageOutput", + "error": "#/components/schemas/ErrorMessageOutput", + "stream_status": "#/components/schemas/StreamStatusMessageOutput" } } }, + "RecordMessageOutput": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "record" + }, + "stream": { + "type": "string" + }, + "data": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "emitted_at": { + "type": "number" + } + }, + "required": ["type", "stream", "data", "emitted_at"], + "additionalProperties": false + }, + "StateMessageOutput": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "state" + }, + "stream": { + "type": "string" + }, + "data": {} + }, + "required": ["type", "stream", "data"], + "additionalProperties": false + }, + "CatalogMessageOutput": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "catalog" + }, + "streams": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "primary_key": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "json_schema": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["name", "primary_key"], + "additionalProperties": false + } + } + }, + "required": ["type", "streams"], + "additionalProperties": false + }, + "LogMessageOutput": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "log" + }, + "level": { + "type": "string", + "enum": ["debug", "info", "warn", "error"] + }, + "message": { + "type": "string" + } + }, + "required": ["type", "level", "message"], + "additionalProperties": false + }, + "ErrorMessageOutput": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "error" + }, + "failure_type": { + "type": "string", + "enum": [ + "config_error", + "system_error", + "transient_error", + "auth_error" + ] + }, + "message": { + "type": "string" + }, + "stream": { + "type": "string" + }, + "stack_trace": { + "type": "string" + } + }, + "required": ["type", "failure_type", "message"], + "additionalProperties": false + }, + "StreamStatusMessageOutput": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "stream_status" + }, + "stream": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["started", "running", "complete", "incomplete"] + } + }, + "required": ["type", "stream", "status"], + "additionalProperties": false + }, "PipelineConfig": { "type": "object", "required": ["source", "destination"], diff --git a/packages/ts-cli/src/openapi/ergonomic/__tests__/assemble.test.ts b/packages/ts-cli/src/openapi/ergonomic/__tests__/assemble.test.ts index 909909ed..79cee867 100644 --- a/packages/ts-cli/src/openapi/ergonomic/__tests__/assemble.test.ts +++ b/packages/ts-cli/src/openapi/ergonomic/__tests__/assemble.test.ts @@ -7,7 +7,7 @@ import { assembleJsonHeader } from '../assemble.js' // Helper to create a minimal flag function flag( - overrides: Partial & { name: string; role: DecomposedFlag['role'] }, + overrides: Partial & { name: string; role: DecomposedFlag['role'] } ): DecomposedFlag { return { cliFlag: '--' + overrides.name, @@ -116,7 +116,7 @@ describe('assembleJsonHeader', () => { source: { name: 'stripe', api_key: 'sk_from_file' }, destination: { name: 'postgres' }, streams: [{ name: 'accounts' }], - }), + }) ) const flags: DecomposedFlag[] = [ @@ -156,7 +156,7 @@ describe('assembleJsonHeader', () => { configPath, JSON.stringify({ source: { name: 'from-file', api_key: 'from-file', base_url: 'from-file' }, - }), + }) ) const flags: DecomposedFlag[] = [ diff --git a/packages/ts-cli/src/openapi/ergonomic/__tests__/ergonomic.test.ts b/packages/ts-cli/src/openapi/ergonomic/__tests__/ergonomic.test.ts index aedb9ae5..a2ef49e2 100644 --- a/packages/ts-cli/src/openapi/ergonomic/__tests__/ergonomic.test.ts +++ b/packages/ts-cli/src/openapi/ergonomic/__tests__/ergonomic.test.ts @@ -18,7 +18,7 @@ const specPath = join( '..', 'docs', 'openapi', - 'engine.json', + 'engine.json' ) const engineSpec: OpenAPISpec = JSON.parse(readFileSync(specPath, 'utf-8')) @@ -165,7 +165,7 @@ describe('ergonomic CLI handler integration', () => { return Promise.resolve( new Response('{"type":"state","stream":"a","data":{}}\n', { headers: { 'content-type': 'application/x-ndjson' }, - }), + }) ) }) diff --git a/packages/ts-cli/src/openapi/ergonomic/decompose.ts b/packages/ts-cli/src/openapi/ergonomic/decompose.ts index e95bde8a..4c0b1d0c 100644 --- a/packages/ts-cli/src/openapi/ergonomic/decompose.ts +++ b/packages/ts-cli/src/openapi/ergonomic/decompose.ts @@ -37,10 +37,7 @@ export interface DecomposedParam { } /** Decompose a single header parameter into ergonomic CLI flags. */ -export function decomposeHeaderParam( - param: OpenAPIParameter, - spec: OpenAPISpec, -): DecomposedParam { +export function decomposeHeaderParam(param: OpenAPIParameter, spec: OpenAPISpec): DecomposedParam { if (!isJsonHeaderParam(param)) { // Non-JSON header: single flag, strip x- prefix const flagCliName = stripXPrefix(param.name) @@ -78,10 +75,7 @@ export function decomposeHeaderParam( } /** Decompose a resolved content schema into individual CLI flags. */ -function decomposeSchema( - schema: ExtendedSchema, - param: OpenAPIParameter, -): DecomposedFlag[] { +function decomposeSchema(schema: ExtendedSchema, param: OpenAPIParameter): DecomposedFlag[] { const flags: DecomposedFlag[] = [] const requiredProps = schema.required ?? [] diff --git a/packages/ts-cli/src/openapi/ergonomic/index.ts b/packages/ts-cli/src/openapi/ergonomic/index.ts index b4fae123..2b0ffa71 100644 --- a/packages/ts-cli/src/openapi/ergonomic/index.ts +++ b/packages/ts-cli/src/openapi/ergonomic/index.ts @@ -57,7 +57,7 @@ export function createErgonomicCli(opts: CreateErgonomicCliOptions): CommandDef } = opts const operations = parseSpec(spec).filter( - (op) => !op.operationId || !exclude.includes(op.operationId), + (op) => !op.operationId || !exclude.includes(op.operationId) ) const subCommands: Record = {} @@ -88,7 +88,7 @@ export function createErgonomicCli(opts: CreateErgonomicCliOptions): CommandDef baseUrl, nameOperation, ndjsonBodyStream, - envPrefixes, + envPrefixes ) } subCommands[toCliFlag(tag)] = defineCommand({ @@ -106,7 +106,7 @@ export function createErgonomicCli(opts: CreateErgonomicCliOptions): CommandDef baseUrl, nameOperation, ndjsonBodyStream, - envPrefixes, + envPrefixes ) } } else { @@ -119,7 +119,7 @@ export function createErgonomicCli(opts: CreateErgonomicCliOptions): CommandDef baseUrl, nameOperation, ndjsonBodyStream, - envPrefixes, + envPrefixes ) } } @@ -135,7 +135,7 @@ export function createErgonomicCli(opts: CreateErgonomicCliOptions): CommandDef function getOpName( op: ParsedOperation, - nameOverride?: (method: string, path: string, op: OpenAPIOperation) => string, + nameOverride?: (method: string, path: string, op: OpenAPIOperation) => string ): string { const rawOp = toRawOp(op) return nameOverride @@ -165,7 +165,7 @@ function buildErgonomicCommand( baseUrl: string, nameOverride: CreateErgonomicCliOptions['nameOperation'], ndjsonBodyStream: CreateErgonomicCliOptions['ndjsonBodyStream'], - envPrefixes: Record, + envPrefixes: Record ): CommandDef { const rawOp = toRawOp(operation) const name = nameOverride @@ -235,7 +235,7 @@ function buildErgonomicCommand( args, async run({ args: cmdArgs }) { const positionals = operation.pathParams.map( - (p) => (cmdArgs as Record)[p.name], + (p) => (cmdArgs as Record)[p.name] ) const opts = cmdArgs as Record From 5143983541f36056376ebafdfa397f3401191456 Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Sun, 29 Mar 2026 13:51:55 -0700 Subject: [PATCH 3/5] ci: trigger CI run Co-Authored-By: Claude Opus 4.6 (1M context) Committed-By-Agent: claude From a4d82950f51eb58d1d539b0b8bd556698dd3068e Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Sun, 29 Mar 2026 14:06:13 -0700 Subject: [PATCH 4/5] style: format engine.json enum arrays to match prettier config Co-Authored-By: Claude Opus 4.6 (1M context) Committed-By-Agent: claude --- docs/openapi/engine.json | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/docs/openapi/engine.json b/docs/openapi/engine.json index c06180d9..6a34affc 100644 --- a/docs/openapi/engine.json +++ b/docs/openapi/engine.json @@ -615,12 +615,7 @@ }, "failure_type": { "type": "string", - "enum": [ - "config_error", - "system_error", - "transient_error", - "auth_error" - ] + "enum": ["config_error", "system_error", "transient_error", "auth_error"] }, "message": { "type": "string" @@ -820,12 +815,7 @@ }, "failure_type": { "type": "string", - "enum": [ - "config_error", - "system_error", - "transient_error", - "auth_error" - ] + "enum": ["config_error", "system_error", "transient_error", "auth_error"] }, "message": { "type": "string" From 3c697c257d94a643d29cb4f8280431448e880b39 Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Tue, 31 Mar 2026 10:42:48 -0700 Subject: [PATCH 5/5] chore: regenerate engine.json after rebase onto v2 Committed-By-Agent: claude --- docs/openapi/engine.json | 194 ++------------------------------------- 1 file changed, 7 insertions(+), 187 deletions(-) diff --git a/docs/openapi/engine.json b/docs/openapi/engine.json index 6a34affc..cbf4dac2 100644 --- a/docs/openapi/engine.json +++ b/docs/openapi/engine.json @@ -250,7 +250,7 @@ "content": { "application/x-ndjson": { "schema": { - "$ref": "#/components/schemas/MessageOutput" + "$ref": "#/components/schemas/Message" } } } @@ -649,205 +649,25 @@ "DestinationOutput": { "oneOf": [ { - "$ref": "#/components/schemas/StateMessageOutput" - }, - { - "$ref": "#/components/schemas/ErrorMessageOutput" - }, - { - "$ref": "#/components/schemas/LogMessageOutput" - } - ], - "type": "object", - "discriminator": { - "propertyName": "type", - "mapping": { - "state": "#/components/schemas/StateMessageOutput", - "error": "#/components/schemas/ErrorMessageOutput", - "log": "#/components/schemas/LogMessageOutput" - } - } - }, - "MessageOutput": { - "oneOf": [ - { - "$ref": "#/components/schemas/RecordMessageOutput" - }, - { - "$ref": "#/components/schemas/StateMessageOutput" - }, - { - "$ref": "#/components/schemas/CatalogMessageOutput" - }, - { - "$ref": "#/components/schemas/LogMessageOutput" + "$ref": "#/components/schemas/StateMessage" }, { - "$ref": "#/components/schemas/ErrorMessageOutput" + "$ref": "#/components/schemas/ErrorMessage" }, { - "$ref": "#/components/schemas/StreamStatusMessageOutput" + "$ref": "#/components/schemas/LogMessage" } ], "type": "object", "discriminator": { "propertyName": "type", "mapping": { - "record": "#/components/schemas/RecordMessageOutput", - "state": "#/components/schemas/StateMessageOutput", - "catalog": "#/components/schemas/CatalogMessageOutput", - "log": "#/components/schemas/LogMessageOutput", - "error": "#/components/schemas/ErrorMessageOutput", - "stream_status": "#/components/schemas/StreamStatusMessageOutput" + "state": "#/components/schemas/StateMessage", + "error": "#/components/schemas/ErrorMessage", + "log": "#/components/schemas/LogMessage" } } }, - "RecordMessageOutput": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "record" - }, - "stream": { - "type": "string" - }, - "data": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "emitted_at": { - "type": "number" - } - }, - "required": ["type", "stream", "data", "emitted_at"], - "additionalProperties": false - }, - "StateMessageOutput": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "state" - }, - "stream": { - "type": "string" - }, - "data": {} - }, - "required": ["type", "stream", "data"], - "additionalProperties": false - }, - "CatalogMessageOutput": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "catalog" - }, - "streams": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "primary_key": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "json_schema": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": ["name", "primary_key"], - "additionalProperties": false - } - } - }, - "required": ["type", "streams"], - "additionalProperties": false - }, - "LogMessageOutput": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "log" - }, - "level": { - "type": "string", - "enum": ["debug", "info", "warn", "error"] - }, - "message": { - "type": "string" - } - }, - "required": ["type", "level", "message"], - "additionalProperties": false - }, - "ErrorMessageOutput": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "error" - }, - "failure_type": { - "type": "string", - "enum": ["config_error", "system_error", "transient_error", "auth_error"] - }, - "message": { - "type": "string" - }, - "stream": { - "type": "string" - }, - "stack_trace": { - "type": "string" - } - }, - "required": ["type", "failure_type", "message"], - "additionalProperties": false - }, - "StreamStatusMessageOutput": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "stream_status" - }, - "stream": { - "type": "string" - }, - "status": { - "type": "string", - "enum": ["started", "running", "complete", "incomplete"] - } - }, - "required": ["type", "stream", "status"], - "additionalProperties": false - }, "PipelineConfig": { "type": "object", "required": ["source", "destination"],