Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
28 changes: 28 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,27 @@ 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()

// Engage no-log mode 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.
if (noLogFlag) {
setNoLogMode(true)
}
Comment thread
jdalton marked this conversation as resolved.
Outdated

// 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 +930,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 +952,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 +972,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