diff --git a/CLI.md b/CLI.md index 35fb8c17..b3c38265 100644 --- a/CLI.md +++ b/CLI.md @@ -82,6 +82,14 @@ When `--json` is passed, output is a single JSON object: The `clean` key is only present when `--clean` is used. +With `--json`, stdout carries nothing but the JSON document — progress lines and verbose +diagnostics are written to stderr instead, so output can be piped straight into a parser: + +```bash +kudu --cli programs list --json | jq '.count' # stdout is pure JSON +kudu --cli programs list --json 2>/dev/null # discard progress entirely +``` + ## Prometheus Metrics Print metrics in Prometheus text format (useful for `node_exporter` textfile collector): diff --git a/src/main/cli.test.ts b/src/main/cli.test.ts index a2be60a4..fc043151 100644 --- a/src/main/cli.test.ts +++ b/src/main/cli.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest' -import { parseCliArgs, ExitCode } from './cli' +import { describe, it, expect, vi, afterEach } from 'vitest' +import { parseCliArgs, ExitCode, cliLog, cliVerbose } from './cli' describe('parseCliArgs', () => { it('parses --json flag', () => { @@ -109,3 +109,59 @@ describe('ExitCode', () => { } }) }) + +describe('JSON stdout purity', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + function captureStreams(): { out: string[]; err: string[] } { + const out: string[] = [] + const err: string[] = [] + vi.spyOn(process.stdout, 'write').mockImplementation((chunk: any) => { + out.push(String(chunk)) + return true + }) + vi.spyOn(process.stderr, 'write').mockImplementation((chunk: any) => { + err.push(String(chunk)) + return true + }) + return { out, err } + } + + it('cliLog writes progress to stderr in JSON mode, keeping stdout parseable', () => { + const { out, err } = captureStreams() + cliLog({ json: true, verbosity: 'normal' }, 'Loading installed programs...') + expect(out).toEqual([]) + expect(err).toEqual(['Loading installed programs...\n']) + }) + + it('cliLog writes to stdout in human mode', () => { + const { out, err } = captureStreams() + cliLog({ json: false, verbosity: 'normal' }, 'Scanning network...') + expect(out).toEqual(['Scanning network...\n']) + expect(err).toEqual([]) + }) + + it('cliLog stays silent in quiet mode regardless of json', () => { + const { out, err } = captureStreams() + cliLog({ json: true, verbosity: 'quiet' }, 'noise') + cliLog({ json: false, verbosity: 'quiet' }, 'noise') + expect(out).toEqual([]) + expect(err).toEqual([]) + }) + + it('cliVerbose writes to stderr in JSON mode', () => { + const { out, err } = captureStreams() + cliVerbose({ json: true, verbosity: 'verbose' }, 'took 12ms') + expect(out).toEqual([]) + expect(err).toEqual([' [verbose] took 12ms\n']) + }) + + it('cliVerbose writes to stdout in human mode', () => { + const { out, err } = captureStreams() + cliVerbose({ json: false, verbosity: 'verbose' }, 'took 12ms') + expect(out).toEqual([' [verbose] took 12ms\n']) + expect(err).toEqual([]) + }) +}) diff --git a/src/main/cli.ts b/src/main/cli.ts index 39113cf1..32f298d6 100644 --- a/src/main/cli.ts +++ b/src/main/cli.ts @@ -53,14 +53,21 @@ function formatBytes(bytes: number): string { return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}` } -function cliLog(ctx: CliContext, msg: string): void { +/** + * Human-facing progress output. In JSON mode this goes to stderr so that + * stdout stays a single parseable JSON document for scripted consumers. + */ +export function cliLog(ctx: CliContext, msg: string): void { if (ctx.verbosity === 'quiet') return - process.stdout.write(msg + '\n') + const stream = ctx.json ? process.stderr : process.stdout + stream.write(msg + '\n') } -function cliVerbose(ctx: CliContext, msg: string): void { +/** Verbose diagnostics. Also kept off stdout in JSON mode. */ +export function cliVerbose(ctx: CliContext, msg: string): void { if (ctx.verbosity !== 'verbose') return - process.stdout.write(` [verbose] ${msg}\n`) + const stream = ctx.json ? process.stderr : process.stdout + stream.write(` [verbose] ${msg}\n`) } function cliOut(ctx: CliContext, data: unknown): void { diff --git a/src/main/ipc/malware-scanner.ipc.ts b/src/main/ipc/malware-scanner.ipc.ts index 20eb19d3..e1d0524d 100644 --- a/src/main/ipc/malware-scanner.ipc.ts +++ b/src/main/ipc/malware-scanner.ipc.ts @@ -153,7 +153,8 @@ async function _initYaraEngine(): Promise { }) _yaraCompileProgress = null - console.log(`[yara] Loaded ${result.loaded} rule files (${result.errors.length} errors)`) + // Diagnostics go to stderr so the CLI's --json mode keeps stdout parseable. + console.error(`[yara] Loaded ${result.loaded} rule files (${result.errors.length} errors)`) if (result.errors.length > 0) { console.warn('[yara] Rule load warnings:', result.errors.slice(0, 20)) } diff --git a/src/main/services/yara-engine.ts b/src/main/services/yara-engine.ts index 03701195..1b339c8c 100644 --- a/src/main/services/yara-engine.ts +++ b/src/main/services/yara-engine.ts @@ -117,7 +117,8 @@ export class YaraEngine { sources.push({ name: `source-${i}`, content: extraSources[i] }) } if (skippedPlatform > 0) { - console.log(`[yara] Skipped ${skippedPlatform} rule files for other platforms`) + // stderr, not stdout — keeps the CLI's --json output parseable. + console.error(`[yara] Skipped ${skippedPlatform} rule files for other platforms`) } if (sources.length === 0) {