@westbayberry/dg 2.0.11 → 2.2.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 (143) hide show
  1. package/README.md +216 -226
  2. package/dist/agents/claude-code.js +113 -0
  3. package/dist/agents/codex.js +65 -0
  4. package/dist/agents/copilot-cli.js +115 -0
  5. package/dist/agents/cursor.js +113 -0
  6. package/dist/agents/gemini.js +107 -0
  7. package/dist/agents/persistence.js +285 -0
  8. package/dist/agents/registry.js +127 -0
  9. package/dist/agents/types.js +1 -0
  10. package/dist/agents/windsurf.js +93 -0
  11. package/dist/api/analyze.js +6 -4
  12. package/dist/audit/detectors.js +0 -11
  13. package/dist/audit/events.js +5 -21
  14. package/dist/audit-ui/AuditApp.js +2 -0
  15. package/dist/audit-ui/components/AuditResultsView.js +55 -92
  16. package/dist/audit-ui/export.js +0 -4
  17. package/dist/audit-ui/format.js +0 -3
  18. package/dist/audit-ui/launch.js +10 -1
  19. package/dist/auth/device-login.js +4 -5
  20. package/dist/auth/login-app.js +7 -7
  21. package/dist/auth/store.js +8 -3
  22. package/dist/bin/dg.js +59 -52
  23. package/dist/commands/agents.js +231 -0
  24. package/dist/commands/audit.js +22 -3
  25. package/dist/commands/config.js +26 -10
  26. package/dist/commands/cooldown.js +389 -0
  27. package/dist/commands/decisions.js +169 -0
  28. package/dist/commands/doctor.js +1 -1
  29. package/dist/commands/help.js +1 -1
  30. package/dist/commands/licenses.js +10 -22
  31. package/dist/commands/logout.js +4 -11
  32. package/dist/commands/router.js +8 -4
  33. package/dist/commands/sbom.js +206 -0
  34. package/dist/commands/scan.js +3 -2
  35. package/dist/commands/service.js +32 -13
  36. package/dist/commands/setup.js +197 -27
  37. package/dist/commands/status.js +5 -2
  38. package/dist/commands/types.js +1 -0
  39. package/dist/commands/update.js +17 -8
  40. package/dist/commands/verify.js +8 -5
  41. package/dist/config/settings.js +154 -65
  42. package/dist/decisions/apply.js +128 -0
  43. package/dist/decisions/remember-prompt.js +92 -0
  44. package/dist/export-ui/ExportDialog.js +198 -0
  45. package/dist/install-ui/LiveInstall.js +2 -2
  46. package/dist/install-ui/block-render.js +21 -4
  47. package/dist/install-ui/prep-spinner.js +32 -0
  48. package/dist/install-ui/prompt.js +14 -0
  49. package/dist/launcher/agent-check.js +466 -0
  50. package/dist/launcher/agent-hook-exec.js +70 -0
  51. package/dist/launcher/agent-hook-io.js +31 -0
  52. package/dist/launcher/cargo-cache.js +40 -0
  53. package/dist/launcher/classify.js +17 -6
  54. package/dist/launcher/env.js +71 -24
  55. package/dist/launcher/install-preflight.js +167 -17
  56. package/dist/launcher/live-install.js +25 -5
  57. package/dist/launcher/output-redaction.js +7 -4
  58. package/dist/launcher/preflight-prompt.js +43 -3
  59. package/dist/launcher/run.js +100 -86
  60. package/dist/launcher/spawn-invocation.js +21 -0
  61. package/dist/policy/cooldown.js +117 -0
  62. package/dist/policy/evaluate.js +5 -21
  63. package/dist/policy/pypi-name.js +17 -0
  64. package/dist/presentation/mode.js +3 -2
  65. package/dist/presentation/package-page.js +9 -0
  66. package/dist/presentation/provenance.js +23 -0
  67. package/dist/presentation/theme.js +7 -7
  68. package/dist/project/dgfile.js +446 -0
  69. package/dist/proxy/auth.js +42 -0
  70. package/dist/proxy/ca.js +29 -9
  71. package/dist/proxy/cooldown-exemptions-file.js +33 -0
  72. package/dist/proxy/enforcement.js +57 -17
  73. package/dist/proxy/metadata-map.js +66 -4
  74. package/dist/proxy/preverified.js +55 -0
  75. package/dist/proxy/server.js +473 -45
  76. package/dist/proxy/worker.js +16 -1
  77. package/dist/publish-set/collect.js +1 -4
  78. package/dist/publish-set/npm.js +8 -5
  79. package/dist/publish-set/pack.js +9 -3
  80. package/dist/runtime/cli.js +0 -4
  81. package/dist/runtime/fatal.js +31 -0
  82. package/dist/runtime/first-run.js +12 -11
  83. package/dist/runtime/node-version.js +43 -6
  84. package/dist/runtime/nudges.js +35 -2
  85. package/dist/sbom/cyclonedx.js +211 -0
  86. package/dist/sbom-ui/SbomApp.js +158 -0
  87. package/dist/sbom-ui/components/SbomHeader.js +32 -0
  88. package/dist/sbom-ui/components/SbomList.js +52 -0
  89. package/dist/sbom-ui/inventory.js +128 -0
  90. package/dist/sbom-ui/launch.js +51 -0
  91. package/dist/sbom-ui/run.js +55 -0
  92. package/dist/sbom-ui/store.js +26 -0
  93. package/dist/scan/collect.js +10 -6
  94. package/dist/scan/command.js +51 -17
  95. package/dist/scan/discovery.js +11 -2
  96. package/dist/scan/render.js +63 -8
  97. package/dist/scan/scanner-report.js +42 -9
  98. package/dist/scan/staged.js +71 -11
  99. package/dist/scan-ui/LegacyApp.js +12 -16
  100. package/dist/scan-ui/alt-screen.js +5 -8
  101. package/dist/scan-ui/components/InteractiveResultsView.js +193 -123
  102. package/dist/scan-ui/components/ProgressBar.js +3 -14
  103. package/dist/scan-ui/components/ProjectSelector.js +1 -1
  104. package/dist/scan-ui/components/ScoreHeader.js +2 -3
  105. package/dist/scan-ui/components/SetupBanner.js +0 -6
  106. package/dist/scan-ui/format-helpers.js +61 -5
  107. package/dist/scan-ui/hooks/useResizeRepaint.js +25 -0
  108. package/dist/scan-ui/hooks/useScan.js +46 -4
  109. package/dist/scan-ui/launch.js +7 -4
  110. package/dist/scan-ui/shims.js +14 -4
  111. package/dist/scripts/detect.js +158 -0
  112. package/dist/scripts/gate.js +170 -0
  113. package/dist/service/state.js +27 -8
  114. package/dist/service/trust-refresh.js +92 -0
  115. package/dist/service/worker.js +23 -1
  116. package/dist/setup/activate-shell.js +28 -0
  117. package/dist/setup/git-hook.js +49 -4
  118. package/dist/setup/plan.js +98 -29
  119. package/dist/setup-ui/gate.js +39 -0
  120. package/dist/setup-ui/offer.js +42 -0
  121. package/dist/setup-ui/selector.js +27 -0
  122. package/dist/setup-ui/tasks.js +56 -0
  123. package/dist/setup-ui/wizard.js +225 -0
  124. package/dist/state/cooldown-held.js +66 -0
  125. package/dist/state/index.js +1 -0
  126. package/dist/state/locks.js +4 -2
  127. package/dist/state/store.js +2 -1
  128. package/dist/util/external-tool.js +25 -0
  129. package/dist/util/git.js +10 -3
  130. package/dist/util/json-file.js +24 -0
  131. package/dist/util/report-writer.js +57 -0
  132. package/dist/util/tty-prompt.js +13 -6
  133. package/dist/verify/local.js +240 -42
  134. package/dist/verify/package-check.js +86 -18
  135. package/dist/verify/preflight.js +242 -49
  136. package/dist/verify/render.js +15 -1
  137. package/npm-shrinkwrap.json +2383 -0
  138. package/package.json +14 -8
  139. package/NOTICE +0 -5
  140. package/dist/commands/completion.js +0 -116
  141. package/dist/commands/explain.js +0 -232
  142. package/dist/commands/unavailable.js +0 -11
  143. package/dist/telemetry/events.js +0 -40
@@ -4,11 +4,7 @@ import { Text, Box } from "ink";
4
4
  import InkSpinner from "ink-spinner";
5
5
  import chalk from "chalk";
6
6
  import { useTerminalSize } from "../hooks/useTerminalSize.js";
7
- // Measured ETA: extrapolates remaining time from the throughput observed so far.
8
- // `value` and `total` are the same numbers the bar renders; `elapsedSeconds` is
9
- // the wall-clock since the bar mounted. Returns null fields when there's not
10
- // enough signal yet (value == 0 or elapsed == 0) so the UI can show
11
- // "calculating…" instead of a misleading number.
7
+ // Null fields until value/elapsed are non-zero so the UI shows "calculating…".
12
8
  export function computeEta(value, total, elapsedSeconds) {
13
9
  if (value <= 0 || elapsedSeconds <= 0 || total <= 0) {
14
10
  return { remainingSeconds: null, ratePerSec: null };
@@ -68,17 +64,10 @@ export const ProgressBar = ({ value, total, label, }) => {
68
64
  timeInfo = `${formatTime(elapsed)} elapsed · ~${formatTime(eta.remainingSeconds)} left${rateInline}`;
69
65
  }
70
66
  const counter = `${value}/${total} ${percent}%`;
71
- // Width thresholds:
72
- // < 30 cols compact: counter only, no bar (bar would soft-wrap and
73
- // break Ink's line-count tracker, leaving stale lines
74
- // above on the next redraw).
75
- // 30-59 → narrow bar (no elapsed-time tail beside the spinner).
76
- // >= 60 → full bar + tail.
67
+ // Below 30 cols the bar would soft-wrap and break Ink's line-count
68
+ // tracker, leaving stale lines above on the next redraw.
77
69
  const compact = termWidth < 30;
78
70
  const narrow = !compact && termWidth < 60;
79
- // Reserve: 4 indent + counter length + 2 padding. Floor at 6 cols so
80
- // even on a borderline-narrow terminal we draw SOMETHING; if there's
81
- // no room at all we fall through to compact mode above.
82
71
  const barWidth = Math.max(6, termWidth - counter.length - 8);
83
72
  const fraction = total > 0 ? Math.min(1, value / total) : 0;
84
73
  const filled = Math.round(fraction * barWidth);
@@ -42,7 +42,7 @@ export const ProjectSelector = ({ projects, onConfirm, onCancel, userStatus }) =
42
42
  }
43
43
  });
44
44
  const ecosystemLabel = (eco) => eco === "npm" ? chalk.magenta("npm") : chalk.blue("pip");
45
- return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsxs(Text, { children: [chalk.bold("Dependency Guardian"), " ", userStatus ? chalk.dim(userStatus) : ""] }), _jsx(Text, { children: "" }), _jsxs(Text, { bold: true, children: ["Found ", projects.length, " project", projects.length !== 1 ? "s" : ""] }), _jsx(Text, { children: "" }), projects.map((proj, i) => {
45
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsxs(Text, { children: [chalk.bold("Dependency Guardian"), " ", userStatus ?? ""] }), _jsx(Text, { children: "" }), _jsxs(Text, { bold: true, children: ["Found ", projects.length, " project", projects.length !== 1 ? "s" : ""] }), _jsx(Text, { children: "" }), projects.map((proj, i) => {
46
46
  const isCursor = i === cursor;
47
47
  const isSelected = selected.has(i);
48
48
  const prefix = isCursor ? chalk.cyan("\u258C") : " ";
@@ -13,10 +13,9 @@ function scoreColor(score, action) {
13
13
  chalk.green.bold;
14
14
  return colorFn(String(score));
15
15
  }
16
- export const ScoreHeader = ({ score, action, total, flagged, clean, userStatus, scanUsage, usageNearLimit, }) => {
16
+ export const ScoreHeader = ({ score, action, compact, total, flagged, clean, userStatus, scanUsage, usageNearLimit, }) => {
17
17
  const logo = renderLogo(action);
18
- const { cols, rows } = useTerminalSize();
19
- const compact = rows < COMPACT_ROWS;
18
+ const { cols } = useTerminalSize();
20
19
  const showLogo = cols >= LOGO_MIN_COLS && !compact;
21
20
  return (_jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: action === "block" ? "red" : action === "warn" ? "yellow" : action === "analysis_incomplete" ? "cyan" : "green", paddingLeft: 1, paddingRight: 1, width: "100%", children: _jsxs(Box, { flexDirection: "row", children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Text, { bold: true, children: ["Dependency Guardian ", userStatus ?? ""] }), !compact && _jsx(Text, { children: " " }), _jsxs(Text, { children: [chalk.dim("Score"), " ", scoreColor(score, action)] }), total !== undefined && (_jsxs(_Fragment, { children: [!compact && _jsx(Text, { children: " " }), _jsxs(Text, { children: [chalk.dim(`${total} package${total !== 1 ? "s" : ""} scanned`), flagged !== undefined && flagged > 0 ? (_jsxs(_Fragment, { children: [" ", chalk.yellow(`${flagged} flagged`), " ", chalk.green(`${clean ?? 0} clean`)] })) : (_jsxs(_Fragment, { children: [" ", chalk.green("all clean")] }))] }), scanUsage && (usageNearLimit
22
21
  ? _jsxs(Text, { color: "yellow", children: [scanUsage, " \u2191 Pro: westbayberry.com/pricing"] })
@@ -1,11 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- // Visible alert shown above the scan results when the user's DG setup has
4
- // gaps — missing api key, missing pre-commit hook, etc. Renders nothing
5
- // when issues is empty so it's free to drop in unconditionally.
6
- //
7
- // Yellow border, single line per issue, fix command in cyan so the user
8
- // can copy-paste it.
9
3
  export const SetupBanner = ({ issues }) => {
10
4
  if (issues.length === 0)
11
5
  return null;
@@ -1,7 +1,6 @@
1
+ import chalk from "chalk";
2
+ import { sanitize } from "../security/sanitize.js";
1
3
  export const USAGE_NEAR_LIMIT_RATIO = 0.8;
2
- // Renders the monthly package-usage readout shown on every scan. `nearLimit`
3
- // drives the dim → yellow + upgrade-nudge escalation; it's only ever true for
4
- // a finite limit at/above 80% used.
5
4
  export function formatUsage(usage) {
6
5
  const used = usage.used.toLocaleString();
7
6
  if (usage.limit === null) {
@@ -12,11 +11,68 @@ export function formatUsage(usage) {
12
11
  nearLimit: usage.used / usage.limit >= USAGE_NEAR_LIMIT_RATIO,
13
12
  };
14
13
  }
14
+ export function formatAccountStatus(tier, loggedIn, name) {
15
+ const cleaned = sanitize(tier).trim().slice(0, 24).toLowerCase();
16
+ const label = cleaned ? cleaned.charAt(0).toUpperCase() + cleaned.slice(1) : loggedIn ? "Account" : "Free";
17
+ if (!loggedIn) {
18
+ return `${chalk.dim(`${label} · `)}${chalk.cyan.bold("dg login")}`;
19
+ }
20
+ const plan = (cleaned === "free" ? chalk.yellow : chalk.green)(`${label} plan`);
21
+ const who = name ? sanitize(name).trim().slice(0, 40) : "";
22
+ return who ? `${chalk.dim(`${who} · `)}${plan}` : plan;
23
+ }
24
+ function isWideCodePoint(cp) {
25
+ return (cp >= 0x1100 &&
26
+ (cp <= 0x115f ||
27
+ cp === 0x2329 ||
28
+ cp === 0x232a ||
29
+ (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) ||
30
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
31
+ (cp >= 0xf900 && cp <= 0xfaff) ||
32
+ (cp >= 0xfe10 && cp <= 0xfe19) ||
33
+ (cp >= 0xfe30 && cp <= 0xfe6b) ||
34
+ (cp >= 0xff00 && cp <= 0xff60) ||
35
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
36
+ (cp >= 0x1f300 && cp <= 0x1f64f) ||
37
+ (cp >= 0x1f900 && cp <= 0x1f9ff) ||
38
+ (cp >= 0x20000 && cp <= 0x3fffd)));
39
+ }
40
+ function isZeroWidthCodePoint(cp) {
41
+ return ((cp >= 0x0300 && cp <= 0x036f) ||
42
+ (cp >= 0x1ab0 && cp <= 0x1aff) ||
43
+ (cp >= 0x20d0 && cp <= 0x20ff) ||
44
+ (cp >= 0xfe00 && cp <= 0xfe0f) ||
45
+ (cp >= 0x200b && cp <= 0x200f) ||
46
+ cp === 0xfeff);
47
+ }
48
+ function codePointWidth(cp) {
49
+ if (isZeroWidthCodePoint(cp))
50
+ return 0;
51
+ return isWideCodePoint(cp) ? 2 : 1;
52
+ }
53
+ export function displayWidth(s) {
54
+ let width = 0;
55
+ for (const ch of s) {
56
+ width += codePointWidth(ch.codePointAt(0) ?? 0);
57
+ }
58
+ return width;
59
+ }
15
60
  export function pad(s, len) {
16
- return s + " ".repeat(Math.max(0, len - s.length));
61
+ return s + " ".repeat(Math.max(0, len - displayWidth(s)));
17
62
  }
18
63
  export function truncate(s, max) {
19
- return s.length <= max ? s : s.slice(0, max - 1) + "…";
64
+ if (displayWidth(s) <= max)
65
+ return s;
66
+ let out = "";
67
+ let width = 0;
68
+ for (const ch of s) {
69
+ const w = codePointWidth(ch.codePointAt(0) ?? 0);
70
+ if (width + w > max - 1)
71
+ break;
72
+ out += ch;
73
+ width += w;
74
+ }
75
+ return out + "…";
20
76
  }
21
77
  export function groupPackages(packages, keyBy = "name") {
22
78
  const map = new Map();
@@ -0,0 +1,25 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useStdout } from "ink";
3
+ import { CLEAR_HOME } from "../alt-screen.js";
4
+ // Ink skips repainting when output matches its memoized last frame, so a bare
5
+ // screen clear on resize leaves the TUI blank until the next state change.
6
+ // useStdout().write is the only public path that erases and repaints the
7
+ // memoized frame unconditionally; CLEAR_HOME first wipes resize artifacts.
8
+ export function useResizeRepaint() {
9
+ const { stdout, write } = useStdout();
10
+ const [resizes, setResizes] = useState(0);
11
+ useEffect(() => {
12
+ const handle = () => setResizes((count) => count + 1);
13
+ stdout.setMaxListeners(stdout.getMaxListeners() + 1);
14
+ stdout.on("resize", handle);
15
+ return () => {
16
+ stdout.off("resize", handle);
17
+ stdout.setMaxListeners(Math.max(0, stdout.getMaxListeners() - 1));
18
+ };
19
+ }, [stdout]);
20
+ useEffect(() => {
21
+ if (resizes === 0)
22
+ return;
23
+ write(CLEAR_HOME);
24
+ }, [resizes, write]);
25
+ }
@@ -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 };
@@ -50,9 +53,10 @@ export function useScan(config) {
50
53
  }
51
54
  started.current = true;
52
55
  void (async () => {
53
- const projects = await discoverScanProjectsAsync(process.cwd(), (progress) => {
56
+ const discovered = await discoverScanProjectsAsync(process.cwd(), (progress) => {
54
57
  dispatch({ type: "DISCOVERY_PROGRESS", path: progress.path, found: progress.found });
55
58
  });
59
+ const projects = discovered.filter((project) => project.ecosystem === "npm" || project.ecosystem === "pypi");
56
60
  if (projects.length === 0) {
57
61
  dispatch({ type: "DISCOVERY_EMPTY", message: "No dependency lockfiles found." });
58
62
  return;
@@ -79,6 +83,28 @@ export function useScan(config) {
79
83
  restartSelection: multiProjects ? restartSelection : null
80
84
  };
81
85
  }
86
+ function computeProjectDecisions(result, entries) {
87
+ try {
88
+ const root = findProjectRoot(process.cwd());
89
+ if (!root) {
90
+ return undefined;
91
+ }
92
+ const file = loadDgFile(root);
93
+ if (!file.readable) {
94
+ return undefined;
95
+ }
96
+ const ecosystems = new Map();
97
+ for (const [ecosystem, packages] of entries) {
98
+ for (const pkg of packages) {
99
+ ecosystems.set(packageKey(pkg.name, pkg.version), ecosystem);
100
+ }
101
+ }
102
+ return applyDecisions(result.packages, (pkg) => ecosystems.get(packageKey(pkg.name, pkg.version)), file, result.action);
103
+ }
104
+ catch {
105
+ return undefined;
106
+ }
107
+ }
82
108
  async function scanProjects(projects, dispatch, signal) {
83
109
  const startMs = Date.now();
84
110
  let skipped = 0;
@@ -134,12 +160,28 @@ async function scanProjects(projects, dispatch, signal) {
134
160
  const firstFailure = outcomes.find((outcome) => "error" in outcome);
135
161
  if (responses.length > 0) {
136
162
  const merged = mergeAnalyzeResponses(responses);
163
+ const base = firstFailure && merged.action === "pass" ? { ...merged, action: "analysis_incomplete" } : merged;
164
+ const ecoByKey = new Map();
165
+ for (const [ecosystem, packages] of entries) {
166
+ for (const pkg of packages) {
167
+ ecoByKey.set(packageKey(pkg.name, pkg.version), ecosystem);
168
+ }
169
+ }
170
+ const result = {
171
+ ...base,
172
+ packages: base.packages.map((pkg) => {
173
+ const ecosystem = ecoByKey.get(packageKey(pkg.name, pkg.version));
174
+ return ecosystem ? { ...pkg, ecosystem } : pkg;
175
+ })
176
+ };
177
+ const decisions = computeProjectDecisions(result, entries);
137
178
  dispatch({
138
179
  type: "SCAN_COMPLETE",
139
- result: firstFailure && merged.action === "pass" ? { ...merged, action: "analysis_incomplete" } : merged,
180
+ result,
140
181
  durationMs: Date.now() - startMs,
141
182
  skippedCount: skipped,
142
- discoveredTotal: total
183
+ discoveredTotal: total,
184
+ ...(decisions ? { decisions } : {})
143
185
  });
144
186
  return;
145
187
  }
@@ -12,11 +12,15 @@ export function shouldLaunchScanTui(options) {
12
12
  if (process.env.TERM === "dumb") {
13
13
  return false;
14
14
  }
15
+ if (!process.stdin.isTTY) {
16
+ return false;
17
+ }
15
18
  return resolvePresentation().mode === "rich";
16
19
  }
17
20
  export async function launchScanTui(initialView = "results") {
18
21
  const ci = process.env.CI;
19
- if (ci === "" || ci === "0" || ci === "false") {
22
+ const ciCleared = ci === "" || ci === "0" || ci === "false";
23
+ if (ciCleared) {
20
24
  delete process.env.CI;
21
25
  }
22
26
  const [{ render }, react, app] = await Promise.all([
@@ -32,13 +36,12 @@ export async function launchScanTui(initialView = "results") {
32
36
  : undefined;
33
37
  enterTui();
34
38
  const instance = render(react.default.createElement(app.App, { config, initialView, updateAvailable }), { exitOnCtrlC: true });
35
- const clearStaleFrameOnResize = () => instance.clear();
36
- process.stdout.on("resize", clearStaleFrameOnResize);
37
39
  try {
38
40
  await instance.waitUntilExit();
39
41
  }
40
42
  finally {
41
- process.stdout.off("resize", clearStaleFrameOnResize);
43
+ if (ciCleared)
44
+ process.env.CI = ci;
42
45
  leaveTui();
43
46
  }
44
47
  }
@@ -1,4 +1,5 @@
1
1
  import { authStatus } from "../auth/store.js";
2
+ import { formatAccountStatus } from "./format-helpers.js";
2
3
  export function isLoggedIn() {
3
4
  try {
4
5
  return authStatus().authenticated;
@@ -7,14 +8,23 @@ export function isLoggedIn() {
7
8
  return false;
8
9
  }
9
10
  }
10
- export function getStoredApiKey() {
11
+ export function accountHeaderLine(usageTier, env = process.env) {
12
+ let loggedIn = false;
13
+ let name;
14
+ let storedTier;
11
15
  try {
12
- const status = authStatus();
13
- return status.authenticated ? status.tokenPreview : null;
16
+ const status = authStatus(env);
17
+ loggedIn = status.authenticated;
18
+ name = status.name ?? status.email;
19
+ storedTier = status.tier;
14
20
  }
15
21
  catch {
16
- return null;
22
+ loggedIn = false;
17
23
  }
24
+ return formatAccountStatus(usageTier ?? storedTier ?? "", loggedIn, name);
25
+ }
26
+ export function effectiveScanAction(raw, effective, mode) {
27
+ return mode === "strict" || effective === undefined ? raw : effective;
18
28
  }
19
29
  export function scanExitCode(action, mode) {
20
30
  if (action === "block") {
@@ -0,0 +1,158 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
3
+ import { isAbsolute, join, relative, resolve } 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 manifestDir = resolve(projectDir, packagePath);
78
+ const containment = relative(resolve(nodeModules), manifestDir);
79
+ if (containment === "" || containment.startsWith("..") || isAbsolute(containment)) {
80
+ continue;
81
+ }
82
+ const wanter = wanterFromManifestDir(manifestDir);
83
+ if (wanter) {
84
+ wanters.push(wanter);
85
+ }
86
+ }
87
+ return wanters;
88
+ }
89
+ function wantersFromWalk(nodeModules) {
90
+ const wanters = [];
91
+ for (const dir of packageDirs(nodeModules)) {
92
+ const wanter = wanterFromManifestDir(dir);
93
+ if (wanter) {
94
+ wanters.push(wanter);
95
+ }
96
+ }
97
+ return wanters;
98
+ }
99
+ function packageDirs(nodeModules) {
100
+ const dirs = [];
101
+ for (const entry of safeReaddir(nodeModules)) {
102
+ if (entry.startsWith(".")) {
103
+ continue;
104
+ }
105
+ if (entry.startsWith("@")) {
106
+ for (const scoped of safeReaddir(join(nodeModules, entry))) {
107
+ if (!scoped.startsWith(".")) {
108
+ dirs.push(join(nodeModules, entry, scoped));
109
+ }
110
+ }
111
+ continue;
112
+ }
113
+ dirs.push(join(nodeModules, entry));
114
+ }
115
+ return dirs;
116
+ }
117
+ function wanterFromManifestDir(dir) {
118
+ let manifest;
119
+ try {
120
+ manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ if (!isPlainObject(manifest) || typeof manifest.name !== "string" || manifest.name.length === 0) {
126
+ return null;
127
+ }
128
+ const scripts = isPlainObject(manifest.scripts) ? manifest.scripts : {};
129
+ const hasGyp = existsSync(join(dir, "binding.gyp"));
130
+ const hooks = LIFECYCLE_HOOKS.filter((hook) => typeof lifecycleCommand(scripts, hook) === "string");
131
+ if (hasGyp) {
132
+ hooks.push("gyp");
133
+ }
134
+ if (hooks.length === 0) {
135
+ return null;
136
+ }
137
+ return {
138
+ name: manifest.name,
139
+ version: typeof manifest.version === "string" ? manifest.version : "",
140
+ hooks,
141
+ scriptsHash: computeScriptsHash(scripts, hasGyp)
142
+ };
143
+ }
144
+ function lifecycleCommand(scripts, hook) {
145
+ const command = scripts[hook];
146
+ return typeof command === "string" && command.length > 0 ? command : null;
147
+ }
148
+ function safeReaddir(dir) {
149
+ try {
150
+ return readdirSync(dir);
151
+ }
152
+ catch {
153
+ return [];
154
+ }
155
+ }
156
+ function isPlainObject(value) {
157
+ return typeof value === "object" && value !== null && !Array.isArray(value);
158
+ }
@@ -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)}) — observed, not blocked · silence: dg config set scriptGate.mode off`)}\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
+ }