Skip to content
Open
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 packages/cli/src/util/cli/with-subcommands-meow-exit.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -109,6 +110,13 @@ export function meowOrExit<const F extends MeowFlags = MeowFlags>(
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()
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/util/cli/with-subcommands.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
Expand Down
89 changes: 89 additions & 0 deletions packages/cli/src/util/output/machine-output-streams.mts
Original file line number Diff line number Diff line change
@@ -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
}
85 changes: 85 additions & 0 deletions packages/cli/test/unit/util/output/machine-output-streams.test.mts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading