From 41ccf9f12aa601bd3a6d92c73f198cd89f811999 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com> Date: Tue, 12 May 2026 01:38:59 +0200 Subject: [PATCH] feat(cli): formalize dev-friendly result output --- packages/cli/src/io/mode.test.ts | 12 ++++++++++++ packages/cli/src/io/print-list.ts | 15 ++++++++++++--- packages/cli/src/sl.test.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/io/mode.test.ts b/packages/cli/src/io/mode.test.ts index cfc9a9fc..98349d3a 100644 --- a/packages/cli/src/io/mode.test.ts +++ b/packages/cli/src/io/mode.test.ts @@ -20,6 +20,12 @@ describe('resolveOutputMode', () => { expect(resolveOutputMode({ explicit: 'pretty', json: true, io: ioWith(true), env: {} })).toBe('json'); }); + it('prefers explicit JSON over every other output setting', () => { + expect(resolveOutputMode({ json: true, explicit: 'pretty', io: ioWith(true), env: { KTX_OUTPUT: 'plain' } })).toBe( + 'json', + ); + }); + it('throws on unknown explicit value', () => { expect(() => resolveOutputMode({ explicit: 'fancy', io: ioWith(true), env: {} })).toThrow(/Invalid --output/); }); @@ -34,6 +40,12 @@ describe('resolveOutputMode', () => { expect(() => resolveOutputMode({ io: ioWith(true), env: { KTX_OUTPUT: 'fancy' } })).toThrow(/Invalid KTX_OUTPUT/); }); + it('rejects invalid KTX_OUTPUT values', () => { + expect(() => resolveOutputMode({ io: ioWith(false), env: { KTX_OUTPUT: 'verbose' } })).toThrow( + 'Invalid KTX_OUTPUT value: verbose. Expected one of pretty, plain, json.', + ); + }); + it('returns plain when CI is set to a truthy value', () => { expect(resolveOutputMode({ io: ioWith(true), env: { CI: 'true' } })).toBe('plain'); expect(resolveOutputMode({ io: ioWith(true), env: { CI: '1' } })).toBe('plain'); diff --git a/packages/cli/src/io/print-list.ts b/packages/cli/src/io/print-list.ts index b66fa7ad..5a83b783 100644 --- a/packages/cli/src/io/print-list.ts +++ b/packages/cli/src/io/print-list.ts @@ -42,6 +42,16 @@ export function printList(args: PrintListArgs): void { } } +export interface KtxJsonResultEnvelope { + kind: string; + data: T; + meta?: Record; +} + +export function writeJsonResult(io: KtxCliIo, envelope: KtxJsonResultEnvelope): void { + io.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); +} + function isEmpty(value: unknown): boolean { return value === undefined || value === null || value === ''; } @@ -61,12 +71,11 @@ function printListPlain(args: PrintListArgs): void { } function printListJson(args: PrintListArgs): void { - const envelope = { + writeJsonResult(args.io, { kind: 'list', data: { items: args.rows }, meta: { command: args.command }, - }; - args.io.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); + }); } function pluralize(count: number, singular: string): string { diff --git a/packages/cli/src/sl.test.ts b/packages/cli/src/sl.test.ts index bd746b0b..b0d4d64e 100644 --- a/packages/cli/src/sl.test.ts +++ b/packages/cli/src/sl.test.ts @@ -400,6 +400,7 @@ joins: [] expect(code).toBe(0); const parsed = JSON.parse(listIo.stdout()); + expect(listIo.stderr()).toBe(''); expect(parsed.kind).toBe('list'); expect(parsed.meta).toEqual({ command: 'sl list' }); expect(parsed.data.items).toHaveLength(1); @@ -412,6 +413,33 @@ joins: [] }); }); + it('prints sl list JSON as a single result envelope', async () => { + const projectDir = join(tempDir, 'project'); + await initKtxProject({ projectDir, projectName: 'warehouse' }); + + const writeIo = makeIo(); + await runKtxSl( + { command: 'write', projectDir, connectionId: 'warehouse', sourceName: 'orders', yaml: ORDERS_YAML }, + writeIo.io, + ); + + const listIo = makeIo(); + await expect( + runKtxSl({ command: 'list', projectDir, connectionId: 'warehouse', json: true }, listIo.io), + ).resolves.toBe(0); + + expect(listIo.stderr()).toBe(''); + expect(JSON.parse(listIo.stdout())).toMatchObject({ + kind: 'list', + data: { + items: expect.any(Array), + }, + meta: { + command: 'sl list', + }, + }); + }); + it('emits sl list with grouping and Clack-style framing when output=pretty', async () => { const projectDir = join(tempDir, 'project'); await initKtxProject({ projectDir, projectName: 'warehouse' });