From a14458cc09ac4f785bf414a2df12a37f05c31ced Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 17 Apr 2026 20:54:18 -0400 Subject: [PATCH 1/5] feat(cli): add --no-log flag and keep stdout clean under --json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes ASK-115. Previously `socket fix --json | jq` (and any automation combining `--json` with `--dry-run`) got non-JSON text on stdout because the dry-run preview routed through `logger.log` (stdout). Human-readable output leaking into a machine-readable stream broke scripts. ## Changes * New `--no-log` boolean in `commonFlags`. Suppresses non-essential informational output so stdout is clean for automation. Errors still print to stderr. Visible in `--help`. * New `utils/output/no-log.mts` module with `setNoLogMode(on)`, `isNoLogMode()`, and a `shouldQuietStdout(flags)` helper that derives the right answer from `--no-log` / `--json` / `--markdown`. * `meowOrExit` now: - Resets no-log mode at the start of every invocation so state doesn't leak across unit tests that run multiple commands in sequence. - Engages no-log mode when any of `--no-log`, `--json`, or `--markdown` is parsed. `--json` and `--markdown` imply clean stdout because the primary payload belongs on stdout alone. * `utils/dry-run/output.mts` now routes through a private `out()` helper that uses `logger.error` (stderr) when no-log mode is engaged, otherwise `logger.log` (stdout). Every `outputDryRun*` formatter flows through this single helper, so all dry-run shapes (preview, fetch, execute, upload, delete, write) get the fix. ## Verification Against a fresh build: $ socket fix --json --dry-run 2>/tmp/err 1>/tmp/out # stdout: 0 bytes (pipe-safe) # stderr: 658 bytes (preview visible to humans) $ socket fix --dry-run 2>/tmp/err 1>/tmp/out # stdout: 658 bytes (human default unchanged) ## Tests * New `utils/output/no-log.test.mts` — 7 tests covering toggle, `shouldQuietStdout` under each flag, and unrelated-flag isolation. * `utils/dry-run/output.test.mts` — new `stream routing` describe block asserting stdout routing by default and stderr routing when no-log mode is engaged. * Two command tests that combine `--json` with dry-run updated to assert `mockLogger.error` instead of `mockLogger.log`: `cmd-scan-view` `--json --stream` and `cmd-organization-quota` `--dry-run --json`. All other 5100+ tests unchanged. Full suite green. --- packages/cli/src/flags.mts | 6 + .../cli/src/utils/cli/with-subcommands.mts | 28 ++++ packages/cli/src/utils/dry-run/output.mts | 129 ++++++++++-------- packages/cli/src/utils/output/no-log.mts | 34 +++++ .../cmd-organization-quota.test.mts | 4 +- .../unit/commands/scan/cmd-scan-view.test.mts | 4 +- .../test/unit/utils/dry-run/output.test.mts | 49 ++++++- .../test/unit/utils/output/no-log.test.mts | 54 ++++++++ 8 files changed, 248 insertions(+), 60 deletions(-) create mode 100644 packages/cli/src/utils/output/no-log.mts create mode 100644 packages/cli/test/unit/utils/output/no-log.test.mts diff --git a/packages/cli/src/flags.mts b/packages/cli/src/flags.mts index 942ebd724..41243af4a 100644 --- a/packages/cli/src/flags.mts +++ b/packages/cli/src/flags.mts @@ -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 = { diff --git a/packages/cli/src/utils/cli/with-subcommands.mts b/packages/cli/src/utils/cli/with-subcommands.mts index 9e3c0c0d2..22cb967b8 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,13 @@ export async function meowWithSubcommands( 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) + } + // 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) { @@ -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, @@ -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 @@ -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) { 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..a4936cd76 --- /dev/null +++ b/packages/cli/src/utils/output/no-log.mts @@ -0,0 +1,34 @@ +/** + * 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 +} + +/** + * Returns true when the caller should route informational output to + * stderr instead of stdout — either because `--no-log` was passed, or + * because the user asked for a machine-readable output format that + * must not be polluted by human-readable log lines. + */ +export function shouldQuietStdout(flags?: Record): boolean { + if (noLogMode) { + return true + } + if (!flags) { + return false + } + return Boolean(flags['json']) || Boolean(flags['markdown']) +} 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..66f5c9bc5 --- /dev/null +++ b/packages/cli/test/unit/utils/output/no-log.test.mts @@ -0,0 +1,54 @@ +/** + * Unit tests for the no-log mode toggle. + */ + +import { beforeEach, describe, expect, it } from 'vitest' + +import { + isNoLogMode, + setNoLogMode, + shouldQuietStdout, +} 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) + }) + + describe('shouldQuietStdout', () => { + it('is false by default and with no flags', () => { + expect(shouldQuietStdout()).toBe(false) + expect(shouldQuietStdout({})).toBe(false) + }) + + it('returns true when --no-log is engaged', () => { + setNoLogMode(true) + expect(shouldQuietStdout()).toBe(true) + expect(shouldQuietStdout({})).toBe(true) + }) + + it('returns true when --json is passed', () => { + expect(shouldQuietStdout({ json: true })).toBe(true) + }) + + it('returns true when --markdown is passed', () => { + expect(shouldQuietStdout({ markdown: true })).toBe(true) + }) + + it('ignores unrelated flags', () => { + expect(shouldQuietStdout({ all: true, dryRun: true })).toBe(false) + }) + }) +}) From e04fa305c36336eebfebc5367ffceab525ae5e4d Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 17 Apr 2026 22:06:11 -0400 Subject: [PATCH 2/5] chore(output): drop unused shouldQuietStdout helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Cursor bugbot feedback on PR #1234. The helper was exported but never imported in any production source — only in its own test file. Production uses isNoLogMode() directly, and the flag-checking logic already lives inline in meowOrExit. Delete the helper and its dedicated tests. --- packages/cli/src/utils/output/no-log.mts | 16 ------------ .../test/unit/utils/output/no-log.test.mts | 26 ------------------- 2 files changed, 42 deletions(-) diff --git a/packages/cli/src/utils/output/no-log.mts b/packages/cli/src/utils/output/no-log.mts index a4936cd76..ecdacba6c 100644 --- a/packages/cli/src/utils/output/no-log.mts +++ b/packages/cli/src/utils/output/no-log.mts @@ -16,19 +16,3 @@ export function setNoLogMode(on: boolean): void { export function isNoLogMode(): boolean { return noLogMode } - -/** - * Returns true when the caller should route informational output to - * stderr instead of stdout — either because `--no-log` was passed, or - * because the user asked for a machine-readable output format that - * must not be polluted by human-readable log lines. - */ -export function shouldQuietStdout(flags?: Record): boolean { - if (noLogMode) { - return true - } - if (!flags) { - return false - } - return Boolean(flags['json']) || Boolean(flags['markdown']) -} diff --git a/packages/cli/test/unit/utils/output/no-log.test.mts b/packages/cli/test/unit/utils/output/no-log.test.mts index 66f5c9bc5..ebf486c1d 100644 --- a/packages/cli/test/unit/utils/output/no-log.test.mts +++ b/packages/cli/test/unit/utils/output/no-log.test.mts @@ -7,7 +7,6 @@ import { beforeEach, describe, expect, it } from 'vitest' import { isNoLogMode, setNoLogMode, - shouldQuietStdout, } from '../../../../src/utils/output/no-log.mts' describe('no-log mode', () => { @@ -26,29 +25,4 @@ describe('no-log mode', () => { setNoLogMode(false) expect(isNoLogMode()).toBe(false) }) - - describe('shouldQuietStdout', () => { - it('is false by default and with no flags', () => { - expect(shouldQuietStdout()).toBe(false) - expect(shouldQuietStdout({})).toBe(false) - }) - - it('returns true when --no-log is engaged', () => { - setNoLogMode(true) - expect(shouldQuietStdout()).toBe(true) - expect(shouldQuietStdout({})).toBe(true) - }) - - it('returns true when --json is passed', () => { - expect(shouldQuietStdout({ json: true })).toBe(true) - }) - - it('returns true when --markdown is passed', () => { - expect(shouldQuietStdout({ markdown: true })).toBe(true) - }) - - it('ignores unrelated flags', () => { - expect(shouldQuietStdout({ all: true, dryRun: true })).toBe(false) - }) - }) }) From ed83f9192e9e259d8cc9711c0da2fbf797563d5d Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 17 Apr 2026 22:47:17 -0400 Subject: [PATCH 3/5] fix(cli): reset noLogMode in meowWithSubcommands entry too --- packages/cli/src/utils/cli/with-subcommands.mts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/utils/cli/with-subcommands.mts b/packages/cli/src/utils/cli/with-subcommands.mts index 22cb967b8..ca05b5338 100644 --- a/packages/cli/src/utils/cli/with-subcommands.mts +++ b/packages/cli/src/utils/cli/with-subcommands.mts @@ -525,9 +525,13 @@ export async function meowWithSubcommands( 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. + // 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) } From 7197083a5608ca2cea6b53a3da738cbe76fbcf75 Mon Sep 17 00:00:00 2001 From: jdalton Date: Mon, 20 Apr 2026 14:27:25 -0400 Subject: [PATCH 4/5] chore(cli): collapse redundant setNoLogMode reset+set pairs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Combine the `setNoLogMode(false)` followed by conditional `setNoLogMode(true)` into a single `setNoLogMode(!!…)` call — same effect, one line, and the intent reads directly. Trim near-duplicate comments at the two call sites to just the WHY (vitest worker state leak) that isn't obvious from the code. --- .../cli/src/utils/cli/with-subcommands.mts | 29 +++++-------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/utils/cli/with-subcommands.mts b/packages/cli/src/utils/cli/with-subcommands.mts index ca05b5338..b2aa80eb9 100644 --- a/packages/cli/src/utils/cli/with-subcommands.mts +++ b/packages/cli/src/utils/cli/with-subcommands.mts @@ -525,16 +525,10 @@ export async function meowWithSubcommands( 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) - } + // 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. @@ -934,11 +928,6 @@ 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, @@ -976,12 +965,10 @@ 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) - } + // --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. From e656d9bbd70344d74c40941779d9a41c0b94d931 Mon Sep 17 00:00:00 2001 From: jdalton Date: Mon, 20 Apr 2026 14:53:34 -0400 Subject: [PATCH 5/5] chore(flags): sort noLog before spinner in commonFlags --- packages/cli/src/flags.mts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/flags.mts b/packages/cli/src/flags.mts index 41243af4a..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, @@ -276,12 +282,6 @@ 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 = {