@westbayberry/dg 2.0.10 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/analyze.js +5 -3
- package/dist/bin/dg.js +1 -1
- package/dist/commands/completion.js +2 -1
- package/dist/commands/config.js +11 -3
- package/dist/commands/decisions.js +155 -0
- package/dist/commands/explain.js +6 -2
- package/dist/commands/router.js +2 -0
- package/dist/commands/scan.js +2 -1
- package/dist/commands/status.js +5 -2
- package/dist/config/settings.js +144 -25
- package/dist/decisions/apply.js +128 -0
- package/dist/decisions/remember-prompt.js +97 -0
- package/dist/install-ui/block-render.js +21 -4
- package/dist/install-ui/prompt.js +14 -0
- package/dist/launcher/install-preflight.js +126 -13
- package/dist/launcher/preflight-prompt.js +29 -2
- package/dist/launcher/run.js +14 -3
- package/dist/policy/cooldown.js +104 -0
- package/dist/policy/evaluate.js +0 -15
- package/dist/presentation/provenance.js +23 -0
- package/dist/project/dgfile.js +307 -0
- package/dist/proxy/enforcement.js +2 -1
- package/dist/proxy/metadata-map.js +25 -1
- package/dist/proxy/server.js +31 -2
- package/dist/scan/collect.js +10 -4
- package/dist/scan/command.js +35 -8
- package/dist/scan/discovery.js +66 -4
- package/dist/scan/render.js +35 -4
- package/dist/scan/scanner-report.js +31 -4
- package/dist/scan/staged.js +69 -10
- package/dist/scan-ui/LegacyApp.js +4 -4
- package/dist/scan-ui/components/InteractiveResultsView.js +64 -7
- package/dist/scan-ui/hooks/useScan.js +31 -3
- package/dist/scan-ui/launch.js +4 -1
- package/dist/scan-ui/shims.js +3 -0
- package/dist/scripts/detect.js +153 -0
- package/dist/scripts/gate.js +170 -0
- package/dist/scripts/rebuild.js +28 -0
- package/dist/setup/plan.js +36 -1
- package/dist/util/json-file.js +24 -0
- package/dist/util/tty-prompt.js +13 -6
- package/dist/verify/package-check.js +12 -0
- package/package.json +9 -1
|
@@ -5,11 +5,14 @@ import chalk from "chalk";
|
|
|
5
5
|
import { writeFileSync } from "node:fs";
|
|
6
6
|
import { resolve as resolvePath } from "node:path";
|
|
7
7
|
import { isLoggedIn } from "../shims.js";
|
|
8
|
+
import { packageKey } from "../../decisions/apply.js";
|
|
8
9
|
import { ScoreHeader, COMPACT_ROWS } from "./ScoreHeader.js";
|
|
9
10
|
import { useExpandAnimation } from "../hooks/useExpandAnimation.js";
|
|
10
11
|
import { useTerminalSize } from "../hooks/useTerminalSize.js";
|
|
11
12
|
import { clearScreen } from "../alt-screen.js";
|
|
12
13
|
import { pad, truncate, groupPackages as sharedGroupPackages, formatUsage } from "../format-helpers.js";
|
|
14
|
+
import { provenanceLabel, provenanceDowngradeLine } from "../../presentation/provenance.js";
|
|
15
|
+
const ACK_PREVIEW_LIMIT = 3;
|
|
13
16
|
function groupPackages(packages) {
|
|
14
17
|
return sharedGroupPackages(packages, "fingerprint");
|
|
15
18
|
}
|
|
@@ -50,6 +53,19 @@ export function packageBadge(pkg) {
|
|
|
50
53
|
return { label: "Unverified", color: chalk.yellow };
|
|
51
54
|
return actionBadge(pkg.action);
|
|
52
55
|
}
|
|
56
|
+
export function provenanceMarker(pkg) {
|
|
57
|
+
const prov = pkg.provenance;
|
|
58
|
+
if (!prov)
|
|
59
|
+
return " ";
|
|
60
|
+
if (prov.downgrade)
|
|
61
|
+
return chalk.yellow("◇ ");
|
|
62
|
+
if (prov.status === "attested")
|
|
63
|
+
return chalk.dim("◆ ");
|
|
64
|
+
return " ";
|
|
65
|
+
}
|
|
66
|
+
function packageDowngradeLine(pkg) {
|
|
67
|
+
return pkg.provenance ? provenanceDowngradeLine(pkg.version, pkg.provenance) : null;
|
|
68
|
+
}
|
|
53
69
|
const EVIDENCE_LIMIT = 2;
|
|
54
70
|
const BADGE_COL = "Unverified".length + 1;
|
|
55
71
|
// Fixed lines outside the scrollable group area:
|
|
@@ -80,6 +96,8 @@ function findingsSummaryHeight(group) {
|
|
|
80
96
|
let h = 0;
|
|
81
97
|
if (rep.license)
|
|
82
98
|
h += 1;
|
|
99
|
+
if (packageDowngradeLine(rep))
|
|
100
|
+
h += 1;
|
|
83
101
|
if (isFree) {
|
|
84
102
|
h += 1;
|
|
85
103
|
}
|
|
@@ -140,6 +158,14 @@ function buildDetailLines(group, safeVersion, maxWidth) {
|
|
|
140
158
|
lines.push(_jsxs(Text, { dimColor: true, children: ["Score: ", rep.score, "/100"] }, "score-info"));
|
|
141
159
|
lines.push(_jsx(Text, { children: "" }, "score-gap"));
|
|
142
160
|
}
|
|
161
|
+
if (rep.provenance) {
|
|
162
|
+
lines.push(_jsxs(Text, { dimColor: true, children: ["Provenance: ", provenanceLabel(rep.provenance)] }, "provenance"));
|
|
163
|
+
const downgrade = packageDowngradeLine(rep);
|
|
164
|
+
if (downgrade) {
|
|
165
|
+
lines.push(_jsx(Text, { wrap: "truncate-end", children: chalk.yellow(downgrade) }, "provenance-downgrade"));
|
|
166
|
+
}
|
|
167
|
+
lines.push(_jsx(Text, { children: "" }, "provenance-gap"));
|
|
168
|
+
}
|
|
143
169
|
if (visibleFindings.length > 0) {
|
|
144
170
|
for (let i = 0; i < visibleFindings.length; i++) {
|
|
145
171
|
const f = visibleFindings[i];
|
|
@@ -194,7 +220,7 @@ function viewReducer(_state, action) {
|
|
|
194
220
|
return { ..._state, expandedKey: action.expandedKey, expandLevel: action.expandLevel, viewport: action.viewport };
|
|
195
221
|
}
|
|
196
222
|
}
|
|
197
|
-
export const InteractiveResultsView = ({ result, config: _config, durationMs, onExit, onBack, discoveredTotal, userStatus, scanUsage: scanUsageProp, initialView, }) => {
|
|
223
|
+
export const InteractiveResultsView = ({ result, config: _config, durationMs, onExit, onBack, discoveredTotal, userStatus, scanUsage: scanUsageProp, initialView, decisions, }) => {
|
|
198
224
|
// Prefer the server's uniform usage block ("used / limit packages this
|
|
199
225
|
// month"); fall back to the legacy freeScansRemaining field, then to the
|
|
200
226
|
// generic placeholder from bin.ts. usageNearLimit drives the yellow + nudge.
|
|
@@ -212,7 +238,20 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
|
|
|
212
238
|
const clean = useMemo(() => result.packages.filter((p) => (p.action ?? "pass") === "pass"), [result.packages]);
|
|
213
239
|
const total = result.packages.length;
|
|
214
240
|
const [searchQuery, setSearchQuery] = useState("");
|
|
215
|
-
const
|
|
241
|
+
const ackByKey = useMemo(() => {
|
|
242
|
+
const map = new Map();
|
|
243
|
+
if (decisions) {
|
|
244
|
+
for (const [key, annotation] of Object.entries(decisions.packages)) {
|
|
245
|
+
if (annotation.acknowledged)
|
|
246
|
+
map.set(key, annotation.acknowledged);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return map;
|
|
250
|
+
}, [decisions]);
|
|
251
|
+
const activeFlagged = useMemo(() => flagged.filter((p) => !ackByKey.has(packageKey(p.name, p.version))), [flagged, ackByKey]);
|
|
252
|
+
const acked = useMemo(() => flagged.filter((p) => ackByKey.has(packageKey(p.name, p.version))), [flagged, ackByKey]);
|
|
253
|
+
const ackGroups = useMemo(() => groupPackages(acked), [acked]);
|
|
254
|
+
const allGroups = useMemo(() => groupPackages(activeFlagged), [activeFlagged]);
|
|
216
255
|
const groups = useMemo(() => {
|
|
217
256
|
if (!searchQuery)
|
|
218
257
|
return allGroups;
|
|
@@ -223,13 +262,18 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
|
|
|
223
262
|
if (members.length > 0)
|
|
224
263
|
matched.push({ packages: members, key: g.key });
|
|
225
264
|
}
|
|
265
|
+
for (const p of acked) {
|
|
266
|
+
if (p.name.toLowerCase().includes(q)) {
|
|
267
|
+
matched.push({ packages: [p], key: `ack|${p.name}@${p.version ?? ""}` });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
226
270
|
for (const p of clean) {
|
|
227
271
|
if (p.name.toLowerCase().includes(q)) {
|
|
228
272
|
matched.push({ packages: [p], key: `pass|${p.name}@${p.version ?? ""}` });
|
|
229
273
|
}
|
|
230
274
|
}
|
|
231
275
|
return matched;
|
|
232
|
-
}, [allGroups, clean, searchQuery]);
|
|
276
|
+
}, [allGroups, acked, clean, searchQuery]);
|
|
233
277
|
const matchCount = useMemo(() => groups.reduce((n, g) => n + g.packages.length, 0), [groups]);
|
|
234
278
|
const [view, dispatchView] = useReducer(viewReducer, {
|
|
235
279
|
cursor: 0,
|
|
@@ -619,7 +663,10 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
|
|
|
619
663
|
searchModeRef.current = searchMode;
|
|
620
664
|
const { rows: termRows, cols: termCols } = useTerminalSize();
|
|
621
665
|
const compact = termRows < COMPACT_ROWS;
|
|
622
|
-
const
|
|
666
|
+
const ackSectionLines = acked.length > 0
|
|
667
|
+
? 2 + Math.min(ackGroups.length, ACK_PREVIEW_LIMIT) + (ackGroups.length > ACK_PREVIEW_LIMIT ? 1 : 0)
|
|
668
|
+
: 0;
|
|
669
|
+
const availableRows = Math.max(5, termRows - (compact ? FIXED_CHROME_COMPACT : FIXED_CHROME) - ackSectionLines);
|
|
623
670
|
const innerWidth = Math.max(40, termCols - 6);
|
|
624
671
|
const detailGroup = useMemo(() => {
|
|
625
672
|
if (!detailPane)
|
|
@@ -1014,7 +1061,7 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
|
|
|
1014
1061
|
const aboveCount = view.viewport;
|
|
1015
1062
|
const belowCount = groups.length - visibleEnd;
|
|
1016
1063
|
const lcCol = 16; // fixed width for license column
|
|
1017
|
-
const nameCol = Math.max(20, innerWidth - BADGE_COL -
|
|
1064
|
+
const nameCol = Math.max(20, innerWidth - BADGE_COL - 16 - lcCol);
|
|
1018
1065
|
// Clamp cursor to valid range (groups may shrink via search filter)
|
|
1019
1066
|
const clampedCursor = groups.length > 0 ? Math.min(view.cursor, groups.length - 1) : 0;
|
|
1020
1067
|
// ── Export menu overlay ──
|
|
@@ -1156,8 +1203,14 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
|
|
|
1156
1203
|
: (lcInfo.riskCategory === "no-license" || lcInfo.riskCategory === "unlicensed" || lcInfo.riskCategory === "network-copyleft") ? chalk.red
|
|
1157
1204
|
: chalk.yellow;
|
|
1158
1205
|
const arrow = level === "summary" ? "\u25BE" : "\u25B8"; // ▾ expanded, ▸ collapsed
|
|
1159
|
-
return (_jsxs(Box, { flexDirection: "column", children: [isCursor ? (_jsxs(Text, { backgroundColor: "#1a1a2e", wrap: "truncate-end", children: [chalk.cyan("\u258C"), " ", chalk.cyan(arrow), " ", ` `, color(pad(label, BADGE_COL)), chalk.bold(pad(truncate(names, nameCol - 2), nameCol)), lcColor(pad(lcStr, lcCol)), color(scoreStr.padStart(3)), " "] })) : (_jsxs(Text, { wrap: "truncate-end", children: [` ${chalk.dim(arrow)} `, color(pad(label, BADGE_COL)), pad(truncate(names, nameCol - 2), nameCol), lcColor(pad(lcStr, lcCol)), color(scoreStr.padStart(3))] })), level === "summary" && (_jsx(FindingsSummary, { group: group, maxWidth: innerWidth - 8, maxLines: group.key === view.expandedKey ? animVisibleLines : undefined }))] }, group.key));
|
|
1160
|
-
}), belowCount > 0 && (_jsxs(Text, { dimColor: true, children: [chalk.cyan(" \u2193"), " ", belowCount, " more below"] }))] })] })), searchQuery && groups.length === 0 && (_jsx(Text, { dimColor: true, children: ` No packages match "${searchQuery}"` })),
|
|
1206
|
+
return (_jsxs(Box, { flexDirection: "column", children: [isCursor ? (_jsxs(Text, { backgroundColor: "#1a1a2e", wrap: "truncate-end", children: [chalk.cyan("\u258C"), " ", chalk.cyan(arrow), " ", ` `, color(pad(label, BADGE_COL)), chalk.bold(pad(truncate(names, nameCol - 2), nameCol)), provenanceMarker(rep), lcColor(pad(lcStr, lcCol)), color(scoreStr.padStart(3)), " "] })) : (_jsxs(Text, { wrap: "truncate-end", children: [` ${chalk.dim(arrow)} `, color(pad(label, BADGE_COL)), pad(truncate(names, nameCol - 2), nameCol), provenanceMarker(rep), lcColor(pad(lcStr, lcCol)), color(scoreStr.padStart(3))] })), level === "summary" && (_jsx(FindingsSummary, { group: group, maxWidth: innerWidth - 8, maxLines: group.key === view.expandedKey ? animVisibleLines : undefined }))] }, group.key));
|
|
1207
|
+
}), belowCount > 0 && (_jsxs(Text, { dimColor: true, children: [chalk.cyan(" \u2193"), " ", belowCount, " more below"] }))] })] })), searchQuery && groups.length === 0 && (_jsx(Text, { dimColor: true, children: ` No packages match "${searchQuery}"` })), acked.length > 0 && !searchQuery && (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, width: "100%", children: [_jsxs(Text, { bold: true, dimColor: true, children: ["Acknowledged (", acked.length, ") \\u00b7 dg.json"] }), ackGroups.slice(0, ACK_PREVIEW_LIMIT).map((group) => {
|
|
1208
|
+
const rep = firstPackage(group);
|
|
1209
|
+
const ack = ackByKey.get(packageKey(rep.name, rep.version));
|
|
1210
|
+
const who = ack?.by ?? "unknown";
|
|
1211
|
+
const when = ack?.at ? ` on ${ack.at.slice(0, 10)}` : "";
|
|
1212
|
+
return (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", chalk.yellow("\u25b8"), " ", groupNames(group), " ", chalk.dim(`accepted by ${who}${when}`)] }, `ack-${group.key}`));
|
|
1213
|
+
}), ackGroups.length > ACK_PREVIEW_LIMIT && (_jsx(Text, { dimColor: true, children: ` +${ackGroups.length - ACK_PREVIEW_LIMIT} more \u2014 dg decisions` }))] })), !compact && _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"] })), acked.length > 0 && (_jsxs(Text, { wrap: "truncate-end", children: [chalk.yellow("\u26a0"), " ", chalk.yellow(String(acked.length)), " ", chalk.dim(`acknowledged warn${acked.length !== 1 ? "s" : ""} \u00b7 dg.json \u00b7 review with 'dg decisions'`)] })), _jsxs(Box, { justifyContent: "space-between", children: [clean.length > 0 ? (_jsxs(Text, { wrap: "truncate-end", children: [chalk.green("\u2713"), " ", chalk.green.bold(String(clean.length)), " ", chalk.dim(`package${clean.length !== 1 ? "s" : ""} passed`), " ", 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" }))] })] }), !compact && _jsx(Text, { dimColor: true, children: chalk.dim("\u2500".repeat(Math.max(20, termCols - 4))) }), searchMode ? (_jsxs(Text, { wrap: "truncate-end", children: [" ", chalk.bold.cyan("/"), " ", searchQuery, chalk.cyan("\u2588"), " ", chalk.dim("Esc clear")] })) : exportMsg ? (_jsxs(Text, { wrap: "truncate-end", children: [" ", chalk.green(exportMsg)] })) : searchQuery ? (_jsxs(Text, { wrap: "truncate-end", children: [" ", chalk.bold.cyan("/"), " ", searchQuery, " ", chalk.dim(`${matchCount} of ${total} packages`), " ", chalk.bold.cyan("Esc"), " ", chalk.dim("clear"), " ", chalk.bold.cyan("q"), " ", chalk.dim("quit")] })) : (_jsxs(Text, { wrap: "truncate-end", children: [" ", groups.length > 0 && (_jsxs(_Fragment, { children: [chalk.bold.cyan("\u2191\u2193"), " ", chalk.dim("navigate"), " ", chalk.bold.cyan("\u23CE"), " ", chalk.dim("expand"), " "] })), total > 0 && (_jsxs(_Fragment, { children: [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")] }))] }));
|
|
1161
1214
|
};
|
|
1162
1215
|
const T = {
|
|
1163
1216
|
branch: chalk.dim("\u251C\u2500\u2500"),
|
|
@@ -1197,6 +1250,10 @@ const FindingsSummary = ({ group, maxWidth, maxLines }) => {
|
|
|
1197
1250
|
const lcLine = licenseLine(rep);
|
|
1198
1251
|
if (lcLine)
|
|
1199
1252
|
allLines.push(lcLine);
|
|
1253
|
+
const downgrade = packageDowngradeLine(rep);
|
|
1254
|
+
if (downgrade) {
|
|
1255
|
+
allLines.push(_jsxs(Text, { wrap: "truncate-end", children: [T.branch, " ", chalk.yellow(downgrade)] }, "provenance-downgrade"));
|
|
1256
|
+
}
|
|
1200
1257
|
// Render findings — API returns tier-gated data:
|
|
1201
1258
|
// Free: { category, severity } — don't show raw IDs, just upgrade prompt
|
|
1202
1259
|
// Pro: { category, severity, title } — show category + title
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { useReducer, useEffect, useRef, useCallback, useState } from "react";
|
|
3
3
|
import { analyzePackages, AnalyzeError, mergeAnalyzeResponses } from "../../api/analyze.js";
|
|
4
|
+
import { applyDecisions, packageKey } from "../../decisions/apply.js";
|
|
5
|
+
import { findProjectRoot, loadDgFile } from "../../project/dgfile.js";
|
|
4
6
|
import { collectScanPackages, discoverScanProjectsAsync } from "../../scan/collect.js";
|
|
5
7
|
function reducer(_state, action) {
|
|
6
8
|
switch (action.type) {
|
|
@@ -21,7 +23,8 @@ function reducer(_state, action) {
|
|
|
21
23
|
result: action.result,
|
|
22
24
|
durationMs: action.durationMs,
|
|
23
25
|
skippedCount: action.skippedCount,
|
|
24
|
-
...(action.discoveredTotal !== undefined ? { discoveredTotal: action.discoveredTotal } : {})
|
|
26
|
+
...(action.discoveredTotal !== undefined ? { discoveredTotal: action.discoveredTotal } : {}),
|
|
27
|
+
...(action.decisions !== undefined ? { decisions: action.decisions } : {})
|
|
25
28
|
};
|
|
26
29
|
case "ERROR":
|
|
27
30
|
return { phase: "error", error: action.error };
|
|
@@ -79,6 +82,28 @@ export function useScan(config) {
|
|
|
79
82
|
restartSelection: multiProjects ? restartSelection : null
|
|
80
83
|
};
|
|
81
84
|
}
|
|
85
|
+
function computeProjectDecisions(result, entries) {
|
|
86
|
+
try {
|
|
87
|
+
const root = findProjectRoot(process.cwd());
|
|
88
|
+
if (!root) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
const file = loadDgFile(root);
|
|
92
|
+
if (!file.readable) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
const ecosystems = new Map();
|
|
96
|
+
for (const [ecosystem, packages] of entries) {
|
|
97
|
+
for (const pkg of packages) {
|
|
98
|
+
ecosystems.set(packageKey(pkg.name, pkg.version), ecosystem);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return applyDecisions(result.packages, (pkg) => ecosystems.get(packageKey(pkg.name, pkg.version)), file, result.action);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
82
107
|
async function scanProjects(projects, dispatch, signal) {
|
|
83
108
|
const startMs = Date.now();
|
|
84
109
|
let skipped = 0;
|
|
@@ -134,12 +159,15 @@ async function scanProjects(projects, dispatch, signal) {
|
|
|
134
159
|
const firstFailure = outcomes.find((outcome) => "error" in outcome);
|
|
135
160
|
if (responses.length > 0) {
|
|
136
161
|
const merged = mergeAnalyzeResponses(responses);
|
|
162
|
+
const result = firstFailure && merged.action === "pass" ? { ...merged, action: "analysis_incomplete" } : merged;
|
|
163
|
+
const decisions = computeProjectDecisions(result, entries);
|
|
137
164
|
dispatch({
|
|
138
165
|
type: "SCAN_COMPLETE",
|
|
139
|
-
result
|
|
166
|
+
result,
|
|
140
167
|
durationMs: Date.now() - startMs,
|
|
141
168
|
skippedCount: skipped,
|
|
142
|
-
discoveredTotal: total
|
|
169
|
+
discoveredTotal: total,
|
|
170
|
+
...(decisions ? { decisions } : {})
|
|
143
171
|
});
|
|
144
172
|
return;
|
|
145
173
|
}
|
package/dist/scan-ui/launch.js
CHANGED
|
@@ -31,11 +31,14 @@ export async function launchScanTui(initialView = "results") {
|
|
|
31
31
|
? `Update available: ${update.current} → ${update.latest} · run dg update`
|
|
32
32
|
: undefined;
|
|
33
33
|
enterTui();
|
|
34
|
+
const instance = render(react.default.createElement(app.App, { config, initialView, updateAvailable }), { exitOnCtrlC: true });
|
|
35
|
+
const clearStaleFrameOnResize = () => instance.clear();
|
|
36
|
+
process.stdout.on("resize", clearStaleFrameOnResize);
|
|
34
37
|
try {
|
|
35
|
-
const instance = render(react.default.createElement(app.App, { config, initialView, updateAvailable }), { exitOnCtrlC: true });
|
|
36
38
|
await instance.waitUntilExit();
|
|
37
39
|
}
|
|
38
40
|
finally {
|
|
41
|
+
process.stdout.off("resize", clearStaleFrameOnResize);
|
|
39
42
|
leaveTui();
|
|
40
43
|
}
|
|
41
44
|
}
|
package/dist/scan-ui/shims.js
CHANGED
|
@@ -16,6 +16,9 @@ export function getStoredApiKey() {
|
|
|
16
16
|
return null;
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
+
export function effectiveScanAction(raw, effective, mode) {
|
|
20
|
+
return mode === "strict" || effective === undefined ? raw : effective;
|
|
21
|
+
}
|
|
19
22
|
export function scanExitCode(action, mode) {
|
|
20
23
|
if (action === "block") {
|
|
21
24
|
return 2;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const LIFECYCLE_HOOKS = ["preinstall", "install", "postinstall"];
|
|
5
|
+
export function computeScriptsHash(scripts, hasGyp) {
|
|
6
|
+
const canonical = JSON.stringify({
|
|
7
|
+
preinstall: lifecycleCommand(scripts, "preinstall"),
|
|
8
|
+
install: lifecycleCommand(scripts, "install"),
|
|
9
|
+
postinstall: lifecycleCommand(scripts, "postinstall"),
|
|
10
|
+
gyp: hasGyp
|
|
11
|
+
});
|
|
12
|
+
return `sha256:${createHash("sha256").update(canonical).digest("hex")}`;
|
|
13
|
+
}
|
|
14
|
+
export function detectScriptWanters(projectDir) {
|
|
15
|
+
const nodeModules = join(projectDir, "node_modules");
|
|
16
|
+
const fromLockfile = wantersFromHiddenLockfile(projectDir, nodeModules);
|
|
17
|
+
const wanters = fromLockfile ?? wantersFromWalk(nodeModules);
|
|
18
|
+
const byName = new Map();
|
|
19
|
+
for (const wanter of wanters) {
|
|
20
|
+
if (!byName.has(wanter.name)) {
|
|
21
|
+
byName.set(wanter.name, wanter);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
25
|
+
}
|
|
26
|
+
export function detectPnpmIgnoredBuilds(projectDir) {
|
|
27
|
+
const modulesYamlPath = join(projectDir, "node_modules", ".modules.yaml");
|
|
28
|
+
let content;
|
|
29
|
+
try {
|
|
30
|
+
content = readFileSync(modulesYamlPath, "utf8");
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
const lines = content.split("\n");
|
|
36
|
+
const startIndex = lines.findIndex((line) => /^ignoredBuilds:/.test(line));
|
|
37
|
+
if (startIndex === -1) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
const startLine = lines[startIndex] ?? "";
|
|
41
|
+
if (/\[\s*\]\s*$/.test(startLine)) {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
const names = [];
|
|
45
|
+
for (const line of lines.slice(startIndex + 1)) {
|
|
46
|
+
const match = /^\s+-\s+(.+?)\s*$/.exec(line);
|
|
47
|
+
if (!match || !match[1]) {
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
names.push(stripYamlQuotes(match[1]));
|
|
51
|
+
}
|
|
52
|
+
return names;
|
|
53
|
+
}
|
|
54
|
+
function stripYamlQuotes(value) {
|
|
55
|
+
if (value.length >= 2 && ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith('"') && value.endsWith('"')))) {
|
|
56
|
+
return value.slice(1, -1);
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
function wantersFromHiddenLockfile(projectDir, nodeModules) {
|
|
61
|
+
const lockfilePath = join(nodeModules, ".package-lock.json");
|
|
62
|
+
let parsed;
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(readFileSync(lockfilePath, "utf8"));
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
if (!isPlainObject(parsed) || !isPlainObject(parsed.packages)) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const wanters = [];
|
|
73
|
+
for (const [packagePath, entry] of Object.entries(parsed.packages)) {
|
|
74
|
+
if (!isPlainObject(entry) || entry.hasInstallScript !== true || !packagePath.startsWith("node_modules/")) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const wanter = wanterFromManifestDir(join(projectDir, packagePath));
|
|
78
|
+
if (wanter) {
|
|
79
|
+
wanters.push(wanter);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return wanters;
|
|
83
|
+
}
|
|
84
|
+
function wantersFromWalk(nodeModules) {
|
|
85
|
+
const wanters = [];
|
|
86
|
+
for (const dir of packageDirs(nodeModules)) {
|
|
87
|
+
const wanter = wanterFromManifestDir(dir);
|
|
88
|
+
if (wanter) {
|
|
89
|
+
wanters.push(wanter);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return wanters;
|
|
93
|
+
}
|
|
94
|
+
function packageDirs(nodeModules) {
|
|
95
|
+
const dirs = [];
|
|
96
|
+
for (const entry of safeReaddir(nodeModules)) {
|
|
97
|
+
if (entry.startsWith(".")) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (entry.startsWith("@")) {
|
|
101
|
+
for (const scoped of safeReaddir(join(nodeModules, entry))) {
|
|
102
|
+
if (!scoped.startsWith(".")) {
|
|
103
|
+
dirs.push(join(nodeModules, entry, scoped));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
dirs.push(join(nodeModules, entry));
|
|
109
|
+
}
|
|
110
|
+
return dirs;
|
|
111
|
+
}
|
|
112
|
+
function wanterFromManifestDir(dir) {
|
|
113
|
+
let manifest;
|
|
114
|
+
try {
|
|
115
|
+
manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
if (!isPlainObject(manifest) || typeof manifest.name !== "string" || manifest.name.length === 0) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
const scripts = isPlainObject(manifest.scripts) ? manifest.scripts : {};
|
|
124
|
+
const hasGyp = existsSync(join(dir, "binding.gyp"));
|
|
125
|
+
const hooks = LIFECYCLE_HOOKS.filter((hook) => typeof lifecycleCommand(scripts, hook) === "string");
|
|
126
|
+
if (hasGyp) {
|
|
127
|
+
hooks.push("gyp");
|
|
128
|
+
}
|
|
129
|
+
if (hooks.length === 0) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
name: manifest.name,
|
|
134
|
+
version: typeof manifest.version === "string" ? manifest.version : "",
|
|
135
|
+
hooks,
|
|
136
|
+
scriptsHash: computeScriptsHash(scripts, hasGyp)
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function lifecycleCommand(scripts, hook) {
|
|
140
|
+
const command = scripts[hook];
|
|
141
|
+
return typeof command === "string" && command.length > 0 ? command : null;
|
|
142
|
+
}
|
|
143
|
+
function safeReaddir(dir) {
|
|
144
|
+
try {
|
|
145
|
+
return readdirSync(dir);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function isPlainObject(value) {
|
|
152
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
153
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { loadUserConfig } from "../config/settings.js";
|
|
2
|
+
import { resolvePresentation } from "../presentation/mode.js";
|
|
3
|
+
import { createTheme } from "../presentation/theme.js";
|
|
4
|
+
import { loadDgFile, saveDgFile } from "../project/dgfile.js";
|
|
5
|
+
import { detectPnpmIgnoredBuilds, detectScriptWanters } from "./detect.js";
|
|
6
|
+
export function evaluateScriptGate(wanters, approvals) {
|
|
7
|
+
const approved = [];
|
|
8
|
+
const denied = [];
|
|
9
|
+
const pending = [];
|
|
10
|
+
const drifted = [];
|
|
11
|
+
for (const wanter of wanters) {
|
|
12
|
+
const entry = approvals[wanter.name];
|
|
13
|
+
if (!entry) {
|
|
14
|
+
pending.push(wanter);
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (entry.scriptsHash !== wanter.scriptsHash) {
|
|
18
|
+
drifted.push({ wanter, priorHash: entry.scriptsHash });
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (entry.decision === "allow") {
|
|
22
|
+
approved.push(wanter);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
denied.push(wanter);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { approved, denied, pending, drifted };
|
|
29
|
+
}
|
|
30
|
+
export function applyScriptDecisions(file, decisions, now) {
|
|
31
|
+
if (decisions.length === 0) {
|
|
32
|
+
return file;
|
|
33
|
+
}
|
|
34
|
+
const npm = { ...file.scriptApprovals.npm };
|
|
35
|
+
for (const input of decisions) {
|
|
36
|
+
npm[input.wanter.name] = {
|
|
37
|
+
decision: input.decision,
|
|
38
|
+
scriptsHash: input.wanter.scriptsHash,
|
|
39
|
+
hooks: input.wanter.hooks,
|
|
40
|
+
...(input.wanter.version ? { approvedVersion: input.wanter.version } : {}),
|
|
41
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
42
|
+
approvedAt: now.toISOString(),
|
|
43
|
+
provenance: input.provenance ?? "prompt"
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
...file,
|
|
48
|
+
scriptApprovals: { ...file.scriptApprovals, npm }
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function recordScriptObservations(options) {
|
|
52
|
+
const file = loadDgFile(options.projectDir);
|
|
53
|
+
if (!file.readable || (!file.exists && !options.createIfMissing) || options.wanters.length === 0) {
|
|
54
|
+
return { written: false, path: file.path };
|
|
55
|
+
}
|
|
56
|
+
const observed = { ...file.scriptApprovals.observed };
|
|
57
|
+
let changed = false;
|
|
58
|
+
for (const wanter of options.wanters) {
|
|
59
|
+
const existing = observed[wanter.name];
|
|
60
|
+
if (existing &&
|
|
61
|
+
existing.version === wanter.version &&
|
|
62
|
+
existing.scriptsHash === wanter.scriptsHash &&
|
|
63
|
+
sameHooks(existing.hooks, wanter.hooks)) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
observed[wanter.name] = {
|
|
67
|
+
version: wanter.version,
|
|
68
|
+
hooks: wanter.hooks,
|
|
69
|
+
scriptsHash: wanter.scriptsHash,
|
|
70
|
+
firstSeen: existing ? existing.firstSeen : options.now.toISOString()
|
|
71
|
+
};
|
|
72
|
+
changed = true;
|
|
73
|
+
}
|
|
74
|
+
if (!changed) {
|
|
75
|
+
return { written: false, path: file.path };
|
|
76
|
+
}
|
|
77
|
+
saveDgFile({ ...file, scriptApprovals: { ...file.scriptApprovals, observed } });
|
|
78
|
+
return { written: true, path: file.path };
|
|
79
|
+
}
|
|
80
|
+
function sameHooks(a, b) {
|
|
81
|
+
return a.length === b.length && a.every((hook, index) => hook === b[index]);
|
|
82
|
+
}
|
|
83
|
+
export function hasExplicitScriptPreference(args, env) {
|
|
84
|
+
if (args.some((arg) => arg === "--ignore-scripts" || arg.startsWith("--ignore-scripts="))) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
return env.npm_config_ignore_scripts !== undefined && env.npm_config_ignore_scripts !== "";
|
|
88
|
+
}
|
|
89
|
+
export function scriptGateInstallArgs(options) {
|
|
90
|
+
if (options.mode !== "enforce") {
|
|
91
|
+
return options.args;
|
|
92
|
+
}
|
|
93
|
+
if (options.manager !== "npm" && options.manager !== "yarn") {
|
|
94
|
+
return options.args;
|
|
95
|
+
}
|
|
96
|
+
if (hasExplicitScriptPreference(options.args, options.env)) {
|
|
97
|
+
return options.args;
|
|
98
|
+
}
|
|
99
|
+
return [...options.args, "--ignore-scripts"];
|
|
100
|
+
}
|
|
101
|
+
export function scriptGateChildEnv(options) {
|
|
102
|
+
if (options.mode !== "enforce" || (options.manager !== "npm" && options.manager !== "yarn")) {
|
|
103
|
+
return {};
|
|
104
|
+
}
|
|
105
|
+
if (hasExplicitScriptPreference(options.args, options.env)) {
|
|
106
|
+
return {};
|
|
107
|
+
}
|
|
108
|
+
return { npm_config_ignore_scripts: "true" };
|
|
109
|
+
}
|
|
110
|
+
const REPORTED_NAME_LIMIT = 6;
|
|
111
|
+
export function scriptGateReportLine(options) {
|
|
112
|
+
const theme = createTheme(resolvePresentation().color);
|
|
113
|
+
if (options.manager === "pnpm") {
|
|
114
|
+
const ignored = options.pnpmIgnoredBuilds ?? [];
|
|
115
|
+
if (ignored.length === 0) {
|
|
116
|
+
return "";
|
|
117
|
+
}
|
|
118
|
+
return `\n ${theme.paint("muted", `dg scripts: pnpm natively blocked install scripts for ${formatNames(ignored)} — review with 'pnpm approve-builds'`)}\n`;
|
|
119
|
+
}
|
|
120
|
+
const wanters = options.wanters ?? [];
|
|
121
|
+
if (wanters.length === 0) {
|
|
122
|
+
return "";
|
|
123
|
+
}
|
|
124
|
+
const names = wanters.map((wanter) => (wanter.version ? `${wanter.name}@${wanter.version}` : wanter.name));
|
|
125
|
+
const noun = wanters.length === 1 ? "package ran" : "packages ran";
|
|
126
|
+
return `\n ${theme.paint("muted", `dg scripts: ${wanters.length} ${noun} install scripts (${formatNames(names)}) — per-package approvals are coming; see 'dg explain script-gate'`)}\n`;
|
|
127
|
+
}
|
|
128
|
+
function formatNames(names) {
|
|
129
|
+
if (names.length <= REPORTED_NAME_LIMIT) {
|
|
130
|
+
return names.join(", ");
|
|
131
|
+
}
|
|
132
|
+
return `${names.slice(0, REPORTED_NAME_LIMIT).join(", ")}, +${names.length - REPORTED_NAME_LIMIT} more`;
|
|
133
|
+
}
|
|
134
|
+
const MUTATING_ACTIONS = {
|
|
135
|
+
npm: new Set(["install", "i", "ci", "add", "update", "dedupe"]),
|
|
136
|
+
yarn: new Set(["add", "install", "upgrade"]),
|
|
137
|
+
pnpm: new Set(["install", "i", "add", "update"])
|
|
138
|
+
};
|
|
139
|
+
export function runScriptGateAfterInstall(options) {
|
|
140
|
+
try {
|
|
141
|
+
const classification = options.classification;
|
|
142
|
+
if (classification.kind !== "protected" || classification.ecosystem !== "javascript") {
|
|
143
|
+
return "";
|
|
144
|
+
}
|
|
145
|
+
const mutatingActions = MUTATING_ACTIONS[classification.manager];
|
|
146
|
+
if (!mutatingActions || !mutatingActions.has(classification.action)) {
|
|
147
|
+
return "";
|
|
148
|
+
}
|
|
149
|
+
const config = loadUserConfig(options.env ?? process.env);
|
|
150
|
+
if (config.scriptGate.mode === "off") {
|
|
151
|
+
return "";
|
|
152
|
+
}
|
|
153
|
+
const projectDir = options.projectDir ?? process.cwd();
|
|
154
|
+
if (classification.manager === "pnpm") {
|
|
155
|
+
return scriptGateReportLine({ manager: "pnpm", pnpmIgnoredBuilds: detectPnpmIgnoredBuilds(projectDir) });
|
|
156
|
+
}
|
|
157
|
+
const wanters = detectScriptWanters(projectDir);
|
|
158
|
+
recordScriptObservations({
|
|
159
|
+
projectDir,
|
|
160
|
+
wanters,
|
|
161
|
+
createIfMissing: config.scriptGate.observe,
|
|
162
|
+
now: options.now ?? new Date()
|
|
163
|
+
});
|
|
164
|
+
return scriptGateReportLine({ manager: classification.manager, wanters });
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// observing must never fail or block an install that already succeeded
|
|
168
|
+
return "";
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const ROOT_LIFECYCLE_HOOKS = ["preinstall", "install", "postinstall", "prepare"];
|
|
2
|
+
export function buildScriptRebuildPlan(manager, packages, includeRootLifecycle) {
|
|
3
|
+
const binaryName = manager === "pnpm" ? "pnpm" : "npm";
|
|
4
|
+
const commands = [];
|
|
5
|
+
if (packages.length > 0) {
|
|
6
|
+
commands.push(binaryName === "pnpm" ? ["rebuild", ...packages] : ["rebuild", ...packages, "--foreground-scripts"]);
|
|
7
|
+
}
|
|
8
|
+
if (includeRootLifecycle && binaryName === "npm") {
|
|
9
|
+
for (const hook of ROOT_LIFECYCLE_HOOKS) {
|
|
10
|
+
commands.push(["run", "--if-present", hook]);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return { binaryName, commands };
|
|
14
|
+
}
|
|
15
|
+
export async function runScriptRebuild(options) {
|
|
16
|
+
const failures = [];
|
|
17
|
+
let exitCode = 0;
|
|
18
|
+
for (const args of options.plan.commands) {
|
|
19
|
+
const result = await options.spawner({ binary: options.binaryPath, args, env: options.env });
|
|
20
|
+
if (result.exitCode !== 0) {
|
|
21
|
+
failures.push({ args, exitCode: result.exitCode });
|
|
22
|
+
if (exitCode === 0) {
|
|
23
|
+
exitCode = result.exitCode;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return { exitCode, failures };
|
|
28
|
+
}
|