@westbayberry/dg 1.3.3 → 2.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.
Files changed (126) hide show
  1. package/LICENSE +1 -201
  2. package/NOTICE +1 -4
  3. package/README.md +293 -0
  4. package/dist/api/analyze.js +210 -0
  5. package/dist/audit/deep.js +180 -0
  6. package/dist/audit/detectors.js +247 -0
  7. package/dist/audit/events.js +41 -0
  8. package/dist/audit/rules.js +426 -0
  9. package/dist/audit-ui/AuditApp.js +39 -0
  10. package/dist/audit-ui/components/AuditHeader.js +24 -0
  11. package/dist/audit-ui/components/AuditResultsView.js +307 -0
  12. package/dist/audit-ui/components/DeepStatusRow.js +11 -0
  13. package/dist/audit-ui/export.js +85 -0
  14. package/dist/audit-ui/format.js +34 -0
  15. package/dist/audit-ui/launch.js +34 -0
  16. package/dist/auth/device-login.js +271 -0
  17. package/dist/auth/env-token.js +6 -0
  18. package/dist/auth/login-app.js +156 -0
  19. package/dist/auth/store.js +147 -0
  20. package/dist/bin/dg.js +71 -0
  21. package/dist/commands/audit.js +362 -0
  22. package/dist/commands/completion.js +116 -0
  23. package/dist/commands/config.js +99 -0
  24. package/dist/commands/doctor.js +39 -0
  25. package/dist/commands/explain.js +100 -0
  26. package/dist/commands/guard-commit.js +158 -0
  27. package/dist/commands/help.js +74 -0
  28. package/dist/commands/licenses.js +435 -0
  29. package/dist/commands/login.js +81 -0
  30. package/dist/commands/logout.js +37 -0
  31. package/dist/commands/router.js +98 -0
  32. package/dist/commands/scan.js +18 -0
  33. package/dist/commands/service.js +475 -0
  34. package/dist/commands/setup.js +302 -0
  35. package/dist/commands/status.js +115 -0
  36. package/dist/commands/suggest.js +35 -0
  37. package/dist/commands/types.js +4 -0
  38. package/dist/commands/unavailable.js +11 -0
  39. package/dist/commands/uninstall.js +111 -0
  40. package/dist/commands/update.js +210 -0
  41. package/dist/commands/verify.js +151 -0
  42. package/dist/commands/version.js +22 -0
  43. package/dist/commands/wrap.js +55 -0
  44. package/dist/config/settings.js +302 -0
  45. package/dist/install-ui/LiveInstall.js +24 -0
  46. package/dist/install-ui/block-render.js +85 -0
  47. package/dist/install-ui/live-install-app.js +48 -0
  48. package/dist/install-ui/prompt.js +24 -0
  49. package/dist/launcher/classify.js +116 -0
  50. package/dist/launcher/env.js +53 -0
  51. package/dist/launcher/live-install.js +50 -0
  52. package/dist/launcher/output-redaction.js +77 -0
  53. package/dist/launcher/preflight-prompt.js +139 -0
  54. package/dist/launcher/resolve-real-binary.js +73 -0
  55. package/dist/launcher/run.js +417 -0
  56. package/dist/policy/evaluate.js +128 -0
  57. package/dist/presentation/mode.js +52 -0
  58. package/dist/presentation/theme.js +29 -0
  59. package/dist/proxy/buffer-budget.js +64 -0
  60. package/dist/proxy/ca.js +126 -0
  61. package/dist/proxy/classify-host.js +26 -0
  62. package/dist/proxy/enforcement.js +102 -0
  63. package/dist/proxy/metadata-map.js +336 -0
  64. package/dist/proxy/server.js +919 -0
  65. package/dist/proxy/upstream-proxy.js +102 -0
  66. package/dist/proxy/worker.js +39 -0
  67. package/dist/publish-set/collect.js +51 -0
  68. package/dist/publish-set/no-exec-shell.js +19 -0
  69. package/dist/publish-set/npm.js +109 -0
  70. package/dist/publish-set/pack.js +36 -0
  71. package/dist/publish-set/pypi.js +59 -0
  72. package/dist/runtime/cli.js +17 -0
  73. package/dist/runtime/first-run.js +60 -0
  74. package/dist/runtime/node-version.js +58 -0
  75. package/dist/runtime/nudges.js +105 -0
  76. package/dist/scan/analyze-worker.js +21 -0
  77. package/dist/scan/collect.js +153 -0
  78. package/dist/scan/command.js +159 -0
  79. package/dist/scan/discovery.js +209 -0
  80. package/dist/scan/render.js +240 -0
  81. package/dist/scan/scanner-report.js +82 -0
  82. package/dist/scan/staged.js +173 -0
  83. package/dist/scan/types.js +1 -0
  84. package/dist/scan-ui/LegacyApp.js +156 -0
  85. package/dist/scan-ui/alt-screen.js +84 -0
  86. package/dist/scan-ui/api-aliases.js +1 -0
  87. package/dist/scan-ui/components/ErrorView.js +23 -0
  88. package/dist/scan-ui/components/InteractiveResultsView.js +1179 -0
  89. package/dist/scan-ui/components/ProgressBar.js +89 -0
  90. package/dist/scan-ui/components/ProjectSelector.js +62 -0
  91. package/dist/scan-ui/components/ScoreHeader.js +20 -0
  92. package/dist/scan-ui/components/SetupBanner.js +13 -0
  93. package/dist/scan-ui/components/Spinner.js +4 -0
  94. package/dist/scan-ui/format-helpers.js +40 -0
  95. package/dist/scan-ui/hooks/useExpandAnimation.js +40 -0
  96. package/dist/scan-ui/hooks/useScan.js +113 -0
  97. package/dist/scan-ui/hooks/useTerminalSize.js +24 -0
  98. package/dist/scan-ui/launch.js +27 -0
  99. package/dist/scan-ui/logo.js +91 -0
  100. package/dist/scan-ui/shims.js +30 -0
  101. package/dist/security/sanitize.js +28 -0
  102. package/dist/service/state.js +837 -0
  103. package/dist/service/trust-store.js +234 -0
  104. package/dist/service/worker.js +88 -0
  105. package/dist/setup/git-hook.js +244 -0
  106. package/dist/setup/optional-support.js +58 -0
  107. package/dist/setup/plan.js +935 -0
  108. package/dist/state/cleanup-registry.js +60 -0
  109. package/dist/state/index.js +5 -0
  110. package/dist/state/locks.js +161 -0
  111. package/dist/state/paths.js +24 -0
  112. package/dist/state/sessions.js +170 -0
  113. package/dist/state/store.js +50 -0
  114. package/dist/telemetry/events.js +40 -0
  115. package/dist/util/git.js +20 -0
  116. package/dist/util/tty-prompt.js +43 -0
  117. package/dist/verify/local.js +400 -0
  118. package/dist/verify/package-check.js +240 -0
  119. package/dist/verify/preflight.js +698 -0
  120. package/dist/verify/render.js +184 -0
  121. package/dist/verify/types.js +1 -0
  122. package/package.json +33 -50
  123. package/dist/index.mjs +0 -54116
  124. package/dist/postinstall.mjs +0 -731
  125. package/dist/python-hook/dg_pip_hook.pth +0 -1
  126. package/dist/python-hook/dg_pip_hook.py +0 -130
@@ -0,0 +1,1179 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useReducer, useMemo, useRef, useEffect, useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import chalk from "chalk";
5
+ import { writeFileSync } from "node:fs";
6
+ import { resolve as resolvePath } from "node:path";
7
+ import { isLoggedIn } from "../shims.js";
8
+ import { ScoreHeader } from "./ScoreHeader.js";
9
+ import { useExpandAnimation } from "../hooks/useExpandAnimation.js";
10
+ import { useTerminalSize } from "../hooks/useTerminalSize.js";
11
+ import { clearScreen } from "../alt-screen.js";
12
+ import { pad, truncate, groupPackages as sharedGroupPackages, formatUsage } from "../format-helpers.js";
13
+ function groupPackages(packages) {
14
+ return sharedGroupPackages(packages, "fingerprint");
15
+ }
16
+ const SEVERITY_LABELS = {
17
+ 5: "CRIT",
18
+ 4: "HIGH",
19
+ 3: "MED",
20
+ 2: "LOW",
21
+ 1: "INFO",
22
+ };
23
+ const SEVERITY_COLORS = {
24
+ 5: (s) => chalk.red.bold(s),
25
+ 4: (s) => chalk.red(s),
26
+ 3: (s) => chalk.yellow(s),
27
+ 2: (s) => chalk.cyan(s),
28
+ 1: (s) => chalk.gray(s),
29
+ };
30
+ function actionBadge(action) {
31
+ // Scanner is the source of truth — badge from the server's action, not score.
32
+ if (action === "block")
33
+ return { label: "Block", color: chalk.red };
34
+ if (action === "warn")
35
+ return { label: "Warn", color: chalk.yellow };
36
+ if (action === "analysis_incomplete")
37
+ return { label: "Unknown", color: chalk.cyan };
38
+ return { label: "Pass", color: chalk.green };
39
+ }
40
+ function isYankedIncomplete(pkg) {
41
+ if (pkg.action !== "analysis_incomplete")
42
+ return false;
43
+ const haystack = [...(pkg.reasons ?? []), ...pkg.findings.map((f) => f.title ?? "")]
44
+ .join(" ")
45
+ .toLowerCase();
46
+ return haystack.includes("unpublish") || haystack.includes("yank") || haystack.includes("removed from the registr");
47
+ }
48
+ export function packageBadge(pkg) {
49
+ if (isYankedIncomplete(pkg))
50
+ return { label: "Removed", color: chalk.yellow };
51
+ return actionBadge(pkg.action);
52
+ }
53
+ const EVIDENCE_LIMIT = 2;
54
+ // Fixed lines outside the scrollable group area:
55
+ // 5 ScoreHeader box | 2 Flagged box top | 2 scroll indicators
56
+ // 1 Flagged box bottom | 4 Clean/Duration box | 1 help bar | 1 margin
57
+ const FIXED_CHROME = 21;
58
+ function firstPackage(group) {
59
+ const rep = group.packages[0];
60
+ if (!rep)
61
+ throw new Error("package group cannot be empty");
62
+ return rep;
63
+ }
64
+ function findingsSummaryHeight(group) {
65
+ const rep = firstPackage(group);
66
+ const visibleFindings = rep.findings.filter((f) => f.severity > 1);
67
+ const isFree = visibleFindings.length > 0 && !visibleFindings[0]?.title;
68
+ let h = 0;
69
+ if (rep.license)
70
+ h += 1; // license info line
71
+ if (isFree) {
72
+ // Free tier: just the upgrade prompt line
73
+ h += 1;
74
+ }
75
+ else if (visibleFindings.length > 0) {
76
+ // Paid tier: one line per finding
77
+ h += visibleFindings.length;
78
+ }
79
+ else if (rep.score > 0) {
80
+ h += 1; // score-only fallback
81
+ }
82
+ if (group.packages.length > 3)
83
+ h += 1;
84
+ return h;
85
+ }
86
+ function findingsDetailHeight(group, safeVersions) {
87
+ const rep = firstPackage(group);
88
+ const visibleFindings = rep.findings
89
+ .filter((f) => f.severity > 1)
90
+ .sort((a, b) => b.severity - a.severity);
91
+ let h = 0;
92
+ if (rep.license)
93
+ h += 1; // license info line
94
+ if (group.packages.length > 3)
95
+ h += 1;
96
+ // Free tier: reasons + upgrade hint
97
+ if (visibleFindings.length === 0 && rep.score > 0) {
98
+ h += (rep.reasons ?? []).length;
99
+ h += 1; // upgrade hint
100
+ }
101
+ const hasEvidence = visibleFindings.some((f) => f.evidence && f.evidence.length > 0);
102
+ for (const finding of visibleFindings) {
103
+ h += 1; // badge + id
104
+ h += 1; // title
105
+ const evidence = finding.evidence ?? [];
106
+ h += Math.min(evidence.length, EVIDENCE_LIMIT);
107
+ if (evidence.length > EVIDENCE_LIMIT)
108
+ h += 1;
109
+ }
110
+ // Upgrade hint for pro tier (findings but no evidence)
111
+ if (visibleFindings.length > 0 && !hasEvidence)
112
+ h += 1;
113
+ if (rep.recommendation)
114
+ h += 1;
115
+ if (safeVersions[rep.name])
116
+ h += 1;
117
+ return h;
118
+ }
119
+ function groupRowHeight(group, level, safeVersions) {
120
+ if (level === null)
121
+ return 1;
122
+ if (level === "summary")
123
+ return 1 + findingsSummaryHeight(group);
124
+ return 1 + findingsDetailHeight(group, safeVersions);
125
+ }
126
+ function nameVer(p) {
127
+ return p.version ? `${p.name}@${p.version}` : p.name;
128
+ }
129
+ function groupNames(group) {
130
+ if (group.packages.length === 1)
131
+ return nameVer(firstPackage(group));
132
+ if (group.packages.length <= 3)
133
+ return group.packages.map(nameVer).join(", ");
134
+ return `${nameVer(firstPackage(group))} + ${group.packages.length - 1} similar`;
135
+ }
136
+ function affectsLine(group) {
137
+ const labels = group.packages.map(nameVer);
138
+ if (labels.length <= 5)
139
+ return labels.join(", ");
140
+ return labels.slice(0, 5).join(", ") + ` + ${labels.length - 5} more`;
141
+ }
142
+ // Chrome lines in detail pane mode:
143
+ // 7 ScoreHeader | 3 detail pane borders+header | 2 scroll indicators
144
+ // 4 Clean/Duration box | 1 separator | 1 help bar
145
+ const DETAIL_PANE_CHROME = 20;
146
+ function buildDetailLines(group, safeVersion, maxWidth) {
147
+ const rep = firstPackage(group);
148
+ const visibleFindings = rep.findings
149
+ .filter((f) => f.severity > 1)
150
+ .sort((a, b) => b.severity - a.severity);
151
+ const lines = [];
152
+ if (group.packages.length > 3) {
153
+ lines.push(_jsxs(Text, { dimColor: true, children: ["Affects: ", affectsLine(group)] }, "affects"));
154
+ lines.push(_jsx(Text, { children: "" }, "affects-gap"));
155
+ }
156
+ if (rep.score > 0) {
157
+ lines.push(_jsxs(Text, { dimColor: true, children: ["Score: ", rep.score, "/100"] }, "score-info"));
158
+ lines.push(_jsx(Text, { children: "" }, "score-gap"));
159
+ }
160
+ // Paid tier: show findings with category + severity
161
+ if (visibleFindings.length > 0) {
162
+ for (let i = 0; i < visibleFindings.length; i++) {
163
+ const f = visibleFindings[i];
164
+ if (!f)
165
+ continue;
166
+ const sevLabel = SEVERITY_LABELS[f.severity] ?? "INFO";
167
+ const sevColor = SEVERITY_COLORS[f.severity] ?? SEVERITY_COLORS[1] ?? chalk.dim;
168
+ const connector = i === visibleFindings.length - 1 ? T.last : T.branch;
169
+ lines.push(_jsxs(Text, { children: [connector, " ", sevColor(pad(sevLabel, 8)), chalk.dim(f.category ?? "")] }, `finding-${i}`));
170
+ }
171
+ lines.push(_jsx(Text, { children: "" }, "findings-gap"));
172
+ }
173
+ else if (rep.score > 0) {
174
+ // Free tier: no findings returned — show upgrade prompt
175
+ lines.push(_jsxs(Text, { color: "yellow", children: [chalk.yellow("\u2192"), " Upgrade to Pro to see risk categories"] }, "upgrade"));
176
+ lines.push(_jsx(Text, { children: "" }, "upgrade-gap"));
177
+ }
178
+ if (rep.recommendation) {
179
+ lines.push(_jsxs(Text, { children: [chalk.dim("Recommendation:"), " ", chalk.cyan(truncate(rep.recommendation, maxWidth - 18))] }, "recommendation"));
180
+ }
181
+ if (safeVersion) {
182
+ lines.push(_jsx(Text, { children: chalk.green(`Safe version: ${rep.name}@${safeVersion}`) }, "safe"));
183
+ }
184
+ return lines;
185
+ }
186
+ function viewReducer(_state, action) {
187
+ switch (action.type) {
188
+ case "MOVE":
189
+ return { ..._state, cursor: action.cursor, viewport: action.viewport };
190
+ case "EXPAND":
191
+ return { ..._state, expandedIndex: action.expandedIndex, expandLevel: action.expandLevel, viewport: action.viewport };
192
+ case "MOVE_EXPAND":
193
+ return { cursor: action.cursor, expandedIndex: action.expandedIndex, expandLevel: action.expandLevel, viewport: action.viewport };
194
+ }
195
+ }
196
+ export const InteractiveResultsView = ({ result, config: _config, durationMs, onExit, onBack, discoveredTotal, userStatus, scanUsage: scanUsageProp, initialView, }) => {
197
+ // Prefer the server's uniform usage block ("used / limit packages this
198
+ // month"); fall back to the legacy freeScansRemaining field, then to the
199
+ // generic placeholder from bin.ts. usageNearLimit drives the yellow + nudge.
200
+ const usageDisplay = result.usage ? formatUsage(result.usage) : null;
201
+ const scanUsage = usageDisplay
202
+ ? usageDisplay.text
203
+ : result.freeScansRemaining !== undefined
204
+ ? `${result.freeScansRemaining.toLocaleString()} packages left`
205
+ : scanUsageProp;
206
+ const usageNearLimit = usageDisplay?.nearLimit ?? false;
207
+ // Bucket by the server `action`, never by score: the server can return
208
+ // block/warn at score 0 (policy verdict / yanked / cooldown), and those must
209
+ // surface as flagged, not hide in "clean".
210
+ const flagged = useMemo(() => result.packages.filter((p) => (p.action ?? "pass") !== "pass"), [result.packages]);
211
+ const clean = useMemo(() => result.packages.filter((p) => (p.action ?? "pass") === "pass"), [result.packages]);
212
+ const total = result.packages.length;
213
+ const [searchQuery, setSearchQuery] = useState("");
214
+ const allGroups = useMemo(() => groupPackages(flagged), [flagged]);
215
+ const allGroupCount = allGroups.length;
216
+ const groups = useMemo(() => {
217
+ if (!searchQuery)
218
+ return allGroups;
219
+ const q = searchQuery.toLowerCase();
220
+ return allGroups.filter(g => g.packages.some(p => p.name.toLowerCase().includes(q)));
221
+ }, [allGroups, searchQuery]);
222
+ const [view, dispatchView] = useReducer(viewReducer, {
223
+ cursor: 0,
224
+ expandLevel: null,
225
+ expandedIndex: null,
226
+ viewport: 0,
227
+ });
228
+ const viewRef = useRef(view);
229
+ viewRef.current = view;
230
+ const [detailPane, setDetailPane] = useState(null);
231
+ const detailPaneRef = useRef(detailPane);
232
+ detailPaneRef.current = detailPane;
233
+ const [showHelp, setShowHelp] = useState(false);
234
+ const showHelpRef = useRef(showHelp);
235
+ showHelpRef.current = showHelp;
236
+ // License-grouped overlay. Toggled with `l`. Shows packages grouped by
237
+ // SPDX license id, sorted by count desc, color-coded by risk category.
238
+ // Up/Down to select a row, Enter to drill into the packages for that
239
+ // license, Esc to back out, l/Esc again to close the overlay.
240
+ const [showLicenses, setShowLicenses] = useState(initialView === "licenses");
241
+ const showLicensesRef = useRef(showLicenses);
242
+ showLicensesRef.current = showLicenses;
243
+ const [licenseCursor, setLicenseCursor] = useState(0);
244
+ const licenseCursorRef = useRef(licenseCursor);
245
+ licenseCursorRef.current = licenseCursor;
246
+ const [licenseDetailIdx, setLicenseDetailIdx] = useState(null);
247
+ const licenseDetailIdxRef = useRef(licenseDetailIdx);
248
+ licenseDetailIdxRef.current = licenseDetailIdx;
249
+ const [licenseDetailScroll, setLicenseDetailScroll] = useState(0);
250
+ // Search inside the license drill-in: type-to-filter packages by name.
251
+ const [licenseSearchMode, setLicenseSearchMode] = useState(false);
252
+ const licenseSearchModeRef = useRef(licenseSearchMode);
253
+ licenseSearchModeRef.current = licenseSearchMode;
254
+ const [licenseSearchQuery, setLicenseSearchQuery] = useState("");
255
+ // Export status (shown briefly in the footer after `e`).
256
+ const [exportMsg, setExportMsg] = useState(null);
257
+ const exportMsgRef = useRef(null);
258
+ const showExportMsg = (s) => {
259
+ setExportMsg(s);
260
+ if (exportMsgRef.current)
261
+ clearTimeout(exportMsgRef.current);
262
+ exportMsgRef.current = setTimeout(() => setExportMsg(null), 4000);
263
+ };
264
+ const [exportMenu, setExportMenu] = useState(null);
265
+ const exportMenuRef = useRef(exportMenu);
266
+ exportMenuRef.current = exportMenu;
267
+ const openExportMenu = (defaultScope = "all") => {
268
+ if (!isLoggedIn()) {
269
+ // Saved reports are a logged-in feature. Show the calm nudge instead
270
+ // of letting the user navigate a menu they can't actually use.
271
+ showExportMsg("Export requires `dg login` (free account)");
272
+ return;
273
+ }
274
+ setExportMenu({ scope: defaultScope, format: "json", activeRow: "scope" });
275
+ };
276
+ /** Build the JS object for a given export scope. The `currentLicenseIdx`
277
+ * parameter scopes "current-license" to the drill-in user is viewing. */
278
+ const buildExportPayload = (scope, currentLicenseIdx) => {
279
+ const blocked = result.packages.filter((p) => p.action === "block");
280
+ const warned = result.packages.filter((p) => p.action === "warn");
281
+ const cleanPkgs = result.packages.filter((p) => (p.action ?? "pass") === "pass");
282
+ const incomplete = result.packages.filter((p) => p.action === "analysis_incomplete");
283
+ const summary = {
284
+ scannedAt: new Date().toISOString(),
285
+ score: result.score,
286
+ action: result.action,
287
+ packagesScanned: result.packages.length,
288
+ blocked: blocked.length,
289
+ warned: warned.length,
290
+ passLowRisk: incomplete.length,
291
+ clean: cleanPkgs.length,
292
+ durationMs,
293
+ };
294
+ if (scope === "summary")
295
+ return summary;
296
+ if (scope === "packages") {
297
+ return result.packages.map((p) => ({
298
+ name: p.name,
299
+ version: p.version,
300
+ score: p.score,
301
+ license: p.license?.spdx ?? p.license?.raw ?? null,
302
+ riskCategory: p.license?.riskCategory ?? null,
303
+ }));
304
+ }
305
+ if (scope === "licenses") {
306
+ return licenseGroups.map((g) => ({
307
+ spdx: g.spdx,
308
+ riskCategory: g.risk,
309
+ count: g.count,
310
+ packages: g.pkgs.map((p) => ({ name: p.name, version: p.version, score: p.score })),
311
+ }));
312
+ }
313
+ if (scope === "current-license" && currentLicenseIdx !== null) {
314
+ const g = licenseGroups[currentLicenseIdx];
315
+ if (!g)
316
+ return null;
317
+ return {
318
+ spdx: g.spdx,
319
+ riskCategory: g.risk,
320
+ count: g.count,
321
+ packages: g.pkgs.map((p) => ({ name: p.name, version: p.version, score: p.score })),
322
+ };
323
+ }
324
+ if (scope === "findings") {
325
+ return [...blocked, ...warned].map((p) => ({
326
+ name: p.name,
327
+ version: p.version,
328
+ score: p.score,
329
+ action: p.action,
330
+ findings: p.findings.map((f) => ({
331
+ severity: f.severity,
332
+ category: f.category ?? null,
333
+ title: f.title ?? null,
334
+ })),
335
+ reasons: p.reasons,
336
+ }));
337
+ }
338
+ // scope === "all"
339
+ return {
340
+ ...summary,
341
+ packages: result.packages,
342
+ safeVersions: result.safeVersions,
343
+ licenses: licenseGroups.map((g) => ({
344
+ spdx: g.spdx,
345
+ riskCategory: g.risk,
346
+ count: g.count,
347
+ packages: g.pkgs.map((p) => ({ name: p.name, version: p.version, score: p.score })),
348
+ })),
349
+ };
350
+ };
351
+ /** Quote a CSV cell — escape double-quotes by doubling, wrap in quotes if
352
+ * the value contains a comma, quote, or newline. */
353
+ const csvCell = (v) => {
354
+ const s = v === null || v === undefined ? "" : String(v);
355
+ return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
356
+ };
357
+ /** Convert a payload + scope to a serialized string in the chosen format
358
+ * plus the file extension. Some combinations are nonsensical (CSV of the
359
+ * full scan tree); we degrade those to JSON with a comment. */
360
+ const formatExport = (payload, scope, format) => {
361
+ if (format === "json") {
362
+ return { body: JSON.stringify(payload, null, 2) + "\n", ext: "json" };
363
+ }
364
+ if (format === "csv") {
365
+ // Tabular scopes only — flatten to header + rows.
366
+ if (scope === "packages") {
367
+ const rows = payload;
368
+ const lines = ["name,version,score,license,riskCategory"];
369
+ for (const r of rows) {
370
+ lines.push([r.name, r.version, r.score, r.license, r.riskCategory].map(csvCell).join(","));
371
+ }
372
+ return { body: lines.join("\n") + "\n", ext: "csv" };
373
+ }
374
+ if (scope === "licenses") {
375
+ const rows = payload;
376
+ const lines = ["spdx,riskCategory,count,package_names"];
377
+ for (const r of rows) {
378
+ const names = r.packages.map((p) => `${p.name}@${p.version}`).join(";");
379
+ lines.push([r.spdx, r.riskCategory, r.count, names].map(csvCell).join(","));
380
+ }
381
+ return { body: lines.join("\n") + "\n", ext: "csv" };
382
+ }
383
+ if (scope === "current-license") {
384
+ const r = payload;
385
+ const lines = ["name,version,score,spdx,riskCategory"];
386
+ for (const p of r.packages) {
387
+ lines.push([p.name, p.version, p.score, r.spdx, r.riskCategory].map(csvCell).join(","));
388
+ }
389
+ return { body: lines.join("\n") + "\n", ext: "csv" };
390
+ }
391
+ if (scope === "findings") {
392
+ const rows = payload;
393
+ const lines = ["name,version,score,action,top_finding_severity,top_finding_category,top_finding_title"];
394
+ for (const r of rows) {
395
+ const f = r.findings[0] ?? { severity: "", category: "", title: "" };
396
+ lines.push([r.name, r.version, r.score, r.action, f.severity, f.category, f.title].map(csvCell).join(","));
397
+ }
398
+ return { body: lines.join("\n") + "\n", ext: "csv" };
399
+ }
400
+ // CSV doesn't make sense for `all` or `summary` — fall back to JSON.
401
+ return { body: JSON.stringify(payload, null, 2) + "\n", ext: "json" };
402
+ }
403
+ // Markdown — for `all`, render summary + packages table + licenses table
404
+ // in one document so the file matches what the user sees in the TUI.
405
+ // For `findings` with an empty list, surface that explicitly so the
406
+ // file isn't just a title and nothing else.
407
+ if (format === "md") {
408
+ const lines = [`# Dependency Guardian — ${scope}`, ""];
409
+ lines.push(`*Scanned at ${new Date().toISOString()}*`, "");
410
+ const renderSummary = (s) => {
411
+ lines.push(`**Score:** ${s.score} (${s.action})`);
412
+ lines.push(`**Scanned:** ${s.packagesScanned} packages in ${(s.durationMs / 1000).toFixed(1)}s`);
413
+ lines.push(`**Block:** ${s.blocked} · **Warn:** ${s.warned} · **Clean:** ${s.clean}`, "");
414
+ };
415
+ const renderPkgRows = (rows) => {
416
+ lines.push("## Packages", "", "| Name | Version | Score | License |", "|---|---|---|---|");
417
+ for (const r of rows)
418
+ lines.push(`| ${r.name} | ${r.version} | ${r.score} | ${r.license ?? "—"} |`);
419
+ lines.push("");
420
+ };
421
+ const renderLicRows = (rows) => {
422
+ lines.push("## Licenses", "", "| License | Risk | Count |", "|---|---|---|");
423
+ for (const r of rows)
424
+ lines.push(`| ${r.spdx} | ${r.riskCategory} | ${r.count} |`);
425
+ lines.push("");
426
+ };
427
+ if (scope === "summary") {
428
+ renderSummary(payload);
429
+ }
430
+ else if (scope === "all") {
431
+ const a = payload;
432
+ renderSummary(a);
433
+ renderPkgRows(a.packages.map((p) => ({
434
+ name: p.name,
435
+ version: p.version,
436
+ score: p.score,
437
+ license: p.license?.spdx ?? p.license?.raw ?? null,
438
+ })));
439
+ renderLicRows(a.licenses);
440
+ }
441
+ else if (scope === "packages") {
442
+ renderPkgRows(payload);
443
+ }
444
+ else if (scope === "licenses") {
445
+ renderLicRows(payload);
446
+ }
447
+ else if (scope === "current-license") {
448
+ const r = payload;
449
+ lines.push(`## ${r.spdx} · ${r.riskCategory} · ${r.count} packages`, "");
450
+ lines.push("| Name | Version | Score |", "|---|---|---|");
451
+ for (const p of r.packages)
452
+ lines.push(`| ${p.name} | ${p.version} | ${p.score} |`);
453
+ }
454
+ else if (scope === "findings") {
455
+ const rows = payload;
456
+ if (rows.length === 0) {
457
+ lines.push("> **No warn or block findings.** All scanned packages passed the gate.", "");
458
+ }
459
+ else {
460
+ for (const r of rows) {
461
+ lines.push(`### ${r.name}@${r.version} · ${r.action.toUpperCase()} · score ${r.score}`);
462
+ for (const f of r.findings)
463
+ lines.push(`- sev ${f.severity}: ${f.title ?? "(hidden)"}`);
464
+ lines.push("");
465
+ }
466
+ }
467
+ }
468
+ return { body: lines.join("\n"), ext: "md" };
469
+ }
470
+ // format === "txt" — quick human-readable, mirrors the md structure
471
+ const txt = [`Dependency Guardian — ${scope}`, "=".repeat(40), ""];
472
+ const txtSummary = (s) => {
473
+ txt.push(`Score: ${s.score} (${s.action})`);
474
+ txt.push(`Scanned: ${s.packagesScanned} packages in ${(s.durationMs / 1000).toFixed(1)}s`);
475
+ txt.push(`Block: ${s.blocked}`);
476
+ txt.push(`Warn: ${s.warned}`);
477
+ txt.push(`Clean: ${s.clean}`, "");
478
+ };
479
+ const txtPackages = (rows) => {
480
+ txt.push("Packages", "-".repeat(40));
481
+ for (const r of rows)
482
+ txt.push(`${r.name}@${r.version} score=${r.score} ${r.license ?? "no-license"}`);
483
+ txt.push("");
484
+ };
485
+ const txtLicenses = (rows) => {
486
+ txt.push("Licenses", "-".repeat(40));
487
+ for (const r of rows)
488
+ txt.push(`${r.spdx.padEnd(36)} ${r.riskCategory.padEnd(18)} ${r.count}`);
489
+ txt.push("");
490
+ };
491
+ if (scope === "summary") {
492
+ txtSummary(payload);
493
+ }
494
+ else if (scope === "all") {
495
+ const a = payload;
496
+ txtSummary(a);
497
+ txtPackages(a.packages.map((p) => ({
498
+ name: p.name,
499
+ version: p.version,
500
+ score: p.score,
501
+ license: p.license?.spdx ?? p.license?.raw ?? null,
502
+ })));
503
+ txtLicenses(a.licenses);
504
+ }
505
+ else if (scope === "packages") {
506
+ txtPackages(payload);
507
+ }
508
+ else if (scope === "licenses") {
509
+ txtLicenses(payload);
510
+ }
511
+ else if (scope === "current-license") {
512
+ const r = payload;
513
+ txt.push(`${r.spdx} (${r.riskCategory})`, "");
514
+ for (const p of r.packages)
515
+ txt.push(` ${p.name}@${p.version} score=${p.score}`);
516
+ }
517
+ else if (scope === "findings") {
518
+ const rows = payload;
519
+ if (rows.length === 0) {
520
+ txt.push("No warn or block findings.", "All scanned packages passed the gate.");
521
+ }
522
+ else {
523
+ for (const r of rows) {
524
+ txt.push(`${r.action.toUpperCase()} ${r.name}@${r.version} score=${r.score}`);
525
+ for (const f of r.findings)
526
+ txt.push(` sev ${f.severity}: ${f.title ?? "(hidden)"}`);
527
+ txt.push("");
528
+ }
529
+ }
530
+ }
531
+ return { body: txt.join("\n") + "\n", ext: "txt" };
532
+ };
533
+ /** Run the export with the given scope/format. Writes
534
+ * ./dg-scan-<ts>-<scope>.<ext> and shows a footer toast. Requires login —
535
+ * saved reports are a logged-in feature. */
536
+ const runExport = (scope, format, currentLicenseIdx) => {
537
+ if (!isLoggedIn()) {
538
+ showExportMsg("Export requires `dg login` (free account)");
539
+ return;
540
+ }
541
+ try {
542
+ const ts = new Date().toISOString().replace(/[:T]/g, "-").replace(/\..*$/, "");
543
+ const payload = buildExportPayload(scope, currentLicenseIdx);
544
+ if (payload === null) {
545
+ showExportMsg(`Nothing to export for scope "${scope}"`);
546
+ return;
547
+ }
548
+ const { body, ext } = formatExport(payload, scope, format);
549
+ const scopeTag = scope === "current-license" && currentLicenseIdx !== null
550
+ ? `${(licenseGroups[currentLicenseIdx]?.spdx ?? "license").replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 32)}`
551
+ : scope;
552
+ const filename = `dg-scan-${ts}-${scopeTag}.${ext}`;
553
+ const path = resolvePath(process.cwd(), filename);
554
+ writeFileSync(path, body, "utf-8");
555
+ showExportMsg(`✓ Exported to ${filename}`);
556
+ }
557
+ catch (e) {
558
+ showExportMsg(`Export failed: ${e.message}`);
559
+ }
560
+ };
561
+ const licenseGroups = useMemo(() => {
562
+ const buckets = new Map();
563
+ for (const pkg of result.packages) {
564
+ const lc = pkg.license;
565
+ const spdx = lc?.spdx ?? lc?.raw ?? "(no license)";
566
+ const risk = lc?.riskCategory ?? "unknown";
567
+ const key = `${risk}::${spdx}`;
568
+ const ex = buckets.get(key);
569
+ if (ex) {
570
+ ex.count += 1;
571
+ ex.pkgs.push(pkg);
572
+ }
573
+ else
574
+ buckets.set(key, { spdx, risk, count: 1, pkgs: [pkg] });
575
+ }
576
+ for (const b of buckets.values()) {
577
+ b.pkgs.sort((a, b) => a.name.localeCompare(b.name));
578
+ }
579
+ const sinkRanks = { "no-license": 2, unknown: 1 };
580
+ return [...buckets.values()].sort((a, b) => {
581
+ const ra = sinkRanks[a.risk] ?? 0;
582
+ const rb = sinkRanks[b.risk] ?? 0;
583
+ if (ra !== rb)
584
+ return ra - rb;
585
+ return b.count - a.count;
586
+ });
587
+ }, [result.packages]);
588
+ const [searchMode, setSearchMode] = useState(false);
589
+ const searchModeRef = useRef(searchMode);
590
+ searchModeRef.current = searchMode;
591
+ const { rows: termRows, cols: termCols } = useTerminalSize();
592
+ const availableRows = Math.max(5, termRows - FIXED_CHROME);
593
+ const innerWidth = Math.max(40, termCols - 6);
594
+ const detailGroupIdx = detailPane?.groupIndex ?? -1;
595
+ const detailLines = useMemo(() => {
596
+ if (detailGroupIdx < 0)
597
+ return [];
598
+ const group = groups[detailGroupIdx];
599
+ if (!group)
600
+ return [];
601
+ return buildDetailLines(group, result.safeVersions[firstPackage(group).name], innerWidth);
602
+ }, [detailGroupIdx, groups, result.safeVersions, innerWidth]);
603
+ const detailContentRows = Math.max(3, termRows - DETAIL_PANE_CHROME);
604
+ const getLevel = (idx) => {
605
+ return view.expandedIndex === idx ? view.expandLevel : null;
606
+ };
607
+ const expandTargetHeight = useMemo(() => {
608
+ if (view.expandedIndex === null || view.expandLevel === null)
609
+ return 0;
610
+ const group = groups[view.expandedIndex];
611
+ if (!group)
612
+ return 0;
613
+ if (view.expandLevel === "summary")
614
+ return findingsSummaryHeight(group);
615
+ return findingsDetailHeight(group, result.safeVersions);
616
+ }, [view.expandedIndex, view.expandLevel, groups, result.safeVersions]);
617
+ const { visibleLines: animVisibleLines } = useExpandAnimation(expandTargetHeight, view.expandedIndex !== null);
618
+ const animatedGroupHeight = (group, level, idx) => {
619
+ if (level === null)
620
+ return 1;
621
+ if (idx === view.expandedIndex)
622
+ return 1 + animVisibleLines;
623
+ return groupRowHeight(group, level, result.safeVersions);
624
+ };
625
+ const visibleEnd = useMemo(() => {
626
+ let consumed = 0;
627
+ let end = view.viewport;
628
+ while (end < groups.length) {
629
+ const level = getLevel(end);
630
+ const endGroup = groups[end];
631
+ if (!endGroup)
632
+ break;
633
+ const h = animatedGroupHeight(endGroup, level, end);
634
+ if (consumed + h > availableRows)
635
+ break;
636
+ consumed += h;
637
+ end++;
638
+ }
639
+ if (end === view.viewport && groups.length > 0)
640
+ end = view.viewport + 1;
641
+ return end;
642
+ }, [view.viewport, groups, view.expandedIndex, view.expandLevel, animVisibleLines, availableRows, result.safeVersions]);
643
+ const adjustViewport = (cursor, expIdx, expLvl, currentStart) => {
644
+ if (cursor < currentStart)
645
+ return cursor;
646
+ const getLvl = (i) => (expIdx === i ? expLvl : null);
647
+ let consumed = 0;
648
+ for (let i = currentStart; i <= cursor && i < groups.length; i++) {
649
+ const g = groups[i];
650
+ if (!g)
651
+ continue;
652
+ consumed += groupRowHeight(g, getLvl(i), result.safeVersions);
653
+ }
654
+ if (consumed <= availableRows)
655
+ return currentStart;
656
+ let newStart = currentStart;
657
+ while (newStart < cursor) {
658
+ newStart++;
659
+ consumed = 0;
660
+ for (let i = newStart; i <= cursor; i++) {
661
+ const g = groups[i];
662
+ if (!g)
663
+ continue;
664
+ consumed += groupRowHeight(g, getLvl(i), result.safeVersions);
665
+ }
666
+ if (consumed <= availableRows)
667
+ break;
668
+ }
669
+ return newStart;
670
+ };
671
+ // Re-clamp viewport when terminal is resized
672
+ useEffect(() => {
673
+ if (groups.length === 0)
674
+ return;
675
+ const { cursor, expandedIndex, expandLevel, viewport } = viewRef.current;
676
+ const clamped = Math.min(viewport, Math.max(0, groups.length - 1));
677
+ const newVp = adjustViewport(cursor, expandedIndex, expandLevel, clamped);
678
+ dispatchView({ type: "MOVE", cursor, viewport: newVp });
679
+ }, [availableRows]);
680
+ // Clamp detail pane scroll when terminal resizes
681
+ useEffect(() => {
682
+ const dp = detailPaneRef.current;
683
+ if (dp && detailLines.length > 0) {
684
+ const maxScroll = Math.max(0, detailLines.length - detailContentRows);
685
+ if (dp.scroll > maxScroll) {
686
+ setDetailPane({ groupIndex: dp.groupIndex, scroll: maxScroll });
687
+ }
688
+ }
689
+ }, [detailContentRows, detailLines.length]);
690
+ useEffect(() => {
691
+ if (detailPane !== null && !groups[detailPane.groupIndex]) {
692
+ setDetailPane(null);
693
+ }
694
+ }, [detailPane, groups]);
695
+ useInput((input, key) => {
696
+ // Export menu has the highest priority — once opened, all keys go here
697
+ // until ⏎/Esc dismisses it. Arrow-driven: ↑↓ scrolls within the active
698
+ // row, ←→ or Tab switches rows. No letter shortcuts (the user found
699
+ // them clunky and they collide with `q` / other meaningful keys).
700
+ if (exportMenuRef.current) {
701
+ const menu = exportMenuRef.current;
702
+ const SCOPES = ["all", "summary", "packages", "licenses", "findings"];
703
+ const FORMATS = ["json", "csv", "md", "txt"];
704
+ const scopes = licenseDetailIdxRef.current !== null
705
+ ? [...SCOPES, "current-license"]
706
+ : SCOPES;
707
+ const move = (arr, cur, dir) => {
708
+ const i = arr.indexOf(cur);
709
+ if (i < 0)
710
+ return arr[0] ?? cur;
711
+ // Clamp at boundaries (no wrap-around) — feels more like a list.
712
+ return arr[Math.max(0, Math.min(arr.length - 1, i + dir))] ?? cur;
713
+ };
714
+ if (key.escape) {
715
+ setExportMenu(null);
716
+ return;
717
+ }
718
+ if (key.return) {
719
+ const m = exportMenuRef.current;
720
+ setExportMenu(null);
721
+ clearScreen();
722
+ runExport(m.scope, m.format, licenseDetailIdxRef.current);
723
+ return;
724
+ }
725
+ if (key.tab || key.leftArrow || key.rightArrow) {
726
+ setExportMenu({ ...menu, activeRow: menu.activeRow === "scope" ? "format" : "scope" });
727
+ return;
728
+ }
729
+ if (key.upArrow || input === "k") {
730
+ if (menu.activeRow === "scope") {
731
+ setExportMenu({ ...menu, scope: move(scopes, menu.scope, -1) });
732
+ }
733
+ else {
734
+ setExportMenu({ ...menu, format: move(FORMATS, menu.format, -1) });
735
+ }
736
+ return;
737
+ }
738
+ if (key.downArrow || input === "j") {
739
+ if (menu.activeRow === "scope") {
740
+ setExportMenu({ ...menu, scope: move(scopes, menu.scope, 1) });
741
+ }
742
+ else {
743
+ setExportMenu({ ...menu, format: move(FORMATS, menu.format, 1) });
744
+ }
745
+ return;
746
+ }
747
+ if (input === "q") {
748
+ setExportMenu(null);
749
+ return;
750
+ }
751
+ return;
752
+ }
753
+ if (showHelpRef.current) {
754
+ if (input === "?" || key.escape)
755
+ setShowHelp(false);
756
+ else if (input === "q")
757
+ onExit();
758
+ return;
759
+ }
760
+ if (showLicensesRef.current) {
761
+ // Drill-in mode: viewing the package list for one license.
762
+ if (licenseDetailIdxRef.current !== null) {
763
+ // Search-typing mode inside drill-in: capture text input.
764
+ if (licenseSearchModeRef.current) {
765
+ if (key.escape) {
766
+ setLicenseSearchMode(false);
767
+ setLicenseSearchQuery("");
768
+ setLicenseDetailScroll(0);
769
+ }
770
+ else if (key.return) {
771
+ setLicenseSearchMode(false);
772
+ }
773
+ else if (key.backspace || key.delete) {
774
+ setLicenseSearchQuery((q) => q.slice(0, -1));
775
+ setLicenseDetailScroll(0);
776
+ }
777
+ else if (input && !key.upArrow && !key.downArrow && /^[\x20-\x7e]+$/.test(input)) {
778
+ setLicenseSearchQuery((q) => q + input);
779
+ setLicenseDetailScroll(0);
780
+ }
781
+ return;
782
+ }
783
+ if (input === "q") {
784
+ onExit();
785
+ return;
786
+ }
787
+ if (input === "/") {
788
+ setLicenseSearchMode(true);
789
+ return;
790
+ }
791
+ if (input === "e") {
792
+ openExportMenu("current-license");
793
+ return;
794
+ }
795
+ if (key.escape) {
796
+ clearScreen();
797
+ licenseDetailIdxRef.current = null;
798
+ setLicenseDetailIdx(null);
799
+ setLicenseDetailScroll(0);
800
+ if (licenseSearchQuery)
801
+ setLicenseSearchQuery("");
802
+ return;
803
+ }
804
+ if (key.upArrow || input === "k") {
805
+ setLicenseDetailScroll((s) => Math.max(0, s - 1));
806
+ return;
807
+ }
808
+ if (key.downArrow || input === "j") {
809
+ const grp = licenseGroups[licenseDetailIdxRef.current];
810
+ if (grp) {
811
+ const q = licenseSearchQuery.toLowerCase();
812
+ const filtered = q
813
+ ? grp.pkgs.filter((p) => p.name.toLowerCase().includes(q))
814
+ : grp.pkgs;
815
+ setLicenseDetailScroll((s) => Math.min(Math.max(0, filtered.length - 1), s + 1));
816
+ }
817
+ return;
818
+ }
819
+ return;
820
+ }
821
+ if (input === "q") {
822
+ onExit();
823
+ return;
824
+ }
825
+ if (input === "e") {
826
+ openExportMenu("licenses");
827
+ return;
828
+ }
829
+ if (key.escape) {
830
+ if (initialView === "licenses")
831
+ return;
832
+ setShowLicenses(false);
833
+ return;
834
+ }
835
+ if (key.upArrow || input === "k") {
836
+ setLicenseCursor((c) => Math.max(0, c - 1));
837
+ return;
838
+ }
839
+ if (key.downArrow || input === "j") {
840
+ setLicenseCursor((c) => Math.min(licenseGroups.length - 1, c + 1));
841
+ return;
842
+ }
843
+ if (key.return) {
844
+ setLicenseDetailIdx(licenseCursorRef.current);
845
+ setLicenseDetailScroll(0);
846
+ return;
847
+ }
848
+ return;
849
+ }
850
+ if (searchModeRef.current) {
851
+ if (key.escape) {
852
+ setSearchMode(false);
853
+ setSearchQuery("");
854
+ dispatchView({ type: "MOVE", cursor: 0, viewport: 0 });
855
+ }
856
+ else if (key.return) {
857
+ setSearchMode(false);
858
+ }
859
+ else if (key.backspace || key.delete) {
860
+ setSearchQuery(prev => prev.slice(0, -1));
861
+ dispatchView({ type: "MOVE", cursor: 0, viewport: 0 });
862
+ }
863
+ else if (input && !key.upArrow && !key.downArrow && /^[\x20-\x7e]+$/.test(input)) {
864
+ setSearchQuery(prev => prev + input);
865
+ dispatchView({ type: "MOVE", cursor: 0, viewport: 0 });
866
+ }
867
+ return;
868
+ }
869
+ const dp = detailPaneRef.current;
870
+ if (dp !== null) {
871
+ const maxScroll = Math.max(0, detailLines.length - detailContentRows);
872
+ if (key.upArrow || input === "k") {
873
+ setDetailPane({ groupIndex: dp.groupIndex, scroll: Math.max(0, dp.scroll - 1) });
874
+ }
875
+ else if (key.downArrow || input === "j") {
876
+ setDetailPane({ groupIndex: dp.groupIndex, scroll: Math.min(maxScroll, dp.scroll + 1) });
877
+ }
878
+ else if (input === "g") {
879
+ setDetailPane({ groupIndex: dp.groupIndex, scroll: 0 });
880
+ }
881
+ else if (input === "G") {
882
+ setDetailPane({ groupIndex: dp.groupIndex, scroll: maxScroll });
883
+ }
884
+ else if (key.escape) {
885
+ setDetailPane(null);
886
+ }
887
+ else if (input === "q") {
888
+ onExit();
889
+ }
890
+ return;
891
+ }
892
+ if (input === "?") {
893
+ setShowHelp(true);
894
+ return;
895
+ }
896
+ if (input === "l") {
897
+ setShowLicenses(true);
898
+ setLicenseCursor(0);
899
+ return;
900
+ }
901
+ if (input === "e") {
902
+ openExportMenu("all");
903
+ return;
904
+ }
905
+ if (key.escape && onBack) {
906
+ onBack();
907
+ return;
908
+ }
909
+ if (input === "q") {
910
+ onExit();
911
+ return;
912
+ }
913
+ if (groups.length === 0) {
914
+ if (key.return)
915
+ onExit();
916
+ else if (input === "/")
917
+ setSearchMode(true);
918
+ return;
919
+ }
920
+ const { cursor, expandLevel: expLvl, expandedIndex: expIdx, viewport: vpStart } = viewRef.current;
921
+ if (key.upArrow || input === "k") {
922
+ const next = Math.max(0, cursor - 1);
923
+ const newVp = adjustViewport(next, expIdx, expLvl, vpStart < next ? vpStart : next);
924
+ dispatchView({ type: "MOVE", cursor: next, viewport: newVp });
925
+ }
926
+ else if (key.downArrow || input === "j") {
927
+ const next = Math.min(groups.length - 1, cursor + 1);
928
+ const newVp = adjustViewport(next, expIdx, expLvl, vpStart);
929
+ dispatchView({ type: "MOVE", cursor: next, viewport: newVp });
930
+ }
931
+ else if (input === "g") {
932
+ const newVp = adjustViewport(0, expIdx, expLvl, 0);
933
+ dispatchView({ type: "MOVE", cursor: 0, viewport: newVp });
934
+ }
935
+ else if (input === "G") {
936
+ const last = groups.length - 1;
937
+ const newVp = adjustViewport(last, expIdx, expLvl, vpStart);
938
+ dispatchView({ type: "MOVE", cursor: last, viewport: newVp });
939
+ }
940
+ else if (key.pageDown) {
941
+ const next = Math.min(groups.length - 1, cursor + availableRows);
942
+ const newVp = adjustViewport(next, expIdx, expLvl, vpStart);
943
+ dispatchView({ type: "MOVE", cursor: next, viewport: newVp });
944
+ }
945
+ else if (key.pageUp) {
946
+ const next = Math.max(0, cursor - availableRows);
947
+ const newVp = adjustViewport(next, expIdx, expLvl, next);
948
+ dispatchView({ type: "MOVE", cursor: next, viewport: newVp });
949
+ }
950
+ else if (key.return) {
951
+ const newLevel = expIdx === cursor && expLvl === "summary" ? null : "summary";
952
+ const newExpIdx = newLevel === null ? null : cursor;
953
+ const newVp = adjustViewport(cursor, newExpIdx, newLevel, vpStart);
954
+ dispatchView({ type: "EXPAND", expandedIndex: newExpIdx, expandLevel: newLevel, viewport: newVp });
955
+ }
956
+ else if (input === "/") {
957
+ setSearchMode(true);
958
+ }
959
+ });
960
+ const visibleGroups = groups.slice(view.viewport, visibleEnd);
961
+ const aboveCount = view.viewport;
962
+ const belowCount = groups.length - visibleEnd;
963
+ const lcCol = 16; // fixed width for license column
964
+ const nameCol = Math.max(20, innerWidth - 22 - lcCol);
965
+ // Clamp cursor to valid range (groups may shrink via search filter)
966
+ const clampedCursor = groups.length > 0 ? Math.min(view.cursor, groups.length - 1) : 0;
967
+ // ── Export menu overlay ──
968
+ if (exportMenu) {
969
+ const SCOPES_RENDER = [
970
+ { value: "all", label: "All" },
971
+ { value: "summary", label: "Summary" },
972
+ { value: "packages", label: "Packages" },
973
+ { value: "licenses", label: "Licenses" },
974
+ { value: "findings", label: "Findings (warn+block)" },
975
+ ];
976
+ if (licenseDetailIdxRef.current !== null) {
977
+ const focus = licenseGroups[licenseDetailIdxRef.current];
978
+ SCOPES_RENDER.push({
979
+ value: "current-license",
980
+ label: `Current license (${focus?.spdx ?? "—"})`,
981
+ });
982
+ }
983
+ const FORMATS_RENDER = [
984
+ { value: "json", label: "JSON" },
985
+ { value: "csv", label: "CSV" },
986
+ { value: "md", label: "Markdown" },
987
+ { value: "txt", label: "Plain text" },
988
+ ];
989
+ const stackedLayout = termCols < 80;
990
+ const colWidth = stackedLayout ? undefined : Math.max(24, Math.floor((termCols - 8) / 2));
991
+ const renderColumn = (title, rows, current, isActive) => (_jsxs(Box, { flexDirection: "column", width: colWidth, flexShrink: 1, marginBottom: stackedLayout ? 1 : 0, children: [_jsxs(Text, { children: [isActive ? chalk.cyan("▌ ") : " ", isActive ? chalk.bold(title) : chalk.dim(title)] }), rows.map((r) => {
992
+ const selected = r.value === current;
993
+ const bullet = selected
994
+ ? (isActive ? chalk.cyan("●") : chalk.green("●"))
995
+ : chalk.dim("○");
996
+ const text = selected ? chalk.bold(r.label) : r.label;
997
+ return (_jsxs(Box, { children: [_jsx(Box, { width: 5, flexShrink: 0, children: _jsxs(Text, { children: [" ", bullet] }) }), _jsx(Box, { flexShrink: 1, children: _jsx(Text, { wrap: "truncate-end", children: text }) })] }, r.value));
998
+ })] }));
999
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScoreHeader, { score: result.score, action: result.action, total: total, flagged: flagged.length, clean: clean.length, userStatus: userStatus, scanUsage: scanUsage, usageNearLimit: usageNearLimit }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingLeft: 2, paddingRight: 2, children: [_jsx(Text, { bold: true, children: "Export" }), _jsx(Text, { children: "" }), _jsxs(Box, { flexDirection: stackedLayout ? "column" : "row", children: [renderColumn("What", SCOPES_RENDER, exportMenu.scope, exportMenu.activeRow === "scope"), renderColumn("Format", FORMATS_RENDER, exportMenu.format, exportMenu.activeRow === "format")] })] }), _jsx(Text, { dimColor: true, children: chalk.dim("─".repeat(Math.max(20, termCols - 4))) }), _jsxs(Text, { children: [" ", chalk.bold.cyan("↑↓"), " ", chalk.dim("scroll"), " ", chalk.bold.cyan("←→/Tab"), " ", chalk.dim("switch row"), " ", chalk.bold.cyan("⏎"), " ", chalk.dim("export"), " ", chalk.bold.cyan("Esc"), " ", chalk.dim("cancel")] })] }));
1000
+ }
1001
+ // ── Help overlay ──
1002
+ if (showHelp) {
1003
+ const isDetail = detailPane !== null;
1004
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScoreHeader, { score: result.score, action: result.action, total: total, flagged: flagged.length, clean: clean.length, userStatus: userStatus, scanUsage: scanUsage, usageNearLimit: usageNearLimit }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingLeft: 2, paddingRight: 2, width: "100%", children: [_jsx(Text, { bold: true, children: "Keyboard Shortcuts" }), _jsx(Text, { children: "" }), _jsx(Text, { bold: true, children: " Navigation" }), _jsxs(Text, { children: [" ", chalk.cyan("\u2191 k"), " ", chalk.dim("Move up")] }), _jsxs(Text, { children: [" ", chalk.cyan("\u2193 j"), " ", chalk.dim("Move down")] }), _jsxs(Text, { children: [" ", chalk.cyan("g"), " ", chalk.dim("Jump to top")] }), _jsxs(Text, { children: [" ", chalk.cyan("G"), " ", chalk.dim("Jump to bottom")] }), !isDetail && _jsxs(Text, { children: [" ", chalk.cyan("PgUp"), " ", chalk.dim("Page up")] }), !isDetail && _jsxs(Text, { children: [" ", chalk.cyan("PgDn"), " ", chalk.dim("Page down")] }), _jsx(Text, { children: "" }), _jsx(Text, { bold: true, children: " Actions" }), !isDetail && _jsxs(Text, { children: [" ", chalk.cyan("\u23CE"), " ", chalk.dim("Expand findings")] }), isDetail && _jsxs(Text, { children: [" ", chalk.cyan("Esc"), " ", chalk.dim("Back to list")] }), !isDetail && _jsxs(Text, { children: [" ", chalk.cyan("/"), " ", chalk.dim("Search packages")] }), !isDetail && _jsxs(Text, { children: [" ", chalk.cyan("l"), " ", chalk.dim("License breakdown (browse + drill-in)")] }), !isDetail && _jsxs(Text, { children: [" ", chalk.cyan("e"), " ", chalk.dim("Export menu — pick scope (all / summary / packages / licenses / findings) and format (JSON / CSV / Markdown / text)")] }), !isDetail && onBack && _jsxs(Text, { children: [" ", chalk.cyan("Esc"), " ", chalk.dim("Back to project selector")] }), _jsxs(Text, { children: [" ", chalk.cyan("q"), " ", chalk.dim("Quit")] }), _jsx(Text, { children: "" }), _jsxs(Text, { dimColor: true, children: [" Press ", chalk.bold.cyan("?"), " or ", chalk.bold.cyan("Esc"), " to close"] })] })] }));
1005
+ }
1006
+ // ── License overlay ──
1007
+ if (showLicenses) {
1008
+ const lcColor = (risk) => {
1009
+ if (risk === "permissive")
1010
+ return chalk.green;
1011
+ if (risk === "weak-copyleft")
1012
+ return chalk.yellow;
1013
+ if (risk === "strong-copyleft")
1014
+ return chalk.yellow.bold;
1015
+ if (risk === "no-license" || risk === "network-copyleft" || risk === "unlicensed")
1016
+ return chalk.red;
1017
+ return chalk.dim;
1018
+ };
1019
+ const totalCount = result.packages.length;
1020
+ const maxCount = licenseGroups[0]?.count ?? 1;
1021
+ if (licenseDetailIdx !== null && licenseGroups[licenseDetailIdx]) {
1022
+ const g = licenseGroups[licenseDetailIdx];
1023
+ const color = lcColor(g.risk);
1024
+ const visibleRows = Math.max(5, termRows - 20);
1025
+ const q = licenseSearchQuery.toLowerCase();
1026
+ const filtered = q
1027
+ ? g.pkgs.filter((p) => p.name.toLowerCase().includes(q))
1028
+ : g.pkgs;
1029
+ const cursorIdx = Math.min(licenseDetailScroll, Math.max(0, filtered.length - 1));
1030
+ const top = Math.max(0, Math.min(cursorIdx - Math.floor((visibleRows - 1) / 2), Math.max(0, filtered.length - visibleRows)));
1031
+ const bottom = Math.min(top + visibleRows, filtered.length);
1032
+ const _above = top;
1033
+ const _below = filtered.length - bottom;
1034
+ const _slice = filtered.slice(top, bottom);
1035
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScoreHeader, { score: result.score, action: result.action, total: total, flagged: flagged.length, clean: clean.length, userStatus: userStatus, scanUsage: scanUsage, usageNearLimit: usageNearLimit }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingLeft: 2, paddingRight: 2, width: "100%", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: color(g.spdx) }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", g.risk, " \u00B7 ", g.count, " package", g.count !== 1 ? "s" : "", q ? ` · ${filtered.length} match${filtered.length !== 1 ? "es" : ""}` : ""] })] }), licenseSearchMode || q ? (_jsx(Box, { children: _jsxs(Text, { children: [" ", chalk.bold.cyan("/"), " ", q || chalk.dim("(type to filter; Esc clears, Enter confirms)"), licenseSearchMode ? chalk.cyan("█") : ""] }) })) : (_jsx(Text, { children: "" })), _above > 0 && _jsx(Text, { dimColor: true, children: ` ↑ ${_above} more above` }), _slice.length === 0 && _jsx(Text, { dimColor: true, children: ` (no packages match "${q}")` }), _slice.map((p, i) => {
1036
+ const absIdx = top + i;
1037
+ const isSelected = absIdx === cursorIdx;
1038
+ const nameCol = Math.max(28, Math.floor(termCols * 0.55));
1039
+ if (isSelected) {
1040
+ return (_jsxs(Text, { backgroundColor: "#1a1a2e", children: [chalk.cyan("▌"), " ", chalk.bold(pad(truncate(p.name, nameCol - 2), nameCol)), chalk.dim(pad(p.version, 16))] }, `${p.name}@${p.version}`));
1041
+ }
1042
+ return (_jsxs(Text, { children: [" ", pad(truncate(p.name, nameCol - 2), nameCol), chalk.dim(pad(p.version, 16))] }, `${p.name}@${p.version}`));
1043
+ }), _below > 0 && _jsx(Text, { dimColor: true, children: ` ↓ ${_below} more below` })] }), _jsx(Text, { dimColor: true, children: chalk.dim("─".repeat(Math.max(20, termCols - 4))) }), _jsxs(Text, { children: [" ", chalk.bold.cyan("↑↓"), " ", chalk.dim("scroll"), " ", chalk.bold.cyan("/"), " ", chalk.dim("search"), " ", chalk.bold.cyan("e"), " ", chalk.dim("export"), " ", chalk.bold.cyan("Esc"), " ", chalk.dim("back"), " ", chalk.bold.cyan("q"), " ", chalk.dim("quit"), exportMsg && _jsxs(_Fragment, { children: [" ", chalk.green(exportMsg)] })] })] }));
1044
+ }
1045
+ // ── License list (default overlay view) ──
1046
+ // Column widths: pick from terminal so long SPDX strings (e.g.
1047
+ // "(BSD-2-Clause OR MIT OR Apache-2.0)") don't blow the layout.
1048
+ const innerCols = Math.max(60, termCols - 6);
1049
+ const spdxCol = Math.min(40, Math.max(22, Math.floor(innerCols * 0.35)));
1050
+ const riskCol = 18;
1051
+ const countCol = 7;
1052
+ const barCol = Math.max(8, innerCols - spdxCol - riskCol - countCol - 4);
1053
+ const visibleRows = Math.max(5, termRows - 20);
1054
+ const cursor = Math.min(licenseCursor, licenseGroups.length - 1);
1055
+ // Scrolling viewport: keep the cursor visible inside a window of
1056
+ // `visibleRows` rows, clamped at the start/end. Replaces the prior
1057
+ // "resize terminal to see all" dead end — ↑↓ now genuinely scrolls
1058
+ // through the full list on any terminal height.
1059
+ const top = Math.max(0, Math.min(cursor - Math.floor((visibleRows - 1) / 2), licenseGroups.length - visibleRows));
1060
+ const bottom = Math.min(top + visibleRows, licenseGroups.length);
1061
+ const hiddenAbove = top;
1062
+ const hiddenBelow = licenseGroups.length - bottom;
1063
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScoreHeader, { score: result.score, action: result.action, total: total, flagged: flagged.length, clean: clean.length, userStatus: userStatus, scanUsage: scanUsage, usageNearLimit: usageNearLimit }), _jsx(Text, { dimColor: true, children: chalk.dim("─".repeat(Math.max(20, termCols - 4))) }), _jsxs(Box, { flexDirection: "column", paddingLeft: 2, paddingRight: 2, width: "100%", children: [_jsxs(Text, { bold: true, children: ["Licenses", " ", chalk.dim(`(${licenseGroups.length} unique across ${totalCount} packages)`)] }), _jsx(Text, { children: "" }), _jsxs(Box, { children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { children: " " }) }), _jsx(Box, { width: spdxCol, flexShrink: 1, children: _jsx(Text, { dimColor: true, children: "SPDX" }) }), _jsx(Box, { width: riskCol, flexShrink: 0, children: _jsx(Text, { dimColor: true, children: "Risk" }) }), _jsx(Box, { width: countCol, flexShrink: 0, children: _jsx(Text, { dimColor: true, children: "Count" }) }), _jsx(Box, { width: barCol, flexShrink: 1, children: _jsx(Text, { dimColor: true, children: "Share" }) })] }), hiddenAbove > 0 && (_jsx(Text, { dimColor: true, children: ` ↑ ${hiddenAbove} more above` })), licenseGroups.slice(top, bottom).map((g, i) => {
1064
+ const absIdx = top + i;
1065
+ const color = lcColor(g.risk);
1066
+ const barLen = Math.max(1, Math.round((g.count / maxCount) * (barCol - 2)));
1067
+ const bar = "█".repeat(Math.min(barLen, barCol - 2));
1068
+ const isSelected = absIdx === cursor;
1069
+ const spdxText = truncate(g.spdx, spdxCol - 1);
1070
+ const riskText = g.risk;
1071
+ if (isSelected) {
1072
+ return (_jsxs(Text, { backgroundColor: "#1a1a2e", children: [chalk.cyan("▌"), " ", chalk.bold(color(pad(spdxText, spdxCol))), chalk.dim(pad(riskText, riskCol)), chalk.bold(pad(String(g.count), countCol)), color(bar)] }, `${g.risk}::${g.spdx}::${absIdx}`));
1073
+ }
1074
+ return (_jsxs(Text, { children: [" ", color(pad(spdxText, spdxCol)), chalk.dim(pad(riskText, riskCol)), chalk.bold(pad(String(g.count), countCol)), color(bar)] }, `${g.risk}::${g.spdx}::${absIdx}`));
1075
+ }), hiddenBelow > 0 && (_jsx(Text, { dimColor: true, children: ` ↓ ${hiddenBelow} more below` }))] }), _jsx(Text, { dimColor: true, children: chalk.dim("─".repeat(Math.max(20, termCols - 4))) }), _jsxs(Text, { children: [" ", chalk.bold.cyan("↑↓"), " ", chalk.dim("navigate"), " ", chalk.bold.cyan("⏎"), " ", chalk.dim("view packages"), " ", chalk.bold.cyan("e"), " ", chalk.dim("export"), " ", chalk.bold.cyan("Esc"), " ", chalk.dim("close"), " ", chalk.bold.cyan("q"), " ", chalk.dim("quit"), exportMsg && _jsxs(_Fragment, { children: [" ", chalk.green(exportMsg)] })] })] }));
1076
+ }
1077
+ // ── Detail pane mode ──
1078
+ if (detailPane !== null) {
1079
+ const dpGroup = groups[detailPane.groupIndex];
1080
+ if (dpGroup) {
1081
+ const dpRep = firstPackage(dpGroup);
1082
+ const { color: dpColor } = packageBadge(dpRep);
1083
+ const dpScroll = detailPane.scroll;
1084
+ const dpAbove = dpScroll;
1085
+ const dpBelow = Math.max(0, detailLines.length - dpScroll - detailContentRows);
1086
+ const dpVisible = detailLines.slice(dpScroll, dpScroll + detailContentRows);
1087
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScoreHeader, { score: result.score, action: result.action, total: total, flagged: flagged.length, clean: clean.length, userStatus: userStatus, scanUsage: scanUsage, usageNearLimit: usageNearLimit }), _jsx(Text, { dimColor: true, children: chalk.dim("\u2500".repeat(Math.max(20, termCols - 4))) }), _jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, width: "100%", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { bold: true, children: [groupNames(dpGroup), dpRep.license ? chalk.dim(" \u00B7 ") + (dpRep.license.riskCategory === "permissive" ? chalk.green(dpRep.license.spdx ?? dpRep.license.raw ?? "") : dpRep.license.riskCategory === "no-license" || dpRep.license.riskCategory === "network-copyleft" ? chalk.red(dpRep.license.spdx ?? dpRep.license.raw ?? "No license") : chalk.yellow(dpRep.license.spdx ?? dpRep.license.raw ?? "")) : ""] }), _jsx(Text, { children: dpColor(`score ${dpRep.score}`) })] }), dpAbove > 0 && (_jsxs(Text, { dimColor: true, children: [chalk.cyan(" \u2191"), " ", dpAbove, " more above"] })), _jsx(Box, { flexDirection: "column", marginLeft: 2, children: dpVisible }), dpBelow > 0 && (_jsxs(Text, { dimColor: true, children: [chalk.cyan(" \u2193"), " ", dpBelow, " more below"] }))] }), _jsx(Text, { dimColor: true, children: chalk.dim("─".repeat(Math.max(20, termCols - 4))) }), _jsx(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, width: "100%", children: _jsxs(Box, { justifyContent: "space-between", children: [clean.length > 0 ? (_jsxs(Text, { children: [chalk.green("\u2713"), " ", chalk.green.bold(String(clean.length)), " ", chalk.dim(`package${clean.length !== 1 ? "s" : ""} passed with score 0`), " ", chalk.dim(`\u00b7 ${(durationMs / 1000).toFixed(1)}s`)] })) : (_jsxs(Text, { dimColor: true, children: [(durationMs / 1000).toFixed(1), "s"] })), result.freeScansRemaining !== undefined && (_jsx(Text, { dimColor: true, children: "Free tier \u00B7 dg login for higher scan limits" }))] }) }), _jsx(Text, { dimColor: true, children: chalk.dim("\u2500".repeat(Math.max(20, termCols - 4))) }), _jsxs(Text, { children: [" ", chalk.bold.cyan("\u2191\u2193"), " ", chalk.dim("scroll"), " ", chalk.bold.cyan("Esc"), " ", chalk.dim("back"), " ", chalk.bold.cyan("q"), " ", chalk.dim("quit")] })] }));
1088
+ }
1089
+ }
1090
+ // ── List mode ──
1091
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScoreHeader, { score: result.score, action: result.action, total: total, flagged: flagged.length, clean: clean.length, userStatus: userStatus, scanUsage: scanUsage, usageNearLimit: usageNearLimit }), groups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: chalk.dim("─".repeat(Math.max(20, termCols - 4))) }), _jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, width: "100%", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, children: "Flagged Packages" }), _jsx(Text, { dimColor: true, children: searchQuery ? `${groups.length} of ${allGroupCount}` : `${clampedCursor + 1}/${groups.length}` })] }), aboveCount > 0 && (_jsxs(Text, { dimColor: true, children: [chalk.cyan(" \u2191"), " ", aboveCount, " more above"] })), visibleGroups.map((group, visIdx) => {
1092
+ const globalIdx = view.viewport + visIdx;
1093
+ const isCursor = globalIdx === clampedCursor;
1094
+ const level = getLevel(globalIdx);
1095
+ const rep = firstPackage(group);
1096
+ const { label, color } = packageBadge(rep);
1097
+ const names = groupNames(group);
1098
+ const scoreStr = String(rep.score);
1099
+ const lcInfo = rep.license;
1100
+ const lcStr = lcInfo ? truncate(lcInfo.spdx ?? lcInfo.raw ?? "", lcCol - 2) : "";
1101
+ const lcColor = !lcInfo ? chalk.dim
1102
+ : lcInfo.riskCategory === "permissive" ? chalk.green
1103
+ : (lcInfo.riskCategory === "no-license" || lcInfo.riskCategory === "unlicensed" || lcInfo.riskCategory === "network-copyleft") ? chalk.red
1104
+ : chalk.yellow;
1105
+ const arrow = level === "summary" ? "\u25BE" : "\u25B8"; // ▾ expanded, ▸ collapsed
1106
+ return (_jsxs(Box, { flexDirection: "column", children: [isCursor ? (_jsxs(Text, { backgroundColor: "#1a1a2e", children: [chalk.cyan("\u258C"), " ", chalk.cyan(arrow), " ", ` `, color(pad(label, 8)), chalk.bold(pad(truncate(names, nameCol - 2), nameCol)), lcColor(pad(lcStr, lcCol)), color(scoreStr.padStart(3)), " "] })) : (_jsxs(Text, { children: [` ${chalk.dim(arrow)} `, color(pad(label, 8)), pad(truncate(names, nameCol - 2), nameCol), lcColor(pad(lcStr, lcCol)), color(scoreStr.padStart(3))] })), level === "summary" && (_jsx(FindingsSummary, { group: group, maxWidth: innerWidth - 8, maxLines: globalIdx === view.expandedIndex ? animVisibleLines : undefined }))] }, group.key));
1107
+ }), belowCount > 0 && (_jsxs(Text, { dimColor: true, children: [chalk.cyan(" \u2193"), " ", belowCount, " more below"] }))] })] })), _jsx(Text, { dimColor: true, children: chalk.dim("\u2500".repeat(Math.max(20, termCols - 4))) }), _jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, width: "100%", children: [discoveredTotal !== undefined && discoveredTotal > total && (_jsxs(Text, { dimColor: true, children: ["Scanned ", total, " of ", discoveredTotal, " packages"] })), _jsxs(Box, { justifyContent: "space-between", children: [clean.length > 0 ? (_jsxs(Text, { children: [chalk.green("\u2713"), " ", chalk.green.bold(String(clean.length)), " ", chalk.dim(`package${clean.length !== 1 ? "s" : ""} passed with score 0`), " ", chalk.dim(`\u00b7 ${(durationMs / 1000).toFixed(1)}s`)] })) : (_jsxs(Text, { dimColor: true, children: [(durationMs / 1000).toFixed(1), "s"] })), result.freeScansRemaining !== undefined && (_jsx(Text, { dimColor: true, children: "Free tier \u00B7 dg login for higher scan limits" }))] })] }), _jsx(Text, { dimColor: true, children: chalk.dim("\u2500".repeat(Math.max(20, termCols - 4))) }), searchMode ? (_jsxs(Text, { children: [" ", chalk.bold.cyan("/"), " ", searchQuery, chalk.cyan("\u2588"), " ", chalk.dim("Esc clear")] })) : (_jsxs(Text, { children: [" ", groups.length > 0 && (_jsxs(_Fragment, { children: [chalk.bold.cyan("\u2191\u2193"), " ", chalk.dim("navigate"), " ", chalk.bold.cyan("\u23CE"), " ", chalk.dim("expand"), " ", chalk.bold.cyan("/"), " ", chalk.dim("search"), " "] })), chalk.bold.cyan("l"), " ", chalk.dim("licenses"), " ", chalk.bold.cyan("e"), " ", chalk.dim("export"), " ", onBack && _jsxs(_Fragment, { children: [chalk.bold.cyan("Esc"), " ", chalk.dim("back"), " "] }), chalk.bold.cyan("q"), " ", chalk.dim("quit"), " ", chalk.dim("\u00B7 Ctrl+C or q to exit"), exportMsg && _jsxs(_Fragment, { children: [" ", chalk.green(exportMsg)] })] }))] }));
1108
+ };
1109
+ const T = {
1110
+ branch: chalk.dim("\u251C\u2500\u2500"),
1111
+ last: chalk.dim("\u2514\u2500\u2500"),
1112
+ pipe: chalk.dim("\u2502"),
1113
+ blank: " ",
1114
+ };
1115
+ const LICENSE_DESCRIPTIONS = {
1116
+ "permissive": "Permissive \u2014 free to use, modify, and distribute. Include the copyright notice.",
1117
+ "weak-copyleft": "Weak copyleft \u2014 changes to this library must be shared, but your code stays private.",
1118
+ "strong-copyleft": "Strong copyleft \u2014 your entire project must be open-sourced under the same license.",
1119
+ "network-copyleft": "Network copyleft \u2014 even SaaS/server use requires releasing your source code.",
1120
+ "no-license": "No license found \u2014 legally all rights reserved. Use may require permission from the author.",
1121
+ "unlicensed": "Explicitly unlicensed \u2014 proprietary software. A commercial agreement is required.",
1122
+ "unknown": "Unrecognized license \u2014 have your legal team review before using.",
1123
+ "deferred": "License declared in a file \u2014 check the LICENSE file in the package.",
1124
+ };
1125
+ function licenseLine(rep) {
1126
+ const lc = rep.license;
1127
+ if (!lc)
1128
+ return null;
1129
+ const spdx = lc.spdx ?? lc.raw ?? "";
1130
+ const desc = LICENSE_DESCRIPTIONS[lc.riskCategory] ?? "";
1131
+ const lcColor = lc.riskCategory === "permissive" ? chalk.green
1132
+ : (lc.riskCategory === "no-license" || lc.riskCategory === "unlicensed" || lc.riskCategory === "network-copyleft") ? chalk.red
1133
+ : chalk.yellow;
1134
+ return (_jsxs(Text, { children: [T.branch, " ", lcColor(spdx), " ", chalk.dim("\u2014"), " ", chalk.dim(desc)] }, "license-info"));
1135
+ }
1136
+ const FindingsSummary = ({ group, maxWidth, maxLines }) => {
1137
+ const rep = firstPackage(group);
1138
+ const visibleFindings = rep.findings
1139
+ .filter((f) => f.severity > 1)
1140
+ .sort((a, b) => b.severity - a.severity);
1141
+ const hasAffects = group.packages.length > 3;
1142
+ const allLines = [];
1143
+ // License info
1144
+ const lcLine = licenseLine(rep);
1145
+ if (lcLine)
1146
+ allLines.push(lcLine);
1147
+ // Render findings — API returns tier-gated data:
1148
+ // Free: { category, severity } — don't show raw IDs, just upgrade prompt
1149
+ // Pro: { category, severity, title } — show category + title
1150
+ // Team: { category, severity, title, evidence } — show everything
1151
+ const isFree = visibleFindings.length > 0 && !visibleFindings[0]?.title;
1152
+ if (isFree) {
1153
+ // Free tier: don't show raw category IDs — just the upgrade prompt
1154
+ allLines.push(_jsxs(Text, { dimColor: true, children: [hasAffects ? T.branch : T.last, " ", chalk.yellow("\u2192"), " ", chalk.yellow("Upgrade to Pro"), " for finding details"] }, "upgrade"));
1155
+ }
1156
+ else {
1157
+ // Paid tier: show findings with category + title
1158
+ for (let idx = 0; idx < visibleFindings.length; idx++) {
1159
+ const f = visibleFindings[idx];
1160
+ if (!f)
1161
+ continue;
1162
+ const isLast = !hasAffects && idx === visibleFindings.length - 1;
1163
+ const connector = isLast ? T.last : T.branch;
1164
+ const sevLabel = SEVERITY_LABELS[f.severity] ?? "INFO";
1165
+ const sevColor = SEVERITY_COLORS[f.severity] ?? SEVERITY_COLORS[1] ?? chalk.dim;
1166
+ const title = f.title ? `: ${f.title}` : "";
1167
+ allLines.push(_jsxs(Text, { children: [connector, " ", sevColor(pad(sevLabel, 5)), " ", chalk.dim(f.category ?? ""), title] }, `finding-${idx}`));
1168
+ }
1169
+ }
1170
+ if (visibleFindings.length === 0 && rep.score > 0) {
1171
+ // No findings at all (shouldn't happen after API change, but safety fallback)
1172
+ allLines.push(_jsxs(Text, { dimColor: true, children: [hasAffects ? T.branch : T.last, " Score: ", rep.score, "/100"] }, "score-only"));
1173
+ }
1174
+ if (hasAffects) {
1175
+ allLines.push(_jsxs(Text, { dimColor: true, children: [T.last, " ", truncate(affectsLine(group), maxWidth - 8)] }, "affects"));
1176
+ }
1177
+ const linesToShow = maxLines !== undefined ? allLines.slice(0, maxLines) : allLines;
1178
+ return (_jsx(Box, { flexDirection: "column", marginLeft: 5, children: linesToShow }));
1179
+ };