diff --git a/packages/cli/src/util/cli/with-subcommands-meow-exit.mts b/packages/cli/src/util/cli/with-subcommands-meow-exit.mts index 29f9f3d249..340a3c4410 100644 --- a/packages/cli/src/util/cli/with-subcommands-meow-exit.mts +++ b/packages/cli/src/util/cli/with-subcommands-meow-exit.mts @@ -18,6 +18,7 @@ import { resetMachineOutputMode, setMachineOutputMode, } from '../output/ambient-mode.mts' +import { applyMachineOutputStreamPolicy } from '../output/machine-output-streams.mts' import { emitBanner, shouldSuppressBanner } from './with-subcommands-banner.mts' import type { CliCommandConfig } from './with-subcommands.mts' @@ -109,6 +110,13 @@ export function meowOrExit( markdown: markdownFlag, quiet: quietFlag, }) + // Route the logger's stdout-bound status helpers (step / substep) to stderr + // when machine-output mode is engaged, so stdout carries only the payload. + applyMachineOutputStreamPolicy({ + json: jsonFlag, + markdown: markdownFlag, + quiet: quietFlag, + }) const compactMode = compactHeaderFlag || (getCI() && !VITEST) const noSpinner = !spinnerFlag || isDebug() diff --git a/packages/cli/src/util/cli/with-subcommands.mts b/packages/cli/src/util/cli/with-subcommands.mts index f20587a4a9..e3c9fe047d 100644 --- a/packages/cli/src/util/cli/with-subcommands.mts +++ b/packages/cli/src/util/cli/with-subcommands.mts @@ -18,6 +18,7 @@ import { resetMachineOutputMode, setMachineOutputMode, } from '../output/ambient-mode.mts' +import { applyMachineOutputStreamPolicy } from '../output/machine-output-streams.mts' import { buildHelpLines } from './with-subcommands-help.mts' import { tryDispatchSubcommand } from './with-subcommands-dispatch.mts' @@ -226,6 +227,13 @@ export async function meowWithSubcommands( markdown: markdownFlag, quiet: quietFlag, }) + // Route the logger's stdout-bound status helpers (step / substep) to stderr + // when machine-output mode is engaged, so stdout carries only the payload. + applyMachineOutputStreamPolicy({ + json: jsonFlag, + markdown: markdownFlag, + quiet: quietFlag, + }) const compactMode = compactHeaderFlag || (getCI() && !VITEST) const noSpinner = !spinnerFlag || isDebug() diff --git a/packages/cli/src/util/output/machine-output-streams.mts b/packages/cli/src/util/output/machine-output-streams.mts new file mode 100644 index 0000000000..b224f981d3 --- /dev/null +++ b/packages/cli/src/util/output/machine-output-streams.mts @@ -0,0 +1,89 @@ +/** + * Stream policy for machine-output mode. + * + * Machine-output mode (--json, --markdown, --quiet) promises that stdout + * carries ONLY the command payload. The logger already routes its status + * helpers (info, warn, success, fail, skip) to stderr, and the spinner routes + * its animation and status methods (including step / substep) to stderr. The + * lib logger is the outlier: its `step` / `substep` helpers write to stdout, + * so a command that reports progress with them contaminates the payload stream + * under --json. + * + * `applyMachineOutputStreamPolicy` is called at the argv-parse boundary. When + * machine-output mode is engaged it shadows the shared logger's `step` / + * `substep` so they emit to stderr like every other status helper; when it is + * not engaged it restores them. `logger.log` is left untouched — it is the + * payload / primary-data channel (JSON, Markdown, and output-kind-gated human + * text all flow through it) and must stay on stdout. + */ + +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' +import { LOG_SYMBOLS } from '@socketsecurity/lib-stable/logger/symbols' + +import { isMachineOutputMode } from './mode.mts' + +import type { MachineModeFlags } from './mode.mts' +import type { Logger } from '@socketsecurity/lib-stable/logger/logger' + +export type StatusMethod = (msg: string, ...extras: unknown[]) => Logger + +export interface SavedStatusMethods { + step: StatusMethod | undefined + substep: StatusMethod | undefined +} + +let saved: SavedStatusMethods | undefined + +/** + * Route the logger's stdout-bound status helpers to stderr while machine-output + * mode is engaged, and restore them otherwise. Called once per invocation at + * the argv-parse boundary, alongside `setMachineOutputMode`. + */ +export function applyMachineOutputStreamPolicy(flags: MachineModeFlags): void { + if (isMachineOutputMode(flags)) { + engageMachineOutputStreams() + } else { + restoreMachineOutputStreams() + } +} + +/** + * Shadow the shared logger's `step` / `substep` so they emit to stderr. Idem- + * potent: a second call while already engaged is a no-op so the saved + * originals are never overwritten with the shadows. + */ +export function engageMachineOutputStreams(): void { + if (saved) { + return + } + const logger = getDefaultLogger() + saved = { + step: logger.step?.bind(logger), + substep: logger.substep?.bind(logger), + } + logger.step = function step(msg: string, ...extras: unknown[]): Logger { + return logger.error(`${LOG_SYMBOLS['step']} ${msg}`, ...extras) + } + logger.substep = function substep(msg: string, ...extras: unknown[]): Logger { + return logger.error(` ${msg}`, ...extras) + } +} + +/** + * Restore the logger's `step` / `substep` to the lib defaults. No-op when the + * policy was never engaged. + */ +export function restoreMachineOutputStreams(): void { + if (!saved) { + return + } + const logger = getDefaultLogger() + const { step, substep } = saved + if (step) { + logger.step = step + } + if (substep) { + logger.substep = substep + } + saved = undefined +} diff --git a/packages/cli/test/unit/util/output/machine-output-streams.test.mts b/packages/cli/test/unit/util/output/machine-output-streams.test.mts new file mode 100644 index 0000000000..87340612c3 --- /dev/null +++ b/packages/cli/test/unit/util/output/machine-output-streams.test.mts @@ -0,0 +1,85 @@ +/** + * Unit tests for the machine-output stream policy. + * + * Verifies that engaging machine-output mode routes the logger's stdout-bound + * status helpers (step / substep) to stderr, that restoring returns them to + * stdout, and that the payload channel (logger.log) is never diverted. + * + * Related Files: - src/util/output/machine-output-streams.mts. + */ + +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + applyMachineOutputStreamPolicy, + restoreMachineOutputStreams, +} from '../../../../src/util/output/machine-output-streams.mts' + +const logger = getDefaultLogger() + +describe('machine-output-streams', () => { + afterEach(() => { + restoreMachineOutputStreams() + vi.restoreAllMocks() + }) + + it('routes substep to stderr under --json', () => { + const errorSpy = vi.spyOn(logger, 'error').mockReturnValue(logger) + const logSpy = vi.spyOn(logger, 'log').mockReturnValue(logger) + + applyMachineOutputStreamPolicy({ json: true }) + logger.substep('working') + + expect(errorSpy).toHaveBeenCalledWith(' working') + expect(logSpy).not.toHaveBeenCalled() + }) + + it('routes step to stderr under --json', () => { + const errorSpy = vi.spyOn(logger, 'error').mockReturnValue(logger) + + applyMachineOutputStreamPolicy({ json: true }) + logger.step('phase one') + + expect(errorSpy).toHaveBeenCalledTimes(1) + expect(errorSpy.mock.calls[0]?.[0]).toContain('phase one') + }) + + it('engages under --markdown and --quiet too', () => { + const errorSpy = vi.spyOn(logger, 'error').mockReturnValue(logger) + + applyMachineOutputStreamPolicy({ markdown: true }) + logger.substep('md') + applyMachineOutputStreamPolicy({ quiet: true }) + logger.substep('quiet') + + expect(errorSpy).toHaveBeenCalledWith(' md') + expect(errorSpy).toHaveBeenCalledWith(' quiet') + }) + + it('leaves substep on stdout when no machine flag is set', () => { + const logSpy = vi.spyOn(logger, 'log').mockReturnValue(logger) + const errorSpy = vi.spyOn(logger, 'error').mockReturnValue(logger) + + applyMachineOutputStreamPolicy({}) + logger.substep('human') + + // The lib default routes substep through logger.log (stdout). + expect(logSpy).toHaveBeenCalledWith(' human') + expect(errorSpy).not.toHaveBeenCalled() + }) + + it('restores substep to stdout after machine mode ends', () => { + const logSpy = vi.spyOn(logger, 'log').mockReturnValue(logger) + const errorSpy = vi.spyOn(logger, 'error').mockReturnValue(logger) + + applyMachineOutputStreamPolicy({ json: true }) + logger.substep('during') + applyMachineOutputStreamPolicy({}) + logger.substep('after') + + expect(errorSpy).toHaveBeenCalledWith(' during') + expect(errorSpy).not.toHaveBeenCalledWith(' after') + expect(logSpy).toHaveBeenCalledWith(' after') + }) +})