@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 +4 -4
- package/bin/cdxgen.js +15 -4
- package/bin/convert.js +7 -0
- package/bin/hbom.js +13 -4
- package/bin/tracebom.js +9 -1
- package/lib/cli/bomAssembly.js +64 -15
- package/lib/cli/cliOptions.js +30 -0
- package/lib/core/logger.js +153 -15
- package/lib/core/ui.js +46 -2
- package/lib/ecosystems/utils.js +9 -1
- package/lib/inventory/envcontext.js +5 -1
- package/lib/inventory/osinfo.js +122 -16
- package/lib/inventory/table.js +9 -5
- package/lib/managers/binary.js +192 -40
- package/package.json +12 -12
- package/types/lib/cli/bomAssembly.d.ts +14 -0
- package/types/lib/cli/bomAssembly.d.ts.map +1 -1
- package/types/lib/cli/cliOptions.d.ts.map +1 -1
- package/types/lib/core/logger.d.ts +23 -1
- package/types/lib/core/logger.d.ts.map +1 -1
- package/types/lib/core/ui.d.ts +1 -1
- package/types/lib/core/ui.d.ts.map +1 -1
- package/types/lib/ecosystems/utils.d.ts +4 -4
- package/types/lib/ecosystems/utils.d.ts.map +1 -1
- package/types/lib/inventory/envcontext.d.ts.map +1 -1
- package/types/lib/inventory/osinfo.d.ts +83 -2
- package/types/lib/inventory/osinfo.d.ts.map +1 -1
- package/types/lib/inventory/table.d.ts +4 -1
- package/types/lib/inventory/table.d.ts.map +1 -1
- package/types/lib/managers/binary.d.ts +71 -0
- package/types/lib/managers/binary.d.ts.map +1 -1
package/README.md
CHANGED
|
@@ -214,7 +214,7 @@ Common asset names:
|
|
|
214
214
|
#### Linux
|
|
215
215
|
|
|
216
216
|
```bash
|
|
217
|
-
VERSION="v13.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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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") ||
|
|
898
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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.
|
|
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.
|
|
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);
|
package/lib/cli/bomAssembly.js
CHANGED
|
@@ -220,7 +220,12 @@ function anchorDetectedParent(
|
|
|
220
220
|
Array.isArray(components) &&
|
|
221
221
|
!components.some((comp) => comp["bom-ref"] === detectedRef)
|
|
222
222
|
) {
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1334
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1346
|
-
|
|
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
|
/**
|
package/lib/cli/cliOptions.js
CHANGED
|
@@ -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;
|
package/lib/core/logger.js
CHANGED
|
@@ -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
|
-
?
|
|
114
|
+
? openSyncFileWriter(process.env.CDXGEN_THOUGHT_LOG)
|
|
29
115
|
: logStream;
|
|
30
116
|
const thinkErrorOutput = process.env.CDXGEN_THOUGHT_LOG
|
|
31
|
-
?
|
|
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
|
-
?
|
|
133
|
+
? openSyncFileWriter(process.env.CDXGEN_TRACE_LOG)
|
|
48
134
|
: logStream;
|
|
49
135
|
const traceErrorOutput = process.env.CDXGEN_TRACE_LOG
|
|
50
|
-
?
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
"
|
|
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
|
-
|
|
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
|
|
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);
|