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
8 changes: 8 additions & 0 deletions CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment thread
dbfx marked this conversation as resolved.

```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):
Expand Down
60 changes: 58 additions & 2 deletions src/main/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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([])
})
})
15 changes: 11 additions & 4 deletions src/main/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion src/main/ipc/malware-scanner.ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,8 @@ async function _initYaraEngine(): Promise<YaraEngine | null> {
})

_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))
}
Expand Down
3 changes: 2 additions & 1 deletion src/main/services/yara-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading