From d1f681b5cc5f8cda95ee508443dae752d58919b1 Mon Sep 17 00:00:00 2001 From: Sergii Bezliudnyi Date: Wed, 30 Sep 2026 13:42:54 +0200 Subject: [PATCH 1/3] fix: preserve binary response bytes in api command --- .../src/commands/__tests__/api-output.spec.ts | 117 ++++++++++++++++++ .../cli/src/commands/__tests__/api.spec.ts | 25 ++-- packages/cli/src/commands/api.ts | 38 ++++-- 3 files changed, 162 insertions(+), 18 deletions(-) create mode 100644 packages/cli/src/commands/__tests__/api-output.spec.ts diff --git a/packages/cli/src/commands/__tests__/api-output.spec.ts b/packages/cli/src/commands/__tests__/api-output.spec.ts new file mode 100644 index 000000000..f05473bb5 --- /dev/null +++ b/packages/cli/src/commands/__tests__/api-output.spec.ts @@ -0,0 +1,117 @@ +import { execFile } from 'node:child_process' +import { createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import { promisify } from 'node:util' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { FixtureSandbox } from '../../testing/fixture-sandbox.js' + +const execFileAsync = promisify(execFile) + +// A ZIP containing report.bin with NUL and invalid UTF-8 bytes. +const archive = Buffer.from( + 'UEsDBBQAAAAAAAAAIVwYQ5RNBwAAAAcAAAAKAAAAcmVwb3J0LmJpbgD//oDDKApQSwECFAMUAAAAAAAAACFcGEOUTQcAAAAH' + + 'AAAACgAAAAAAAAAAAAAAgAEAAAAAcmVwb3J0LmJpblBLBQYAAAAAAQABADgAAAAvAAAAAAA=', + 'base64', +) + +describe('checkly api response bytes', () => { + let sandbox: FixtureSandbox + let baseURL: string + const server = createServer((request, response) => { + switch (request.url) { + case '/redirect': + response.writeHead(302, { location: '/binary' }).end() + break + case '/binary': + response.writeHead(200, { 'content-type': 'application/zip' }).end(archive) + break + case '/binary-json': + response.writeHead(200, { 'content-type': 'application/octet-stream' }).end(Buffer.from([0x22, 0xff, 0x22])) + break + case '/json': + response.writeHead(200, { 'content-type': 'application/json' }).end('{\n "name": "Café"\n}') + break + case '/json-bom': + response.writeHead(200, { 'content-type': 'application/json' }).end('\uFEFF{"name":"Café"}') + break + case '/text': + response.writeHead(200, { 'content-type': 'text/plain' }).end('Café\n') + break + case '/empty': + response.writeHead(204).end() + break + default: + response.writeHead(404, { 'content-type': 'application/json' }).end('{ "message": "Not found" }') + } + }) + + beforeAll(async () => { + sandbox = await FixtureSandbox.create({}) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + baseURL = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + }) + + afterAll(async () => { + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) + await sandbox.destroy() + }) + + function run (...args: string[]) { + return execFileAsync(process.execPath, [sandbox.abspath('node_modules/checkly/bin/run.cjs'), 'api', ...args], { + cwd: sandbox.root, + encoding: 'buffer', + env: { + ...process.env, + CHECKLY_SKIP_AUTH: '1', + CHECKLY_API_KEY: 'test-key', + CHECKLY_ACCOUNT_ID: 'test-account', + CHECKLY_ENV: 'local', + CHECKLY_API_URL: baseURL, + CHECKLY_E2E_CLI_VERSION: '99.0.0', + NO_PROXY: '127.0.0.1', + no_proxy: '127.0.0.1', + }, + }) + } + + it.each<[string, Buffer]>([ + ['/binary', archive], + ['/redirect', archive], + ['/binary-json', Buffer.from([0x22, 0xff, 0x22])], + ])('preserves binary response bytes from %s', async (endpoint, expected) => { + const { stdout } = await run(endpoint) + expect(stdout).toEqual(expected) + }) + + it('keeps verbose diagnostics out of the binary response', async () => { + const { stdout, stderr } = await run('/binary', '--verbose') + expect(stdout).toEqual(archive) + expect(stderr.toString()).toContain('< 200 OK') + }) + + it('includes headers before the unchanged binary body', async () => { + const { stdout } = await run('/binary', '--include') + const bodyOffset = stdout.indexOf('\n\n') + 2 + expect(stdout.subarray(0, bodyOffset).toString()).toContain('HTTP/1.1 200 OK\n') + expect(stdout.subarray(bodyOffset)).toEqual(archive) + }) + + it.each([ + ['/json', '{"name":"Café"}\n'], + ['/json-bom', '{"name":"Café"}\n'], + ['/text', 'Café\n'], + ['/empty', ''], + ])('writes the expected response from %s', async (endpoint, expected) => { + const { stdout } = await run(endpoint) + expect(stdout).toEqual(Buffer.from(expected)) + }) + + it('prints the JSON error body and HTTP hint before exiting with code 1', async () => { + const result = await run('/missing').catch(error => error) + expect(result).toMatchObject({ + code: 1, + stdout: Buffer.from('{"message":"Not found"}\n'), + }) + expect(result.stderr.toString()).toContain('Endpoint not found.') + }) +}) diff --git a/packages/cli/src/commands/__tests__/api.spec.ts b/packages/cli/src/commands/__tests__/api.spec.ts index 979928c1e..9a369c494 100644 --- a/packages/cli/src/commands/__tests__/api.spec.ts +++ b/packages/cli/src/commands/__tests__/api.spec.ts @@ -34,7 +34,7 @@ function createCommand (...argv: string[]) { beforeEach(() => { vi.clearAllMocks() vi.mocked(api.request).mockResolvedValue({ - data: [{ id: '1', name: 'Test Check' }], + data: Buffer.from('[{"id":"1","name":"Test Check"}]'), status: 200, statusText: 'OK', headers: {}, @@ -135,7 +135,7 @@ describe('checkly api', () => { describe('error handling', () => { it('exits with code 1 on 4xx response', async () => { vi.mocked(api.request).mockResolvedValue({ - data: { message: 'Not found' }, + data: Buffer.from('{"message":"Not found"}'), status: 404, statusText: 'Not Found', headers: {}, @@ -147,7 +147,7 @@ describe('checkly api', () => { it('shows docs hint on 404', async () => { vi.mocked(api.request).mockResolvedValue({ - data: { message: 'Not found' }, + data: Buffer.from('{"message":"Not found"}'), status: 404, statusText: 'Not Found', headers: {}, @@ -162,7 +162,7 @@ describe('checkly api', () => { it('shows auth hint on 401', async () => { vi.mocked(api.request).mockResolvedValue({ - data: { message: 'Unauthorized' }, + data: Buffer.from('{"message":"Unauthorized"}'), status: 401, statusText: 'Unauthorized', headers: {}, @@ -177,7 +177,7 @@ describe('checkly api', () => { it('shows permission hint on 403', async () => { vi.mocked(api.request).mockResolvedValue({ - data: { message: 'Forbidden' }, + data: Buffer.from('{"message":"Forbidden"}'), status: 403, statusText: 'Forbidden', headers: {}, @@ -192,7 +192,7 @@ describe('checkly api', () => { it('outputs error body before exiting', async () => { vi.mocked(api.request).mockResolvedValue({ - data: { message: 'Not found' }, + data: Buffer.from('{"message":"Not found"}'), status: 404, statusText: 'Not Found', headers: {}, @@ -205,21 +205,22 @@ describe('checkly api', () => { it('exits cleanly on 2xx with empty body', async () => { vi.mocked(api.request).mockResolvedValue({ - data: '', + data: Buffer.alloc(0), status: 204, statusText: 'No Content', headers: {}, config: {} as any, }) - const cmd = createCommand('/v1/checks/123', '-X', 'DELETE') + const cmd = createCommand('/v1/checks/123', '-X', 'DELETE', '--jq', '.') await cmd.run() + expect(execFile).not.toHaveBeenCalled() }) }) describe('--include', () => { it('outputs status line and headers', async () => { vi.mocked(api.request).mockResolvedValue({ - data: { ok: true }, + data: Buffer.from('{"ok":true}'), status: 200, statusText: 'OK', headers: { 'content-type': 'application/json' }, @@ -236,7 +237,7 @@ describe('checkly api', () => { describe('--verbose', () => { it('writes request and response info to stderr', async () => { vi.mocked(api.request).mockResolvedValue({ - data: { ok: true }, + data: Buffer.from('{"ok":true}'), status: 200, statusText: 'OK', headers: { 'content-type': 'application/json' }, @@ -252,13 +253,15 @@ describe('checkly api', () => { describe('--jq', () => { it('pipes output through system jq', async () => { + const stdin = { write: vi.fn(), end: vi.fn() } vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, callback: any) => { callback(null, '"filtered"', '') - return {} as any + return { stdin } as any }) const cmd = createCommand('/v1/checks', '--jq', '.[0].name') await cmd.run() expect(execFile).toHaveBeenCalledWith('jq', ['.[0].name'], expect.anything(), expect.anything()) + expect(stdin.write).toHaveBeenCalledWith('[{"id":"1","name":"Test Check"}]') expect(cmd.log).toHaveBeenCalledWith('"filtered"') }) diff --git a/packages/cli/src/commands/api.ts b/packages/cli/src/commands/api.ts index c012b951f..88858981d 100644 --- a/packages/cli/src/commands/api.ts +++ b/packages/cli/src/commands/api.ts @@ -1,4 +1,5 @@ import { Args, Flags } from '@oclif/core' +import { isUtf8 } from 'node:buffer' import { execFile } from 'node:child_process' import { readFile } from 'node:fs/promises' import { AuthCommand } from './authCommand.js' @@ -14,6 +15,7 @@ export default class Api extends AuthCommand { static idempotent = false static description = 'Make an authenticated HTTP request to the Checkly API.\n' + 'Pass-through for any endpoint — handles auth automatically.\n' + + 'JSON responses are formatted; other response bodies are written unchanged.\n' + 'See https://www.checklyhq.com/docs/api for available endpoints.\n' + 'OpenAPI spec: https://api.checklyhq.com/openapi.json' @@ -123,6 +125,7 @@ export default class Api extends AuthCommand { params, data, headers: customHeaders, + responseType: 'arraybuffer' as const, validateStatus: () => true, } @@ -134,7 +137,7 @@ export default class Api extends AuthCommand { this.logToStderr('') } - const response = await api.request(requestConfig) + const response = await api.request(requestConfig) if (flags.verbose) { this.logToStderr(`< ${response.status} ${response.statusText}`) @@ -146,7 +149,7 @@ export default class Api extends AuthCommand { this.logToStderr('') } - const responseData = response.data + const responseData = parseResponse(response.data) if (flags.include) { this.log(`HTTP/1.1 ${response.status} ${response.statusText}`) @@ -165,12 +168,20 @@ export default class Api extends AuthCommand { return } - const json = typeof responseData === 'string' ? responseData : formatJson(responseData) - - if (flags.jq) { - await this.applyJq(json, flags.jq) + if (Buffer.isBuffer(responseData) && !flags.jq) { + await new Promise((resolve, reject) => { + process.stdout.write(responseData, error => error ? reject(error) : resolve()) + }) } else { - this.log(json) + const json = Buffer.isBuffer(responseData) + ? responseData.toString('utf-8') + : typeof responseData === 'string' ? responseData : formatJson(responseData) + + if (flags.jq) { + await this.applyJq(json, flags.jq) + } else { + this.log(json) + } } if (response.status === 404) { @@ -214,6 +225,19 @@ export default class Api extends AuthCommand { } } +function parseResponse (data: Buffer): unknown { + if (data.length === 0) return '' + + if (isUtf8(data)) { + try { + return JSON.parse(data.toString('utf-8').replace(/^\uFEFF/, '')) + } catch { + // Keep non-JSON bodies as bytes, including their original line endings. + } + } + return data +} + function formatJson (data: unknown): string { if (process.stdout.isTTY) { return JSON.stringify(data, null, 2) From 2bb23681c953936baf10c33531ef1e70b87110a8 Mon Sep 17 00:00:00 2001 From: Sergii Bezliudnyi Date: Thu, 1 Oct 2026 21:29:54 +0200 Subject: [PATCH 2/3] fix: stream api downloads and clarify JSON filtering --- .../src/commands/__tests__/api-output.spec.ts | 65 ++++++++++++++++++- .../cli/src/commands/__tests__/api.spec.ts | 33 +++++----- packages/cli/src/commands/api.ts | 58 ++++++++++------- 3 files changed, 116 insertions(+), 40 deletions(-) diff --git a/packages/cli/src/commands/__tests__/api-output.spec.ts b/packages/cli/src/commands/__tests__/api-output.spec.ts index f05473bb5..090f845c2 100644 --- a/packages/cli/src/commands/__tests__/api-output.spec.ts +++ b/packages/cli/src/commands/__tests__/api-output.spec.ts @@ -1,11 +1,13 @@ -import { execFile } from 'node:child_process' +import { execFile, spawnSync } from 'node:child_process' import { createServer } from 'node:http' import type { AddressInfo } from 'node:net' import { promisify } from 'node:util' +import { once } from 'node:events' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { FixtureSandbox } from '../../testing/fixture-sandbox.js' const execFileAsync = promisify(execFile) +const hasJq = spawnSync('jq', ['--version']).status === 0 // A ZIP containing report.bin with NUL and invalid UTF-8 bytes. const archive = Buffer.from( @@ -13,6 +15,7 @@ const archive = Buffer.from( + 'AAAACgAAAAAAAAAAAAAAgAEAAAAAcmVwb3J0LmJpblBLBQYAAAAAAQABADgAAAAvAAAAAAA=', 'base64', ) +const jsonFile = '{\n "count": 1e3, "id": 9007199254740993\n}\n' describe('checkly api response bytes', () => { let sandbox: FixtureSandbox @@ -34,6 +37,29 @@ describe('checkly api response bytes', () => { case '/json-bom': response.writeHead(200, { 'content-type': 'application/json' }).end('\uFEFF{"name":"Café"}') break + case '/json-file': + response.writeHead(200, { 'content-type': 'application/octet-stream' }).end(jsonFile) + break + case '/json-attachment': + response.writeHead(200, { + 'content-type': 'application/json', + 'content-disposition': 'attachment; filename="report.json"', + }).end(jsonFile) + break + case '/json-suffix': + response.writeHead(200, { 'content-type': 'Application/Problem+JSON; charset=utf-8' }).end('{ "ok": true }') + break + case '/invalid-json': + response.writeHead(200, { 'content-type': 'application/json' }).end('{invalid}\n') + break + case '/stream': { + response.writeHead(200, { 'content-type': 'application/octet-stream' }) + response.write(archive.subarray(0, 32)) + const finish = () => response.end(archive.subarray(32)) + server.once('first-chunk', finish) + response.on('close', () => server.removeListener('first-chunk', finish)) + break + } case '/text': response.writeHead(200, { 'content-type': 'text/plain' }).end('Café\n') break @@ -78,6 +104,8 @@ describe('checkly api response bytes', () => { ['/binary', archive], ['/redirect', archive], ['/binary-json', Buffer.from([0x22, 0xff, 0x22])], + ['/json-file', Buffer.from(jsonFile)], + ['/json-attachment', Buffer.from(jsonFile)], ])('preserves binary response bytes from %s', async (endpoint, expected) => { const { stdout } = await run(endpoint) expect(stdout).toEqual(expected) @@ -99,6 +127,8 @@ describe('checkly api response bytes', () => { it.each([ ['/json', '{"name":"Café"}\n'], ['/json-bom', '{"name":"Café"}\n'], + ['/json-suffix', '{"ok":true}\n'], + ['/invalid-json', '{invalid}\n'], ['/text', 'Café\n'], ['/empty', ''], ])('writes the expected response from %s', async (endpoint, expected) => { @@ -106,6 +136,39 @@ describe('checkly api response bytes', () => { expect(stdout).toEqual(Buffer.from(expected)) }) + it.each(['/binary', '/text', '/invalid-json'])('rejects --jq on non-JSON response from %s', async endpoint => { + const result = await run(endpoint, '--jq', '.').catch(error => error) + expect(result).toMatchObject({ code: 1, stdout: Buffer.alloc(0) }) + expect(result.stderr.toString()).toContain('Response is not JSON; --jq cannot be applied') + }) + + it.skipIf(!hasJq).each(['/json', '/json-file', '/json-attachment'])( + 'allows --jq to parse JSON from %s', async endpoint => { + const { stdout } = await run(endpoint, '--jq', 'keys') + expect(JSON.parse(stdout.toString())).toEqual(endpoint === '/json' ? ['name'] : ['count', 'id']) + }, + ) + + it('writes the first download chunk before the response finishes', async () => { + const download = run('/stream') + const output = once(download.child.stdout!, 'data') + try { + await Promise.race([ + output, + new Promise((_, reject) => { + const timer = setTimeout(() => reject(new Error('No output before the response finished')), 10000) + output.finally(() => clearTimeout(timer)) + }), + ]) + server.emit('first-chunk') + const { stdout } = await download + expect(stdout).toEqual(archive) + } finally { + server.emit('first-chunk') + await download.catch(() => {}) + } + }, 15000) + it('prints the JSON error body and HTTP hint before exiting with code 1', async () => { const result = await run('/missing').catch(error => error) expect(result).toMatchObject({ diff --git a/packages/cli/src/commands/__tests__/api.spec.ts b/packages/cli/src/commands/__tests__/api.spec.ts index 9a369c494..f5e03bb15 100644 --- a/packages/cli/src/commands/__tests__/api.spec.ts +++ b/packages/cli/src/commands/__tests__/api.spec.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Readable } from 'node:stream' vi.mock('../../rest/api', () => ({ api: { @@ -34,10 +35,10 @@ function createCommand (...argv: string[]) { beforeEach(() => { vi.clearAllMocks() vi.mocked(api.request).mockResolvedValue({ - data: Buffer.from('[{"id":"1","name":"Test Check"}]'), + data: Readable.from([Buffer.from('[{"id":"1","name":"Test Check"}]')]), status: 200, statusText: 'OK', - headers: {}, + headers: { 'content-type': 'application/json' }, config: {} as any, }) }) @@ -135,10 +136,10 @@ describe('checkly api', () => { describe('error handling', () => { it('exits with code 1 on 4xx response', async () => { vi.mocked(api.request).mockResolvedValue({ - data: Buffer.from('{"message":"Not found"}'), + data: Readable.from([Buffer.from('{"message":"Not found"}')]), status: 404, statusText: 'Not Found', - headers: {}, + headers: { 'content-type': 'application/json' }, config: {} as any, }) const cmd = createCommand('/v1/nonexistent') @@ -147,10 +148,10 @@ describe('checkly api', () => { it('shows docs hint on 404', async () => { vi.mocked(api.request).mockResolvedValue({ - data: Buffer.from('{"message":"Not found"}'), + data: Readable.from([Buffer.from('{"message":"Not found"}')]), status: 404, statusText: 'Not Found', - headers: {}, + headers: { 'content-type': 'application/json' }, config: {} as any, }) const cmd = createCommand('/v1/nonexistent') @@ -162,10 +163,10 @@ describe('checkly api', () => { it('shows auth hint on 401', async () => { vi.mocked(api.request).mockResolvedValue({ - data: Buffer.from('{"message":"Unauthorized"}'), + data: Readable.from([Buffer.from('{"message":"Unauthorized"}')]), status: 401, statusText: 'Unauthorized', - headers: {}, + headers: { 'content-type': 'application/json' }, config: {} as any, }) const cmd = createCommand('/v1/checks') @@ -177,10 +178,10 @@ describe('checkly api', () => { it('shows permission hint on 403', async () => { vi.mocked(api.request).mockResolvedValue({ - data: Buffer.from('{"message":"Forbidden"}'), + data: Readable.from([Buffer.from('{"message":"Forbidden"}')]), status: 403, statusText: 'Forbidden', - headers: {}, + headers: { 'content-type': 'application/json' }, config: {} as any, }) const cmd = createCommand('/v1/checks') @@ -192,10 +193,10 @@ describe('checkly api', () => { it('outputs error body before exiting', async () => { vi.mocked(api.request).mockResolvedValue({ - data: Buffer.from('{"message":"Not found"}'), + data: Readable.from([Buffer.from('{"message":"Not found"}')]), status: 404, statusText: 'Not Found', - headers: {}, + headers: { 'content-type': 'application/json' }, config: {} as any, }) const cmd = createCommand('/v1/nonexistent') @@ -205,10 +206,10 @@ describe('checkly api', () => { it('exits cleanly on 2xx with empty body', async () => { vi.mocked(api.request).mockResolvedValue({ - data: Buffer.alloc(0), + data: Readable.from([]), status: 204, statusText: 'No Content', - headers: {}, + headers: { 'content-type': 'application/json' }, config: {} as any, }) const cmd = createCommand('/v1/checks/123', '-X', 'DELETE', '--jq', '.') @@ -220,7 +221,7 @@ describe('checkly api', () => { describe('--include', () => { it('outputs status line and headers', async () => { vi.mocked(api.request).mockResolvedValue({ - data: Buffer.from('{"ok":true}'), + data: Readable.from([Buffer.from('{"ok":true}')]), status: 200, statusText: 'OK', headers: { 'content-type': 'application/json' }, @@ -237,7 +238,7 @@ describe('checkly api', () => { describe('--verbose', () => { it('writes request and response info to stderr', async () => { vi.mocked(api.request).mockResolvedValue({ - data: Buffer.from('{"ok":true}'), + data: Readable.from([Buffer.from('{"ok":true}')]), status: 200, statusText: 'OK', headers: { 'content-type': 'application/json' }, diff --git a/packages/cli/src/commands/api.ts b/packages/cli/src/commands/api.ts index 88858981d..aedc74a83 100644 --- a/packages/cli/src/commands/api.ts +++ b/packages/cli/src/commands/api.ts @@ -2,6 +2,7 @@ import { Args, Flags } from '@oclif/core' import { isUtf8 } from 'node:buffer' import { execFile } from 'node:child_process' import { readFile } from 'node:fs/promises' +import type { Readable } from 'node:stream' import { AuthCommand } from './authCommand.js' import { api } from '../rest/api.js' import { parseFields } from '../helpers/api-fields.js' @@ -15,7 +16,7 @@ export default class Api extends AuthCommand { static idempotent = false static description = 'Make an authenticated HTTP request to the Checkly API.\n' + 'Pass-through for any endpoint — handles auth automatically.\n' - + 'JSON responses are formatted; other response bodies are written unchanged.\n' + + 'JSON responses are formatted; attachments and other bodies are streamed unchanged.\n' + 'See https://www.checklyhq.com/docs/api for available endpoints.\n' + 'OpenAPI spec: https://api.checklyhq.com/openapi.json' @@ -125,7 +126,7 @@ export default class Api extends AuthCommand { params, data, headers: customHeaders, - responseType: 'arraybuffer' as const, + responseType: 'stream' as const, validateStatus: () => true, } @@ -137,7 +138,7 @@ export default class Api extends AuthCommand { this.logToStderr('') } - const response = await api.request(requestConfig) + const response = await api.request(requestConfig) if (flags.verbose) { this.logToStderr(`< ${response.status} ${response.statusText}`) @@ -149,8 +150,6 @@ export default class Api extends AuthCommand { this.logToStderr('') } - const responseData = parseResponse(response.data) - if (flags.include) { this.log(`HTTP/1.1 ${response.status} ${response.statusText}`) for (const [k, v] of Object.entries(response.headers)) { @@ -161,26 +160,33 @@ export default class Api extends AuthCommand { this.log() } - if (responseData === undefined || responseData === null || responseData === '') { - if (response.status >= 400) { - this.exit(1) - } - return - } + const contentType = String(response.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase() + const isJson = contentType === 'application/json' || contentType.endsWith('+json') + const isAttachment = /^\s*attachment(?:;|$)/i.test(String(response.headers['content-disposition'] ?? '')) - if (Buffer.isBuffer(responseData) && !flags.jq) { - await new Promise((resolve, reject) => { - process.stdout.write(responseData, error => error ? reject(error) : resolve()) - }) + if (!flags.jq && (!isJson || isAttachment)) { + for await (const chunk of response.data) { + await writeBody(chunk) + } } else { - const json = Buffer.isBuffer(responseData) - ? responseData.toString('utf-8') - : typeof responseData === 'string' ? responseData : formatJson(responseData) - - if (flags.jq) { - await this.applyJq(json, flags.jq) - } else { - this.log(json) + const chunks: Buffer[] = [] + for await (const chunk of response.data) { + chunks.push(chunk as Buffer) + } + const responseData = parseResponse(Buffer.concat(chunks)) + + if (Buffer.isBuffer(responseData)) { + if (flags.jq) { + this.error('Response is not JSON; --jq cannot be applied.', { exit: 1 }) + } + await writeBody(responseData) + } else if (responseData !== undefined && responseData !== null && responseData !== '') { + const json = typeof responseData === 'string' ? responseData : formatJson(responseData) + if (flags.jq) { + await this.applyJq(json, flags.jq) + } else { + this.log(json) + } } } @@ -225,6 +231,12 @@ export default class Api extends AuthCommand { } } +function writeBody (data: Buffer): Promise { + return new Promise((resolve, reject) => { + process.stdout.write(data, error => error ? reject(error) : resolve()) + }) +} + function parseResponse (data: Buffer): unknown { if (data.length === 0) return '' From ce91064de3ab3c16af933da5a55fba2856b01689 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Fri, 2 Oct 2026 14:55:33 +0900 Subject: [PATCH 3/3] refactor(cli): simplify buffered response handling in api command Read the buffered body with stream/consumers buffer() and replace parseResponse's Buffer-or-value-or-'' return with parseJson, which returns undefined for non-JSON bodies. Output is unchanged except that a body consisting of the JSON string "" is now printed like any other top-level JSON string instead of being suppressed. The streaming branch keeps awaiting each stdout write: pipeline() with end: false resolves before stdout has flushed, so an exit right after it can truncate piped output. Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/commands/api.ts | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/commands/api.ts b/packages/cli/src/commands/api.ts index aedc74a83..7e46ac132 100644 --- a/packages/cli/src/commands/api.ts +++ b/packages/cli/src/commands/api.ts @@ -3,6 +3,7 @@ import { isUtf8 } from 'node:buffer' import { execFile } from 'node:child_process' import { readFile } from 'node:fs/promises' import type { Readable } from 'node:stream' +import { buffer } from 'node:stream/consumers' import { AuthCommand } from './authCommand.js' import { api } from '../rest/api.js' import { parseFields } from '../helpers/api-fields.js' @@ -169,18 +170,15 @@ export default class Api extends AuthCommand { await writeBody(chunk) } } else { - const chunks: Buffer[] = [] - for await (const chunk of response.data) { - chunks.push(chunk as Buffer) - } - const responseData = parseResponse(Buffer.concat(chunks)) + const body = await buffer(response.data) + const responseData = body.length > 0 ? parseJson(body) : null - if (Buffer.isBuffer(responseData)) { + if (responseData === undefined) { if (flags.jq) { this.error('Response is not JSON; --jq cannot be applied.', { exit: 1 }) } - await writeBody(responseData) - } else if (responseData !== undefined && responseData !== null && responseData !== '') { + await writeBody(body) + } else if (responseData !== null) { const json = typeof responseData === 'string' ? responseData : formatJson(responseData) if (flags.jq) { await this.applyJq(json, flags.jq) @@ -237,17 +235,14 @@ function writeBody (data: Buffer): Promise { }) } -function parseResponse (data: Buffer): unknown { - if (data.length === 0) return '' - - if (isUtf8(data)) { - try { - return JSON.parse(data.toString('utf-8').replace(/^\uFEFF/, '')) - } catch { - // Keep non-JSON bodies as bytes, including their original line endings. - } +// Returns undefined when the body is not valid UTF-8 JSON (JSON.parse never yields undefined). +function parseJson (data: Buffer): unknown { + if (!isUtf8(data)) return undefined + try { + return JSON.parse(data.toString('utf-8').replace(/^\uFEFF/, '')) + } catch { + return undefined } - return data } function formatJson (data: unknown): string {