diff --git a/packages/cli/src/flags.mts b/packages/cli/src/flags.mts index 942ebd724..7437fcf95 100644 --- a/packages/cli/src/flags.mts +++ b/packages/cli/src/flags.mts @@ -269,6 +269,12 @@ export const commonFlags: MeowFlags = { // Only show in root command in debug mode. 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.', + }, spinner: { type: 'boolean', default: true, diff --git a/packages/cli/src/utils/cli/with-subcommands.mts b/packages/cli/src/utils/cli/with-subcommands.mts index 9e3c0c0d2..b2aa80eb9 100644 --- a/packages/cli/src/utils/cli/with-subcommands.mts +++ b/packages/cli/src/utils/cli/with-subcommands.mts @@ -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, @@ -510,11 +511,13 @@ 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 } @@ -522,6 +525,11 @@ export async function meowWithSubcommands( const compactMode = !!compactHeaderFlag || !!(getCI() && !VITEST) const noSpinner = spinnerFlag === false || isDebug() + // Reset unconditionally: module-level state is shared across vitest + // cases in the same worker, so a prior run with --no-log could leak + // into this invocation. + setNoLogMode(!!noLogFlag) + // 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) { @@ -937,12 +945,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 @@ -951,6 +965,11 @@ export function meowOrExit( const compactMode = !!compactHeaderFlag || !!(getCI() && !VITEST) const noSpinner = spinnerFlag === false || isDebug() + // --json / --markdown imply --no-log: their stdout belongs to the + // primary payload, so informational output must go to stderr. Reset + // unconditionally to clear any prior in-worker vitest state. + setNoLogMode(!!(noLogFlag || jsonFlag || markdownFlag)) + // Use CI spinner style when --no-spinner is passed. // This prevents the spinner from interfering with debug output. if (noSpinner) { diff --git a/packages/cli/src/utils/dry-run/output.mts b/packages/cli/src/utils/dry-run/output.mts index 2dc9ae0cd..83d5846bb 100644 --- a/packages/cli/src/utils/dry-run/output.mts +++ b/packages/cli/src/utils/dry-run/output.mts @@ -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' @@ -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('') } /** @@ -76,23 +93,23 @@ export function outputDryRunFetch( resourceName: string, queryParams?: Record, ): 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('') } /** @@ -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('') } /** @@ -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('') } /** @@ -147,25 +164,25 @@ export function outputDryRunUpload( resourceType: string, details: Record, ): 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, )) { - 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('') } /** @@ -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('') } diff --git a/packages/cli/src/utils/output/no-log.mts b/packages/cli/src/utils/output/no-log.mts new file mode 100644 index 000000000..ecdacba6c --- /dev/null +++ b/packages/cli/src/utils/output/no-log.mts @@ -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 +} diff --git a/packages/cli/test/unit/commands/organization/cmd-organization-quota.test.mts b/packages/cli/test/unit/commands/organization/cmd-organization-quota.test.mts index 352b48363..1406d4da7 100644 --- a/packages/cli/test/unit/commands/organization/cmd-organization-quota.test.mts +++ b/packages/cli/test/unit/commands/organization/cmd-organization-quota.test.mts @@ -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]'), ) }) diff --git a/packages/cli/test/unit/commands/scan/cmd-scan-view.test.mts b/packages/cli/test/unit/commands/scan/cmd-scan-view.test.mts index c3ce7f0e2..6cb0144f9 100644 --- a/packages/cli/test/unit/commands/scan/cmd-scan-view.test.mts +++ b/packages/cli/test/unit/commands/scan/cmd-scan-view.test.mts @@ -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'), ) }) diff --git a/packages/cli/test/unit/utils/dry-run/output.test.mts b/packages/cli/test/unit/utils/dry-run/output.test.mts index 90099c5f3..a31037a91 100644 --- a/packages/cli/test/unit/utils/dry-run/output.test.mts +++ b/packages/cli/test/unit/utils/dry-run/output.test.mts @@ -1,13 +1,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -// Mock the logger before importing the module. +// Mock the logger before importing the module. Dry-run output routes +// through `.log` (stdout) by default, and flips to `.error` (stderr) +// when `--no-log` / JSON mode is engaged. `mockLog` aggregates both +// streams so existing "string-appeared-in-output" assertions stay stable; +// `mockStdoutLog` / `mockStderrLog` let the routing test distinguish. const mockLog = vi.fn() +const mockStdoutLog = vi.fn() +const mockStderrLog = vi.fn() vi.mock('@socketsecurity/lib/logger', () => ({ getDefaultLogger: () => ({ - log: mockLog, + log: (...args: unknown[]) => { + mockLog(...args) + mockStdoutLog(...args) + }, + error: (...args: unknown[]) => { + mockLog(...args) + mockStderrLog(...args) + }, }), })) +import { setNoLogMode } from '../../../../src/utils/output/no-log.mts' + // Import after mocking. const { outputDryRunFetch } = await import('../../../../src/utils/dry-run/output.mts') @@ -110,4 +125,34 @@ describe('dry-run output utilities', () => { expect(output).toContain('boolFalse: false') }) }) + + describe('stream routing', () => { + beforeEach(() => { + // Ensure no-log state doesn't leak between tests. + setNoLogMode(false) + mockStdoutLog.mockClear() + mockStderrLog.mockClear() + }) + + afterEach(() => { + setNoLogMode(false) + }) + + it('stays on stdout by default (interactive human use)', () => { + outputDryRunFetch('anything', { k: 'v' }) + + expect(mockStdoutLog).toHaveBeenCalled() + expect(mockStderrLog).not.toHaveBeenCalled() + }) + + it('routes to stderr when no-log mode is engaged', () => { + // Gives automation users `socket fix --no-log --dry-run | jq`-safe + // stdout — dry-run text is informational, not the primary payload. + setNoLogMode(true) + outputDryRunFetch('anything', { k: 'v' }) + + expect(mockStdoutLog).not.toHaveBeenCalled() + expect(mockStderrLog).toHaveBeenCalled() + }) + }) }) diff --git a/packages/cli/test/unit/utils/output/no-log.test.mts b/packages/cli/test/unit/utils/output/no-log.test.mts new file mode 100644 index 000000000..ebf486c1d --- /dev/null +++ b/packages/cli/test/unit/utils/output/no-log.test.mts @@ -0,0 +1,28 @@ +/** + * Unit tests for the no-log mode toggle. + */ + +import { beforeEach, describe, expect, it } from 'vitest' + +import { + isNoLogMode, + setNoLogMode, +} from '../../../../src/utils/output/no-log.mts' + +describe('no-log mode', () => { + beforeEach(() => { + // Reset module-level state between tests to avoid leakage. + setNoLogMode(false) + }) + + it('defaults to off', () => { + expect(isNoLogMode()).toBe(false) + }) + + it('setNoLogMode flips the toggle', () => { + setNoLogMode(true) + expect(isNoLogMode()).toBe(true) + setNoLogMode(false) + expect(isNoLogMode()).toBe(false) + }) +})