From f81ca80f57f2a015bd983ef02b0c8325c4375148 Mon Sep 17 00:00:00 2001 From: sid sri Date: Fri, 24 Jul 2026 01:55:59 +0530 Subject: [PATCH] feat(cli): add HTTP debug logging --- src/cli-schema.ts | 5 + src/cli.ts | 9 +- src/completion/complete.ts | 1 + src/lib/cloud-client.ts | 3 +- src/lib/es-client.ts | 5 +- src/lib/http-debug.ts | 80 ++++++++++++++++ src/lib/kibana-client.ts | 3 +- test/cli-schema.test.ts | 12 +++ test/cli.test.ts | 14 +++ test/completion/complete.test.ts | 6 ++ test/lib/cloud-client.test.ts | 40 ++++++++ test/lib/es-client.test.ts | 28 ++++++ test/lib/http-debug.test.ts | 157 +++++++++++++++++++++++++++++++ test/lib/kibana-client.test.ts | 31 ++++++ test/support/capture-output.ts | 40 ++++++++ 15 files changed, 429 insertions(+), 5 deletions(-) create mode 100644 src/lib/http-debug.ts create mode 100644 test/lib/http-debug.test.ts create mode 100644 test/support/capture-output.ts diff --git a/src/cli-schema.ts b/src/cli-schema.ts index 57185c4c..16868add 100644 --- a/src/cli-schema.ts +++ b/src/cli-schema.ts @@ -109,6 +109,11 @@ const ENVIRONMENT: CliEnvironment = { required: false, description: 'Override the Elastic Cloud admin API base URL', }, + { + name: 'ELASTIC_DEBUG', + required: false, + description: 'Set to 1 to print HTTP request and response details to stderr', + }, ], configFiles: [ { path: '~/.elasticrc.yml', required: false, description: 'Primary config file (recommended)' }, diff --git a/src/cli.ts b/src/cli.ts index 566f73a6..5c5dd3a4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -52,11 +52,18 @@ if (hasGlobalFlags) { .option('--output-fields ', 'comma-separated list of fields to include in output (dot-notation supported)') .option('--output-template ', 'Mustache-like template for custom text output (e.g. "{{id}}: {{name}}")') } -program.option('--json', 'output as JSON') +program + .option('--json', 'output as JSON') + .option('--debug', 'print HTTP request and response details to stderr') // preAction hook (skipped for --help paths since the hook never fires) if (!wantsHelp) { program.hook('preAction', async (thisCommand, actionCommand) => { + if (thisCommand.opts().debug === true) { + const { setHttpDebugEnabled } = await import('./lib/http-debug.js') + setHttpDebugEnabled(true) + } + const skipActionNames: ReadonlySet = new Set(['version', 'completion', '__complete', 'status']) if (skipActionNames.has(actionCommand.name())) return // Groups with no sub-command will just call group.help() — no real action fires. diff --git a/src/completion/complete.ts b/src/completion/complete.ts index 5dfc4612..714169f4 100644 --- a/src/completion/complete.ts +++ b/src/completion/complete.ts @@ -158,6 +158,7 @@ export async function buildCompletionTree (rewrittenWords: readonly string[]): P root.option('--use-context ', 'override the active context from the config file') root.option('--command-profile ', 'restrict available commands to a deployment profile') root.option('--json', 'output as JSON') + root.option('--debug', 'print HTTP request and response details to stderr') root.option('--output-fields ', 'comma-separated list of fields to include in output') root.option('--output-template ', 'Mustache-like template for custom text output') diff --git a/src/lib/cloud-client.ts b/src/lib/cloud-client.ts index d9748c9a..b543bdde 100644 --- a/src/lib/cloud-client.ts +++ b/src/lib/cloud-client.ts @@ -5,6 +5,7 @@ import type { HttpMethod } from '../cloud/types.ts' import { getResolvedConfig } from '../config/store.ts' +import { fetchWithHttpDebug } from './http-debug.ts' import { isLoopbackUrl } from './is-loopback-host.ts' import { clientHeaders } from './meta.ts' @@ -69,7 +70,7 @@ export class CloudClient { init.body = JSON.stringify(params.body) } - const response = await this._fetch(url, init) + const response = await fetchWithHttpDebug(this._fetch, url, init) if (!response.ok) { const text = await response.text() diff --git a/src/lib/es-client.ts b/src/lib/es-client.ts index fdb9b498..086a8f08 100644 --- a/src/lib/es-client.ts +++ b/src/lib/es-client.ts @@ -5,6 +5,7 @@ import { getResolvedConfig } from '../config/store.ts' import { buildAuthHeader, type ApiKeyOrBasicAuth } from './auth.ts' +import { fetchWithHttpDebug } from './http-debug.ts' import { clientHeaders } from './meta.ts' export interface EsRequestParams { @@ -99,11 +100,11 @@ export class EsClient { } const isHead = params.method.toUpperCase() === 'HEAD' + const method = fetchBody !== undefined && params.method.toUpperCase() === 'GET' ? 'POST' : params.method let response: Response try { - const method = fetchBody !== undefined && params.method.toUpperCase() === 'GET' ? 'POST' : params.method - response = await this._fetch(url, { + response = await fetchWithHttpDebug(this._fetch, url, { method, headers, ...(fetchBody !== undefined && { body: fetchBody }), diff --git a/src/lib/http-debug.ts b/src/lib/http-debug.ts new file mode 100644 index 00000000..00050a3f --- /dev/null +++ b/src/lib/http-debug.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +const REDACTED_HEADERS = new Set([ + 'authorization', + 'proxy-authorization', + 'x-api-key', + 'cookie', + 'set-cookie', +]) + +let cliDebugEnabled = false + +/** + * Enables or disables HTTP debugging for the current CLI process. + */ +export function setHttpDebugEnabled (enabled: boolean): void { + cliDebugEnabled = enabled +} + +/** + * Returns whether HTTP debug output is enabled by CLI flag or environment. + */ +export function isHttpDebugEnabled (): boolean { + return cliDebugEnabled || process.env['ELASTIC_DEBUG'] === '1' +} + +function writeHeaders (prefix: string, headers: RequestInit['headers']): void { + for (const [name, value] of new Headers(headers)) { + const printableValue = REDACTED_HEADERS.has(name.toLowerCase()) ? '(redacted)' : value + process.stderr.write(`${prefix} ${name}: ${printableValue}\n`) + } +} + +/** + * Executes a fetch call and writes opt-in request and response diagnostics to stderr. + * + * Credential-bearing headers are redacted case-insensitively. The response is + * cloned before its body is inspected so callers can consume the original body. + */ +export async function fetchWithHttpDebug ( + fetchImplementation: typeof fetch, + url: string, + init: RequestInit +): Promise { + if (!isHttpDebugEnabled()) { + return fetchImplementation(url, init) + } + + process.stderr.write(`> ${init.method ?? 'GET'} ${url}\n`) + writeHeaders('>', init.headers) + if (typeof init.body === 'string') { + process.stderr.write(`\n${init.body}\n`) + } + + let response: Response + try { + response = await fetchImplementation(url, init) + } catch (error) { + process.stderr.write(`< Request failed: ${String(error)}\n`) + throw error + } + + const statusText = response.statusText.length > 0 ? ` ${response.statusText}` : '' + process.stderr.write(`< ${response.status}${statusText}\n`) + writeHeaders('<', response.headers) + + try { + const body = await response.clone().text() + if (body.length > 0) { + process.stderr.write(`\n${body}\n`) + } + } catch (error) { + process.stderr.write(`< Response body unavailable: ${String(error)}\n`) + } + + return response +} diff --git a/src/lib/kibana-client.ts b/src/lib/kibana-client.ts index d7e171da..c6103c40 100644 --- a/src/lib/kibana-client.ts +++ b/src/lib/kibana-client.ts @@ -7,6 +7,7 @@ import fs from 'node:fs' import path from 'node:path' import { getResolvedConfig } from '../config/store.ts' import { buildAuthHeader, type ApiKeyOrBasicAuth } from './auth.ts' +import { fetchWithHttpDebug } from './http-debug.ts' import { isLoopbackUrl } from './is-loopback-host.ts' import { clientHeaders } from './meta.ts' @@ -98,7 +99,7 @@ export class KibanaClient { init.body = JSON.stringify(params.body) } - const response = await this._fetch(url, init) + const response = await fetchWithHttpDebug(this._fetch, url, init) if (!response.ok) { const text = await response.text() diff --git a/test/cli-schema.test.ts b/test/cli-schema.test.ts index f1b0efa0..17d4fd24 100644 --- a/test/cli-schema.test.ts +++ b/test/cli-schema.test.ts @@ -33,4 +33,16 @@ describe('cli schema', () => { assert.ok(Array.isArray(schema['namespaces'])) assert.ok((schema['namespaces'] as Array<{ segment?: string }>).some(ns => ns.segment === 'stack')) }) + + it('documents the HTTP debug flag and environment variable', async () => { + const { code, stdout, stderr } = await runCliSchema() + assert.equal(code, 0, stderr) + + const schema = JSON.parse(stdout) as { + globalOptions: Array<{ name: string }> + environment: { variables: Array<{ name: string }> } + } + assert.ok(schema.globalOptions.some(option => option.name === 'debug')) + assert.ok(schema.environment.variables.some(variable => variable.name === 'ELASTIC_DEBUG')) + }) }) diff --git a/test/cli.test.ts b/test/cli.test.ts index cc1afeef..b354af12 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -31,6 +31,7 @@ function makeProgram(): InstanceType { prog.option('--config-file ', 'path to a config file (default: ~/.elasticrc.yml)') prog.option('--use-context ', 'override the active context from the config file') prog.option('--json', 'output as JSON') + prog.option('--debug', 'print HTTP request and response details to stderr') return prog } @@ -54,6 +55,13 @@ describe('elastic CLI -- global flags', () => { assert.ok(!opt.required, '--json should be a boolean flag (no required value)') }) + it('registers --debug as a boolean flag', () => { + const prog = makeProgram() + const opt = prog.options.find((o) => o.long === '--debug') + assert.ok(opt != null, 'expected --debug option') + assert.ok(!opt.required, '--debug should be a boolean flag (no required value)') + }) + it('registers --version as a boolean flag', () => { const prog = makeProgram() const opt = prog.options.find((o) => o.long === '--version') @@ -86,6 +94,12 @@ describe('elastic CLI -- global flags', () => { assert.equal(prog.opts()['json'], true) }) + it('parses --debug as true when provided', () => { + const prog = makeProgram() + prog.parse(['--debug'], { from: 'user' }) + assert.equal(prog.opts()['debug'], true) + }) + it('parses --config-file value correctly', () => { const prog = makeProgram() prog.parse(['--config-file', '/some/path.yml'], { from: 'user' }) diff --git a/test/completion/complete.test.ts b/test/completion/complete.test.ts index 33efd415..b2df6155 100644 --- a/test/completion/complete.test.ts +++ b/test/completion/complete.test.ts @@ -73,6 +73,12 @@ describe('buildCompletionTree -- top-level commands', () => { assert.ok(opt != null, 'expected --use-context global option') }) + it('exposes global --debug option on the root program', async () => { + const root = await buildCompletionTree([]) + const opt = root.options.find(o => o.long === '--debug') + assert.ok(opt != null, 'expected --debug global option') + }) + it('does not load the es subtree when the first word is unrelated', async () => { const root = await buildCompletionTree(['cloud']) const stack = root.commands.find(c => c.name() === 'stack')! diff --git a/test/lib/cloud-client.test.ts b/test/lib/cloud-client.test.ts index 350836d3..398a191c 100644 --- a/test/lib/cloud-client.test.ts +++ b/test/lib/cloud-client.test.ts @@ -9,6 +9,7 @@ import { getCloudClient, _testResetCloudClient } from '../../src/lib/cloud-clien import { setResolvedConfig } from '../../src/config/store.ts' import type { ResolvedConfig } from '../../src/config/types.ts' import { clientHeaders } from '../../src/lib/meta.ts' +import { captureProcessOutput } from '../support/capture-output.ts' afterEach(() => { _testResetCloudClient() @@ -115,6 +116,45 @@ describe('getCloudClient', () => { }) }) + it('honours ELASTIC_DEBUG and redacts the Cloud API key', async () => { + const previousDebug = process.env['ELASTIC_DEBUG'] + process.env['ELASTIC_DEBUG'] = '1' + setResolvedConfig({ + context: { + cloud: { + url: 'https://api.elastic-cloud.com', + auth: { api_key: 'cloud-secret' }, + }, + }, + }) + const client = getCloudClient() + client._testSetFetch((() => + Promise.resolve(new Response('{"id":"deployment-1"}', { + status: 201, + statusText: 'Created', + headers: { 'content-type': 'application/json' }, + })) + ) as typeof fetch) + + try { + const { stderr } = await captureProcessOutput(() => + client.request({ + method: 'POST', + path: '/api/v1/deployments', + body: { name: 'example' }, + }) + ) + assert.match(stderr, /> POST https:\/\/api\.elastic-cloud\.com\/api\/v1\/deployments/) + assert.match(stderr, /> authorization: \(redacted\)/) + assert.match(stderr, /\{"name":"example"\}/) + assert.match(stderr, /< 201 Created/) + assert.doesNotMatch(stderr, /cloud-secret/) + } finally { + if (previousDebug === undefined) delete process.env['ELASTIC_DEBUG'] + else process.env['ELASTIC_DEBUG'] = previousDebug + } + }) + it('sends ApiKey authorization header', async () => { setResolvedConfig({ context: { cloud: { url: 'https://api.elastic-cloud.com', auth: { api_key: 'secret-key' } } } }) const client = getCloudClient() diff --git a/test/lib/es-client.test.ts b/test/lib/es-client.test.ts index 286d07a6..8fa5b640 100644 --- a/test/lib/es-client.test.ts +++ b/test/lib/es-client.test.ts @@ -9,6 +9,7 @@ import { EsClient, EsResponseError, EsConnectionError, getEsClient, _testResetEs import { setResolvedConfig } from '../../src/config/store.ts' import type { ResolvedConfig } from '../../src/config/types.ts' import { clientHeaders } from '../../src/lib/meta.ts' +import { captureProcessOutput } from '../support/capture-output.ts' function makeApiKeyConfig (url: string, apiKey: string): ResolvedConfig { return { context: { elasticsearch: { url, auth: { api_key: apiKey } } } } @@ -303,6 +304,33 @@ describe('EsClient.request', () => { ) }) + it('honours ELASTIC_DEBUG without writing to stdout or exposing credentials', async () => { + const previousDebug = process.env['ELASTIC_DEBUG'] + process.env['ELASTIC_DEBUG'] = '1' + const client = makeClient({ api_key: 'es-secret' }) + client._testSetFetch((() => + Promise.resolve(new Response('{"hits":[]}', { + status: 200, + headers: { 'content-type': 'application/json' }, + })) + ) as typeof fetch) + + try { + const output = await captureProcessOutput(() => + client.request({ method: 'POST', path: '/_search', body: { query: { match_all: {} } } }) + ) + assert.equal(output.stdout, '') + assert.match(output.stderr, /> POST http:\/\/localhost:9200\/_search/) + assert.match(output.stderr, /> authorization: \(redacted\)/) + assert.match(output.stderr, /\{"query":\{"match_all":\{\}\}\}/) + assert.match(output.stderr, /< 200/) + assert.doesNotMatch(output.stderr, /es-secret/) + } finally { + if (previousDebug === undefined) delete process.env['ELASTIC_DEBUG'] + else process.env['ELASTIC_DEBUG'] = previousDebug + } + }) + it('sets redirect to error', async () => { const client = makeClient() let capturedInit: RequestInit = {} diff --git a/test/lib/http-debug.test.ts b/test/lib/http-debug.test.ts new file mode 100644 index 00000000..967473a1 --- /dev/null +++ b/test/lib/http-debug.test.ts @@ -0,0 +1,157 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { + fetchWithHttpDebug, + isHttpDebugEnabled, + setHttpDebugEnabled, +} from '../../src/lib/http-debug.ts' +import { captureProcessOutput } from '../support/capture-output.ts' + +const originalElasticDebug = process.env['ELASTIC_DEBUG'] + +afterEach(() => { + setHttpDebugEnabled(false) + if (originalElasticDebug === undefined) { + delete process.env['ELASTIC_DEBUG'] + } else { + process.env['ELASTIC_DEBUG'] = originalElasticDebug + } +}) + +describe('HTTP debug activation', () => { + it('is disabled by default', () => { + delete process.env['ELASTIC_DEBUG'] + assert.equal(isHttpDebugEnabled(), false) + }) + + it('is enabled only for ELASTIC_DEBUG=1', () => { + process.env['ELASTIC_DEBUG'] = 'true' + assert.equal(isHttpDebugEnabled(), false) + process.env['ELASTIC_DEBUG'] = '1' + assert.equal(isHttpDebugEnabled(), true) + }) + + it('allows the CLI flag to enable debugging independently of the environment', () => { + process.env['ELASTIC_DEBUG'] = '0' + setHttpDebugEnabled(true) + assert.equal(isHttpDebugEnabled(), true) + }) +}) + +describe('fetchWithHttpDebug', () => { + it('delegates without writing output when debugging is disabled', async () => { + delete process.env['ELASTIC_DEBUG'] + let receivedInit: RequestInit | undefined + const response = new Response('ok', { status: 200 }) + const fakeFetch = ((url: string, init: RequestInit) => { + assert.equal(url, 'https://example.test/data') + receivedInit = init + return Promise.resolve(response) + }) as typeof fetch + + const output = await captureProcessOutput(async () => { + const result = await fetchWithHttpDebug(fakeFetch, 'https://example.test/data', { method: 'GET' }) + assert.strictEqual(result, response) + }) + + assert.deepEqual(receivedInit, { method: 'GET' }) + assert.deepEqual(output, { stdout: '', stderr: '' }) + }) + + it('logs request and response details while redacting credential headers', async () => { + setHttpDebugEnabled(true) + const response = new Response('{"ok":true}', { + status: 201, + statusText: 'Created', + headers: { + 'content-type': 'application/json', + 'set-cookie': 'session=response-secret', + }, + }) + const fakeFetch = (() => Promise.resolve(response)) as typeof fetch + + let returnedBody = '' + const output = await captureProcessOutput(async () => { + const result = await fetchWithHttpDebug(fakeFetch, 'https://example.test/_search', { + method: 'POST', + headers: { + Authorization: 'ApiKey request-secret', + 'x-API-key': 'second-secret', + Cookie: 'session=cookie-secret', + Accept: 'application/json', + }, + body: '{"query":{"match_all":{}}}', + }) + returnedBody = await result.text() + }) + + assert.equal(output.stdout, '') + assert.match(output.stderr, /> POST https:\/\/example\.test\/_search/) + assert.match(output.stderr, /> authorization: \(redacted\)/) + assert.match(output.stderr, /> x-api-key: \(redacted\)/) + assert.match(output.stderr, /> cookie: \(redacted\)/) + assert.match(output.stderr, /> accept: application\/json/) + assert.match(output.stderr, /\{"query":\{"match_all":\{\}\}\}/) + assert.match(output.stderr, /< 201 Created/) + assert.match(output.stderr, /< content-type: application\/json/) + assert.match(output.stderr, /< set-cookie: \(redacted\)/) + assert.match(output.stderr, /\{"ok":true\}/) + assert.doesNotMatch(output.stderr, /request-secret|second-secret|cookie-secret|response-secret/) + assert.equal(returnedBody, '{"ok":true}', 'debug logging must not consume the caller response') + }) + + it('logs requests without bodies and responses with empty bodies', async () => { + setHttpDebugEnabled(true) + const fakeFetch = (() => Promise.resolve(new Response(null, { status: 204 }))) as typeof fetch + + const { stderr } = await captureProcessOutput(async () => { + await fetchWithHttpDebug(fakeFetch, 'https://example.test/empty', { + method: 'HEAD', + headers: new Headers({ Accept: 'application/json' }), + }) + }) + + assert.match(stderr, /> HEAD https:\/\/example\.test\/empty/) + assert.match(stderr, /< 204/) + }) + + it('logs network failures and preserves the original error', async () => { + setHttpDebugEnabled(true) + const failure = new TypeError('connection refused') + const fakeFetch = (() => Promise.reject(failure)) as typeof fetch + + const { stderr } = await captureProcessOutput(async () => { + await assert.rejects( + () => fetchWithHttpDebug(fakeFetch, 'https://example.test/fail', { method: 'GET' }), + (error: unknown) => error === failure + ) + }) + + assert.match(stderr, /> GET https:\/\/example\.test\/fail/) + assert.match(stderr, /< Request failed: .*connection refused/) + }) + + it('does not let an unreadable debug copy break the caller response', async () => { + setHttpDebugEnabled(true) + const response = new Response('caller can still read this', { status: 200 }) + Object.defineProperty(response, 'clone', { + value: () => { throw new Error('clone unavailable') }, + }) + const fakeFetch = (() => Promise.resolve(response)) as typeof fetch + + const { stderr } = await captureProcessOutput(async () => { + const result = await fetchWithHttpDebug(fakeFetch, 'https://example.test/data', { + method: 'GET', + headers: [['X-Test', 'value']], + }) + assert.equal(await result.text(), 'caller can still read this') + }) + + assert.match(stderr, /< Response body unavailable: .*clone unavailable/) + }) +}) diff --git a/test/lib/kibana-client.test.ts b/test/lib/kibana-client.test.ts index 3108733f..fb712166 100644 --- a/test/lib/kibana-client.test.ts +++ b/test/lib/kibana-client.test.ts @@ -9,6 +9,7 @@ import { KibanaClient, getKibanaClient, _testResetKibanaClient } from '../../src import { setResolvedConfig } from '../../src/config/store.ts' import type { ResolvedConfig } from '../../src/config/types.ts' import { clientHeaders } from '../../src/lib/meta.ts' +import { captureProcessOutput } from '../support/capture-output.ts' afterEach(() => { _testResetKibanaClient() @@ -168,6 +169,36 @@ describe('KibanaClient.request', () => { ) }) + it('honours ELASTIC_DEBUG and redacts basic authentication', async () => { + const previousDebug = process.env['ELASTIC_DEBUG'] + process.env['ELASTIC_DEBUG'] = '1' + const client = makeClient({ username: 'elastic', password: 'kb-secret' }) + client._testSetFetch((() => + Promise.resolve(new Response('{"id":"dashboard-1"}', { + status: 200, + headers: { 'content-type': 'application/json' }, + })) + ) as typeof fetch) + + try { + const { stderr } = await captureProcessOutput(() => + client.request({ + method: 'POST', + path: '/api/saved_objects', + body: { attributes: { title: 'Dashboard' } }, + }) + ) + assert.match(stderr, /> POST http:\/\/localhost:5601\/api\/saved_objects/) + assert.match(stderr, /> authorization: \(redacted\)/) + assert.match(stderr, /\{"attributes":\{"title":"Dashboard"\}\}/) + assert.match(stderr, /< 200/) + assert.doesNotMatch(stderr, /kb-secret/) + } finally { + if (previousDebug === undefined) delete process.env['ELASTIC_DEBUG'] + else process.env['ELASTIC_DEBUG'] = previousDebug + } + }) + it('returns empty object for empty response body', async () => { const client = makeClient() client._testSetFetch((() => diff --git a/test/support/capture-output.ts b/test/support/capture-output.ts new file mode 100644 index 00000000..42cc3275 --- /dev/null +++ b/test/support/capture-output.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface CapturedProcessOutput { + stdout: string + stderr: string +} + +/** + * Captures process output while an asynchronous operation runs. + */ +export async function captureProcessOutput (run: () => Promise): Promise { + const stdoutChunks: string[] = [] + const stderrChunks: string[] = [] + const originalStdoutWrite = process.stdout.write + const originalStderrWrite = process.stderr.write + + process.stdout.write = ((chunk: string | Uint8Array) => { + stdoutChunks.push(String(chunk)) + return true + }) as typeof process.stdout.write + process.stderr.write = ((chunk: string | Uint8Array) => { + stderrChunks.push(String(chunk)) + return true + }) as typeof process.stderr.write + + try { + await run() + } finally { + process.stdout.write = originalStdoutWrite + process.stderr.write = originalStderrWrite + } + + return { + stdout: stdoutChunks.join(''), + stderr: stderrChunks.join(''), + } +}