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