@westbayberry/dg 2.1.0 → 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 (138) 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 +1 -1
  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 +15 -7
  26. package/dist/commands/cooldown.js +389 -0
  27. package/dist/commands/decisions.js +23 -9
  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 +6 -4
  33. package/dist/commands/sbom.js +206 -0
  34. package/dist/commands/scan.js +1 -1
  35. package/dist/commands/service.js +32 -13
  36. package/dist/commands/setup.js +197 -27
  37. package/dist/commands/types.js +1 -0
  38. package/dist/commands/update.js +17 -8
  39. package/dist/commands/verify.js +8 -5
  40. package/dist/config/settings.js +14 -44
  41. package/dist/decisions/remember-prompt.js +5 -10
  42. package/dist/export-ui/ExportDialog.js +198 -0
  43. package/dist/install-ui/LiveInstall.js +2 -2
  44. package/dist/install-ui/block-render.js +1 -1
  45. package/dist/install-ui/prep-spinner.js +32 -0
  46. package/dist/launcher/agent-check.js +466 -0
  47. package/dist/launcher/agent-hook-exec.js +70 -0
  48. package/dist/launcher/agent-hook-io.js +31 -0
  49. package/dist/launcher/cargo-cache.js +40 -0
  50. package/dist/launcher/classify.js +17 -6
  51. package/dist/launcher/env.js +71 -24
  52. package/dist/launcher/install-preflight.js +51 -14
  53. package/dist/launcher/live-install.js +25 -5
  54. package/dist/launcher/output-redaction.js +7 -4
  55. package/dist/launcher/preflight-prompt.js +14 -1
  56. package/dist/launcher/run.js +89 -86
  57. package/dist/launcher/spawn-invocation.js +21 -0
  58. package/dist/policy/cooldown.js +18 -5
  59. package/dist/policy/evaluate.js +5 -6
  60. package/dist/policy/pypi-name.js +17 -0
  61. package/dist/presentation/mode.js +3 -2
  62. package/dist/presentation/package-page.js +9 -0
  63. package/dist/presentation/theme.js +7 -7
  64. package/dist/project/dgfile.js +145 -6
  65. package/dist/proxy/auth.js +42 -0
  66. package/dist/proxy/ca.js +29 -9
  67. package/dist/proxy/cooldown-exemptions-file.js +33 -0
  68. package/dist/proxy/enforcement.js +55 -16
  69. package/dist/proxy/metadata-map.js +41 -3
  70. package/dist/proxy/preverified.js +55 -0
  71. package/dist/proxy/server.js +448 -49
  72. package/dist/proxy/worker.js +16 -1
  73. package/dist/publish-set/collect.js +1 -4
  74. package/dist/publish-set/npm.js +8 -5
  75. package/dist/publish-set/pack.js +9 -3
  76. package/dist/runtime/cli.js +0 -4
  77. package/dist/runtime/fatal.js +31 -0
  78. package/dist/runtime/first-run.js +12 -11
  79. package/dist/runtime/node-version.js +43 -6
  80. package/dist/runtime/nudges.js +35 -2
  81. package/dist/sbom/cyclonedx.js +211 -0
  82. package/dist/sbom-ui/SbomApp.js +158 -0
  83. package/dist/sbom-ui/components/SbomHeader.js +32 -0
  84. package/dist/sbom-ui/components/SbomList.js +52 -0
  85. package/dist/sbom-ui/inventory.js +128 -0
  86. package/dist/sbom-ui/launch.js +51 -0
  87. package/dist/sbom-ui/run.js +55 -0
  88. package/dist/sbom-ui/store.js +26 -0
  89. package/dist/scan/collect.js +10 -6
  90. package/dist/scan/command.js +17 -10
  91. package/dist/scan/discovery.js +11 -2
  92. package/dist/scan/render.js +28 -4
  93. package/dist/scan/scanner-report.js +15 -9
  94. package/dist/scan/staged.js +2 -1
  95. package/dist/scan-ui/LegacyApp.js +9 -13
  96. package/dist/scan-ui/alt-screen.js +5 -8
  97. package/dist/scan-ui/components/InteractiveResultsView.js +133 -120
  98. package/dist/scan-ui/components/ProgressBar.js +3 -14
  99. package/dist/scan-ui/components/ProjectSelector.js +1 -1
  100. package/dist/scan-ui/components/ScoreHeader.js +2 -3
  101. package/dist/scan-ui/components/SetupBanner.js +0 -6
  102. package/dist/scan-ui/format-helpers.js +61 -5
  103. package/dist/scan-ui/hooks/useResizeRepaint.js +25 -0
  104. package/dist/scan-ui/hooks/useScan.js +16 -2
  105. package/dist/scan-ui/launch.js +7 -4
  106. package/dist/scan-ui/shims.js +11 -4
  107. package/dist/scripts/detect.js +7 -2
  108. package/dist/scripts/gate.js +1 -1
  109. package/dist/service/state.js +27 -8
  110. package/dist/service/trust-refresh.js +92 -0
  111. package/dist/service/worker.js +23 -1
  112. package/dist/setup/activate-shell.js +28 -0
  113. package/dist/setup/git-hook.js +49 -4
  114. package/dist/setup/plan.js +73 -39
  115. package/dist/setup-ui/gate.js +39 -0
  116. package/dist/setup-ui/offer.js +42 -0
  117. package/dist/setup-ui/selector.js +27 -0
  118. package/dist/setup-ui/tasks.js +56 -0
  119. package/dist/setup-ui/wizard.js +225 -0
  120. package/dist/state/cooldown-held.js +66 -0
  121. package/dist/state/index.js +1 -0
  122. package/dist/state/locks.js +4 -2
  123. package/dist/state/store.js +2 -1
  124. package/dist/util/external-tool.js +25 -0
  125. package/dist/util/git.js +10 -3
  126. package/dist/util/report-writer.js +57 -0
  127. package/dist/verify/local.js +240 -42
  128. package/dist/verify/package-check.js +84 -28
  129. package/dist/verify/preflight.js +242 -49
  130. package/dist/verify/render.js +15 -1
  131. package/npm-shrinkwrap.json +2383 -0
  132. package/package.json +6 -8
  133. package/NOTICE +0 -5
  134. package/dist/commands/completion.js +0 -117
  135. package/dist/commands/explain.js +0 -236
  136. package/dist/commands/unavailable.js +0 -11
  137. package/dist/scripts/rebuild.js +0 -28
  138. 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
+ }
@@ -53,9 +53,10 @@ export function useScan(config) {
53
53
  }
54
54
  started.current = true;
55
55
  void (async () => {
56
- const projects = await discoverScanProjectsAsync(process.cwd(), (progress) => {
56
+ const discovered = await discoverScanProjectsAsync(process.cwd(), (progress) => {
57
57
  dispatch({ type: "DISCOVERY_PROGRESS", path: progress.path, found: progress.found });
58
58
  });
59
+ const projects = discovered.filter((project) => project.ecosystem === "npm" || project.ecosystem === "pypi");
59
60
  if (projects.length === 0) {
60
61
  dispatch({ type: "DISCOVERY_EMPTY", message: "No dependency lockfiles found." });
61
62
  return;
@@ -159,7 +160,20 @@ async function scanProjects(projects, dispatch, signal) {
159
160
  const firstFailure = outcomes.find((outcome) => "error" in outcome);
160
161
  if (responses.length > 0) {
161
162
  const merged = mergeAnalyzeResponses(responses);
162
- const result = firstFailure && merged.action === "pass" ? { ...merged, action: "analysis_incomplete" } : merged;
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
+ };
163
177
  const decisions = computeProjectDecisions(result, entries);
164
178
  dispatch({
165
179
  type: "SCAN_COMPLETE",
@@ -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,20 @@ 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);
18
25
  }
19
26
  export function effectiveScanAction(raw, effective, mode) {
20
27
  return mode === "strict" || effective === undefined ? raw : effective;
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { existsSync, readdirSync, readFileSync } from "node:fs";
3
- import { join } from "node:path";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
4
  const LIFECYCLE_HOOKS = ["preinstall", "install", "postinstall"];
5
5
  export function computeScriptsHash(scripts, hasGyp) {
6
6
  const canonical = JSON.stringify({
@@ -74,7 +74,12 @@ function wantersFromHiddenLockfile(projectDir, nodeModules) {
74
74
  if (!isPlainObject(entry) || entry.hasInstallScript !== true || !packagePath.startsWith("node_modules/")) {
75
75
  continue;
76
76
  }
77
- const wanter = wanterFromManifestDir(join(projectDir, packagePath));
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);
78
83
  if (wanter) {
79
84
  wanters.push(wanter);
80
85
  }
@@ -123,7 +123,7 @@ export function scriptGateReportLine(options) {
123
123
  }
124
124
  const names = wanters.map((wanter) => (wanter.version ? `${wanter.name}@${wanter.version}` : wanter.name));
125
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`;
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
127
  }
128
128
  function formatNames(names) {
129
129
  if (names.length <= REPORTED_NAME_LIMIT) {
@@ -1,10 +1,11 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
2
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
3
+ import { dirname, isAbsolute, join, relative } from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { loadUserConfig } from "../config/settings.js";
7
7
  import { applyTrustInstall, applyTrustUninstall, readCertificateFingerprints, readServiceTrustRecord, renderTrustStorePlanLines, resolveTrustInstallPlan, TrustStoreError, TrustToolMissingError, writeServiceTrustRecord } from "./trust-store.js";
8
+ import { clearTrustRefreshError, readTrustRefreshError } from "./trust-refresh.js";
8
9
  import { acquireLockSync, cleanupSessionSync, createSessionSync, preserveCorruptCleanupRegistrySync, resolveDgPaths, CLEANUP_REGISTRY_LOCK } from "../state/index.js";
9
10
  export const SERVICE_SENTINEL = "dg-service-mode-v1";
10
11
  export const TRUST_SENTINEL = "dg-service-trust-v1";
@@ -292,6 +293,7 @@ export function installServiceTrust(env, now = new Date()) {
292
293
  const plan = trustStoreOperation(() => resolveTrustInstallPlan(runtime.caPath, env ?? process.env));
293
294
  const existing = readServiceTrustRecord(paths.trustRecordPath, TRUST_SENTINEL);
294
295
  if (existing && existing.fingerprintSha256 === plan.fingerprintSha256 && existing.provider === plan.provider && existing.target === plan.target) {
296
+ clearTrustRefreshError(paths.serviceDir);
295
297
  const state = {
296
298
  ...current,
297
299
  trustInstalled: true,
@@ -311,6 +313,7 @@ export function installServiceTrust(env, now = new Date()) {
311
313
  trustStoreOperation(() => applyTrustUninstall(existing));
312
314
  }
313
315
  const record = trustStoreOperation(() => applyTrustInstall(plan, now, TRUST_SENTINEL));
316
+ clearTrustRefreshError(paths.serviceDir);
314
317
  const state = {
315
318
  ...current,
316
319
  trustInstalled: true,
@@ -339,6 +342,7 @@ export function uninstallServiceTrust(env, now = new Date()) {
339
342
  if (record) {
340
343
  trustStoreOperation(() => applyTrustUninstall(record));
341
344
  }
345
+ clearTrustRefreshError(paths.serviceDir);
342
346
  const removedTrust = removeFile(paths.trustRecordPath);
343
347
  if (!current.configured && !removedTrust) {
344
348
  return {
@@ -518,31 +522,42 @@ function readState(paths) {
518
522
  function applyRuntimeDiagnostics(paths, state) {
519
523
  const runtime = readRuntime(paths) ?? state.proxy;
520
524
  if (!runtime) {
521
- return state;
525
+ return withTrustRefreshError(paths, state);
522
526
  }
523
527
  const drift = detectTrustDrift(state.trust, runtime);
524
528
  if (!state.running) {
525
- return {
529
+ return withTrustRefreshError(paths, {
526
530
  ...state,
527
531
  proxy: runtime,
528
532
  trustDrift: drift,
529
533
  lastError: "stale service runtime state: runtime file exists while service is stopped"
530
- };
534
+ });
531
535
  }
532
536
  const health = runtimeHealth(runtime);
533
537
  if (!health.healthy) {
534
- return {
538
+ return withTrustRefreshError(paths, {
535
539
  ...state,
536
540
  running: false,
537
541
  proxy: runtime,
538
542
  trustDrift: drift,
539
543
  lastError: `stale service runtime state: ${health.reason}`
540
- };
544
+ });
541
545
  }
542
- return {
546
+ return withTrustRefreshError(paths, {
543
547
  ...state,
544
548
  proxy: runtime,
545
549
  trustDrift: drift
550
+ });
551
+ }
552
+ function withTrustRefreshError(paths, state) {
553
+ const refreshError = readTrustRefreshError(paths.serviceDir);
554
+ if (!refreshError) {
555
+ return state;
556
+ }
557
+ return {
558
+ ...state,
559
+ lastError: state.lastError
560
+ ?? `service CA trust refresh failed at ${refreshError.at}: ${refreshError.message}. Run 'dg service trust install --yes' to repair OS trust for the rotated CA.`
546
561
  };
547
562
  }
548
563
  function runtimeHealth(runtime) {
@@ -711,7 +726,7 @@ function cleanupRuntime(paths, runtime) {
711
726
  if (runtime?.pid && processIsAlive(runtime.pid)) {
712
727
  killProcess(runtime.pid);
713
728
  }
714
- if (runtime?.sessionDir && runtime.sessionDir.startsWith(paths.paths.sessionsDir)) {
729
+ if (runtime?.sessionDir && isContainedIn(paths.paths.sessionsDir, runtime.sessionDir)) {
715
730
  rmSync(runtime.sessionDir, {
716
731
  force: true,
717
732
  recursive: true
@@ -719,6 +734,10 @@ function cleanupRuntime(paths, runtime) {
719
734
  }
720
735
  removeFile(paths.runtimePath);
721
736
  }
737
+ function isContainedIn(parent, child) {
738
+ const related = relative(parent, child);
739
+ return related.length > 0 && !related.startsWith("..") && !isAbsolute(related);
740
+ }
722
741
  function processIsAlive(pid) {
723
742
  if (pid <= 0) {
724
743
  return false;
@@ -0,0 +1,92 @@
1
+ import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { applyTrustInstall, applyTrustUninstall, readServiceTrustRecord, resolveTrustInstallPlan, writeServiceTrustRecord } from "./trust-store.js";
4
+ export const TRUST_REFRESH_ERROR_FILENAME = "trust-refresh-error.json";
5
+ export function trustRefreshErrorPath(serviceDir) {
6
+ return join(serviceDir, TRUST_REFRESH_ERROR_FILENAME);
7
+ }
8
+ export function readTrustRefreshError(serviceDir) {
9
+ try {
10
+ const path = trustRefreshErrorPath(serviceDir);
11
+ if (!existsSync(path)) {
12
+ return undefined;
13
+ }
14
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
15
+ if (typeof parsed.at !== "string" || typeof parsed.message !== "string") {
16
+ return undefined;
17
+ }
18
+ return {
19
+ at: parsed.at,
20
+ message: parsed.message
21
+ };
22
+ }
23
+ catch {
24
+ return undefined;
25
+ }
26
+ }
27
+ export function clearTrustRefreshError(serviceDir) {
28
+ rmSync(trustRefreshErrorPath(serviceDir), {
29
+ force: true
30
+ });
31
+ }
32
+ export function refreshServiceTrustAfterCaRotation(options) {
33
+ const record = readServiceTrustRecord(options.trustRecordPath, options.sentinel);
34
+ if (!record) {
35
+ return;
36
+ }
37
+ try {
38
+ const plan = resolveTrustInstallPlan(options.caPath, options.env);
39
+ applyTrustUninstall(record);
40
+ const next = applyTrustInstall(plan, options.now ?? new Date(), options.sentinel);
41
+ sweepStaleDgTrustFiles(next.provider, next.target);
42
+ writeServiceTrustRecord(options.trustRecordPath, next);
43
+ clearTrustRefreshError(options.serviceDir);
44
+ }
45
+ catch (error) {
46
+ writeTrustRefreshError(options.serviceDir, error, options.now ?? new Date());
47
+ }
48
+ }
49
+ function sweepStaleDgTrustFiles(provider, targetPath) {
50
+ if (provider === "darwin-user-keychain") {
51
+ return;
52
+ }
53
+ const dir = dirname(targetPath);
54
+ let entries;
55
+ try {
56
+ entries = readdirSync(dir);
57
+ }
58
+ catch {
59
+ return;
60
+ }
61
+ for (const entry of entries) {
62
+ if (!/^dependency-guardian-[0-9a-f]{16}\.(pem|crt)$/.test(entry)) {
63
+ continue;
64
+ }
65
+ const path = join(dir, entry);
66
+ if (path === targetPath) {
67
+ continue;
68
+ }
69
+ rmSync(path, {
70
+ force: true
71
+ });
72
+ }
73
+ }
74
+ function writeTrustRefreshError(serviceDir, error, now) {
75
+ try {
76
+ mkdirSync(serviceDir, {
77
+ recursive: true,
78
+ mode: 0o700
79
+ });
80
+ const record = {
81
+ at: now.toISOString(),
82
+ message: error instanceof Error ? error.message : String(error)
83
+ };
84
+ writeFileSync(trustRefreshErrorPath(serviceDir), `${JSON.stringify(record, null, 2)}\n`, {
85
+ encoding: "utf8",
86
+ mode: 0o600
87
+ });
88
+ }
89
+ catch {
90
+ return;
91
+ }
92
+ }
@@ -1,6 +1,8 @@
1
1
  import { createServer } from "node:http";
2
2
  import { readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { startProductionHttpProxy } from "../proxy/server.js";
4
+ import { resolveServicePaths, TRUST_SENTINEL } from "./state.js";
5
+ import { refreshServiceTrustAfterCaRotation } from "./trust-refresh.js";
4
6
  const sessionPath = process.argv[2];
5
7
  const apiBaseUrl = process.argv[3];
6
8
  const runtimePath = process.argv[4];
@@ -37,11 +39,31 @@ process.on("SIGTERM", () => {
37
39
  process.on("SIGINT", () => {
38
40
  close().finally(() => process.exit(0));
39
41
  });
42
+ function exitOnFatal(error) {
43
+ const message = error instanceof Error ? error.message : String(error);
44
+ try {
45
+ process.stderr.write(`dg service worker: unexpected error — ${message}\n`);
46
+ }
47
+ catch {
48
+ // stderr is gone; nothing left to report to.
49
+ }
50
+ close().finally(() => process.exit(1));
51
+ }
52
+ process.on("uncaughtException", exitOnFatal);
53
+ process.on("unhandledRejection", exitOnFatal);
54
+ const servicePaths = resolveServicePaths(process.env);
40
55
  proxy = await startProductionHttpProxy({
41
56
  session,
42
57
  apiBaseUrl: requiredApiBaseUrl,
43
58
  classification,
44
- env: process.env
59
+ env: process.env,
60
+ onCaRotate: () => refreshServiceTrustAfterCaRotation({
61
+ serviceDir: servicePaths.serviceDir,
62
+ trustRecordPath: servicePaths.trustRecordPath,
63
+ sentinel: TRUST_SENTINEL,
64
+ caPath: session.files.ca,
65
+ env: process.env
66
+ })
45
67
  });
46
68
  healthServer = await startHealthServer(proxy.port);
47
69
  const healthAddress = healthServer.address();
@@ -0,0 +1,28 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { basename } from "node:path";
3
+ import { isCiEnv } from "../presentation/mode.js";
4
+ export function activationOffer(options = {}) {
5
+ const env = options.env ?? process.env;
6
+ if (env.DG_ACTIVATED_SHELL) {
7
+ return "none";
8
+ }
9
+ const stdin = options.stdin ?? process.stdin;
10
+ const stdout = options.stdout ?? process.stdout;
11
+ if (!stdin.isTTY || !stdout.isTTY || isCiEnv(env)) {
12
+ return "hint";
13
+ }
14
+ return "prompt";
15
+ }
16
+ // dg cannot mutate the parent shell's environment, so activation nests a protected interactive child shell.
17
+ export function activateShell(options = {}) {
18
+ const env = options.env ?? process.env;
19
+ const spawner = options.spawner ?? defaultShellSpawner;
20
+ const binary = env.SHELL || "/bin/sh";
21
+ const name = basename(binary);
22
+ return spawner({
23
+ binary,
24
+ args: name === "bash" || name === "zsh" ? ["-i"] : [],
25
+ env: { ...env, DG_ACTIVATED_SHELL: "1" }
26
+ });
27
+ }
28
+ const defaultShellSpawner = (request) => spawnSync(request.binary, request.args, { stdio: "inherit", env: request.env }).status ?? 0;