@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.
Files changed (43) hide show
  1. package/dist/api/analyze.js +5 -3
  2. package/dist/bin/dg.js +1 -1
  3. package/dist/commands/completion.js +2 -1
  4. package/dist/commands/config.js +11 -3
  5. package/dist/commands/decisions.js +155 -0
  6. package/dist/commands/explain.js +6 -2
  7. package/dist/commands/router.js +2 -0
  8. package/dist/commands/scan.js +2 -1
  9. package/dist/commands/status.js +5 -2
  10. package/dist/config/settings.js +144 -25
  11. package/dist/decisions/apply.js +128 -0
  12. package/dist/decisions/remember-prompt.js +97 -0
  13. package/dist/install-ui/block-render.js +21 -4
  14. package/dist/install-ui/prompt.js +14 -0
  15. package/dist/launcher/install-preflight.js +126 -13
  16. package/dist/launcher/preflight-prompt.js +29 -2
  17. package/dist/launcher/run.js +14 -3
  18. package/dist/policy/cooldown.js +104 -0
  19. package/dist/policy/evaluate.js +0 -15
  20. package/dist/presentation/provenance.js +23 -0
  21. package/dist/project/dgfile.js +307 -0
  22. package/dist/proxy/enforcement.js +2 -1
  23. package/dist/proxy/metadata-map.js +25 -1
  24. package/dist/proxy/server.js +31 -2
  25. package/dist/scan/collect.js +10 -4
  26. package/dist/scan/command.js +35 -8
  27. package/dist/scan/discovery.js +66 -4
  28. package/dist/scan/render.js +35 -4
  29. package/dist/scan/scanner-report.js +31 -4
  30. package/dist/scan/staged.js +69 -10
  31. package/dist/scan-ui/LegacyApp.js +4 -4
  32. package/dist/scan-ui/components/InteractiveResultsView.js +64 -7
  33. package/dist/scan-ui/hooks/useScan.js +31 -3
  34. package/dist/scan-ui/launch.js +4 -1
  35. package/dist/scan-ui/shims.js +3 -0
  36. package/dist/scripts/detect.js +153 -0
  37. package/dist/scripts/gate.js +170 -0
  38. package/dist/scripts/rebuild.js +28 -0
  39. package/dist/setup/plan.js +36 -1
  40. package/dist/util/json-file.js +24 -0
  41. package/dist/util/tty-prompt.js +13 -6
  42. package/dist/verify/package-check.js +12 -0
  43. 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 allGroups = useMemo(() => groupPackages(flagged), [flagged]);
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 availableRows = Math.max(5, termRows - (compact ? FIXED_CHROME_COMPACT : FIXED_CHROME));
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 - 14 - lcCol);
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}"` })), !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"] })), _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")] })) : 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"), exportMsg && _jsxs(_Fragment, { children: [" ", chalk.green(exportMsg)] })] })) : (_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"), " ", chalk.dim("\u00B7 Ctrl+C or q to exit"), exportMsg && _jsxs(_Fragment, { children: [" ", chalk.green(exportMsg)] })] }))] }));
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: firstFailure && merged.action === "pass" ? { ...merged, action: "analysis_incomplete" } : merged,
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
  }
@@ -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
  }
@@ -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
+ }