Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { ResolvedConfig } from './config/types.ts'
import { getResolvedConfig } from './config/store.ts'
import { extractSchemaArgs, validateSchemaArgs } from './lib/schema-args.ts'
import type { SchemaArgDefinition } from './lib/schema-args.ts'
import { renderText } from './output.ts'

/** pre-built schema for coercing string → number, reused per option invocation */
const numberSchema = z.coerce.number()
Expand Down Expand Up @@ -121,6 +122,13 @@ export interface CommandConfig<T extends z.ZodType = z.ZodType> {
* stdin or file, validates against the schema, then passes the typed result to the handler.
*/
input?: T
/**
* optional text renderer for non-JSON output mode.
* when provided, called with the handler result and the full parsed result to produce a string
* written to stdout. when omitted, the factory auto-renders via {@link renderText}.
* never called when `--format=json` is active.
*/
formatOutput?: (result: JsonValue, parsed: ParsedResult<z.infer<T>>) => string
}

/**
Expand Down Expand Up @@ -530,8 +538,10 @@ export function defineCommand<T extends z.ZodType>(config: CommandConfig<T>): Op
assert(handlerResult !== undefined, `command ${JSON.stringify(config.name)}: handler must return a JsonValue`)
if (fmt === 'json') {
process.stdout.write(JSON.stringify(handlerResult) + '\n')
} else if (config.formatOutput !== undefined) {
process.stdout.write(config.formatOutput(handlerResult, parsed))
} else {
process.stdout.write(JSON.stringify(handlerResult, null, 2) + '\n')
process.stdout.write(renderText(handlerResult))
}
})

Expand Down
90 changes: 90 additions & 0 deletions src/output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* Copyright Elasticsearch B.V. and contributors
* SPDX-License-Identifier: Apache-2.0
*/

import type { JsonValue } from './factory.ts'

/** A flat object whose values are all JSON primitives — renderable as a table row. */
type FlatRecord = Record<string, string | number | boolean | null>

/** Returns true when `val` is a non-null, non-array object with only primitive values. */
function isFlatObject(val: JsonValue): val is FlatRecord {
if (val === null || typeof val !== 'object' || Array.isArray(val)) return false
return Object.values(val).every((v) => v === null || typeof v !== 'object')
}

/** Returns true when `val` is a JSON primitive (string, number, boolean, or null). */
function isPrimitive(val: JsonValue): val is string | number | boolean | null {
return val === null || typeof val !== 'object'
}

/**
* Renders an array of flat objects as a plain-text column-aligned table.
*
* Column widths are the maximum of the header length and the widest cell value.
* Columns are separated by two spaces. Trailing whitespace is trimmed from each line.
* Returns an empty string for an empty array.
*
* @example
* ```ts
* renderTable([{ name: 'foo', count: 3 }, { name: 'bar', count: 12 }])
* // name count
* // ---- -----
* // foo 3
* // bar 12
* ```
*/
export function renderTable(rows: FlatRecord[]): string {
if (rows.length === 0) return ''

const headers = Object.keys(rows[0]!)
const colWidths = headers.map((h) => {
const maxVal = Math.max(...rows.map((r) => String(r[h] ?? '').length))
return Math.max(h.length, maxVal)
})

const formatRow = (cells: string[]): string =>
cells.map((cell, i) => cell.padEnd(colWidths[i]!)).join(' ').trimEnd()

const headerRow = formatRow(headers)
const separator = colWidths.map((w) => '-'.repeat(w)).join(' ').trimEnd()
const dataRows = rows.map((row) =>
formatRow(headers.map((h) => String(row[h] ?? '')))
)

return [headerRow, separator, ...dataRows].join('\n') + '\n'
}

/**
* Auto-renders a `JsonValue` as human-readable terminal text.
*
* Rendering rules (simplest match wins):
* - **Primitives** (`string | number | boolean | null`): printed as their string representation
* - **Array of flat objects** (all values are primitives): rendered as a column-aligned table via {@link renderTable}
* - **Array of primitives**: one item per line
* - **Empty array**: single newline
* - **Everything else**: falls back to pretty-printed JSON
*
* Command handlers that need richer control should supply a `formatOutput` function
* on their `CommandConfig` rather than relying on this auto-renderer.
*/
export function renderText(value: JsonValue): string {
if (isPrimitive(value)) {
return String(value) + '\n'
}

if (Array.isArray(value)) {
if (value.length === 0) return '\n'

if (value.every(isFlatObject)) {
return renderTable(value)
}

if (value.every(isPrimitive)) {
return value.map((v) => String(v)).join('\n') + '\n'
}
}

return JSON.stringify(value, null, 2) + '\n'
}
163 changes: 163 additions & 0 deletions test/factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1613,7 +1613,170 @@ describe('defineCommand', () => {
})
})

describe('text output rendering', () => {
async function captureOutput(fn: () => Promise<unknown>): Promise<string> {
let out = ''
const orig = process.stdout.write.bind(process.stdout)
process.stdout.write = (chunk: unknown) => { out += String(chunk); return true }
try { await fn() } finally { process.stdout.write = orig }
return out
}

async function invokeText(cmd: OpaqueCommandHandle, cmdArgv: string[] = []): Promise<string> {
const { Command } = await import('commander')
const prog = new Command('elastic')
prog.option('--format <fmt>', 'output format')
prog.addCommand(cmd)
prog.exitOverride()
cmd.exitOverride()
return captureOutput(() => prog.parseAsync([cmd.name(), ...cmdArgv], { from: 'user' }))
}

describe('formatOutput callback', () => {
it('is called with the handler result in text mode', async () => {
const received: unknown[] = []
const cmd = defineCommand({
name: 'status',
description: 'Status',
handler: () => ({ ok: true }),
formatOutput: (result) => { received.push(result); return 'custom\n' },
})
await invokeText(cmd)
assert.equal(received.length, 1)
assert.deepEqual(received[0], { ok: true })
})

it('is called with the parsed result in text mode', async () => {
const receivedParsed: unknown[] = []
const cmd = defineCommand({
name: 'status',
description: 'Status',
options: [{ long: 'verbose', type: 'boolean', description: 'Verbose' }],
handler: () => ({ ok: true }),
formatOutput: (result, parsed) => { receivedParsed.push(parsed); return 'ok\n' },
})
await invokeText(cmd, ['--verbose'])
assert.equal(receivedParsed.length, 1)
const p = receivedParsed[0] as ParsedResult
assert.equal(p.options['verbose'], true)
})

it('output written is the string returned by formatOutput', async () => {
const cmd = defineCommand({
name: 'status',
description: 'Status',
handler: () => ({ ok: true }),
formatOutput: () => 'custom output line\n',
})
const out = await invokeText(cmd)
assert.equal(out, 'custom output line\n')
})

it('is NOT called when --format=json is provided', async () => {
let called = false
const cmd = defineCommand({
name: 'status',
description: 'Status',
handler: () => ({ ok: true }),
formatOutput: () => { called = true; return 'custom\n' },
})
const { Command } = await import('commander')
const prog = new Command('elastic')
prog.option('--format <fmt>', 'output format')
prog.addCommand(cmd)
prog.exitOverride()
cmd.exitOverride()
await captureOutput(() => prog.parseAsync(['--format', 'json', cmd.name()], { from: 'user' }))
assert.equal(called, false, 'formatOutput must not be called in JSON mode')
})

it('JSON mode still writes compact JSON even when formatOutput is defined', async () => {
const cmd = defineCommand({
name: 'status',
description: 'Status',
handler: () => ({ ok: true }),
formatOutput: () => 'custom\n',
})
const { Command } = await import('commander')
const prog = new Command('elastic')
prog.option('--format <fmt>', 'output format')
prog.addCommand(cmd)
prog.exitOverride()
cmd.exitOverride()
const out = await captureOutput(() => prog.parseAsync(['--format', 'json', cmd.name()], { from: 'user' }))
assert.deepEqual(JSON.parse(out), { ok: true })
})
})

describe('auto-rendering (no formatOutput provided)', () => {
it('renders a string result as plain text', async () => {
const cmd = defineCommand({
name: 'echo',
description: 'Echo',
handler: () => 'hello world',
})
const out = await invokeText(cmd)
assert.equal(out, 'hello world\n')
})

it('renders a number result as its string form', async () => {
const cmd = defineCommand({
name: 'count',
description: 'Count',
handler: () => 42,
})
const out = await invokeText(cmd)
assert.equal(out, '42\n')
})

it('renders an array of primitives one per line', async () => {
const cmd = defineCommand({
name: 'list',
description: 'List',
handler: () => ['alpha', 'beta', 'gamma'],
})
const out = await invokeText(cmd)
assert.equal(out, 'alpha\nbeta\ngamma\n')
})

it('renders an array of flat objects as a table', async () => {
const cmd = defineCommand({
name: 'list',
description: 'List',
handler: () => [
{ name: 'foo', status: 'ok' },
{ name: 'bar', status: 'error' },
],
})
const out = await invokeText(cmd)
assert.match(out, /name/)
assert.match(out, /status/)
assert.match(out, /foo/)
assert.match(out, /bar/)
assert.match(out, /^-+/m)
})

it('falls back to pretty-printed JSON for a plain object', 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')
})

it('falls back to pretty-printed JSON for nested arrays', async () => {
const cmd = defineCommand({
name: 'status',
description: 'Status',
handler: () => [{ name: 'foo', tags: ['a', 'b'] }],
})
const out = await invokeText(cmd)
assert.equal(out, JSON.stringify([{ name: 'foo', tags: ['a', 'b'] }], null, 2) + '\n')
})
})
})

describe('defineGroup', () => {
describe('skeleton', () => {
Expand Down
Loading
Loading