diff --git a/src/config/commands.ts b/src/config/commands.ts index f1b2187a..e67aeee4 100644 --- a/src/config/commands.ts +++ b/src/config/commands.ts @@ -504,6 +504,13 @@ function fieldOptions (): Array<{ long: string; type: 'string'; description: str ] } +/** Appends any warnings from a handler result as `Warning: ` lines. */ +function appendWarnings (base: string, result: JsonValue): string { + const warnings = (result as Record).warnings + if (!Array.isArray(warnings)) return base + return base + warnings.map((w) => `Warning: ${w}\n`).join('') +} + function buildContextGroup (): OpaqueCommandHandle { const addCmd = defineCommand({ name: 'add', @@ -516,6 +523,7 @@ function buildContextGroup (): OpaqueCommandHandle { ...fieldOptions(), ], handler: async (parsed) => handleContextAdd(parsed), + formatOutput: (result) => appendWarnings(`Context '${(result as unknown as CommandSummary).context}' added.\n`, result), }) const removeCmd = defineCommand({ @@ -527,6 +535,7 @@ function buildContextGroup (): OpaqueCommandHandle { { long: 'force', type: 'boolean', description: 'allow removing the current context' }, ], handler: async (parsed) => handleContextRemove(parsed), + formatOutput: (result) => appendWarnings(`Context '${(result as unknown as CommandSummary).context}' removed.\n`, result), }) const editCmd = defineCommand({ @@ -539,6 +548,7 @@ function buildContextGroup (): OpaqueCommandHandle { ...fieldOptions(), ], handler: async (parsed) => handleContextEdit(parsed), + formatOutput: (result) => appendWarnings(`Context '${(result as unknown as CommandSummary).context}' updated.\n`, result), }) const listCmd = defineCommand({ @@ -546,6 +556,11 @@ function buildContextGroup (): OpaqueCommandHandle { description: 'List all contexts defined in the config file', options: [CONFIG_FILE_OPT], handler: async (parsed) => handleContextList(parsed.options), + formatOutput: (result) => { + const r = result as { contexts: Array<{ name: string; current: boolean }> } + if (r.contexts.length === 0) return 'No contexts configured.\n' + return r.contexts.map((c) => (c.current ? `* ${c.name}` : ` ${c.name}`)).join('\n') + '\n' + }, }) return defineGroup({ name: 'context', description: 'Manage contexts in the elastic config file' }, listCmd, addCmd, editCmd, removeCmd) @@ -558,6 +573,9 @@ function buildCurrentContextGroup (): OpaqueCommandHandle { positionalArg: { name: 'name', description: 'context name', required: true }, options: [CONFIG_FILE_OPT], handler: async (parsed) => handleCurrentContextSet(parsed), + formatOutput: (result) => appendWarnings( + `Switched to context '${(result as { current: string }).current}'.\n`, result + ), }) const getCmd = defineCommand({ @@ -565,6 +583,7 @@ function buildCurrentContextGroup (): OpaqueCommandHandle { description: 'Print the current context name', options: [CONFIG_FILE_OPT], handler: async (parsed) => handleCurrentContextGet(parsed.options), + formatOutput: (result) => (result as { current: string }).current + '\n', }) return defineGroup( diff --git a/src/output.ts b/src/output.ts index a7f9d1de..1a1fc2c9 100644 --- a/src/output.ts +++ b/src/output.ts @@ -81,6 +81,12 @@ export function renderText(value: JsonValue): string { } } + if (isFlatObject(value)) { + return Object.entries(value) + .map(([k, v]) => `${k}: ${v ?? ''}`) + .join('\n') + '\n' + } + return JSON.stringify(value, null, 2) + '\n' } diff --git a/test/config/text-output.test.ts b/test/config/text-output.test.ts new file mode 100644 index 00000000..5c07040a --- /dev/null +++ b/test/config/text-output.test.ts @@ -0,0 +1,171 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, before, after } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { Command } from 'commander' +import { registerConfigCommands } from '../../src/config/commands.ts' + +/** + * Runs a config sub-command in-process (text mode), capturing stdout and + * stderr and restoring process.exitCode (error envelopes set it to 1). + */ +async function runCapture (args: string[]): Promise<{ stdout: string; stderr: string }> { + const prog = new Command('elastic') + const group = registerConfigCommands() + prog.addCommand(group) + prog.exitOverride() + group.exitOverride() + let stdout = '' + let stderr = '' + const origOut = process.stdout.write.bind(process.stdout) + const origErr = process.stderr.write.bind(process.stderr) + const origExitCode = process.exitCode + // The factory writes plain strings; the test runner flushes Buffer chunks + // to stdout between awaits. Capture only strings and pass Buffers through + // so the runner's reporting protocol is not corrupted. + process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => { + if (typeof chunk === 'string') { stdout += chunk; return true } + return origOut(chunk as Uint8Array, ...(rest as [])) + }) as typeof process.stdout.write + process.stderr.write = ((chunk: unknown, ...rest: unknown[]) => { + if (typeof chunk === 'string') { stderr += chunk; return true } + return origErr(chunk as Uint8Array, ...(rest as [])) + }) as typeof process.stderr.write + try { + await prog.parseAsync(['config', ...args], { from: 'user' }) + } finally { + process.stdout.write = origOut + process.stderr.write = origErr + process.exitCode = origExitCode + } + return { stdout, stderr } +} + +async function runText (args: string[]): Promise { + return (await runCapture(args)).stdout +} + +describe('config commands text output', () => { + let dir: string + let cfg: string + + before(async () => { + dir = await mkdtemp(join(tmpdir(), 'elastic-cli-text-')) + cfg = join(dir, 'cfg.yml') + }) + after(async () => rm(dir, { recursive: true, force: true })) + + it('context add prints a confirmation sentence', async () => { + const out = await runText([ + 'context', 'add', 'local', + '--config-file', cfg, + '--es-url', 'http://localhost:9200', + '--es-api-key', 'k1', + '--inline-secrets', + ]) + assert.match(out, /^Context 'local' added\.\n/) + }) + + it('context list marks the current context with an asterisk', async () => { + await runText([ + 'context', 'add', 'staging', + '--config-file', cfg, + '--es-url', 'https://staging:9200', + '--es-api-key', 'k2', + '--inline-secrets', + ]) + const out = await runText(['context', 'list', '--config-file', cfg]) + assert.equal(out, '* local\n staging\n') + }) + + it('current-context set prints a switch confirmation', async () => { + const out = await runText(['current-context', 'set', 'staging', '--config-file', cfg]) + assert.match(out, /^Switched to context 'staging'\.\n/) + }) + + it('current-context get prints just the name', async () => { + const out = await runText(['current-context', 'get', '--config-file', cfg]) + assert.equal(out, 'staging\n') + }) + + it('context edit prints an update confirmation', async () => { + const out = await runText([ + 'context', 'edit', 'local', + '--config-file', cfg, + '--es-url', 'http://new:9200', + '--inline-secrets', + ]) + assert.match(out, /^Context 'local' updated\.\n/) + }) + + it('context remove prints a removal confirmation', async () => { + const out = await runText(['context', 'remove', 'local', '--config-file', cfg]) + assert.match(out, /^Context 'local' removed\.\n/) + }) + + it('context add of an existing name without --force errors', async () => { + const { stderr } = await runCapture([ + 'context', 'add', 'staging', + '--config-file', cfg, + '--es-url', 'http://x:9200', + '--es-api-key', 'k', + '--inline-secrets', + ]) + assert.match(stderr, /already exists/) + }) + + it('context add with no fields errors', async () => { + const { stderr } = await runCapture(['context', 'add', 'bare', '--config-file', cfg]) + assert.match(stderr, /No context fields provided/) + }) + + it('context edit of an unknown context errors', async () => { + const { stderr } = await runCapture([ + 'context', 'edit', 'missing', + '--config-file', cfg, + '--es-url', 'http://y:9200', + ]) + assert.match(stderr, /not found/) + }) + + it('current-context set of an unknown context errors', async () => { + const { stderr } = await runCapture(['current-context', 'set', 'nope', '--config-file', cfg]) + assert.match(stderr, /not found/) + }) + + it('context remove of the current context without --force errors', async () => { + const { stderr } = await runCapture(['context', 'remove', 'staging', '--config-file', cfg]) + assert.match(stderr, /current context/) + }) + + it('context remove of an unknown context errors', async () => { + const { stderr } = await runCapture(['context', 'remove', 'ghost', '--config-file', cfg]) + assert.match(stderr, /not found/) + }) + + it('context add --force overwrites an existing context', async () => { + const out = await runText([ + 'context', 'add', 'staging', + '--config-file', cfg, + '--es-url', 'http://forced:9200', + '--es-username', 'user', + '--es-password', 'pw', + '--inline-secrets', + '--force', + ]) + assert.match(out, /^Context 'staging' added\.\n/) + }) + + it('context remove of the last context deletes the config file', async () => { + const out = await runText(['context', 'remove', 'staging', '--config-file', cfg, '--force']) + assert.match(out, /removed/) + const listOut = await runText(['context', 'list', '--config-file', cfg]) + assert.equal(listOut, 'No contexts configured.\n') + }) +}) diff --git a/test/factory.test.ts b/test/factory.test.ts index bb6c4d5f..9bdbcab2 100644 --- a/test/factory.test.ts +++ b/test/factory.test.ts @@ -1881,14 +1881,14 @@ describe('defineCommand', () => { assert.deepEqual(JSON.parse(out), { ok: true, count: 3 }) }) - it('factory writes handler return value as pretty-printed JSON in text mode', async () => { + it('factory writes flat object handler result as key:value lines in text mode', async () => { const cmd = defineCommand({ name: 'status', description: 'Get status', handler: () => ({ ok: true }), }) const out = await invokeUnderRoot(cmd, [], []) - assert.equal(out, JSON.stringify({ ok: true }, null, 2) + '\n') + assert.equal(out, 'ok: true\n') }) it('factory handles async handler return value', async () => { @@ -2144,14 +2144,14 @@ describe('text output rendering', () => { assert.match(out, /[─├┤┼]/) }) - it('falls back to pretty-printed JSON for a plain object', async () => { + it('renders a flat object as key:value lines', async () => { const cmd = defineCommand({ name: 'status', description: 'Status', handler: () => ({ ok: true, count: 3 }), }) const out = await invokeText(cmd) - assert.equal(out, JSON.stringify({ ok: true, count: 3 }, null, 2) + '\n') + assert.equal(out, 'ok: true\ncount: 3\n') }) it('falls back to pretty-printed JSON for nested arrays', async () => { diff --git a/test/output.test.ts b/test/output.test.ts index 06df53e3..79724634 100644 --- a/test/output.test.ts +++ b/test/output.test.ts @@ -125,8 +125,22 @@ describe('renderText', () => { }) }) + describe('flat objects — key: value pairs', () => { + it('renders a flat object as key: value lines', () => { + assert.equal(renderText({ status: 'ok', count: 3 }), 'status: ok\ncount: 3\n') + }) + + it('renders null values as empty string', () => { + assert.equal(renderText({ name: 'foo', value: null }), 'name: foo\nvalue: \n') + }) + + it('renders a single-key flat object', () => { + assert.equal(renderText({ version: '1.2.3' }), 'version: 1.2.3\n') + }) + }) + describe('complex types — fall back to pretty JSON', () => { - it('renders a plain object as pretty-printed JSON', () => { + it('renders a nested object as pretty-printed JSON', () => { const val = { key: 'value', nested: { x: 1 } } assert.equal(renderText(val), JSON.stringify(val, null, 2) + '\n') }) @@ -140,11 +154,6 @@ describe('renderText', () => { const val = ['hello', { key: 1 }] assert.equal(renderText(val as never), JSON.stringify(val, null, 2) + '\n') }) - - it('renders a flat object (not an array) as pretty-printed JSON', () => { - const val = { status: 'ok', count: 3 } - assert.equal(renderText(val), JSON.stringify(val, null, 2) + '\n') - }) }) })