Skip to content

Commit 9d06ee4

Browse files
authored
feat(cli): machine-output mode — stream discipline, flag propagation, scrubber (#1234)
Three-layer redesign that delivers pipe-safe --json/--dry-run while scaling to every command and child process socket-cli spawns. Layer 1 — stream discipline (CLAUDE.md SHARED STANDARDS): - stdout carries ONLY the data a command was asked to produce. - stderr carries everything else: progress, spinners, status, warnings, errors, dry-run previews, context, banners, prompts. - Under --json/--markdown, stdout MUST parse as the declared format. - Targeted audit of output formatters: dry-run previews, scan-report status lines ("Writing json report to …"), coana-fix "Copying …" message, oops dry-run preview all moved from stdout to stderr. Layer 2 — per-(tool, subcommand) flag propagation (utils/spawn/machine-mode.mts): - coana: --silent + --socket-mode <tempfile>; read file. - sfw: transparent — forward to inner PM (npm/pnpm/yarn/yarn-berry/zpm/pip). - npm: --json + --loglevel=error on JSON-aware subcommands. - pnpm: --reporter=json on JSON-emitting subcommands; --reporter=silent otherwise, via a fallbackArgs rule so the two reporter flags never collide. - yarn classic: --json --silent. - yarn berry: --json where supported + full YARN_ENABLE_* env quieting. - yarn 6 / zpm: --json on supported cmds; --silent on install+add. - vltpkg: --view=json uniformly. - pip/pip3/uv/cargo/gem/go: per-tool quiet/json flags. - Universal env injection: NO_COLOR, FORCE_COLOR=0, CLICOLOR_FORCE=0. Layer 3 — output scrubber (utils/output/scrubber.mts): - Transform stream that catches the residue from tools that don't cooperate (synp "Created …" line, zpm ANSI leaks, gem progress dots, unknown binaries). NDJSON-aware line classifier: 1. NUL sentinel spans (socket-cli's own output) → stdout. 2. JSON.parse succeeds → stdout. 3. Known noise patterns (log prefixes, status glyphs) → stderr. 4. Unknown → stderr (safe default). - Uses ansiRegex() from @socketsecurity/lib/ansi for OSC+CSI stripping. - Tracing via SOCKET_SCRUB_TRACE=1 for debugging. - Tool-specific adapters (synp, zpm, gem) plug into the classifier registry — small, inspectable, jc-style. - Soft buffer cap (maxBufferChars) surfaces a one-time "exceeded cap; scrubbing continues without fallback" warning. A hard cap with passthrough is deliberately deferred — a child tool emitting 100 MB without a newline is an upstream bug, and silently flushing a partial line as payload would corrupt the stream. Layer 4 — sentinel wrapping (utils/output/emit-payload.mts): - emitPayload() wraps the primary payload in NUL-bracketed sentinels (\0SOCKET_PAYLOAD_BEGIN\0 / \0SOCKET_PAYLOAD_END\0) under machine mode so the scrubber extracts it unambiguously. - NUL never appears in legitimate JSON/Markdown, so sentinels are zero-ambiguity even against a cooperating child tool. Doc comments in mode.mts and scrubber.mts explicitly call out the NUL-bracketing so the guarantee is grounded in prose, not invisible source bytes. Ambient mode context (utils/output/ambient-mode.mts): - meowWithSubcommands / meowOrExit call setMachineOutputMode() once per invocation with the parsed flags, resetting prior state so sequential vitest cases don't leak mode. - Spawn wrappers consult getMachineOutputMode() to decide whether to apply flag forwarding and scrubbing. Flag rename: - --no-log → --quiet. Reframed description: "Route non-essential output (status, progress, warnings) to stderr so stdout carries only the payload. Implied by --json and --markdown." Deletions: - utils/output/no-log.mts + setNoLogMode/isNoLogMode module-level state. - out() wrapper in utils/dry-run/output.mts — plain logger.error now. Tests: - 74 new unit tests across mode, emit-payload, scrubber, pipe-safety, machine-mode. - 134 existing test assertions updated to expect dry-run text on stderr (mockLogger.error) rather than stdout (mockLogger.log). - All 5,202 unit tests pass; type-check and lint clean. Follow-ups tracked separately: - Cross-repo: structured NDJSON event contract ("reason" field per cargo's --message-format=json). - Upstream: coana native --machine-output that routes logger to stderr; zpm NO_COLOR respect; synp --quiet/--json flags. - Full socket-cli-wide sweep of the remaining ~500 logger.log call sites for stream-discipline conformance (lib-side fix to @socketsecurity/lib will automate most of this).
1 parent f479f27 commit 9d06ee4

68 files changed

Lines changed: 2188 additions & 261 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ If user repeats instruction 2+ times, ask: "Should I add this to CLAUDE.md?"
114114
- HTTP Requests: NEVER use `fetch()` — use `httpJson`/`httpText`/`httpRequest` from `@socketsecurity/lib/http-request`
115115
- `Promise.race` / `Promise.any`: NEVER pass a long-lived promise (interrupt signal, pool member) into a race inside a loop. Each call re-attaches `.then` handlers to every arm; handlers accumulate on surviving promises until they settle. For concurrency limiters, use a single-waiter "slot available" signal (resolved by each task's `.then`) instead of re-racing `executing[]`. See nodejs/node#17469 and `@watchable/unpromise`. Race with two fresh arms (e.g. one-shot `withTimeout`) is safe.
116116
- File existence: ALWAYS `existsSync` from `node:fs`. NEVER `fs.access`, `fs.stat`-for-existence, or an async `fileExists` wrapper. Import: `import { existsSync, promises as fs } from 'node:fs'`.
117+
- Stream discipline: stdout carries ONLY the data the command was asked to produce (JSON payload, report, fix output — the thing a script pipes into `jq` or redirects to a file). stderr carries everything else: progress, spinners, status, warnings, errors, dry-run previews, context, banners, prompts. Test: would `command | jq` or `command > file` make sense with only the stdout content? If no, it belongs on stderr. Under `--json` / `--markdown`, stdout MUST parse as the declared format with no prefix/suffix text.
117118

118119
### Documentation Policy
119120

packages/cli/src/commands/fix/coana-fix.mts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,16 @@ export async function coanaFix(
9999
spinner,
100100
} = fixConfig
101101

102-
// Determine stdio based on output mode:
103-
// - 'ignore' when outputKind === 'json': suppress all coana output, return clean JSON response
104-
// - 'inherit' otherwise: user sees coana progress in real-time
102+
// Under json/markdown mode we route coana's chatter away from our
103+
// stdout (its JSON report comes from --output-file, not stdout, so
104+
// coana stdout is entirely informational). 'ignore' drops it; that
105+
// was the previous behavior and it remains safe. When interactive we
106+
// inherit so the user sees coana progress in real-time.
105107
const coanaStdio = outputKind === 'json' ? 'ignore' : 'inherit'
108+
// Ask coana to silence its own Winston logger under json mode. Belt
109+
// and braces with stdio:'ignore' and harmless if coana ignores the
110+
// flag.
111+
const coanaSilenceArgs = outputKind === 'json' ? ['--silent'] : []
106112

107113
const fixEnv = await getFixEnv()
108114
debugDir({ fixEnv })
@@ -214,6 +220,7 @@ export async function coanaFix(
214220
try {
215221
const fixCResult = await spawnCoanaDlx(
216222
[
223+
...coanaSilenceArgs,
217224
'compute-fixes-and-upgrade-purls',
218225
cwd,
219226
'--manifests-tar-hash',
@@ -254,7 +261,9 @@ export async function coanaFix(
254261

255262
// Copy to outputFile if provided.
256263
if (outputFile) {
257-
logger.info(`Copying fixes result to ${outputFile}`)
264+
// Status message — belongs on stderr so stdout stays payload-only
265+
// when a consumer is piping `socket fix --json`.
266+
logger.error(`Copying fixes result to ${outputFile}`)
258267
const tmpContent = await fs.readFile(tmpFile, 'utf8')
259268
await fs.writeFile(outputFile, tmpContent, 'utf8')
260269
}
@@ -427,6 +436,7 @@ export async function coanaFix(
427436
// eslint-disable-next-line no-await-in-loop
428437
const fixCResult = await spawnCoanaDlx(
429438
[
439+
...coanaSilenceArgs,
430440
'compute-fixes-and-upgrade-purls',
431441
cwd,
432442
'--manifests-tar-hash',

packages/cli/src/commands/oops/cmd-oops.mts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -60,24 +60,26 @@ async function run(
6060
const dryRun = !!cli.flags['dryRun']
6161

6262
if (dryRun) {
63-
logger.log('')
64-
logger.log(`${DRY_RUN_LABEL}: Would trigger an intentional error`)
65-
logger.log('')
66-
logger.log(
63+
// Dry-run previews are contextual output; route to stderr per the
64+
// stream discipline rule so stdout stays payload-only.
65+
logger.error('')
66+
logger.error(`${DRY_RUN_LABEL}: Would trigger an intentional error`)
67+
logger.error('')
68+
logger.error(
6769
' This command throws an error for development/testing purposes.',
6870
)
69-
logger.log(` Error message: "This error was intentionally left blank."`)
70-
logger.log('')
71+
logger.error(` Error message: "This error was intentionally left blank."`)
72+
logger.error('')
7173
if (json && !justThrow) {
72-
logger.log(' Output format: JSON error response')
74+
logger.error(' Output format: JSON error response')
7375
} else if (markdown && !justThrow) {
74-
logger.log(' Output format: Markdown error message')
76+
logger.error(' Output format: Markdown error message')
7577
} else {
76-
logger.log(' Output format: Thrown Error exception')
78+
logger.error(' Output format: Thrown Error exception')
7779
}
78-
logger.log('')
79-
logger.log(' Run without --dry-run to trigger the error.')
80-
logger.log('')
80+
logger.error('')
81+
logger.error(' Run without --dry-run to trigger the error.')
82+
logger.error('')
8183
return
8284
}
8385

packages/cli/src/commands/organization/output-quota.mts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { getDefaultLogger } from '@socketsecurity/lib/logger'
22

33
import { failMsgWithBadge } from '../../utils/error/fail-msg-with-badge.mts'
4+
import { emitPayload } from '../../utils/output/emit-payload.mts'
45
import { mdHeader } from '../../utils/output/markdown.mts'
56
import { serializeResultJson } from '../../utils/output/result-json.mjs'
67

@@ -59,7 +60,9 @@ export async function outputQuota(
5960
}
6061

6162
if (outputKind === 'json') {
62-
logger.log(serializeResultJson(result))
63+
// Sentinel-wrap the JSON so pipe-safety is preserved even if a
64+
// downstream spawn in the same process writes to stdout.
65+
emitPayload(serializeResultJson(result), { flags: { json: true } })
6366
return
6467
}
6568
if (!result.ok) {
@@ -71,11 +74,10 @@ export async function outputQuota(
7174
const refreshLine = `Next refresh: ${formatRefresh(result.data.nextWindowRefresh)}`
7275

7376
if (outputKind === 'markdown') {
74-
logger.log(mdHeader('Quota'))
75-
logger.log('')
76-
logger.log(`- ${usageLine}`)
77-
logger.log(`- ${refreshLine}`)
78-
logger.log('')
77+
const md = [mdHeader('Quota'), '', `- ${usageLine}`, `- ${refreshLine}`].join(
78+
'\n',
79+
)
80+
emitPayload(md, { flags: { markdown: true } })
7981
return
8082
}
8183

packages/cli/src/commands/scan/output-scan-report.mts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ export async function outputScanReport(
111111
: toJsonReport(scanReport.data as ScanReport, includeLicensePolicy)
112112

113113
if (filepath && filepath !== '-') {
114-
logger.log('Writing json report to', filepath)
114+
logger.error('Writing json report to', filepath)
115115
return await fs.writeFile(filepath, json)
116116
}
117117

@@ -129,7 +129,7 @@ export async function outputScanReport(
129129
)
130130

131131
if (filepath && filepath !== '-') {
132-
logger.log('Writing markdown report to', filepath)
132+
logger.error('Writing markdown report to', filepath)
133133
return await fs.writeFile(filepath, md)
134134
}
135135

packages/cli/src/commands/scan/perform-reachability-analysis.mts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from '../../constants/socket.mts'
1111
import { extractTier1ReachabilityScanId } from '../../utils/coana/extract-scan-id.mjs'
1212
import { spawnCoanaDlx } from '../../utils/dlx/spawn.mjs'
13+
import { getMachineOutputMode } from '../../utils/output/ambient-mode.mts'
1314
import { hasEnterpriseOrgPlan } from '../../utils/organization.mts'
1415
import { handleApiCall } from '../../utils/socket/api.mjs'
1516
import { setupSdk } from '../../utils/socket/sdk.mjs'
@@ -173,7 +174,11 @@ export async function performReachabilityAnalysis(
173174
? outputPath
174175
: DOT_SOCKET_DOT_FACTS_JSON
175176
// Build Coana arguments.
177+
// Under machine-output mode, --silent suppresses coana's Winston
178+
// logger entirely; the report still lands in --socket-mode's file.
179+
const machineMode = getMachineOutputMode()
176180
const coanaArgs = [
181+
...(machineMode ? ['--silent'] : []),
177182
'run',
178183
analysisTarget,
179184
'--output-dir',
@@ -238,13 +243,15 @@ export async function performReachabilityAnalysis(
238243
coanaEnv['SOCKET_BRANCH_NAME'] = branchName
239244
}
240245

241-
// Run Coana with the manifests tar hash.
246+
// Run Coana with the manifests tar hash. Under machine mode we drop
247+
// coana stdout; --silent plus 'ignore' ensures our own stdout stays
248+
// pipe-safe for --json consumers.
242249
const coanaResult = await spawnCoanaDlx(coanaArgs, orgSlug, {
243250
coanaVersion: reachabilityOptions.reachVersion || undefined,
244251
cwd,
245252
env: coanaEnv,
246253
spinner,
247-
stdio: 'inherit',
254+
stdio: machineMode ? 'ignore' : 'inherit',
248255
})
249256

250257
if (wasSpinning) {

packages/cli/src/flags.mts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,12 @@ export const commonFlags: MeowFlags = {
269269
// Only show in root command in debug mode.
270270
hidden: true,
271271
},
272+
quiet: {
273+
type: 'boolean',
274+
default: false,
275+
description:
276+
'Route non-essential output (status, progress, warnings) to stderr so stdout carries only the payload. Implied by --json and --markdown.',
277+
},
272278
spinner: {
273279
type: 'boolean',
274280
default: true,

packages/cli/src/utils/cli/with-subcommands.mts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ import {
4141
} from '../config.mts'
4242
import { isDebug } from '../debug.mts'
4343
import { tildify } from '../fs/home-path.mts'
44+
import {
45+
resetMachineOutputMode,
46+
setMachineOutputMode,
47+
} from '../output/ambient-mode.mts'
4448
import { getFlagListOutput, getHelpListOutput } from '../output/formatting.mts'
4549
import { getVisibleTokenPrefix } from '../socket/sdk.mjs'
4650
import {
@@ -510,15 +514,30 @@ export async function meowWithSubcommands(
510514
const {
511515
compactHeader: compactHeaderFlag,
512516
config: configFlag,
517+
json: jsonFlag,
518+
markdown: markdownFlag,
513519
org: orgFlag,
520+
quiet: quietFlag,
514521
spinner: spinnerFlag,
515522
} = cli1.flags as {
516523
compactHeader: boolean
517524
config: string
525+
json: boolean | undefined
526+
markdown: boolean | undefined
518527
org: string
528+
quiet: boolean | undefined
519529
spinner: boolean
520530
}
521531

532+
// Re-derive from the current argv so ambient mode doesn't leak across
533+
// sequential invocations (e.g. multiple vitest cases in one worker).
534+
resetMachineOutputMode()
535+
setMachineOutputMode({
536+
json: jsonFlag,
537+
markdown: markdownFlag,
538+
quiet: quietFlag,
539+
})
540+
522541
const compactMode = !!compactHeaderFlag || !!(getCI() && !VITEST)
523542
const noSpinner = spinnerFlag === false || isDebug()
524543

@@ -937,17 +956,32 @@ export function meowOrExit(
937956
const {
938957
compactHeader: compactHeaderFlag,
939958
help: helpFlag,
959+
json: jsonFlag,
960+
markdown: markdownFlag,
940961
org: orgFlag,
962+
quiet: quietFlag,
941963
spinner: spinnerFlag,
942964
version: versionFlag,
943965
} = cli.flags as {
944966
compactHeader: boolean
945967
help: boolean
968+
json: boolean | undefined
969+
markdown: boolean | undefined
946970
org: string
971+
quiet: boolean | undefined
947972
spinner: boolean
948973
version: boolean | undefined
949974
}
950975

976+
// Apply machine-output mode from this command's flags. Reset first
977+
// so prior in-worker state doesn't leak across sequential invocations.
978+
resetMachineOutputMode()
979+
setMachineOutputMode({
980+
json: jsonFlag,
981+
markdown: markdownFlag,
982+
quiet: quietFlag,
983+
})
984+
951985
const compactMode = !!compactHeaderFlag || !!(getCI() && !VITEST)
952986
const noSpinner = spinnerFlag === false || isDebug()
953987

packages/cli/src/utils/dlx/spawn.mts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ import { SOCKET_CLI_PYTHON_PATH } from '../../env/socket-cli-python-path.mts'
6262
import { getSynpVersion } from '../../env/synp-version.mts'
6363
import { getErrorCause, InputError } from '../error/errors.mts'
6464
import { isSeaBinary } from '../sea/detect.mts'
65+
import {
66+
applyMachineModeIfActive,
67+
inferSubcommand,
68+
} from '../spawn/apply-machine-mode.mts'
6569
import { socketHttpRequest } from '../socket/api.mjs'
6670
import { spawnNode } from '../spawn/spawn-node.mjs'
6771
import { getDefaultApiToken, getDefaultProxyUrl } from '../socket/sdk.mjs'
@@ -500,6 +504,23 @@ export async function spawnSfwDlx(
500504
options?: DlxOptions | undefined,
501505
spawnExtra?: SpawnExtra | undefined,
502506
): Promise<DlxSpawnResult> {
507+
// sfw is a transparent proxy: args is [innerTool, innerSubcommand?, ...rest].
508+
// Machine-mode flags forward to the inner tool so its stdout stays
509+
// pipe-safe under --json.
510+
const [innerTool, ...innerArgs] = args
511+
const innerSubcommand = inferSubcommand(innerArgs)
512+
const innerApplied = innerTool
513+
? applyMachineModeIfActive({
514+
args: innerArgs,
515+
env: undefined,
516+
subcommand: innerSubcommand,
517+
tool: innerTool,
518+
})
519+
: { args: [...innerArgs], env: {} }
520+
const effectiveArgs = innerTool
521+
? [innerTool, ...innerApplied.args]
522+
: [...args]
523+
503524
const resolution = resolveSfw()
504525

505526
// Use local sfw if available.
@@ -511,13 +532,16 @@ export async function spawnSfwDlx(
511532
} as DlxOptions
512533

513534
const spawnArgs =
514-
detection.type === 'binary' ? args : [resolution.path, ...args]
535+
detection.type === 'binary'
536+
? effectiveArgs
537+
: [resolution.path, ...effectiveArgs]
515538
const spawnCommand = detection.type === 'binary' ? resolution.path : 'node'
516539

517540
const spawnPromise = spawn(spawnCommand, spawnArgs, {
518541
...dlxOptions,
519542
env: {
520543
...process.env,
544+
...innerApplied.env,
521545
...spawnEnv,
522546
},
523547
stdio: (spawnExtra?.['stdio'] as StdioOptions | undefined) ?? 'inherit',
@@ -534,8 +558,15 @@ export async function spawnSfwDlx(
534558
}
535559
return await spawnDlx(
536560
resolution.details,
537-
args,
538-
{ force: false, ...options },
561+
effectiveArgs,
562+
{
563+
force: false,
564+
...options,
565+
env: {
566+
...innerApplied.env,
567+
...options?.env,
568+
},
569+
},
539570
spawnExtra,
540571
)
541572
}

0 commit comments

Comments
 (0)