Skip to content

Commit eb8d8d9

Browse files
committed
fix(cli): keep --json stdout payload-only by routing logger status to stderr
Under machine-output mode (--json/--markdown/--quiet) stdout must carry only the command payload, but the lib logger routes its step/substep status helpers to stdout while every other status helper and the spinner go to stderr. Progress messages therefore leaked onto the payload stream (the original ASK-115 report: 'socket fix --json' emitted stray output on stdout). Route step/substep to stderr at the argv-parse boundary when machine-output mode is engaged; logger.log is left untouched as the payload/primary-data channel.
1 parent 3090a17 commit eb8d8d9

4 files changed

Lines changed: 190 additions & 0 deletions

File tree

packages/cli/src/util/cli/with-subcommands-meow-exit.mts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
resetMachineOutputMode,
1919
setMachineOutputMode,
2020
} from '../output/ambient-mode.mts'
21+
import { applyMachineOutputStreamPolicy } from '../output/machine-output-streams.mts'
2122
import { emitBanner, shouldSuppressBanner } from './with-subcommands-banner.mts'
2223

2324
import type { CliCommandConfig } from './with-subcommands.mts'
@@ -109,6 +110,13 @@ export function meowOrExit<const F extends MeowFlags = MeowFlags>(
109110
markdown: markdownFlag,
110111
quiet: quietFlag,
111112
})
113+
// Route the logger's stdout-bound status helpers (step / substep) to stderr
114+
// when machine-output mode is engaged, so stdout carries only the payload.
115+
applyMachineOutputStreamPolicy({
116+
json: jsonFlag,
117+
markdown: markdownFlag,
118+
quiet: quietFlag,
119+
})
112120

113121
const compactMode = compactHeaderFlag || (getCI() && !VITEST)
114122
const noSpinner = !spinnerFlag || isDebug()

packages/cli/src/util/cli/with-subcommands.mts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
resetMachineOutputMode,
1919
setMachineOutputMode,
2020
} from '../output/ambient-mode.mts'
21+
import { applyMachineOutputStreamPolicy } from '../output/machine-output-streams.mts'
2122

2223
import { buildHelpLines } from './with-subcommands-help.mts'
2324
import { tryDispatchSubcommand } from './with-subcommands-dispatch.mts'
@@ -226,6 +227,13 @@ export async function meowWithSubcommands(
226227
markdown: markdownFlag,
227228
quiet: quietFlag,
228229
})
230+
// Route the logger's stdout-bound status helpers (step / substep) to stderr
231+
// when machine-output mode is engaged, so stdout carries only the payload.
232+
applyMachineOutputStreamPolicy({
233+
json: jsonFlag,
234+
markdown: markdownFlag,
235+
quiet: quietFlag,
236+
})
229237

230238
const compactMode = compactHeaderFlag || (getCI() && !VITEST)
231239
const noSpinner = !spinnerFlag || isDebug()
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* Stream policy for machine-output mode.
3+
*
4+
* Machine-output mode (--json, --markdown, --quiet) promises that stdout
5+
* carries ONLY the command payload. The logger already routes its status
6+
* helpers (info, warn, success, fail, skip) to stderr, and the spinner routes
7+
* its animation and status methods (including step / substep) to stderr. The
8+
* lib logger is the outlier: its `step` / `substep` helpers write to stdout,
9+
* so a command that reports progress with them contaminates the payload stream
10+
* under --json.
11+
*
12+
* `applyMachineOutputStreamPolicy` is called at the argv-parse boundary. When
13+
* machine-output mode is engaged it shadows the shared logger's `step` /
14+
* `substep` so they emit to stderr like every other status helper; when it is
15+
* not engaged it restores them. `logger.log` is left untouched — it is the
16+
* payload / primary-data channel (JSON, Markdown, and output-kind-gated human
17+
* text all flow through it) and must stay on stdout.
18+
*/
19+
20+
import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default'
21+
import { LOG_SYMBOLS } from '@socketsecurity/lib-stable/logger/symbols'
22+
23+
import { isMachineOutputMode } from './mode.mts'
24+
25+
import type { MachineModeFlags } from './mode.mts'
26+
import type { Logger } from '@socketsecurity/lib-stable/logger/logger'
27+
28+
export type StatusMethod = (msg: string, ...extras: unknown[]) => Logger
29+
30+
export interface SavedStatusMethods {
31+
step: StatusMethod | undefined
32+
substep: StatusMethod | undefined
33+
}
34+
35+
let saved: SavedStatusMethods | undefined
36+
37+
/**
38+
* Route the logger's stdout-bound status helpers to stderr while machine-output
39+
* mode is engaged, and restore them otherwise. Called once per invocation at
40+
* the argv-parse boundary, alongside `setMachineOutputMode`.
41+
*/
42+
export function applyMachineOutputStreamPolicy(flags: MachineModeFlags): void {
43+
if (isMachineOutputMode(flags)) {
44+
engageMachineOutputStreams()
45+
} else {
46+
restoreMachineOutputStreams()
47+
}
48+
}
49+
50+
/**
51+
* Shadow the shared logger's `step` / `substep` so they emit to stderr. Idem-
52+
* potent: a second call while already engaged is a no-op so the saved
53+
* originals are never overwritten with the shadows.
54+
*/
55+
export function engageMachineOutputStreams(): void {
56+
if (saved) {
57+
return
58+
}
59+
const logger = getDefaultLogger()
60+
saved = {
61+
step: logger.step?.bind(logger),
62+
substep: logger.substep?.bind(logger),
63+
}
64+
logger.step = function step(msg: string, ...extras: unknown[]): Logger {
65+
return logger.error(`${LOG_SYMBOLS['step']} ${msg}`, ...extras)
66+
}
67+
logger.substep = function substep(msg: string, ...extras: unknown[]): Logger {
68+
return logger.error(` ${msg}`, ...extras)
69+
}
70+
}
71+
72+
/**
73+
* Restore the logger's `step` / `substep` to the lib defaults. No-op when the
74+
* policy was never engaged.
75+
*/
76+
export function restoreMachineOutputStreams(): void {
77+
if (!saved) {
78+
return
79+
}
80+
const logger = getDefaultLogger()
81+
const { step, substep } = saved
82+
if (step) {
83+
logger.step = step
84+
}
85+
if (substep) {
86+
logger.substep = substep
87+
}
88+
saved = undefined
89+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* Unit tests for the machine-output stream policy.
3+
*
4+
* Verifies that engaging machine-output mode routes the logger's stdout-bound
5+
* status helpers (step / substep) to stderr, that restoring returns them to
6+
* stdout, and that the payload channel (logger.log) is never diverted.
7+
*
8+
* Related Files: - src/util/output/machine-output-streams.mts.
9+
*/
10+
11+
import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default'
12+
import { afterEach, describe, expect, it, vi } from 'vitest'
13+
14+
import {
15+
applyMachineOutputStreamPolicy,
16+
restoreMachineOutputStreams,
17+
} from '../../../../src/util/output/machine-output-streams.mts'
18+
19+
const logger = getDefaultLogger()
20+
21+
describe('machine-output-streams', () => {
22+
afterEach(() => {
23+
restoreMachineOutputStreams()
24+
vi.restoreAllMocks()
25+
})
26+
27+
it('routes substep to stderr under --json', () => {
28+
const errorSpy = vi.spyOn(logger, 'error').mockReturnValue(logger)
29+
const logSpy = vi.spyOn(logger, 'log').mockReturnValue(logger)
30+
31+
applyMachineOutputStreamPolicy({ json: true })
32+
logger.substep('working')
33+
34+
expect(errorSpy).toHaveBeenCalledWith(' working')
35+
expect(logSpy).not.toHaveBeenCalled()
36+
})
37+
38+
it('routes step to stderr under --json', () => {
39+
const errorSpy = vi.spyOn(logger, 'error').mockReturnValue(logger)
40+
41+
applyMachineOutputStreamPolicy({ json: true })
42+
logger.step('phase one')
43+
44+
expect(errorSpy).toHaveBeenCalledTimes(1)
45+
expect(errorSpy.mock.calls[0]?.[0]).toContain('phase one')
46+
})
47+
48+
it('engages under --markdown and --quiet too', () => {
49+
const errorSpy = vi.spyOn(logger, 'error').mockReturnValue(logger)
50+
51+
applyMachineOutputStreamPolicy({ markdown: true })
52+
logger.substep('md')
53+
applyMachineOutputStreamPolicy({ quiet: true })
54+
logger.substep('quiet')
55+
56+
expect(errorSpy).toHaveBeenCalledWith(' md')
57+
expect(errorSpy).toHaveBeenCalledWith(' quiet')
58+
})
59+
60+
it('leaves substep on stdout when no machine flag is set', () => {
61+
const logSpy = vi.spyOn(logger, 'log').mockReturnValue(logger)
62+
const errorSpy = vi.spyOn(logger, 'error').mockReturnValue(logger)
63+
64+
applyMachineOutputStreamPolicy({})
65+
logger.substep('human')
66+
67+
// The lib default routes substep through logger.log (stdout).
68+
expect(logSpy).toHaveBeenCalledWith(' human')
69+
expect(errorSpy).not.toHaveBeenCalled()
70+
})
71+
72+
it('restores substep to stdout after machine mode ends', () => {
73+
const logSpy = vi.spyOn(logger, 'log').mockReturnValue(logger)
74+
const errorSpy = vi.spyOn(logger, 'error').mockReturnValue(logger)
75+
76+
applyMachineOutputStreamPolicy({ json: true })
77+
logger.substep('during')
78+
applyMachineOutputStreamPolicy({})
79+
logger.substep('after')
80+
81+
expect(errorSpy).toHaveBeenCalledWith(' during')
82+
expect(errorSpy).not.toHaveBeenCalledWith(' after')
83+
expect(logSpy).toHaveBeenCalledWith(' after')
84+
})
85+
})

0 commit comments

Comments
 (0)