@cdxgen/cdxgen 13.0.0 → 13.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -214,7 +214,7 @@ Common asset names:
214
214
  #### Linux
215
215
 
216
216
  ```bash
217
- VERSION="v13.0.0"
217
+ VERSION="v13.0.1"
218
218
  ASSET="cdx-audit-linux-amd64"
219
219
  BASE_URL="https://github.com/cdxgen/cdxgen/releases/download/${VERSION}"
220
220
 
@@ -228,7 +228,7 @@ chmod +x "${ASSET}"
228
228
  #### macOS
229
229
 
230
230
  ```bash
231
- VERSION="v13.0.0"
231
+ VERSION="v13.0.1"
232
232
  ASSET="cdx-audit-darwin-arm64"
233
233
  BASE_URL="https://github.com/cdxgen/cdxgen/releases/download/${VERSION}"
234
234
 
@@ -242,7 +242,7 @@ chmod +x "${ASSET}"
242
242
  #### Windows (PowerShell)
243
243
 
244
244
  ```powershell
245
- $Version = "v13.0.0"
245
+ $Version = "v13.0.1"
246
246
  $Asset = "cdx-audit-windows-amd64.exe"
247
247
  $BaseUrl = "https://github.com/cdxgen/cdxgen/releases/download/$Version"
248
248
 
@@ -267,7 +267,7 @@ steps:
267
267
  env:
268
268
  GH_TOKEN: ${{ github.token }}
269
269
  run: |
270
- gh release download v13.0.0 \
270
+ gh release download v13.0.1 \
271
271
  --repo cdxgen/cdxgen \
272
272
  --pattern 'cdx-audit-linux-amd64' \
273
273
  --pattern 'cdx-audit-linux-amd64.sha256'
package/bin/cdxgen.js CHANGED
@@ -887,15 +887,26 @@ if (args.bomAuditIncludeTrusted && args.bomAuditOnlyTrusted) {
887
887
 
888
888
  if (args.tui && !readEnvironmentVariable("CI")) {
889
889
  const cdxuiPath = resolvePluginBinary("cdxui");
890
- if (cdxuiPath) {
891
- const cdxgenArgs = process.argv.slice(2).filter((a) => a !== "--tui");
890
+ if (!cdxuiPath) {
891
+ console.error(
892
+ "The terminal user interface requires the cdxui plugin binary, which is not installed for this platform.\nInstall @cdxgen/cdxgen-plugins-bin, or set CDXUI_CMD to a cdxui binary, then retry. Continuing without the interface.",
893
+ );
894
+ } else {
895
+ const cdxgenArgs = process.argv
896
+ .slice(2)
897
+ .filter((a) => a !== "--tui" && !a.startsWith("--tui="));
898
+ // Arguments cross the process boundary separated by the unit separator
899
+ // rather than by spaces: a scan path may legitimately contain a space, and
900
+ // re-splitting on whitespace would silently turn it into two paths.
892
901
  const cdxuiResult = safeSpawnSync(cdxuiPath, ["--generate"], {
893
902
  stdio: "inherit",
894
903
  env: {
895
904
  ...process.env,
896
905
  CDXGEN_CMD:
897
- readEnvironmentVariable("CDXGEN_CMD") || process.argv[1] || "cdxgen",
898
- CDXGEN_ARGS: cdxgenArgs.join(" "),
906
+ readEnvironmentVariable("CDXGEN_CMD") ||
907
+ [process.argv[0], process.argv[1]].filter(Boolean).join("\x1f") ||
908
+ "cdxgen",
909
+ CDXGEN_ARGS: cdxgenArgs.join("\x1f"),
899
910
  },
900
911
  });
901
912
  process.exit(cdxuiResult.status || 0);
package/bin/convert.js CHANGED
@@ -7,6 +7,7 @@ import process from "node:process";
7
7
  import yargs from "yargs";
8
8
  import { hideBin } from "yargs/helpers";
9
9
 
10
+ import { installConsoleShim, restoreConsole } from "../lib/core/ui.js";
10
11
  import {
11
12
  retrieveCdxgenVersion,
12
13
  safeExistsSync,
@@ -74,6 +75,12 @@ const args = _yargs
74
75
  .help()
75
76
  .wrap(Math.min(120, yargs().terminalWidth())).argv;
76
77
 
78
+ // stdout carries the payload and nothing else; every diagnostic goes to the
79
+ // live region's stream. Without the shim a `--print`/`--json` document shares
80
+ // stdout with progress messages and cannot be piped into a parser.
81
+ installConsoleShim();
82
+ process.once("exit", restoreConsole);
83
+
77
84
  const loadCycloneDxBom = async (inputPath) => {
78
85
  if (!safeExistsSync(inputPath)) {
79
86
  console.error(`Input file '${inputPath}' not found.`);
package/bin/hbom.js CHANGED
@@ -8,6 +8,7 @@ import { hideBin } from "yargs/helpers";
8
8
 
9
9
  import { createHBom } from "../lib/cli/index.js";
10
10
  import { thoughtLog } from "../lib/core/logger.js";
11
+ import { installConsoleShim, restoreConsole } from "../lib/core/ui.js";
11
12
  import {
12
13
  DEBUG_MODE,
13
14
  isDryRun,
@@ -192,6 +193,12 @@ const args = _yargs
192
193
  })
193
194
  .wrap(Math.min(120, yargs().terminalWidth())).argv;
194
195
 
196
+ // stdout carries the payload and nothing else; every diagnostic goes to the
197
+ // live region's stream. Without the shim a `--print`/`--json` document shares
198
+ // stdout with progress messages and cannot be piped into a parser.
199
+ installConsoleShim();
200
+ process.once("exit", restoreConsole);
201
+
195
202
  if (args.help) {
196
203
  console.log(`${retrieveCdxgenVersion()}\n`);
197
204
  _yargs.showHelp();
@@ -334,8 +341,9 @@ async function loadBomFromInputFile(inputFile) {
334
341
  function printHbomDiagnosticsReport(bomJson) {
335
342
  const hbomSummary = getHbomSummary(bomJson);
336
343
  if (options.json) {
337
- console.log(
338
- JSON.stringify(
344
+ // A machine-readable report is a payload too, not a diagnostic.
345
+ process.stdout.write(
346
+ `${JSON.stringify(
339
347
  {
340
348
  actionableDiagnosticCount: hbomSummary.actionableDiagnosticCount,
341
349
  architecture: hbomSummary.architecture,
@@ -359,7 +367,7 @@ function printHbomDiagnosticsReport(bomJson) {
359
367
  },
360
368
  null,
361
369
  2,
362
- ),
370
+ )}\n`,
363
371
  );
364
372
  return;
365
373
  }
@@ -459,7 +467,8 @@ async function runDiagnosticsCommand() {
459
467
  }
460
468
  const output = JSON.stringify(bomJson, null, options.pretty ? 2 : null);
461
469
  if (options.print) {
462
- console.log(output);
470
+ // The document is the payload, so it bypasses the console shim.
471
+ process.stdout.write(`${output}\n`);
463
472
  } else {
464
473
  const outputDirectory = getOutputDirectory(options.output);
465
474
  if (outputDirectory && !safeExistsSync(outputDirectory)) {
package/bin/tracebom.js CHANGED
@@ -7,6 +7,7 @@ import { hideBin } from "yargs/helpers";
7
7
 
8
8
  import { validateSpecVersion } from "../lib/cli/cliOptions.js";
9
9
  import { createDynamicBom } from "../lib/cli/index.js";
10
+ import { installConsoleShim, restoreConsole } from "../lib/core/ui.js";
10
11
  import {
11
12
  DEBUG_MODE,
12
13
  retrieveCdxgenVersion,
@@ -200,6 +201,12 @@ const args = _yargs
200
201
  })
201
202
  .wrap(Math.min(120, yargs().terminalWidth())).argv;
202
203
 
204
+ // stdout carries the payload and nothing else; every diagnostic goes to the
205
+ // live region's stream. Without the shim a `--print`/`--json` document shares
206
+ // stdout with progress messages and cannot be piped into a parser.
207
+ installConsoleShim();
208
+ process.once("exit", restoreConsole);
209
+
203
210
  if (args.help) {
204
211
  console.log(`${retrieveCdxgenVersion()}\n`);
205
212
  _yargs.showHelp();
@@ -274,7 +281,8 @@ const options = {
274
281
  const jsonPayload = JSON.stringify(bomJson, null, DEBUG_MODE ? 2 : null);
275
282
 
276
283
  if (args.print) {
277
- console.log(jsonPayload);
284
+ // The document is the payload, so it bypasses the console shim.
285
+ process.stdout.write(`${jsonPayload}\n`);
278
286
  }
279
287
 
280
288
  safeWriteSync(options.output, jsonPayload);
@@ -220,7 +220,12 @@ function anchorDetectedParent(
220
220
  Array.isArray(components) &&
221
221
  !components.some((comp) => comp["bom-ref"] === detectedRef)
222
222
  ) {
223
- components.push({ ...detectedParent });
223
+ // The detected parent carries transient keys (`license`, `evidence`,
224
+ // `_integrity`, `qualifiers`, `repository`, `homepage`) that only
225
+ // `metadata.component` is cleaned of. As a regular component it must go
226
+ // through the same conversion or schema validation rejects the document
227
+ // (issue #4320). The copy keeps the caller's context object untouched.
228
+ components.push(cleanParentComponent({ ...detectedParent }));
224
229
  }
225
230
  // The caller owns the array it passed, and postProcess merges these edges
226
231
  // across generators, so the re-anchoring is returned rather than spliced in.
@@ -1302,8 +1307,7 @@ export function processHashes(pkg, component) {
1302
1307
  addComponentHash(ahash.alg, ahash.content, component);
1303
1308
  }
1304
1309
  } else if (pkg._shasum) {
1305
- const ahash = { alg: "SHA-1", content: pkg._shasum };
1306
- component.hashes.push(ahash);
1310
+ addComponentHash("SHA-1", pkg._shasum, component);
1307
1311
  } else if (pkg._integrity) {
1308
1312
  const integrity = parse(pkg._integrity) || {};
1309
1313
  // Components may have multiple hashes with various lengths. Check each one
@@ -1326,24 +1330,69 @@ export function processHashes(pkg, component) {
1326
1330
  }
1327
1331
  }
1328
1332
 
1333
+ // Digest length in hex characters for each algorithm CycloneDX names. A digest
1334
+ // that decodes to the wrong length for its algorithm is corrupt, whatever
1335
+ // encoding it arrived in.
1336
+ const HASH_HEX_LENGTHS = {
1337
+ MD5: 32,
1338
+ "SHA-1": 40,
1339
+ "SHA-256": 64,
1340
+ "SHA-384": 96,
1341
+ "SHA-512": 128,
1342
+ "SHA3-256": 64,
1343
+ "SHA3-384": 96,
1344
+ "SHA3-512": 128,
1345
+ "BLAKE2b-256": 64,
1346
+ "BLAKE2b-384": 96,
1347
+ "BLAKE2b-512": 128,
1348
+ };
1349
+
1350
+ // Lengths the CycloneDX hash-content pattern accepts, for algorithms not in the
1351
+ // table above.
1352
+ const HASH_HEX_LENGTHS_ANY = new Set([32, 40, 64, 96, 128]);
1353
+
1329
1354
  /**
1330
1355
  * Adds a hash to component.
1356
+ *
1357
+ * Registries hand out digests in hex or in the base64 form npm uses for
1358
+ * `integrity`, and both are accepted. A digest that is neither, or that decodes
1359
+ * to the wrong length for its algorithm, is dropped rather than copied through:
1360
+ * CycloneDX constrains `hash.content` to a hex digest, so emitting it anyway
1361
+ * fails schema validation for the whole document over one bad package.
1331
1362
  */
1332
1363
  export function addComponentHash(alg, digest, component) {
1333
- let hash;
1334
- // If it is a valid hash simply use it
1364
+ const hash = normalizeHashContent(alg, digest);
1365
+ if (!hash) {
1366
+ return;
1367
+ }
1368
+ component.hashes.push({ alg: alg, content: hash });
1369
+ }
1370
+
1371
+ /**
1372
+ * Normalize a digest to the lower-case hex form CycloneDX requires.
1373
+ *
1374
+ * @param {string} alg Hash algorithm name
1375
+ * @param {string} digest Digest in hex or base64
1376
+ * @returns {string|undefined} Hex digest, or undefined when it cannot be trusted
1377
+ */
1378
+ export function normalizeHashContent(alg, digest) {
1379
+ if (!digest || typeof digest !== "string") {
1380
+ return undefined;
1381
+ }
1382
+ const expectedLength = HASH_HEX_LENGTHS[alg];
1383
+ const isExpectedLength = (value) =>
1384
+ expectedLength
1385
+ ? value.length === expectedLength
1386
+ : HASH_HEX_LENGTHS_ANY.has(value.length);
1387
+
1335
1388
  if (new RegExp(HASH_PATTERN).test(digest)) {
1336
- hash = digest;
1337
- } else {
1338
- // Check if base64 encoded
1339
- const isBase64Encoded =
1340
- Buffer.from(digest, "base64").toString("base64") === digest;
1341
- hash = isBase64Encoded
1342
- ? Buffer.from(digest, "base64").toString("hex")
1343
- : digest;
1389
+ return isExpectedLength(digest) ? digest : undefined;
1344
1390
  }
1345
- const ahash = { alg: alg, content: hash };
1346
- component.hashes.push(ahash);
1391
+ // npm and friends carry the digest base64-encoded. Node decodes leniently, so
1392
+ // the decoded length is the check that matters: a truncated or invented value
1393
+ // decodes to something, just not to a digest of the right size.
1394
+ const decoded = Buffer.from(digest, "base64").toString("hex");
1395
+ return isExpectedLength(decoded) ? decoded : undefined;
1347
1396
  }
1348
1397
 
1349
1398
  /**
@@ -14,6 +14,7 @@
14
14
  import { join, resolve } from "node:path";
15
15
 
16
16
  import { isSecureMode, readEnvironmentVariable } from "../core/activity.js";
17
+ import { PROJECT_TYPE_ALIASES } from "../core/env.js";
17
18
  import {
18
19
  DEFAULT_CDX_SPEC_VERSION,
19
20
  normalizeCycloneDxComponentTypeFilter,
@@ -217,6 +218,24 @@ export function buildInitialOptions(args, { filePath, isRemoteOrPurl }) {
217
218
  return res;
218
219
  }
219
220
 
221
+ /**
222
+ * Check whether the supplied project types include an OS inventory type
223
+ * (`os` or an alias such as `linux`, `windows`, `macos`).
224
+ *
225
+ * @param {string|string[]|undefined|null} projectTypes Project types.
226
+ * @returns {boolean} True when an OS inventory type is requested.
227
+ */
228
+ function hasOsProjectType(projectTypes) {
229
+ if (!projectTypes) {
230
+ return false;
231
+ }
232
+ const values = Array.isArray(projectTypes) ? projectTypes : [projectTypes];
233
+ const osTypes = new Set(["os", ...(PROJECT_TYPE_ALIASES.os || [])]);
234
+ return values
235
+ .flatMap((projectType) => `${projectType}`.split(","))
236
+ .some((projectType) => osTypes.has(projectType.trim().toLowerCase()));
237
+ }
238
+
220
239
  /**
221
240
  * @typedef {object} OptionsWarning
222
241
  * @property {"info"|"warn"|"error"} level Severity level
@@ -263,6 +282,17 @@ export function applyPostConstructionOverrides(options, context) {
263
282
  "The cbom command does not support --component-type. Use cdxgen with --include-crypto when you need component-type filtering.",
264
283
  });
265
284
  }
285
+ // Crypto evidence collection analyses source code with atom, which is
286
+ // meaningless and extremely slow for OS inventories (discussion #4310).
287
+ // Refuse the combination and direct users to obom.
288
+ if (hasOsProjectType(options.projectType)) {
289
+ warnings.push({
290
+ level: "error",
291
+ message:
292
+ "The cbom command does not support OS inventory project types (-t os). Use the obom command instead to generate an OBOM for an operating system installation.",
293
+ });
294
+ return warnings;
295
+ }
266
296
  options.includeCrypto = true;
267
297
  }
268
298
  options.evidence = true;
@@ -1,6 +1,8 @@
1
1
  import { Console } from "node:console";
2
2
  import fs from "node:fs";
3
3
  import process from "node:process";
4
+ import { Writable } from "node:stream";
5
+ import { isMainThread } from "node:worker_threads";
4
6
 
5
7
  // Environment variables here are read with raw `process.env` rather than
6
8
  // `readEnvironmentVariable`: that helper lives in activity.js, which imports
@@ -12,6 +14,18 @@ import process from "node:process";
12
14
  const logStream =
13
15
  process.env.CDXGEN_LOG_STREAM === "stdout" ? process.stdout : process.stderr;
14
16
 
17
+ /**
18
+ * The stream every human-readable diagnostic is written to.
19
+ *
20
+ * Exported so that the live region and the table renderer resolve the same
21
+ * destination as the loggers do. A caller that sets `CDXGEN_LOG_STREAM=stdout`
22
+ * gets the pre-v13 behaviour for all of it, not just for the thought and trace
23
+ * logs.
24
+ *
25
+ * @type {NodeJS.WritableStream}
26
+ */
27
+ export const diagnosticStream = logStream;
28
+
15
29
  // Custom lightweight colors formatting helper replacing yoctocolors
16
30
  const colors = {
17
31
  cyanBright: (text) => `\x1b[96m${text}\x1b[39m`,
@@ -24,11 +38,83 @@ export const THINK_MODE =
24
38
  ["true", "1"].includes(process.env.CDXGEN_THINK_MODE) ||
25
39
  process.env.CDXGEN_DEBUG_MODE === "verbose";
26
40
 
41
+ // Log files are written synchronously rather than through
42
+ // `fs.createWriteStream`. A write stream buffers, so anything still queued when
43
+ // the process exits is lost, leaving readers that tail these files a truncated
44
+ // log with no closing `</think>` marker. Writing through one shared descriptor
45
+ // per path also keeps the two Console channels from interleaving at
46
+ // independent file offsets.
47
+ const syncFileWriters = new Map();
48
+
49
+ /**
50
+ * Open a synchronous writer for a log file path.
51
+ *
52
+ * `Console` requires a real stream, so this is a `Writable` whose `_write`
53
+ * completes synchronously: bytes reach the descriptor as each record is
54
+ * logged, and nothing is left queued for an event loop turn that a process
55
+ * exit may never take.
56
+ *
57
+ * @param {string} filePath Destination path
58
+ * @returns {Writable} Writer, or the diagnostic stream when the path is unwritable
59
+ */
60
+ function openSyncFileWriter(filePath) {
61
+ const existing = syncFileWriters.get(filePath);
62
+ if (existing) {
63
+ return existing;
64
+ }
65
+ let fd;
66
+ try {
67
+ // Worker threads log to the same path through their own instance of this
68
+ // module, so the descriptor is opened in append mode: O_APPEND lands every
69
+ // write at the end of the file no matter how many writers share it, where
70
+ // a truncating open gives each writer its own offset and they punch holes
71
+ // through each other's records. The main thread truncates once so a run
72
+ // still starts from an empty log.
73
+ if (isMainThread) {
74
+ try {
75
+ fs.truncateSync(filePath, 0);
76
+ } catch {
77
+ // No file yet: the append open below creates it.
78
+ }
79
+ }
80
+ fd = fs.openSync(filePath, "a");
81
+ } catch {
82
+ // An unwritable path must not take the whole run down: fall back to the
83
+ // diagnostic stream so the records stay visible somewhere.
84
+ return logStream;
85
+ }
86
+ const writer = new Writable({
87
+ write(chunk, _encoding, callback) {
88
+ if (fd !== undefined) {
89
+ try {
90
+ fs.writeSync(fd, chunk);
91
+ } catch {
92
+ // A full disk or a revoked descriptor must not abort BOM generation.
93
+ }
94
+ }
95
+ callback();
96
+ },
97
+ });
98
+ writer.closeSync = () => {
99
+ if (fd === undefined) {
100
+ return;
101
+ }
102
+ try {
103
+ fs.closeSync(fd);
104
+ } finally {
105
+ fd = undefined;
106
+ syncFileWriters.delete(filePath);
107
+ }
108
+ };
109
+ syncFileWriters.set(filePath, writer);
110
+ return writer;
111
+ }
112
+
27
113
  const thinkOutput = process.env.CDXGEN_THOUGHT_LOG
28
- ? fs.createWriteStream(process.env.CDXGEN_THOUGHT_LOG)
114
+ ? openSyncFileWriter(process.env.CDXGEN_THOUGHT_LOG)
29
115
  : logStream;
30
116
  const thinkErrorOutput = process.env.CDXGEN_THOUGHT_LOG
31
- ? fs.createWriteStream(process.env.CDXGEN_THOUGHT_LOG)
117
+ ? thinkOutput
32
118
  : process.stderr;
33
119
  const thinkLogger = new Console({
34
120
  stdout: thinkOutput,
@@ -44,10 +130,10 @@ export const TRACE_MODE =
44
130
  process.env.CDXGEN_DEBUG_MODE === "verbose";
45
131
 
46
132
  const traceOutput = process.env.CDXGEN_TRACE_LOG
47
- ? fs.createWriteStream(process.env.CDXGEN_TRACE_LOG)
133
+ ? openSyncFileWriter(process.env.CDXGEN_TRACE_LOG)
48
134
  : logStream;
49
135
  const traceErrorOutput = process.env.CDXGEN_TRACE_LOG
50
- ? fs.createWriteStream(process.env.CDXGEN_TRACE_LOG)
136
+ ? traceOutput
51
137
  : process.stderr;
52
138
  const traceLogger = new Console({
53
139
  stdout: traceOutput,
@@ -80,14 +166,18 @@ export function thoughtLog(s, args) {
80
166
  thinkLogger.log(colorizeText(`${s}`));
81
167
  }
82
168
  }
169
+ let thoughtBlockClosed = false;
170
+
83
171
  /**
84
172
  * Closes the think log group by emitting the closing `</think>` marker.
85
- * Has no effect if THINK_MODE is not enabled.
173
+ * Has no effect if THINK_MODE is not enabled, and emits the marker at most
174
+ * once so an explicit call followed by the exit handler cannot produce two.
86
175
  *
87
176
  * @returns {void}
88
177
  */
89
178
  export function thoughtEnd() {
90
- if (THINK_MODE) {
179
+ if (THINK_MODE && !thoughtBlockClosed) {
180
+ thoughtBlockClosed = true;
91
181
  thinkLogger.groupEnd();
92
182
  thinkLogger.log(colorizeText("</think>"));
93
183
  }
@@ -103,6 +193,33 @@ function colorizeText(s) {
103
193
  return colors.dim(s);
104
194
  }
105
195
 
196
+ // Fields serialized for each trace type. The allow-list is per-type because a
197
+ // single shared list either drops the fields a type exists to carry or leaks
198
+ // unbounded caller objects into the stream: `activity` and `phase` records
199
+ // carry no field in common with `spawn` and `http`.
200
+ const TRACE_FIELDS = {
201
+ activity: [
202
+ "identifier",
203
+ "kind",
204
+ "status",
205
+ "target",
206
+ "reason",
207
+ "networkIntent",
208
+ "command",
209
+ "cwd",
210
+ ],
211
+ phase: ["phase", "state", "detail", "note", "done", "total", "elapsedMs"],
212
+ };
213
+
214
+ const DEFAULT_TRACE_FIELDS = [
215
+ "command",
216
+ "cwd",
217
+ "protocol",
218
+ "host",
219
+ "path",
220
+ "pathname",
221
+ ];
222
+
106
223
  /**
107
224
  * Log trace messages
108
225
  *
@@ -123,15 +240,10 @@ export function traceLog(traceType, args) {
123
240
  message.type = traceType;
124
241
  }
125
242
  if (args) {
126
- for (const k of [
127
- "command",
128
- "cwd",
129
- "protocol",
130
- "host",
131
- "path",
132
- "pathname",
133
- ]) {
134
- if (args[k]) {
243
+ for (const k of TRACE_FIELDS[traceType] || DEFAULT_TRACE_FIELDS) {
244
+ // Numeric progress counters are meaningful at zero, so presence is
245
+ // tested rather than truthiness.
246
+ if (args[k] !== undefined && args[k] !== null && args[k] !== "") {
135
247
  message[k] = args[k];
136
248
  }
137
249
  }
@@ -140,3 +252,29 @@ export function traceLog(traceType, args) {
140
252
  traceLogger.log(JSON.stringify(message, null, null));
141
253
  }
142
254
  }
255
+
256
+ let logStreamsClosed = false;
257
+
258
+ /**
259
+ * Terminate the think block and close the log file descriptors.
260
+ *
261
+ * Readers tail these files as a live feed, so a run that ends without the
262
+ * closing `</think>` marker leaves the consumer holding an unterminated block.
263
+ * Registered on process exit and safe to call more than once.
264
+ *
265
+ * @returns {void}
266
+ */
267
+ export function closeLogStreams() {
268
+ if (logStreamsClosed) {
269
+ return;
270
+ }
271
+ logStreamsClosed = true;
272
+ thoughtEnd();
273
+ for (const writer of [...syncFileWriters.values()]) {
274
+ writer.closeSync();
275
+ }
276
+ }
277
+
278
+ // The writers are synchronous, so this handler only has to emit the terminator
279
+ // and release the descriptors — work that is legal during `exit`.
280
+ process.on("exit", closeLogStreams);
package/lib/core/ui.js CHANGED
@@ -17,6 +17,7 @@ import process from "node:process";
17
17
  import { formatWithOptions, inspect } from "node:util";
18
18
 
19
19
  import { readEnvironmentVariable } from "./activity.js";
20
+ import { diagnosticStream, TRACE_MODE, traceLog } from "./logger.js";
20
21
  import { stringWidth, stripAnsi } from "./text.js";
21
22
 
22
23
  // Frame interval, and the floor on how often detail/progress updates redraw.
@@ -94,7 +95,11 @@ const PHASE_OUTCOMES = {
94
95
  * @returns {{stream: object, interactive: boolean, color: boolean, unicode: boolean, glyphs: object, columns: () => number, rows: () => number}} Capability snapshot
95
96
  */
96
97
  export function detectCapabilities(overrides = {}) {
97
- const stream = overrides.stream || process.stderr;
98
+ // `CDXGEN_LOG_STREAM=stdout` is the documented escape hatch for callers that
99
+ // scraped v12's stdout. Resolving the destination here rather than pinning
100
+ // stderr is what makes it apply to the region, the banners and the tables
101
+ // instead of only to the thought and trace logs.
102
+ const stream = overrides.stream || diagnosticStream;
98
103
  const env = overrides.env;
99
104
  const read = (name) => (env ? env[name] : readEnvironmentVariable(name));
100
105
  const isSet = (name) => {
@@ -282,7 +287,7 @@ function formatArgs(args) {
282
287
  * Create a live-region controller.
283
288
  *
284
289
  * @param {object} [options] Controller options
285
- * @param {object} [options.stream] Writable stream (default `process.stderr`)
290
+ * @param {object} [options.stream] Writable stream (default: the diagnostic stream, stderr unless `CDXGEN_LOG_STREAM=stdout`)
286
291
  * @param {number|string} [options.level] Verbosity ladder 0..3
287
292
  * @param {("text"|"json")} [options.format] Log serialization format
288
293
  * @param {boolean} [options.noProgress] Force-disable the live region
@@ -538,6 +543,40 @@ export function createUi(options = {}) {
538
543
  }
539
544
  }
540
545
 
546
+ /**
547
+ * Mirror a phase transition onto the trace stream.
548
+ *
549
+ * The live region is a terminal affordance, so a piped consumer such as the
550
+ * cdxui terminal UI would otherwise have to scrape formatted lines to learn
551
+ * what cdxgen is doing. Emitting the phase model structurally lets it render
552
+ * its own progress. `progress` transitions are throttled to the frame
553
+ * interval so a per-file loop costs one event per frame, not one per file.
554
+ *
555
+ * @param {object} handle Phase handle
556
+ * @param {string} state One of started, progress, succeeded, failed, skipped
557
+ */
558
+ function tracePhase(handle, state) {
559
+ if (!TRACE_MODE) {
560
+ return;
561
+ }
562
+ const at = now();
563
+ if (state === "progress") {
564
+ if (at - (handle.tracedAt || 0) < REFRESH_MS) {
565
+ return;
566
+ }
567
+ handle.tracedAt = at;
568
+ }
569
+ traceLog("phase", {
570
+ phase: handle.label,
571
+ state,
572
+ detail: handle.detailText ?? undefined,
573
+ note: handle.note ?? undefined,
574
+ done: handle.total > 0 ? handle.done : undefined,
575
+ total: handle.total > 0 ? handle.total : undefined,
576
+ elapsedMs: at - handle.startedAt,
577
+ });
578
+ }
579
+
541
580
  function commitPhase(phase) {
542
581
  const outcome = PHASE_OUTCOMES[phase.status] || PHASE_OUTCOMES.succeeded;
543
582
  const glyph = caps.glyphs[phase.status] || caps.glyphs.succeeded;
@@ -568,6 +607,7 @@ export function createUi(options = {}) {
568
607
  startedAt: now(),
569
608
  note: null,
570
609
  committed: false,
610
+ tracedAt: 0,
571
611
 
572
612
  /**
573
613
  * Replace the trailing detail text on this phase's row.
@@ -578,6 +618,7 @@ export function createUi(options = {}) {
578
618
  detail(text) {
579
619
  if (!this.committed) {
580
620
  this.detailText = text ? `${text}` : null;
621
+ tracePhase(this, "progress");
581
622
  requestRender();
582
623
  }
583
624
  return this;
@@ -594,6 +635,7 @@ export function createUi(options = {}) {
594
635
  if (!this.committed) {
595
636
  this.done = Number(done) || 0;
596
637
  this.total = Number(total) || 0;
638
+ tracePhase(this, "progress");
597
639
  requestRender();
598
640
  }
599
641
  return this;
@@ -631,6 +673,7 @@ export function createUi(options = {}) {
631
673
  },
632
674
  };
633
675
  running.push(handle);
676
+ tracePhase(handle, "started");
634
677
  startTimer();
635
678
  requestRender(true);
636
679
  return handle;
@@ -643,6 +686,7 @@ export function createUi(options = {}) {
643
686
  handle.committed = true;
644
687
  handle.status = status;
645
688
  handle.note = note ? `${note}` : null;
689
+ tracePhase(handle, status);
646
690
  const index = running.indexOf(handle);
647
691
  if (index >= 0) {
648
692
  running.splice(index, 1);