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
6 changes: 6 additions & 0 deletions packages/cli/src/flags.mts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,12 @@ export const commonFlags: MeowFlags = {
// Hidden to allow custom documenting of the negated `--no-spinner` variant.
hidden: true,
},
noLog: {
type: 'boolean',
default: false,
description:
'Suppress non-essential log output so stdout is clean for automation (e.g. piping --json through jq). Errors still print to stderr.',
},
}

export const outputFlags: MeowFlags = {
Expand Down
32 changes: 32 additions & 0 deletions packages/cli/src/utils/cli/with-subcommands.mts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
import { isDebug } from '../debug.mts'
import { tildify } from '../fs/home-path.mts'
import { getFlagListOutput, getHelpListOutput } from '../output/formatting.mts'
import { setNoLogMode } from '../output/no-log.mts'
import { getVisibleTokenPrefix } from '../socket/sdk.mjs'
import {
renderLogoWithFallback,
Expand Down Expand Up @@ -510,18 +511,31 @@ export async function meowWithSubcommands(
const {
compactHeader: compactHeaderFlag,
config: configFlag,
noLog: noLogFlag,
org: orgFlag,
spinner: spinnerFlag,
} = cli1.flags as {
compactHeader: boolean
config: string
noLog: boolean
org: string
spinner: boolean
}

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

// Reset first so prior test runs (module-level state is shared across
// vitest cases in the same worker) can't leak their noLogMode setting
// into this invocation. Then engage as early as possible so subsequent
// informational output in this function and downstream commands knows
// to stay on stderr. Keeps stdout clean for `--json | jq` style
// pipelines.
setNoLogMode(false)
if (noLogFlag) {
setNoLogMode(true)
}
Comment thread
jdalton marked this conversation as resolved.

// Use CI spinner style when --no-spinner is passed or debug mode is enabled.
// This prevents the spinner from interfering with debug output.
if (noSpinner) {
Expand Down Expand Up @@ -920,6 +934,11 @@ export function meowOrExit(
const command = `${parentName} ${cliConfig.commandName}`
lastSeenCommand = command

// Reset no-log mode for each command invocation so state doesn't leak
// across unit tests that exercise multiple commands in sequence. The
// flag is re-engaged below if the parsed flags call for it.
setNoLogMode(false)

// This exits if .printHelp() is called either by meow itself or by us.
const cli = meow({
argv,
Expand All @@ -937,12 +956,18 @@ export function meowOrExit(
const {
compactHeader: compactHeaderFlag,
help: helpFlag,
json: jsonFlag,
markdown: markdownFlag,
noLog: noLogFlag,
org: orgFlag,
spinner: spinnerFlag,
version: versionFlag,
} = cli.flags as {
compactHeader: boolean
help: boolean
json: boolean | undefined
markdown: boolean | undefined
noLog: boolean | undefined
org: string
spinner: boolean
version: boolean | undefined
Expand All @@ -951,6 +976,13 @@ export function meowOrExit(
const compactMode = !!compactHeaderFlag || !!(getCI() && !VITEST)
const noSpinner = spinnerFlag === false || isDebug()

// Engage no-log mode when the user asked for it directly, or when
// `--json` / `--markdown` is in effect — in both cases stdout belongs
// to the primary payload and informational output should go to stderr.
if (noLogFlag || jsonFlag || markdownFlag) {
setNoLogMode(true)
}

// Use CI spinner style when --no-spinner is passed.
// This prevents the spinner from interfering with debug output.
if (noSpinner) {
Expand Down
129 changes: 73 additions & 56 deletions packages/cli/src/utils/dry-run/output.mts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,31 @@
*
* Provides standardized output formatting for dry-run mode that shows users
* what actions WOULD be performed without actually executing them.
*
* Output routes through stderr when the caller engaged no-log mode
* (`--no-log`) or asked for a machine-readable output stream, so dry-run
* preview text never pollutes `--json` / `--markdown` payloads piped to
* other tools. Otherwise stays on stdout where humans expect it.
*/

import { getDefaultLogger } from '@socketsecurity/lib/logger'

import { DRY_RUN_LABEL } from '../../constants/cli.mts'
import { isNoLogMode } from '../output/no-log.mts'

const logger = getDefaultLogger()

// Route to stderr only when the user asked for automation-friendly
// output. Keeps the human-readable default on stdout so existing
// interactive workflows and their tests are unaffected.
function out(message: string): void {
if (isNoLogMode()) {
logger.error(message)
} else {
logger.log(message)
}
}

export interface DryRunAction {
type:
| 'create'
Expand All @@ -35,36 +52,36 @@ export interface DryRunPreview {
* Format and output a dry-run preview.
*/
export function outputDryRunPreview(preview: DryRunPreview): void {
logger.log('')
logger.log(`${DRY_RUN_LABEL}: ${preview.summary}`)
logger.log('')
out('')
out(`${DRY_RUN_LABEL}: ${preview.summary}`)
out('')

if (!preview.actions.length) {
logger.log(' No actions would be performed.')
out(' No actions would be performed.')
} else {
logger.log(' Actions that would be performed:')
out(' Actions that would be performed:')
for (const action of preview.actions) {
const targetStr = action.target ? ` → ${action.target}` : ''
logger.log(` - [${action.type}] ${action.description}${targetStr}`)
out(` - [${action.type}] ${action.description}${targetStr}`)
if (action.details) {
for (const [key, value] of Object.entries(action.details)) {
logger.log(` ${key}: ${JSON.stringify(value)}`)
out(` ${key}: ${JSON.stringify(value)}`)
}
}
}
}

logger.log('')
out('')
if (preview.wouldSucceed !== undefined) {
logger.log(
out(
preview.wouldSucceed
? ' Would complete successfully.'
: ' Would fail (see details above).',
)
}
logger.log('')
logger.log(' Run without --dry-run to execute these actions.')
logger.log('')
out('')
out(' Run without --dry-run to execute these actions.')
out('')
}

/**
Expand All @@ -76,23 +93,23 @@ export function outputDryRunFetch(
resourceName: string,
queryParams?: Record<string, string | number | boolean | undefined>,
): void {
logger.log('')
logger.log(`${DRY_RUN_LABEL}: Would fetch ${resourceName}`)
logger.log('')
out('')
out(`${DRY_RUN_LABEL}: Would fetch ${resourceName}`)
out('')

if (queryParams && Object.keys(queryParams).length > 0) {
logger.log(' Query parameters:')
out(' Query parameters:')
for (const [key, value] of Object.entries(queryParams)) {
if (value !== undefined && value !== '') {
logger.log(` ${key}: ${value}`)
out(` ${key}: ${value}`)
}
}
logger.log('')
out('')
}

logger.log(' This is a read-only operation that does not modify any data.')
logger.log(' Run without --dry-run to fetch and display the data.')
logger.log('')
out(' This is a read-only operation that does not modify any data.')
out(' Run without --dry-run to fetch and display the data.')
out('')
}

/**
Expand All @@ -103,18 +120,18 @@ export function outputDryRunExecute(
args: string[],
description?: string,
): void {
logger.log('')
logger.log(
out('')
out(
`${DRY_RUN_LABEL}: Would execute ${description || 'external command'}`,
)
logger.log('')
logger.log(` Command: ${command}`)
out('')
out(` Command: ${command}`)
if (args.length > 0) {
logger.log(` Arguments: ${args.join(' ')}`)
out(` Arguments: ${args.join(' ')}`)
}
logger.log('')
logger.log(' Run without --dry-run to execute this command.')
logger.log('')
out('')
out(' Run without --dry-run to execute this command.')
out('')
}

/**
Expand All @@ -125,19 +142,19 @@ export function outputDryRunWrite(
description: string,
changes?: string[],
): void {
logger.log('')
logger.log(`${DRY_RUN_LABEL}: Would ${description}`)
logger.log('')
logger.log(` Target file: ${filePath}`)
out('')
out(`${DRY_RUN_LABEL}: Would ${description}`)
out('')
out(` Target file: ${filePath}`)
if (changes && changes.length > 0) {
logger.log(' Changes:')
out(' Changes:')
for (const change of changes) {
logger.log(` - ${change}`)
out(` - ${change}`)
}
}
logger.log('')
logger.log(' Run without --dry-run to apply these changes.')
logger.log('')
out('')
out(' Run without --dry-run to apply these changes.')
out('')
}

/**
Expand All @@ -147,25 +164,25 @@ export function outputDryRunUpload(
resourceType: string,
details: Record<string, unknown>,
): void {
logger.log('')
logger.log(`${DRY_RUN_LABEL}: Would upload ${resourceType}`)
logger.log('')
logger.log(' Details:')
out('')
out(`${DRY_RUN_LABEL}: Would upload ${resourceType}`)
out('')
out(' Details:')
for (const [key, value] of Object.entries(details)) {
if (typeof value === 'object' && value !== null) {
logger.log(` ${key}:`)
out(` ${key}:`)
for (const [subKey, subValue] of Object.entries(
value as Record<string, unknown>,
)) {
logger.log(` ${subKey}: ${JSON.stringify(subValue)}`)
out(` ${subKey}: ${JSON.stringify(subValue)}`)
}
} else {
logger.log(` ${key}: ${JSON.stringify(value)}`)
out(` ${key}: ${JSON.stringify(value)}`)
}
}
logger.log('')
logger.log(' Run without --dry-run to perform this upload.')
logger.log('')
out('')
out(' Run without --dry-run to perform this upload.')
out('')
}

/**
Expand All @@ -175,12 +192,12 @@ export function outputDryRunDelete(
resourceType: string,
identifier: string,
): void {
logger.log('')
logger.log(`${DRY_RUN_LABEL}: Would delete ${resourceType}`)
logger.log('')
logger.log(` Target: ${identifier}`)
logger.log('')
logger.log(' This action cannot be undone.')
logger.log(' Run without --dry-run to perform this deletion.')
logger.log('')
out('')
out(`${DRY_RUN_LABEL}: Would delete ${resourceType}`)
out('')
out(` Target: ${identifier}`)
out('')
out(' This action cannot be undone.')
out(' Run without --dry-run to perform this deletion.')
out('')
}
18 changes: 18 additions & 0 deletions packages/cli/src/utils/output/no-log.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Module-level "no-log" mode used to keep stdout clean for automation.
*
* When enabled (via `--no-log`, or implicitly by `--json` / `--markdown`),
* informational CLI output routes to stderr instead of stdout. The primary
* result payload (JSON, Markdown, or plain-text report) is still the only
* thing that appears on stdout, so consumers can pipe it safely.
*/

let noLogMode = false

export function setNoLogMode(on: boolean): void {
noLogMode = on
}

export function isNoLogMode(): boolean {
return noLogMode
}
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,9 @@ describe('cmd-organization-quota', () => {
)

expect(mockHandleQuota).not.toHaveBeenCalled()
expect(mockLogger.log).toHaveBeenCalledWith(
// With --json, dry-run output routes to stderr so stdout stays
// pipe-safe for JSON consumers.
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('[DryRun]'),
)
})
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/test/unit/commands/scan/cmd-scan-view.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,9 @@ describe('cmd-scan-view', () => {
context,
)

expect(mockLogger.log).toHaveBeenCalledWith(
// Dry-run output routes to stderr when --json is set so the
// primary payload stays pipe-safe on stdout.
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('stream'),
)
})
Expand Down
Loading