@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.
- package/LICENSE +1 -201
- package/NOTICE +1 -4
- package/README.md +293 -0
- package/dist/api/analyze.js +210 -0
- package/dist/audit/deep.js +180 -0
- package/dist/audit/detectors.js +247 -0
- package/dist/audit/events.js +41 -0
- package/dist/audit/rules.js +426 -0
- package/dist/audit-ui/AuditApp.js +39 -0
- package/dist/audit-ui/components/AuditHeader.js +24 -0
- package/dist/audit-ui/components/AuditResultsView.js +307 -0
- package/dist/audit-ui/components/DeepStatusRow.js +11 -0
- package/dist/audit-ui/export.js +85 -0
- package/dist/audit-ui/format.js +34 -0
- package/dist/audit-ui/launch.js +34 -0
- package/dist/auth/device-login.js +271 -0
- package/dist/auth/env-token.js +6 -0
- package/dist/auth/login-app.js +156 -0
- package/dist/auth/store.js +147 -0
- package/dist/bin/dg.js +71 -0
- package/dist/commands/audit.js +357 -0
- package/dist/commands/completion.js +116 -0
- package/dist/commands/config.js +99 -0
- package/dist/commands/doctor.js +39 -0
- package/dist/commands/explain.js +100 -0
- package/dist/commands/guard-commit.js +158 -0
- package/dist/commands/help.js +74 -0
- package/dist/commands/licenses.js +435 -0
- package/dist/commands/login.js +81 -0
- package/dist/commands/logout.js +37 -0
- package/dist/commands/router.js +98 -0
- package/dist/commands/scan.js +18 -0
- package/dist/commands/service.js +475 -0
- package/dist/commands/setup.js +302 -0
- package/dist/commands/status.js +115 -0
- package/dist/commands/suggest.js +35 -0
- package/dist/commands/types.js +4 -0
- package/dist/commands/unavailable.js +11 -0
- package/dist/commands/uninstall.js +111 -0
- package/dist/commands/update.js +210 -0
- package/dist/commands/verify.js +151 -0
- package/dist/commands/version.js +22 -0
- package/dist/commands/wrap.js +55 -0
- package/dist/config/settings.js +302 -0
- package/dist/install-ui/LiveInstall.js +24 -0
- package/dist/install-ui/block-render.js +83 -0
- package/dist/install-ui/live-install-app.js +48 -0
- package/dist/install-ui/prompt.js +24 -0
- package/dist/launcher/classify.js +116 -0
- package/dist/launcher/env.js +53 -0
- package/dist/launcher/live-install.js +50 -0
- package/dist/launcher/output-redaction.js +77 -0
- package/dist/launcher/preflight-prompt.js +139 -0
- package/dist/launcher/resolve-real-binary.js +73 -0
- package/dist/launcher/run.js +417 -0
- package/dist/policy/evaluate.js +128 -0
- package/dist/presentation/mode.js +52 -0
- package/dist/presentation/theme.js +29 -0
- package/dist/proxy/buffer-budget.js +64 -0
- package/dist/proxy/ca.js +126 -0
- package/dist/proxy/classify-host.js +26 -0
- package/dist/proxy/enforcement.js +102 -0
- package/dist/proxy/metadata-map.js +336 -0
- package/dist/proxy/server.js +909 -0
- package/dist/proxy/upstream-proxy.js +102 -0
- package/dist/proxy/worker.js +39 -0
- package/dist/publish-set/collect.js +51 -0
- package/dist/publish-set/no-exec-shell.js +19 -0
- package/dist/publish-set/npm.js +109 -0
- package/dist/publish-set/pack.js +36 -0
- package/dist/publish-set/pypi.js +59 -0
- package/dist/runtime/cli.js +17 -0
- package/dist/runtime/first-run.js +60 -0
- package/dist/runtime/node-version.js +58 -0
- package/dist/runtime/nudges.js +105 -0
- package/dist/scan/analyze-worker.js +21 -0
- package/dist/scan/collect.js +153 -0
- package/dist/scan/command.js +159 -0
- package/dist/scan/discovery.js +209 -0
- package/dist/scan/render.js +240 -0
- package/dist/scan/scanner-report.js +82 -0
- package/dist/scan/staged.js +173 -0
- package/dist/scan/types.js +1 -0
- package/dist/scan-ui/LegacyApp.js +156 -0
- package/dist/scan-ui/alt-screen.js +84 -0
- package/dist/scan-ui/api-aliases.js +1 -0
- package/dist/scan-ui/components/ErrorView.js +23 -0
- package/dist/scan-ui/components/InteractiveResultsView.js +1166 -0
- package/dist/scan-ui/components/ProgressBar.js +89 -0
- package/dist/scan-ui/components/ProjectSelector.js +62 -0
- package/dist/scan-ui/components/ScoreHeader.js +20 -0
- package/dist/scan-ui/components/SetupBanner.js +13 -0
- package/dist/scan-ui/components/Spinner.js +4 -0
- package/dist/scan-ui/format-helpers.js +40 -0
- package/dist/scan-ui/hooks/useExpandAnimation.js +40 -0
- package/dist/scan-ui/hooks/useScan.js +113 -0
- package/dist/scan-ui/hooks/useTerminalSize.js +24 -0
- package/dist/scan-ui/launch.js +27 -0
- package/dist/scan-ui/logo.js +91 -0
- package/dist/scan-ui/shims.js +30 -0
- package/dist/security/sanitize.js +28 -0
- package/dist/service/state.js +837 -0
- package/dist/service/trust-store.js +234 -0
- package/dist/service/worker.js +88 -0
- package/dist/setup/git-hook.js +244 -0
- package/dist/setup/optional-support.js +58 -0
- package/dist/setup/plan.js +899 -0
- package/dist/state/cleanup-registry.js +60 -0
- package/dist/state/index.js +5 -0
- package/dist/state/locks.js +161 -0
- package/dist/state/paths.js +24 -0
- package/dist/state/sessions.js +170 -0
- package/dist/state/store.js +50 -0
- package/dist/telemetry/events.js +40 -0
- package/dist/util/git.js +20 -0
- package/dist/util/tty-prompt.js +43 -0
- package/dist/verify/local.js +400 -0
- package/dist/verify/package-check.js +240 -0
- package/dist/verify/preflight.js +698 -0
- package/dist/verify/render.js +184 -0
- package/dist/verify/types.js +1 -0
- package/package.json +33 -50
- package/dist/index.mjs +0 -54141
- package/dist/postinstall.mjs +0 -731
- package/dist/python-hook/dg_pip_hook.pth +0 -1
- 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
|
+
};
|