dependency-radar 0.6.1 → 0.8.0

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/dist/cli.js CHANGED
@@ -8,14 +8,24 @@ const path_1 = __importDefault(require("path"));
8
8
  const child_process_1 = require("child_process");
9
9
  const os_1 = require("os");
10
10
  const aggregator_1 = require("./aggregator");
11
+ const explain_1 = require("./explain");
11
12
  const importGraphRunner_1 = require("./runners/importGraphRunner");
12
13
  const npmAudit_1 = require("./runners/npmAudit");
13
14
  const npmLs_1 = require("./runners/npmLs");
14
15
  const npmOutdated_1 = require("./runners/npmOutdated");
16
+ const lockfileSignals_1 = require("./runners/lockfileSignals");
15
17
  const report_1 = require("./report");
18
+ const compare_1 = require("./compare");
19
+ const outputFormats_1 = require("./outputFormats");
20
+ const why_1 = require("./why");
21
+ const schema_1 = require("./schema");
16
22
  const failOn_1 = require("./failOn");
17
23
  const promises_1 = __importDefault(require("fs/promises"));
18
24
  const utils_1 = require("./utils");
25
+ const skippedToolResult = {
26
+ ok: true,
27
+ status: "skipped",
28
+ };
19
29
  function normalizeSlashes(p) {
20
30
  return p.split(path_1.default.sep).join("/");
21
31
  }
@@ -424,6 +434,8 @@ function inferPackageManager(rootPkg) {
424
434
  return "pnpm";
425
435
  if (raw.startsWith("yarn@") || raw === "yarn")
426
436
  return "yarn";
437
+ if (raw.startsWith("bun@") || raw === "bun")
438
+ return "bun";
427
439
  if (raw.startsWith("npm@") || raw === "npm")
428
440
  return "npm";
429
441
  return undefined;
@@ -436,6 +448,8 @@ async function detectPackageManager(projectPath, rootPkg, workspaceType) {
436
448
  return workspaceType;
437
449
  if (await detectYarnPnP(projectPath))
438
450
  return "yarn";
451
+ if ((await (0, utils_1.pathExists)(path_1.default.join(projectPath, "bun.lock"))) || (await (0, utils_1.pathExists)(path_1.default.join(projectPath, "bun.lockb"))))
452
+ return "bun";
439
453
  if (await (0, utils_1.pathExists)(path_1.default.join(projectPath, "node_modules", ".pnpm")))
440
454
  return "pnpm";
441
455
  if (await (0, utils_1.pathExists)(path_1.default.join(projectPath, "node_modules", ".yarn-state.yml")))
@@ -443,10 +457,14 @@ async function detectPackageManager(projectPath, rootPkg, workspaceType) {
443
457
  return "npm";
444
458
  }
445
459
  async function detectScanManager(projectPath, fallback) {
460
+ if (fallback === "bun" && ((await (0, utils_1.pathExists)(path_1.default.join(projectPath, "bun.lock"))) || (await (0, utils_1.pathExists)(path_1.default.join(projectPath, "bun.lockb")))))
461
+ return "bun";
446
462
  if (await (0, utils_1.pathExists)(path_1.default.join(projectPath, "pnpm-lock.yaml")))
447
463
  return "pnpm";
448
464
  if (await (0, utils_1.pathExists)(path_1.default.join(projectPath, "yarn.lock")))
449
465
  return "yarn";
466
+ if ((await (0, utils_1.pathExists)(path_1.default.join(projectPath, "bun.lock"))) || (await (0, utils_1.pathExists)(path_1.default.join(projectPath, "bun.lockb"))))
467
+ return "bun";
450
468
  if ((await (0, utils_1.pathExists)(path_1.default.join(projectPath, "package-lock.json"))) ||
451
469
  (await (0, utils_1.pathExists)(path_1.default.join(projectPath, "npm-shrinkwrap.json")))) {
452
470
  return "npm";
@@ -910,11 +928,15 @@ function buildCombinedDependencyGraph(rootPath, packageMetas, dependencyGraphs)
910
928
  return { name: "dependency-radar-workspace", version: "0.0.0", dependencies };
911
929
  }
912
930
  /**
913
- * Parse command-line tokens into a populated CliOptions object.
931
+ * Parse CLI tokens and return a configured CliOptions object reflecting the requested command and flags.
932
+ *
933
+ * Recognizes an optional leading command (scan, explain, compare, why, schema), positional operands for
934
+ * commands that require them (package name for explain/why, compare path for compare), and these flags:
935
+ * --project, --quiet, --out, --keep-temp, --offline, --json, --format, --sbom, --target-node,
936
+ * --audit-signatures, --schema, --timestamp, --open, --no-report, --fail-on, --help / -h.
914
937
  *
915
- * Recognizes a leading non-flag token as the command and the following flags:
916
- * --project, --out, --keep-temp, --offline, --json, --open, --no-report, --fail-on, and --help / -h.
917
- * The --offline flag disables both audit and outdated checks.
938
+ * The --offline flag disables both audit and outdated checks. Unknown options or unexpected positional
939
+ * arguments cause the process to exit with an error.
918
940
  *
919
941
  * @param argv - Array of CLI tokens (typically process.argv.slice(2))
920
942
  * @returns The resolved CliOptions with defaults applied and values overridden by argv
@@ -922,7 +944,9 @@ function buildCombinedDependencyGraph(rootPath, packageMetas, dependencyGraphs)
922
944
  function parseArgs(argv) {
923
945
  const opts = {
924
946
  command: "scan",
947
+ commandProvided: false,
925
948
  project: process.cwd(),
949
+ quiet: false,
926
950
  out: "dependency-radar.html",
927
951
  keepTemp: false,
928
952
  audit: true,
@@ -931,27 +955,87 @@ function parseArgs(argv) {
931
955
  open: false,
932
956
  noReport: false,
933
957
  failOn: new Set(),
958
+ format: "html",
959
+ auditSignatures: false,
960
+ schema: false,
961
+ outProvided: false,
962
+ timestamp: false,
934
963
  };
935
964
  const args = [...argv];
936
965
  if (args[0] && !args[0].startsWith("-")) {
937
- opts.command = args.shift();
966
+ const command = args.shift();
967
+ if (command === "scan" || command === "explain" || command === "compare" || command === "why" || command === "schema") {
968
+ opts.command = command;
969
+ opts.commandProvided = true;
970
+ }
971
+ else {
972
+ opts.invalidCommand = command;
973
+ return opts;
974
+ }
938
975
  }
939
976
  while (args.length) {
940
977
  const arg = args.shift();
941
978
  if (!arg)
942
979
  break;
943
- if (arg === "--project" && args[0])
944
- opts.project = args.shift();
945
- else if (arg === "--out" && args[0])
946
- opts.out = args.shift();
980
+ if (!arg.startsWith("-") && (opts.command === "explain" || opts.command === "why") && !opts.packageName) {
981
+ opts.packageName = arg;
982
+ }
983
+ else if (!arg.startsWith("-") && opts.command === "compare" && !opts.comparePath) {
984
+ opts.comparePath = arg;
985
+ }
986
+ else if (!arg.startsWith("-")) {
987
+ console.error(`Unexpected argument: "${arg}".`);
988
+ process.exit(1);
989
+ }
990
+ else if (arg === "--project")
991
+ opts.project = takeOptionValue(args, arg, true);
992
+ else if (arg === "--quiet")
993
+ opts.quiet = true;
994
+ else if (arg === "--out") {
995
+ opts.out = takeOptionValue(args, arg, true);
996
+ opts.outProvided = true;
997
+ }
947
998
  else if (arg === "--keep-temp")
948
999
  opts.keepTemp = true;
949
1000
  else if (arg === "--offline") {
950
1001
  opts.audit = false;
951
1002
  opts.outdated = false;
952
1003
  }
953
- else if (arg === "--json")
1004
+ else if (arg === "--json") {
954
1005
  opts.json = true;
1006
+ opts.format = "json";
1007
+ }
1008
+ else if (arg === "--format") {
1009
+ const format = takeOptionValue(args, arg);
1010
+ if (!isReportFormat(format)) {
1011
+ console.error(`Unknown --format: "${format}". Supported formats: html, json, sarif, cyclonedx, spdx.`);
1012
+ process.exit(1);
1013
+ }
1014
+ opts.format = format;
1015
+ opts.json = format === "json";
1016
+ }
1017
+ else if (arg === "--sbom") {
1018
+ const format = takeOptionValue(args, arg);
1019
+ if (format !== "cyclonedx" && format !== "spdx") {
1020
+ console.error('Unknown --sbom format. Supported formats: cyclonedx, spdx.');
1021
+ process.exit(1);
1022
+ }
1023
+ opts.format = format;
1024
+ }
1025
+ else if (arg === "--target-node") {
1026
+ const value = Number.parseInt(takeOptionValue(args, arg), 10);
1027
+ if (!Number.isFinite(value) || value <= 0) {
1028
+ console.error("--target-node must be a positive Node.js major version.");
1029
+ process.exit(1);
1030
+ }
1031
+ opts.targetNodeMajor = value;
1032
+ }
1033
+ else if (arg === "--audit-signatures")
1034
+ opts.auditSignatures = true;
1035
+ else if (arg === "--schema")
1036
+ opts.schema = true;
1037
+ else if (arg === "--timestamp")
1038
+ opts.timestamp = true;
955
1039
  else if (arg === "--open")
956
1040
  opts.open = true;
957
1041
  else if (arg === "--no-report")
@@ -979,31 +1063,128 @@ function parseArgs(argv) {
979
1063
  printHelp();
980
1064
  process.exit(0);
981
1065
  }
1066
+ else {
1067
+ console.error(`Unknown option: "${arg}".`);
1068
+ process.exit(1);
1069
+ }
982
1070
  }
983
1071
  return opts;
984
1072
  }
1073
+ /**
1074
+ * Extracts and returns the next CLI token as the value for a given option, consuming it from `args`.
1075
+ *
1076
+ * @param args - Remaining argv tokens; the first element will be removed and returned.
1077
+ * @param option - The option name shown in the error message when a value is missing.
1078
+ * @param allowLeadingDash - When `true`, permit values that begin with `-`; otherwise treat such tokens as missing values.
1079
+ * @returns The consumed option value.
1080
+ * @remarks Exits the process with code 1 and prints an error if no valid value is present.
1081
+ */
1082
+ function takeOptionValue(args, option, allowLeadingDash = false) {
1083
+ const value = args[0];
1084
+ if (!value || (!allowLeadingDash && value.startsWith("-"))) {
1085
+ console.error(`Missing value for ${option}.`);
1086
+ process.exit(1);
1087
+ }
1088
+ return args.shift();
1089
+ }
1090
+ /**
1091
+ * Checks whether a string is a supported report format.
1092
+ *
1093
+ * @param value - The string to test
1094
+ * @returns `true` if `value` is one of `html`, `json`, `sarif`, `cyclonedx`, or `spdx`, `false` otherwise.
1095
+ */
1096
+ function isReportFormat(value) {
1097
+ return value === "html" || value === "json" || value === "sarif" || value === "cyclonedx" || value === "spdx";
1098
+ }
1099
+ /**
1100
+ * Formats a numeric date/time component as a two-digit string.
1101
+ *
1102
+ * @param value - The numeric part (e.g., day, month, hour, or minute) to format
1103
+ * @returns The value as a two-character string, padded with a leading zero when needed
1104
+ */
1105
+ function padDatePart(value) {
1106
+ return String(value).padStart(2, "0");
1107
+ }
1108
+ /**
1109
+ * Formats a Date into a filesystem-safe timestamp string suitable for filenames.
1110
+ *
1111
+ * @param date - The date to format (local time is used).
1112
+ * @returns A string in the form `YYYY-MM-DD_HH-MM-SS` (zero-padded).
1113
+ */
1114
+ function formatFilenameTimestamp(date) {
1115
+ return [
1116
+ date.getFullYear(),
1117
+ padDatePart(date.getMonth() + 1),
1118
+ padDatePart(date.getDate()),
1119
+ ].join("-") + "_" + [
1120
+ padDatePart(date.getHours()),
1121
+ padDatePart(date.getMinutes()),
1122
+ padDatePart(date.getSeconds()),
1123
+ ].join("-");
1124
+ }
1125
+ /**
1126
+ * Insert a local timestamp into the filename portion of an output path.
1127
+ *
1128
+ * If the path has a file extension, the timestamp is inserted before the extension;
1129
+ * otherwise the timestamp is appended to the filename. The directory portion is preserved.
1130
+ *
1131
+ * @param outputPath - The file path whose filename will receive the timestamp
1132
+ * @param date - Date to derive the timestamp from; defaults to the current date/time
1133
+ * @returns The input path with a timestamp embedded into the filename, preserving directory and extension
1134
+ */
1135
+ function addTimestampToOutputPath(outputPath, date = new Date()) {
1136
+ const parsed = path_1.default.parse(outputPath);
1137
+ const timestamp = formatFilenameTimestamp(date);
1138
+ const basename = parsed.ext
1139
+ ? `${parsed.name}.${timestamp}${parsed.ext}`
1140
+ : `${parsed.name}.${timestamp}`;
1141
+ return path_1.default.join(parsed.dir, basename);
1142
+ }
1143
+ /**
1144
+ * Determines whether the given package manager uses registry-based collectors.
1145
+ *
1146
+ * @returns `true` if the manager is `npm`, `pnpm`, or `yarn`, `false` otherwise.
1147
+ */
1148
+ function supportsRegistryCollectors(manager) {
1149
+ return manager === "npm" || manager === "pnpm" || manager === "yarn";
1150
+ }
985
1151
  /**
986
1152
  * Print the CLI usage and available options to the console.
987
1153
  *
988
1154
  * Displays the command synopsis and descriptions for supported flags including
989
- * --project, --out, --json, --no-report, --keep-temp, --offline, --open, and --fail-on.
1155
+ * --project, --out, --json, --timestamp, --no-report, --keep-temp, --offline, --open, and --fail-on.
990
1156
  */
991
1157
  function printHelp() {
992
1158
  console.log(`dependency-radar [scan] [options]
1159
+ dependency-radar explain <package-name> [options]
1160
+ dependency-radar why <package-name> [options]
1161
+ dependency-radar compare <previous dependency-radar.json> [options]
993
1162
 
994
1163
  If no command is provided, \`scan\` is run by default.
995
1164
 
996
1165
  Options:
997
1166
  --project <path> Project folder (default: cwd)
998
- --out <path> Output HTML file (default: dependency-radar.html)
1167
+ --quiet Suppress progress/info logs but keep summary and failures
1168
+ --out <path> Output file (default depends on format)
1169
+ --format <format> Output format: html, json, sarif, cyclonedx, spdx
1170
+ --sbom <format> Write an SBOM: cyclonedx or spdx
1171
+ --target-node <n> Add Node major compatibility findings
1172
+ --audit-signatures Verify npm registry signatures/provenance (opt-in, online only)
1173
+ --schema Print JSON schema, or write it when --out is provided
999
1174
  --json Write aggregated data to JSON (default filename: dependency-radar.json)
1175
+ --timestamp Add a local timestamp to generated report filenames
1000
1176
  --no-report Do not write HTML/JSON report files or temp artifacts to disk
1001
1177
  --keep-temp Keep .dependency-radar folder
1002
1178
  --offline Skip npm audit and npm outdated (useful for offline scans)
1003
1179
  --open Open the generated report using the system default application
1004
1180
  --fail-on <rules> Fail with exit code 1 when selected rules are violated
1005
1181
  Supported: reachable-vuln, production-vuln, high-severity-vuln,
1006
- licence-mismatch, copyleft-detected, unknown-licence
1182
+ licence-mismatch, copyleft-detected, unknown-licence,
1183
+ supply-chain-source
1184
+
1185
+ \`explain\` reuses the same local scan model and prints a terminal view for one package.
1186
+ \`why\` prints shortest dependency paths for one package.
1187
+ \`compare\` scans the current project and compares it with a previous JSON report.
1007
1188
  `);
1008
1189
  }
1009
1190
  /**
@@ -1070,6 +1251,20 @@ function shouldUseColor() {
1070
1251
  return Boolean(process.stdout.isTTY);
1071
1252
  }
1072
1253
  const COLOR_ENABLED = shouldUseColor();
1254
+ function supportsTerminalHyperlinks() {
1255
+ if (!process.stdout.isTTY)
1256
+ return false;
1257
+ if (process.env.NO_COLOR !== undefined)
1258
+ return false;
1259
+ if (process.env.TERM === "dumb")
1260
+ return false;
1261
+ return true;
1262
+ }
1263
+ function formatTerminalLink(label, url) {
1264
+ if (!supportsTerminalHyperlinks())
1265
+ return label;
1266
+ return `\u001B]8;;${url}\u0007${label}\u001B]8;;\u0007`;
1267
+ }
1073
1268
  /**
1074
1269
  * Wraps text with ANSI color or style escape sequences when terminal coloring is enabled.
1075
1270
  *
@@ -1270,10 +1465,10 @@ function printCliSummary(summary) {
1270
1465
  const bullet = "•";
1271
1466
  console.log("");
1272
1467
  console.log("Summary:");
1273
- console.log(`${bullet} Direct deps scanned: ${summary.directDeps}`);
1274
- console.log(`${bullet} Transitive deps scanned: ${summary.transitiveDeps}`);
1468
+ console.log(`${bullet} Direct dependencies scanned: ${summary.directDeps}`);
1469
+ console.log(`${bullet} Transitive dependencies scanned: ${summary.transitiveDeps}`);
1275
1470
  console.log(`${bullet} Vulnerable packages: ${summary.vulnerablePackages} (${summary.reachableVulnerablePackages} reachable)`);
1276
- console.log(`${bullet} Unused installed deps: ${summary.unusedInstalledDeps}`);
1471
+ console.log(`${bullet} Dependencies with no static import reference: ${summary.unusedInstalledDeps}`);
1277
1472
  console.log(`${bullet} License mismatches: ${summary.licenseMismatches}`);
1278
1473
  console.log(`${bullet} Major upgrade blockers: ${summary.majorUpgradeBlockers}`);
1279
1474
  const blockerDetails = [];
@@ -1297,36 +1492,45 @@ function printCliSummary(summary) {
1297
1492
  }
1298
1493
  console.log("");
1299
1494
  }
1495
+ function formatLabel(format) {
1496
+ if (format === "html")
1497
+ return "Report";
1498
+ if (format === "json")
1499
+ return "JSON";
1500
+ if (format === "sarif")
1501
+ return "SARIF";
1502
+ if (format === "cyclonedx")
1503
+ return "CycloneDX SBOM";
1504
+ return "SPDX SBOM";
1505
+ }
1300
1506
  /**
1301
- * Run the CLI "scan" command to collect and aggregate dependency data for a project or workspace.
1507
+ * Run a full dependency analysis for a project/workspace and return the aggregated results.
1508
+ *
1509
+ * Performs workspace detection, per-package collection (audit, dependency trees, import graphs, outdated), merges collected data, runs aggregation and policy evaluation, and optionally writes report artifacts to disk. May create a temporary directory under the project (projectPath/.dependency-radar) and will remove it unless `opts.keepTemp` is set.
1302
1510
  *
1303
- * Detects workspace type and package manager, runs per-package collectors (audit, dependency tree, import graph, outdated),
1304
- * merges collected signals into a workspace-level model, and writes a JSON or HTML report according to CLI options.
1305
- * Manages a temporary working directory and optionally opens the generated report. Exits the process with a non-zero code
1306
- * on fatal errors or when configured policy violations are detected.
1511
+ * @param opts - CLI-resolved options controlling project path, enabled collectors (audit/outdated), artifact format/output, reporting flags, and policy rules.
1512
+ * @param options.shouldWriteArtifacts - If true, write report artifacts (JSON/HTML/SBOM) to the resolved output path.
1513
+ * @param options.emitArtifactSummary - If true, print a summary line about artifact creation to stdout.
1514
+ * @param options.emitWorkspacePackageSummary - If true, print a brief workspace package summary when a workspace is detected.
1515
+ * @returns An AnalysisExecutionResult containing the aggregated report, CLI summary, policy violations, dependency counts, timing, output path/creation status, collector availability, and workspace metadata.
1307
1516
  */
1308
- async function run() {
1309
- var _a;
1310
- const opts = parseArgs(process.argv.slice(2));
1311
- if (opts.command !== "scan") {
1312
- printHelp();
1313
- process.exit(1);
1314
- return;
1315
- }
1316
- const shouldWriteArtifacts = !opts.noReport;
1517
+ async function executeAnalysis(opts, options) {
1518
+ var _a, _b;
1519
+ const shouldWriteArtifacts = options.shouldWriteArtifacts;
1317
1520
  const projectPath = path_1.default.resolve(opts.project);
1318
- let summary;
1319
- let policyViolations = [];
1320
- if (opts.noReport && opts.keepTemp) {
1321
- console.log(statusLine("⚠", "--keep-temp is ignored when --no-report is enabled."));
1322
- }
1323
- if (opts.json && opts.out === "dependency-radar.html") {
1324
- opts.out = "dependency-radar.json";
1325
- }
1326
- let outputPath = path_1.default.resolve(opts.out);
1521
+ let outputPath = opts.outProvided
1522
+ ? path_1.default.resolve(opts.out)
1523
+ : path_1.default.resolve(projectPath, opts.out);
1327
1524
  const startTime = Date.now();
1328
1525
  let dependencyCount = 0;
1329
1526
  let outputCreated = false;
1527
+ if (opts.command === "scan" && opts.noReport && opts.keepTemp && !opts.quiet) {
1528
+ console.log(statusLine("⚠", "--keep-temp is ignored when --no-report is enabled."));
1529
+ }
1530
+ if (!opts.outProvided && opts.format !== "html") {
1531
+ opts.out = (0, outputFormats_1.defaultOutputName)(opts.format);
1532
+ outputPath = path_1.default.resolve(projectPath, opts.out);
1533
+ }
1330
1534
  if (shouldWriteArtifacts) {
1331
1535
  try {
1332
1536
  const stat = await promises_1.default.stat(outputPath).catch(() => undefined);
@@ -1335,22 +1539,24 @@ async function run() {
1335
1539
  if ((stat && stat.isDirectory()) ||
1336
1540
  endsWithSeparator ||
1337
1541
  (!stat && !hasExtension)) {
1338
- outputPath = path_1.default.join(outputPath, opts.json ? "dependency-radar.json" : "dependency-radar.html");
1542
+ outputPath = path_1.default.join(outputPath, (0, outputFormats_1.defaultOutputName)(opts.format));
1339
1543
  }
1340
1544
  }
1341
- catch (e) {
1545
+ catch {
1342
1546
  // ignore, best-effort path normalization
1343
1547
  }
1548
+ if (opts.timestamp) {
1549
+ outputPath = addTimestampToOutputPath(outputPath);
1550
+ }
1344
1551
  }
1345
1552
  const tempDir = path_1.default.join(projectPath, ".dependency-radar");
1346
- // Stage 1: detect workspace/package-manager context and collect tool versions.
1347
1553
  const workspace = await detectWorkspace(projectPath);
1348
1554
  const yarnPnP = await detectYarnPnP(projectPath);
1349
1555
  if (workspace.type === "yarn" && workspace.packagePaths.length === 0) {
1350
- console.error("Yarn Plug'n'Play (nodeLinker: pnp) detected. This is not supported yet.");
1351
- console.error("Switch to nodeLinker: node-modules or run in a non-PnP environment.");
1352
- process.exit(1);
1353
- return;
1556
+ if (!opts.quiet) {
1557
+ console.log(statusLine("⚠", "Yarn Plug'n'Play detected; using the root package and yarn.lock where possible."));
1558
+ }
1559
+ workspace.packagePaths = [projectPath];
1354
1560
  }
1355
1561
  const hasProjectNodeModules = await (0, utils_1.pathExists)(path_1.default.join(projectPath, "node_modules"));
1356
1562
  if (!hasProjectNodeModules) {
@@ -1358,9 +1564,9 @@ async function run() {
1358
1564
  ? "single project"
1359
1565
  : `${workspace.type.toUpperCase()} workspace`;
1360
1566
  const yarnHint = yarnPnP
1361
- ? " Yarn Plug'n'Play appears enabled; Dependency Radar currently requires node_modules linker."
1567
+ ? " Yarn Plug'n'Play appears enabled; lockfile graph data will be used where possible, but package metadata from zip/cache files is not crawled in this release."
1362
1568
  : "";
1363
- console.warn(colorLeadingSymbol(`⚠ node_modules was not found at ${projectPath}. Scan completeness may be reduced for this ${workspaceHint}. Run your package manager install (npm install, pnpm install, or yarn install) before scanning.${yarnHint}`));
1569
+ console.warn(colorLeadingSymbol(`⚠ node_modules was not found at ${projectPath}. Scan completeness may be reduced for this ${workspaceHint}. Run your package manager install before scanning when local package metadata is required.${yarnHint}`));
1364
1570
  }
1365
1571
  const rootPkg = await readJsonFile(path_1.default.join(projectPath, "package.json"));
1366
1572
  const projectDependencyPolicy = workspace.pnpmWorkspaceOverrides
@@ -1379,42 +1585,49 @@ async function run() {
1379
1585
  getToolVersion("pnpm", projectPath),
1380
1586
  getToolVersion("yarn", projectPath),
1381
1587
  ]);
1588
+ const bunVersion = await getToolVersion("bun", projectPath);
1382
1589
  const toolVersions = compactToolVersions({
1383
1590
  npm: npmVersion,
1384
1591
  pnpm: pnpmVersion,
1385
1592
  yarn: yarnVersion,
1593
+ bun: bunVersion,
1386
1594
  });
1387
1595
  const packageManagerVersion = scanManager === "npm"
1388
1596
  ? npmVersion
1389
1597
  : scanManager === "pnpm"
1390
1598
  ? pnpmVersion
1391
- : yarnVersion;
1392
- if (packageManager === "yarn" && yarnPnP) {
1393
- console.error("Yarn Plug'n'Play (nodeLinker: pnp) detected. This is not supported yet.");
1394
- console.error("Switch to nodeLinker: node-modules or run in a non-PnP environment.");
1395
- process.exit(1);
1396
- return;
1599
+ : scanManager === "yarn"
1600
+ ? yarnVersion
1601
+ : bunVersion;
1602
+ if (!opts.quiet && packageManager === "yarn" && yarnPnP) {
1603
+ console.log(statusLine("⚠", "Yarn Plug'n'Play detected; using lockfile-derived graph data where available."));
1397
1604
  }
1398
1605
  const packagePaths = workspace.packagePaths;
1399
1606
  const workspaceLabel = workspace.type === "none"
1400
1607
  ? "Single project"
1401
1608
  : `${workspace.type.toUpperCase()} workspace`;
1402
- console.log(statusLine("✔", `${workspaceLabel} detected`));
1403
- if (workspace.type !== "none" && scanManager !== workspace.type) {
1609
+ if (!opts.quiet) {
1610
+ console.log(statusLine("", `${workspaceLabel} detected`));
1611
+ }
1612
+ if (!opts.quiet && workspace.type !== "none" && scanManager !== workspace.type) {
1404
1613
  console.log(statusLine("✔", `Using ${scanManager.toUpperCase()} for dependency data (lockfile detected)`));
1405
1614
  }
1406
- const spinner = startSpinner(`Scanning ${workspaceLabel} at ${projectPath}`);
1615
+ const spinner = createProgressReporter(`Scanning ${workspaceLabel} at ${projectPath}`, opts.quiet);
1407
1616
  try {
1408
1617
  if (shouldWriteArtifacts) {
1409
1618
  await (0, utils_1.ensureDir)(tempDir);
1410
1619
  }
1411
- // Stage 2: run per-package collectors and persist raw tool outputs.
1412
1620
  const packageMetas = await readWorkspacePackageMeta(projectPath, packagePaths);
1413
1621
  const workspaceClassification = buildWorkspaceClassification(projectPath, packageMetas);
1414
1622
  const perPackageAudit = [];
1415
1623
  const perPackageLs = [];
1416
1624
  const perPackageImportGraph = [];
1417
1625
  const perPackageOutdated = [];
1626
+ const supplyChainResult = await (0, lockfileSignals_1.runLockfileSupplyChainSignals)(projectPath, tempDir, {
1627
+ persistToDisk: shouldWriteArtifacts,
1628
+ auditSignatures: opts.auditSignatures,
1629
+ offline: !opts.audit
1630
+ }).catch((err) => ({ ok: false, error: String(err) }));
1418
1631
  for (const meta of packageMetas) {
1419
1632
  spinner.update(`Scanning ${workspaceLabel} (${perPackageLs.length + 1}/${packageMetas.length}) at ${projectPath}`);
1420
1633
  const pkgTempDir = path_1.default.join(tempDir, meta.name.replace(/[^a-zA-Z0-9._-]/g, "_"));
@@ -1423,8 +1636,9 @@ async function run() {
1423
1636
  }
1424
1637
  const [a, l, ig, o] = await Promise.all([
1425
1638
  opts.audit
1639
+ && supportsRegistryCollectors(scanManager)
1426
1640
  ? (0, npmAudit_1.runPackageAudit)(meta.path, pkgTempDir, scanManager, yarnVersion, { persistToDisk: shouldWriteArtifacts }).catch((err) => ({ ok: false, error: String(err) }))
1427
- : Promise.resolve(undefined),
1641
+ : Promise.resolve(opts.audit ? skippedToolResult : undefined),
1428
1642
  (0, npmLs_1.runNpmLs)(meta.path, pkgTempDir, scanManager, {
1429
1643
  contextLabel: meta.name,
1430
1644
  lockfileSearchRoot: projectPath,
@@ -1435,31 +1649,36 @@ async function run() {
1435
1649
  persistToDisk: shouldWriteArtifacts,
1436
1650
  }).catch((err) => ({ ok: false, error: String(err) })),
1437
1651
  opts.outdated
1652
+ && supportsRegistryCollectors(scanManager)
1438
1653
  ? (0, npmOutdated_1.runPackageOutdated)(meta.path, pkgTempDir, scanManager, { persistToDisk: shouldWriteArtifacts }).catch((err) => ({ ok: false, error: String(err) }))
1439
- : Promise.resolve(undefined),
1654
+ : Promise.resolve(opts.outdated ? skippedToolResult : undefined),
1440
1655
  ]);
1441
1656
  perPackageAudit.push(a);
1442
1657
  perPackageLs.push(l);
1443
1658
  perPackageImportGraph.push(ig);
1444
1659
  perPackageOutdated.push({ attempted: Boolean(opts.outdated), result: o });
1445
1660
  }
1446
- // Stage 3: merge per-package results into a workspace-level view.
1447
1661
  if (opts.audit) {
1448
1662
  const auditOk = perPackageAudit.every((r) => r && r.ok);
1449
- if (auditOk) {
1450
- spinner.log(statusLine("✔", `${scanManager.toUpperCase()} audit data collected`));
1663
+ const auditSkipped = perPackageAudit.every((r) => (r === null || r === void 0 ? void 0 : r.status) === "skipped");
1664
+ if (!opts.quiet || !auditOk) {
1665
+ spinner.log(statusLine(auditSkipped ? "ℹ" : auditOk ? "✔" : "✖", `${scanManager.toUpperCase()} audit data ${auditSkipped ? "skipped" : auditOk ? "collected" : "unavailable"}`));
1666
+ }
1667
+ }
1668
+ if (opts.auditSignatures && !opts.quiet) {
1669
+ const audit = supplyChainResult.ok ? (_a = supplyChainResult.data) === null || _a === void 0 ? void 0 : _a.signatureAudit : undefined;
1670
+ if ((audit === null || audit === void 0 ? void 0 : audit.status) === "skipped") {
1671
+ spinner.log(statusLine("⚠", "npm audit signatures skipped (--offline)"));
1451
1672
  }
1452
1673
  else {
1453
- spinner.log(statusLine("✖", `${scanManager.toUpperCase()} audit data unavailable`));
1674
+ spinner.log(statusLine((audit === null || audit === void 0 ? void 0 : audit.ok) ? "✔" : "✖", `npm audit signatures ${(audit === null || audit === void 0 ? void 0 : audit.ok) ? "verified" : "unavailable"}`));
1454
1675
  }
1455
1676
  }
1456
1677
  if (opts.outdated) {
1457
1678
  const outdatedOk = perPackageOutdated.every((r) => r.result && r.result.ok);
1458
- if (outdatedOk) {
1459
- spinner.log(statusLine("✔", `${scanManager.toUpperCase()} outdated data collected`));
1460
- }
1461
- else {
1462
- spinner.log(statusLine("✖", `${scanManager.toUpperCase()} outdated data unavailable`));
1679
+ const outdatedSkipped = perPackageOutdated.every((r) => { var _a; return ((_a = r.result) === null || _a === void 0 ? void 0 : _a.status) === "skipped"; });
1680
+ if (!opts.quiet || !outdatedOk) {
1681
+ spinner.log(statusLine(outdatedSkipped ? "ℹ" : outdatedOk ? "✔" : "✖", `${scanManager.toUpperCase()} outdated data ${outdatedSkipped ? "skipped" : outdatedOk ? "collected" : "unavailable"}`));
1463
1682
  }
1464
1683
  }
1465
1684
  const mergedAuditData = mergeAuditResults(perPackageAudit.map((r) => (r && r.ok ? r.data : undefined)));
@@ -1476,7 +1695,6 @@ async function run() {
1476
1695
  : undefined;
1477
1696
  const npmLsResult = { ok: true, data: mergedGraphData };
1478
1697
  const importGraphResult = { ok: true, data: mergedImportGraphData };
1479
- // Build a merged package.json view for aggregator direct-dep checks.
1480
1698
  const mergedPkgForAggregator = mergeDepsFromWorkspace(packageMetas, workspaceClassification.workspacePackageNames, workspaceClassification.localDependencyNames);
1481
1699
  const auditFailure = opts.audit
1482
1700
  ? perPackageAudit.find((r) => r && !r.ok)
@@ -1491,18 +1709,18 @@ async function run() {
1491
1709
  if (lsFailures.length > 0) {
1492
1710
  const packageList = lsFailures.map((entry) => { var _a; return (_a = entry.meta) === null || _a === void 0 ? void 0 : _a.name; }).filter(Boolean);
1493
1711
  spinner.log(`Dependency tree warning: ${lsFailures.length} package${lsFailures.length === 1 ? "" : "s"} failed (${packageList.join(", ")}).`);
1494
- spinner.log(`First dependency tree error: ${((_a = lsFailures[0].result) === null || _a === void 0 ? void 0 : _a.error) || "pnpm ls failed"}`);
1712
+ spinner.log(`First dependency tree error: ${((_b = lsFailures[0].result) === null || _b === void 0 ? void 0 : _b.error) || "pnpm ls failed"}`);
1495
1713
  }
1496
1714
  if (importFailures.length > 0) {
1497
1715
  spinner.log(`Import graph warning: ${importFailures.length} package${importFailures.length === 1 ? "" : "s"} failed (${importFailures[0].error || "import graph failed"})`);
1498
1716
  }
1499
- // Stage 4: aggregate all signals into the final report model.
1500
1717
  const aggregated = await (0, aggregator_1.aggregateData)({
1501
1718
  projectPath,
1502
1719
  auditResult,
1503
1720
  npmLsResult,
1504
1721
  importGraphResult,
1505
1722
  outdatedResult,
1723
+ supplyChainResult,
1506
1724
  pkgOverride: mergedPkgForAggregator,
1507
1725
  projectPackageJson: rootPkg,
1508
1726
  ...(projectDependencyPolicy ? { projectDependencyPolicy } : {}),
@@ -1530,78 +1748,258 @@ async function run() {
1530
1748
  arch: process.arch,
1531
1749
  ci: isCI(),
1532
1750
  ...(toolVersions ? { toolVersions } : {}),
1751
+ ...(typeof opts.targetNodeMajor === "number" ? { targetNodeMajor: opts.targetNodeMajor } : {}),
1533
1752
  });
1534
1753
  dependencyCount = Object.keys(aggregated.dependencies).length;
1535
1754
  const importGraphComplete = perPackageImportGraph.every((result) => result.ok);
1536
- summary = buildCliSummary(aggregated, {
1755
+ const summary = buildCliSummary(aggregated, {
1537
1756
  importGraphComplete,
1538
1757
  });
1539
- policyViolations = (0, failOn_1.evaluatePolicyViolations)(aggregated, opts.failOn);
1540
- if (workspace.type !== "none") {
1758
+ const policyViolations = (0, failOn_1.evaluatePolicyViolations)(aggregated, opts.failOn);
1759
+ if (!opts.quiet && options.emitWorkspacePackageSummary && workspace.type !== "none") {
1541
1760
  console.log(`Detected ${workspace.type.toUpperCase()} workspace with ${packagePaths.length} package${packagePaths.length === 1 ? "" : "s"}.`);
1542
1761
  }
1543
1762
  if (dependencyCount > 0 && shouldWriteArtifacts) {
1544
- if (opts.json) {
1763
+ if (opts.format === "json") {
1545
1764
  await promises_1.default.mkdir(path_1.default.dirname(outputPath), { recursive: true });
1546
1765
  await promises_1.default.writeFile(outputPath, JSON.stringify(aggregated, null, 2), "utf8");
1547
1766
  }
1767
+ else if (opts.format === "sarif") {
1768
+ await promises_1.default.mkdir(path_1.default.dirname(outputPath), { recursive: true });
1769
+ await promises_1.default.writeFile(outputPath, (0, outputFormats_1.renderSarif)(aggregated), "utf8");
1770
+ }
1771
+ else if (opts.format === "cyclonedx") {
1772
+ await promises_1.default.mkdir(path_1.default.dirname(outputPath), { recursive: true });
1773
+ await promises_1.default.writeFile(outputPath, (0, outputFormats_1.renderCycloneDx)(aggregated), "utf8");
1774
+ }
1775
+ else if (opts.format === "spdx") {
1776
+ await promises_1.default.mkdir(path_1.default.dirname(outputPath), { recursive: true });
1777
+ await promises_1.default.writeFile(outputPath, (0, outputFormats_1.renderSpdx)(aggregated), "utf8");
1778
+ }
1548
1779
  else {
1549
1780
  await (0, report_1.renderReport)(aggregated, outputPath);
1550
1781
  }
1551
1782
  outputCreated = true;
1552
1783
  }
1553
1784
  spinner.stop(true);
1554
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
1555
- console.log(statusLine("✔", `Scan complete: ${dependencyCount} dependencies analysed in ${elapsed}s`));
1556
- if (!shouldWriteArtifacts) {
1557
- console.log(statusLine("ℹ", "Report output disabled (--no-report); no report artifacts written."));
1785
+ const elapsedSeconds = ((Date.now() - startTime) / 1000).toFixed(1);
1786
+ if (!opts.quiet) {
1787
+ console.log(statusLine("✔", `Scan complete: ${dependencyCount} dependencies analysed in ${elapsedSeconds}s`));
1558
1788
  }
1559
- else if (outputCreated) {
1560
- console.log(statusLine("✔", `${opts.json ? "JSON" : "Report"} written to ${outputPath}`));
1561
- }
1562
- else {
1563
- console.log(statusLine("✖", `No dependencies were found - ${opts.json ? "JSON file" : "Report"} not created`));
1789
+ if (!opts.quiet && options.emitArtifactSummary) {
1790
+ if (!shouldWriteArtifacts) {
1791
+ console.log(statusLine("ℹ", "Report output disabled (--no-report); no report artifacts written."));
1792
+ }
1793
+ else if (outputCreated) {
1794
+ console.log(statusLine("✔", `${formatLabel(opts.format)} written to ${outputPath}`));
1795
+ }
1796
+ else {
1797
+ console.log(statusLine("✖", `No dependencies were found - ${formatLabel(opts.format)} not created`));
1798
+ }
1564
1799
  }
1800
+ return {
1801
+ aggregated,
1802
+ summary,
1803
+ policyViolations,
1804
+ dependencyCount,
1805
+ elapsedSeconds,
1806
+ outputCreated,
1807
+ outputPath,
1808
+ shouldWriteArtifacts,
1809
+ collectorAvailability: {
1810
+ audit: !opts.audit
1811
+ ? "skipped"
1812
+ : perPackageAudit.every((result) => result && result.ok)
1813
+ ? "available"
1814
+ : "unavailable",
1815
+ importGraphComplete,
1816
+ },
1817
+ workspace,
1818
+ packagePaths,
1819
+ };
1565
1820
  }
1566
1821
  catch (err) {
1567
1822
  spinner.stop(false);
1568
- console.error("Failed to generate report:", err);
1569
- process.exit(1);
1823
+ throw err;
1570
1824
  }
1571
1825
  finally {
1572
1826
  if (shouldWriteArtifacts) {
1573
1827
  if (!opts.keepTemp) {
1574
1828
  await (0, utils_1.removeDir)(tempDir);
1575
1829
  }
1576
- else {
1830
+ else if (!opts.quiet) {
1577
1831
  console.log(statusLine("✔", `Temporary data kept at ${tempDir}`));
1578
1832
  }
1579
1833
  }
1580
1834
  }
1581
- if (opts.open && !shouldWriteArtifacts) {
1582
- console.log(statusLine("✖", "Skipping auto-open because --no-report is enabled."));
1835
+ }
1836
+ async function runScanCommand(opts) {
1837
+ const result = await executeAnalysis(opts, {
1838
+ shouldWriteArtifacts: !opts.noReport,
1839
+ emitArtifactSummary: true,
1840
+ emitWorkspacePackageSummary: true,
1841
+ });
1842
+ if (!opts.quiet) {
1843
+ if (opts.open && !result.shouldWriteArtifacts) {
1844
+ console.log(statusLine("✖", "Skipping auto-open because --no-report is enabled."));
1845
+ }
1846
+ else if (opts.open && result.outputCreated && !isCI()) {
1847
+ console.log(statusLine("↗", `Opening ${path_1.default.basename(result.outputPath)} using system default ${opts.format === "html" ? "browser" : "application"}.`));
1848
+ openInBrowser(result.outputPath);
1849
+ }
1850
+ else if (opts.open && result.outputCreated && isCI()) {
1851
+ console.log(statusLine("✖", "Skipping auto-open in CI environment."));
1852
+ }
1853
+ }
1854
+ printCliSummary(result.summary);
1855
+ printPolicyViolations(result.policyViolations);
1856
+ if (!opts.quiet) {
1857
+ console.log(`Enrich this scan with maintenance signals, upgrade readiness, and risk modelling at ${formatTerminalLink("https://www.dependency-radar.com", "https://www.dependency-radar.com")}`);
1858
+ }
1859
+ if (result.policyViolations.length > 0) {
1860
+ process.exit(1);
1861
+ }
1862
+ }
1863
+ async function runExplainCommand(opts) {
1864
+ var _a;
1865
+ const packageName = (_a = opts.packageName) === null || _a === void 0 ? void 0 : _a.trim();
1866
+ if (!packageName) {
1867
+ console.error("Missing package name for explain. Usage: dependency-radar explain <package-name>");
1868
+ process.exit(1);
1869
+ return;
1870
+ }
1871
+ const result = await executeAnalysis(opts, {
1872
+ shouldWriteArtifacts: false,
1873
+ emitArtifactSummary: false,
1874
+ emitWorkspacePackageSummary: false,
1875
+ });
1876
+ const matches = (0, explain_1.findDependenciesByPackageName)(result.aggregated, packageName);
1877
+ console.log("");
1878
+ console.log((0, explain_1.formatExplainOutput)(packageName, matches, {
1879
+ audit: result.collectorAvailability.audit,
1880
+ importGraphComplete: result.collectorAvailability.importGraphComplete,
1881
+ }));
1882
+ if (matches.length === 0) {
1883
+ process.exit(1);
1884
+ }
1885
+ }
1886
+ async function runWhyCommand(opts) {
1887
+ var _a;
1888
+ const packageName = (_a = opts.packageName) === null || _a === void 0 ? void 0 : _a.trim();
1889
+ if (!packageName) {
1890
+ console.error("Missing package name for why. Usage: dependency-radar why <package-name>");
1891
+ process.exit(1);
1892
+ return;
1893
+ }
1894
+ const result = await executeAnalysis(opts, {
1895
+ shouldWriteArtifacts: false,
1896
+ emitArtifactSummary: false,
1897
+ emitWorkspacePackageSummary: false,
1898
+ });
1899
+ console.log("");
1900
+ const output = (0, why_1.formatWhyOutput)(result.aggregated, packageName);
1901
+ console.log(output);
1902
+ if (output.startsWith("Package not found")) {
1903
+ process.exit(1);
1904
+ }
1905
+ }
1906
+ async function runCompareCommand(opts) {
1907
+ var _a;
1908
+ const previousPath = (_a = opts.comparePath) === null || _a === void 0 ? void 0 : _a.trim();
1909
+ if (!previousPath) {
1910
+ console.error("Missing previous report path. Usage: dependency-radar compare <previous dependency-radar.json>");
1911
+ process.exit(1);
1912
+ return;
1583
1913
  }
1584
- else if (opts.open && outputCreated && !isCI()) {
1585
- console.log(statusLine("↗", `Opening ${path_1.default.basename(outputPath)} using system default ${opts.json ? "application" : "browser"}.`));
1586
- openInBrowser(outputPath);
1914
+ let previous;
1915
+ try {
1916
+ const parsed = JSON.parse(await promises_1.default.readFile(path_1.default.resolve(previousPath), "utf8"));
1917
+ const schemaVersion = parsed && typeof parsed === "object" ? parsed.schemaVersion : undefined;
1918
+ if (!parsed ||
1919
+ typeof parsed !== "object" ||
1920
+ schemaVersion !== schema_1.REPORT_SCHEMA_VERSION ||
1921
+ !parsed.project ||
1922
+ !parsed.summary ||
1923
+ !parsed.dependencies ||
1924
+ typeof parsed.dependencies !== "object") {
1925
+ console.error(`Previous report schema mismatch: expected schemaVersion ${schema_1.REPORT_SCHEMA_VERSION}, found ${schemaVersion !== null && schemaVersion !== void 0 ? schemaVersion : "missing"}.`);
1926
+ process.exit(1);
1927
+ return;
1928
+ }
1929
+ previous = parsed;
1587
1930
  }
1588
- else if (opts.open && outputCreated && isCI()) {
1589
- console.log(statusLine("✖", "Skipping auto-open in CI environment."));
1931
+ catch (err) {
1932
+ console.error(`Could not read previous report at ${previousPath}: ${err instanceof Error ? err.message : String(err)}`);
1933
+ process.exit(1);
1934
+ return;
1590
1935
  }
1591
- if (summary) {
1592
- printCliSummary(summary);
1936
+ const result = await executeAnalysis(opts, {
1937
+ shouldWriteArtifacts: false,
1938
+ emitArtifactSummary: false,
1939
+ emitWorkspacePackageSummary: false,
1940
+ });
1941
+ console.log("");
1942
+ console.log((0, compare_1.formatCompareOutput)((0, compare_1.compareReports)(previous, result.aggregated)));
1943
+ }
1944
+ /**
1945
+ * Run the CLI entrypoint and dispatch to the selected command.
1946
+ */
1947
+ async function run() {
1948
+ const opts = parseArgs(process.argv.slice(2));
1949
+ if (opts.invalidCommand) {
1950
+ printHelp();
1951
+ process.exit(1);
1952
+ return;
1593
1953
  }
1594
- else {
1595
- console.log("");
1954
+ try {
1955
+ if ((opts.schema && !opts.commandProvided) || opts.command === "schema") {
1956
+ await runSchemaCommand(opts);
1957
+ return;
1958
+ }
1959
+ if (opts.command === "explain") {
1960
+ await runExplainCommand(opts);
1961
+ return;
1962
+ }
1963
+ if (opts.command === "why") {
1964
+ await runWhyCommand(opts);
1965
+ return;
1966
+ }
1967
+ if (opts.command === "compare") {
1968
+ await runCompareCommand(opts);
1969
+ return;
1970
+ }
1971
+ await runScanCommand(opts);
1596
1972
  }
1597
- printPolicyViolations(policyViolations);
1598
- // Always show CTA as the last output
1599
- console.log("Enrich this scan with maintenance signals, upgrade readiness, and risk modelling at dependency-radar.com");
1600
- if (policyViolations.length > 0) {
1973
+ catch (err) {
1974
+ console.error("Failed to generate report:", err);
1601
1975
  process.exit(1);
1602
1976
  }
1603
1977
  }
1978
+ async function runSchemaCommand(opts) {
1979
+ const schema = (0, schema_1.renderReportJsonSchema)();
1980
+ if (opts.outProvided) {
1981
+ const outputPath = path_1.default.resolve(opts.out);
1982
+ await promises_1.default.mkdir(path_1.default.dirname(outputPath), { recursive: true });
1983
+ await promises_1.default.writeFile(outputPath, schema, "utf8");
1984
+ if (!opts.quiet) {
1985
+ console.log(statusLine("✔", `JSON schema written to ${outputPath}`));
1986
+ }
1987
+ return;
1988
+ }
1989
+ console.log(schema);
1990
+ }
1604
1991
  run();
1992
+ function createProgressReporter(text, quiet) {
1993
+ if (!quiet)
1994
+ return startSpinner(text);
1995
+ return {
1996
+ stop: () => { },
1997
+ update: () => { },
1998
+ log: (line) => {
1999
+ process.stdout.write(`${colorLeadingSymbol(line)}\n`);
2000
+ },
2001
+ };
2002
+ }
1605
2003
  /**
1606
2004
  * Displays a rotating CLI spinner with a message and returns controls to stop, update, or log lines.
1607
2005
  *