@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
@@ -0,0 +1,206 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { basename, resolve } from "node:path";
3
+ import { writeReportAtomic } from "../util/report-writer.js";
4
+ import { discoverScanProjects, SBOM_LOCKFILE_ECOSYSTEMS } from "../scan/collect.js";
5
+ import { buildCycloneDxSbom, collectSbomComponents, readRootComponent } from "../sbom/cyclonedx.js";
6
+ import { resolvePresentation } from "../presentation/mode.js";
7
+ import { createTheme } from "../presentation/theme.js";
8
+ import { buildSbomRows } from "../sbom-ui/inventory.js";
9
+ import { launchSbomTui, shouldLaunchSbomTui } from "../sbom-ui/launch.js";
10
+ import { dgVersion } from "./version.js";
11
+ import { EXIT_TOOL_ERROR, EXIT_USAGE } from "./types.js";
12
+ export const sbomCommand = {
13
+ name: "sbom",
14
+ summary: "Inventory the project's dependencies as a CycloneDX 1.5 software bill of materials.",
15
+ usage: "dg sbom [path] [--output <path>] [--reproducible]",
16
+ args: [{ name: "[path]", summary: "Project directory to inventory (default: current directory)." }],
17
+ flags: [
18
+ { flag: "--output", value: "<path>", summary: "Write the document to a file without opening the interactive view (alias -o)." },
19
+ { flag: "--json", summary: "Print the raw CycloneDX document to stdout instead of the interactive view." },
20
+ { flag: "--reproducible", summary: "Byte-stable output: derive the serial number from the components and drop the timestamp (honors SOURCE_DATE_EPOCH)." }
21
+ ],
22
+ examples: ["dg sbom", "dg sbom -o sbom.cdx.json", "dg sbom --json | cyclonedx validate", "dg sbom --reproducible"],
23
+ details: [
24
+ "Reads the project's lockfiles and inventories every resolved dependency as a CycloneDX 1.5 component with its purl, license, and integrity hash.",
25
+ "In a terminal it opens an interactive view: the inventory appears immediately, then dg's scanner runs over the npm and PyPI components and fills in a BLOCK/WARN/PASS verdict per package — malware, provenance downgrades, and cooldown — while cargo stays inventory only. Browse with the arrow keys, search with /, filter with f, and press w to write the document. Signing in unlocks verdicts; without it the view shows the inventory alone.",
26
+ "Piped, with -o, or with --json it stays offline and prints the raw CycloneDX document so you can attach it to a release or feed any CycloneDX-aware tool — no scan, no account needed. Use --reproducible (or set SOURCE_DATE_EPOCH) for identical bytes across runs so a committed SBOM diffs cleanly."
27
+ ],
28
+ handler: (context) => runSbomCommand(context)
29
+ };
30
+ export function runSbomCommand(context, cwd = process.cwd(), now = new Date(), env = process.env) {
31
+ const parsed = parseArgs(context.args);
32
+ if ("error" in parsed) {
33
+ return { exitCode: EXIT_USAGE, stdout: "", stderr: `dg sbom: ${parsed.error}. Usage: ${sbomCommand.usage}\n` };
34
+ }
35
+ const root = resolve(cwd, parsed.targetPath ?? ".");
36
+ const projects = discoverScanProjects(root, SBOM_LOCKFILE_ECOSYSTEMS);
37
+ const { components, dropped } = collectSbomComponents(projects);
38
+ const rootComponent = readRootComponent(root);
39
+ const reproducible = parsed.reproducible || env.SOURCE_DATE_EPOCH !== undefined;
40
+ const bom = buildCycloneDxSbom(components, {
41
+ ...timestampOption(reproducible, now, env),
42
+ serialNumber: "urn:uuid:00000000-0000-0000-0000-000000000000",
43
+ toolVersion: dgVersion(),
44
+ ...(rootComponent.component ? { rootComponent: rootComponent.component } : {})
45
+ });
46
+ const finalBom = {
47
+ ...bom,
48
+ serialNumber: reproducible ? deterministicSerialNumber(bom) : `urn:uuid:${randomUUID()}`
49
+ };
50
+ const document = `${JSON.stringify(finalBom, null, 2)}\n`;
51
+ const notes = [];
52
+ if (components.length === 0) {
53
+ notes.push(`dg sbom: no resolvable dependencies found under ${root} — emitting an empty SBOM.`);
54
+ }
55
+ if (dropped.length > 0) {
56
+ notes.push(`dg sbom: omitted ${dropped.length} dependency(ies) without a pinned version (${formatDropped(dropped)}). Pin them (e.g. name==version) to include them.`);
57
+ }
58
+ if (rootComponent.malformed) {
59
+ notes.push(`dg sbom: ${root}/package.json could not be parsed — the SBOM has no named subject component.`);
60
+ }
61
+ const stderr = notes.length > 0 ? `${notes.join("\n")}\n` : "";
62
+ if (parsed.outputPath) {
63
+ try {
64
+ writeReportAtomic(resolve(cwd, parsed.outputPath), document);
65
+ }
66
+ catch (error) {
67
+ return { exitCode: EXIT_TOOL_ERROR, stdout: "", stderr: `dg sbom: could not write ${parsed.outputPath}: ${error instanceof Error ? error.message : "unknown write error"}\n` };
68
+ }
69
+ return { exitCode: 0, stdout: `Wrote CycloneDX SBOM (${components.length} components) to ${parsed.outputPath}\n`, stderr };
70
+ }
71
+ const presentation = resolvePresentation();
72
+ if (parsed.json || !presentation.isTTY) {
73
+ return { exitCode: 0, stdout: document, stderr };
74
+ }
75
+ const subject = finalBom.metadata.component
76
+ ? `${finalBom.metadata.component.name}${finalBom.metadata.component.version ? `@${finalBom.metadata.component.version}` : ""}`
77
+ : basename(root) || ".";
78
+ if (shouldLaunchSbomTui({ json: parsed.json, outputPath: parsed.outputPath })) {
79
+ void launchSbomTui({ rows: buildSbomRows(components), dropped, subject, document, env, cwd }).then(() => process.exit(typeof process.exitCode === "number" ? process.exitCode : 0), (error) => {
80
+ process.stderr.write(`dg sbom view failed: ${error instanceof Error ? error.message : "unknown error"}\n`);
81
+ process.exit(1);
82
+ });
83
+ return { exitCode: 0, stdout: "", stderr: "" };
84
+ }
85
+ return { exitCode: 0, stdout: renderSbomSummary(finalBom, dropped, subject, createTheme(presentation.color)), stderr };
86
+ }
87
+ const ANSI_PATTERN = /\x1b\[[0-9;]*m/gu;
88
+ function visibleWidth(text) {
89
+ return text.replace(ANSI_PATTERN, "").length;
90
+ }
91
+ export function renderSbomSummary(bom, dropped, subject, theme, terminalWidth = process.stdout.columns ?? 100) {
92
+ const components = bom.components;
93
+ const total = components.length;
94
+ const count = (prefix) => components.filter((c) => c["bom-ref"].startsWith(prefix)).length;
95
+ const ecosystems = [["npm", count("pkg:npm/")], ["pypi", count("pkg:pypi/")], ["cargo", count("pkg:cargo/")]]
96
+ .filter(([, n]) => n > 0)
97
+ .sort((a, b) => b[1] - a[1]);
98
+ const withLicense = components.filter((c) => c.licenses && c.licenses.length > 0).length;
99
+ const withHash = components.filter((c) => c.hashes && c.hashes.length > 0).length;
100
+ const accent = (text) => theme.paint("accent", text);
101
+ const muted = (text) => theme.paint("muted", text);
102
+ const warn = (text) => theme.paint("warn", text);
103
+ const denomWidth = String(total).length;
104
+ const license = { label: "License", covered: withLicense, deficit: total - withLicense, gap: "unknown" };
105
+ const integrity = { label: "Integrity", covered: withHash, deficit: total - withHash, gap: "no checksum" };
106
+ const gapWidth = Math.max(1, String(license.deficit).length, String(integrity.deficit).length);
107
+ const gapClause = (q) => (q.deficit > 0 ? `${String(q.deficit).padStart(gapWidth)} ${q.gap}` : "");
108
+ const clauseWidth = Math.max(gapClause(license).length, gapClause(integrity).length);
109
+ const qualityRow = (q) => {
110
+ const clause = gapClause(q);
111
+ const painted = clause ? warn(clause) : "";
112
+ const fraction = muted(`${String(q.covered).padStart(denomWidth)} / ${total} covered`);
113
+ return `${muted(q.label.padEnd(10))}${painted}${" ".repeat(clauseWidth - clause.length)} ${fraction}`;
114
+ };
115
+ const breakdown = ecosystems.map(([name, n]) => `${n} ${name}`).join(muted(" · "));
116
+ const rows = [
117
+ `${accent(String(total))} ${total === 1 ? "component" : "components"}${breakdown ? ` ${muted(breakdown)}` : ""}`,
118
+ "",
119
+ qualityRow(license),
120
+ qualityRow(integrity),
121
+ ...(dropped.length > 0
122
+ ? [`${muted("Omitted".padEnd(10))}${warn(`${String(dropped.length).padStart(gapWidth)} unpinned`)}${muted(", left out of the document")}`]
123
+ : []),
124
+ "",
125
+ muted(`CycloneDX 1.5 · ${subject} · inventory only`)
126
+ ];
127
+ const inner = Math.min(Math.max(0, ...rows.map(visibleWidth)), Math.max(24, terminalWidth - 4));
128
+ const bar = muted("─".repeat(inner + 2));
129
+ const side = muted("│");
130
+ const boxed = [
131
+ `${muted("╭")}${bar}${muted("╮")}`,
132
+ ...rows.map((row) => `${side} ${row}${" ".repeat(Math.max(0, inner - visibleWidth(row)))} ${side}`),
133
+ `${muted("╰")}${bar}${muted("╯")}`
134
+ ];
135
+ const footer = [
136
+ ` ${muted("→ write it:")} dg sbom -o sbom.cdx.json ${muted("· --json prints it")}`,
137
+ ` ${accent("dg scan")} ${muted("for BLOCK / WARN / PASS verdicts")}`
138
+ ];
139
+ return `${[...boxed, ...footer].join("\n")}\n`;
140
+ }
141
+ function timestampOption(reproducible, now, env) {
142
+ if (!reproducible) {
143
+ return { timestamp: now.toISOString() };
144
+ }
145
+ const epoch = env.SOURCE_DATE_EPOCH;
146
+ if (epoch !== undefined && /^\d+$/.test(epoch.trim())) {
147
+ const date = new Date(Number.parseInt(epoch.trim(), 10) * 1000);
148
+ const iso = Number.isFinite(date.getTime()) ? date.toISOString() : "";
149
+ if (/^\d{4}-/u.test(iso)) {
150
+ return { timestamp: iso };
151
+ }
152
+ }
153
+ return {};
154
+ }
155
+ function deterministicSerialNumber(bom) {
156
+ const hex = createHash("sha256").update(JSON.stringify(bom.components)).digest("hex");
157
+ const version = `4${hex.slice(13, 16)}`;
158
+ const variantNibble = ((Number.parseInt(hex.slice(16, 17) || "0", 16) & 0x3) | 0x8).toString(16);
159
+ const variant = `${variantNibble}${hex.slice(17, 20)}`;
160
+ return `urn:uuid:${hex.slice(0, 8)}-${hex.slice(8, 12)}-${version}-${variant}-${hex.slice(20, 32)}`;
161
+ }
162
+ function formatDropped(dropped) {
163
+ const shown = dropped.slice(0, 5).join(", ");
164
+ return dropped.length > 5 ? `${shown}, +${dropped.length - 5} more` : shown;
165
+ }
166
+ function parseArgs(args) {
167
+ let targetPath = null;
168
+ let outputPath = null;
169
+ let reproducible = false;
170
+ let json = false;
171
+ for (let i = 0; i < args.length; i += 1) {
172
+ const arg = args[i];
173
+ if (arg === undefined) {
174
+ continue;
175
+ }
176
+ if (arg === "--output" || arg === "-o") {
177
+ const value = args[i + 1];
178
+ if (value === undefined || value.startsWith("-")) {
179
+ return { error: `${arg} needs a path` };
180
+ }
181
+ outputPath = value;
182
+ i += 1;
183
+ continue;
184
+ }
185
+ if (arg.startsWith("--output=")) {
186
+ outputPath = arg.slice("--output=".length);
187
+ continue;
188
+ }
189
+ if (arg === "--reproducible") {
190
+ reproducible = true;
191
+ continue;
192
+ }
193
+ if (arg === "--json") {
194
+ json = true;
195
+ continue;
196
+ }
197
+ if (arg.startsWith("-")) {
198
+ return { error: `unknown flag '${arg}'` };
199
+ }
200
+ if (targetPath !== null) {
201
+ return { error: `unexpected argument '${arg}'` };
202
+ }
203
+ targetPath = arg;
204
+ }
205
+ return { targetPath, outputPath, reproducible, json };
206
+ }
@@ -13,7 +13,7 @@ export const scanCommand = {
13
13
  ],
14
14
  examples: ["dg scan", "dg scan ./packages/api", "dg scan --json -o scan.json", "dg scan --staged"],
15
15
  details: [
16
- "Reads lockfiles, scores each dependency through the scanner, and never changes project files. In a terminal it opens the full-screen results browser; piped or with --json/--sarif it prints machine output. Exit codes: 0 clean, 1 warn, 2 block, 4 analysis incomplete, 64 usage error."
16
+ "Reads lockfiles, scores each dependency through the scanner, and never changes project files. In a terminal it opens the full-screen results browser; piped or with --json/--sarif it prints machine output. Exit codes: 0 clean, 1 warn, 2 block, 4 analysis incomplete, 10 nothing to scan, 64 usage error."
17
17
  ],
18
18
  handler: runScanCommand
19
19
  };
@@ -2,6 +2,8 @@ import { EXIT_UNAVAILABLE, EXIT_USAGE } from "./types.js";
2
2
  import { renderCommandHelp } from "./help.js";
3
3
  import { buildServiceUninstallPlan, buildTrustInstallPlan, buildTrustUninstallPlan, installServiceTrust, readServiceState, renderServicePlan, resolveServicePaths, restartService, ServiceNotConfiguredError, ServiceProxyError, ServiceTrustStoreError, ServiceTrustToolMissingError, startService, stopService, uninstallService, uninstallServiceTrust } from "../service/state.js";
4
4
  import { LockBusyError } from "../state/locks.js";
5
+ import { resolvePresentation } from "../presentation/mode.js";
6
+ import { createTheme } from "../presentation/theme.js";
5
7
  const trustCommand = {
6
8
  name: "trust",
7
9
  summary: "Manage explicit service-mode trust installation.",
@@ -208,10 +210,12 @@ function mutationPlanWrites(action) {
208
210
  function serviceMutation(action, run) {
209
211
  try {
210
212
  const result = run();
213
+ const theme = createTheme(resolvePresentation().color);
211
214
  const changed = result.changed ? actionPastTense(action) : `already ${action === "start" ? "running" : "stopped"}`;
215
+ const detail = result.state.running && result.state.proxy ? ` ${theme.paint("muted", `at ${result.state.proxy.proxyUrl}`)}` : "";
212
216
  return {
213
217
  exitCode: 0,
214
- stdout: `Dependency Guardian service ${changed}.\n${renderServiceStatus(result.state)}`,
218
+ stdout: `${theme.paint("pass", "✓")} Service ${changed}${detail} ${theme.paint("muted", "· dg service status")}\n`,
215
219
  stderr: ""
216
220
  };
217
221
  }
@@ -399,20 +403,34 @@ function parseJsonArgs(command, args) {
399
403
  };
400
404
  }
401
405
  function renderServiceStatus(state) {
406
+ const theme = createTheme(resolvePresentation().color);
407
+ const pass = (text) => `${theme.paint("pass", "✓")} ${text}`;
408
+ const warn = (text) => `${theme.paint("warn", "⚠")} ${text}`;
409
+ const configured = state.configured
410
+ ? pass("yes")
411
+ : warn(`no — run ${theme.paint("accent", "dg setup --service --yes")}`);
412
+ const running = state.running && state.proxy
413
+ ? pass(`yes ${theme.paint("muted", `(${state.proxy.proxyUrl})`)}`)
414
+ : warn(state.configured ? `no — run ${theme.paint("accent", "dg service start")}` : "no");
415
+ const trust = state.trust
416
+ ? pass(`${state.trust.provider} ${theme.paint("muted", `(${state.trust.fingerprintSha256.slice(0, 12)}…)`)}`)
417
+ : warn(`not installed — run ${theme.paint("accent", "dg service trust install --yes")}`);
402
418
  const lines = [
403
- "Dependency Guardian service status",
419
+ "Dependency Guardian service",
404
420
  "",
405
- `configured: ${state.configured ? "yes" : "no"}`,
406
- `running: ${state.running ? "yes" : "no"}`,
407
- `trust installed: ${state.trustInstalled ? "yes" : "no"}`,
408
- `trust provider: ${state.trust ? state.trust.provider : "not installed"}`,
409
- `trust fingerprint: ${state.trust ? state.trust.fingerprintSha256 : "not installed"}`,
410
- `trust drift: ${state.trustDrift ? state.trustDrift.message : "none"}`,
411
- `service proxy: ${state.proxy ? state.proxy.proxyUrl : "not running"}`,
412
- `health endpoint: ${state.proxy ? state.proxy.healthUrl : "not running"}`,
413
- `last error: ${state.lastError ?? "none"}`,
414
- `policy synced: ${state.policySyncedAt ?? "never"}`
421
+ ` Configured ${configured}`,
422
+ ` Running ${running}`,
423
+ ` Trust ${trust}`
415
424
  ];
425
+ if (state.trustDrift) {
426
+ lines.push(` Trust drift ${warn(state.trustDrift.message)}`);
427
+ }
428
+ lines.push(` Policy sync ${state.policySyncedAt ? pass(state.policySyncedAt) : theme.paint("muted", "never")}`);
429
+ if (state.lastError) {
430
+ lines.push(` Last error ${warn(state.lastError)}`);
431
+ }
432
+ lines.push("");
433
+ lines.push(`Full diagnostics: ${theme.paint("accent", "dg service doctor")}`);
416
434
  return `${lines.join("\n")}\n`;
417
435
  }
418
436
  function serviceStatusJson(state) {
@@ -503,10 +521,11 @@ function actionPastTense(action) {
503
521
  return "restarted";
504
522
  }
505
523
  function serviceNotConfiguredResult() {
524
+ const theme = createTheme(resolvePresentation().color);
506
525
  return {
507
526
  exitCode: EXIT_USAGE,
508
527
  stdout: "",
509
- stderr: "dg service is not configured. Run 'dg setup --service --yes' first; no service or trust state was changed.\n"
528
+ stderr: `${theme.paint("warn", "dg service is not configured")} — run ${theme.paint("accent", "dg setup --service --yes")} first ${theme.paint("muted", "(no service or trust state was changed)")}\n`
510
529
  };
511
530
  }
512
531
  function trustStoreFailureResult(planText, action, message) {
@@ -1,34 +1,40 @@
1
1
  import { EXIT_UNAVAILABLE, EXIT_USAGE } from "./types.js";
2
2
  import { LockBusyError } from "../state/index.js";
3
- import { activationCommand as shellActivationCommand, applySetupPlanWithLock, buildSetupPlan, renderSetupPlan } from "../setup/plan.js";
3
+ import { activationCommand as shellActivationCommand, applySetupPlanWithLock, buildSetupPlan, renderSetupPlan, tildifyPath as tildify } from "../setup/plan.js";
4
+ import { activateShell, activationOffer } from "../setup/activate-shell.js";
4
5
  import { buildServiceSetupPlan, configureService, renderServicePlan } from "../service/state.js";
5
6
  import { optionalSupportGate } from "../setup/optional-support.js";
6
7
  import { resolvePresentation } from "../presentation/mode.js";
7
8
  import { createTheme } from "../presentation/theme.js";
8
9
  import { closeSync, openSync, readSync } from "node:fs";
9
- import { homedir } from "node:os";
10
10
  import { authStatus } from "../auth/store.js";
11
+ import { resolveWebBase } from "../auth/device-login.js";
12
+ import { applyAgentHook, collectAgentOffers } from "../agents/registry.js";
13
+ import { applyGitHook, commitGuardOffer } from "../setup/git-hook.js";
14
+ import { markSecurityNotesShown, securityNotesShown } from "../setup-ui/gate.js";
11
15
  export const setupCommand = {
12
16
  name: "setup",
13
- summary: "Protect plain npm/pip/yarn installs (no dg prefix needed).",
14
- usage: "dg setup [--print] [--yes] [--shell <auto|zsh|bash|fish>] [--service]",
17
+ summary: "Protect this machine — shell installs, AI-agent installs, and a repo's commits.",
18
+ usage: "dg setup [--print] [--yes] [--agents] [--guard-commit] [--shell <auto|zsh|bash|fish>] [--service]",
15
19
  flags: [
16
20
  { flag: "--print", summary: "Preview the exact write plan and change nothing." },
17
- { flag: "--yes", summary: "Apply without the interactive prompt (required when non-interactive)." },
21
+ { flag: "--yes", summary: "Apply the shell firewall only, without prompts (add --agents / --guard-commit to opt in the rest)." },
22
+ { flag: "--agents", summary: "With --yes: also route every detected AI agent's installs through dg." },
23
+ { flag: "--guard-commit", summary: "With --yes: also scan this repo's commits (per-repo)." },
18
24
  { flag: "--shell", value: "<auto|zsh|bash|fish>", summary: "Target shell rc to write (default: auto-detect)." },
19
25
  { flag: "--service", summary: "Set up service mode (Pro/Team; persistent proxy + managed CA)." }
20
26
  ],
21
- examples: ["dg setup --print", "dg setup --yes", "dg setup --shell zsh --yes"],
27
+ examples: ["dg setup", "dg setup --print", "dg setup --yes --agents", "dg setup --shell zsh --yes"],
22
28
  details: [
23
- "In a terminal, shows what it writes and asks before applying, then reloads your shell. Use --yes to apply non-interactively and --print to preview only.",
24
- "Writes only dg-owned reversible files and repairs setup drift; undo with dg uninstall. The per-repo git pre-commit hook is separate see dg guard-commit."
29
+ "In a terminal it shows one consent screen listing everything it will protect your shell's installs plus every detected AI agent, with the exact files it writes — and applies them on a single yes. Commits in the current repo are a separate per-repo question.",
30
+ "Non-interactively, --yes applies just the shell firewall; add --agents and/or --guard-commit to opt those in. --print previews. Writes only dg-owned reversible files; undo it all with dg uninstall."
25
31
  ],
26
32
  handler: (context) => setupHandler(context.args)
27
33
  };
28
34
  const WINDOWS_UNSUPPORTED = "dg setup (automatic interception) does not support Windows yet.\n" +
29
35
  "You can still protect installs on Windows: prefix the command with dg,\n" +
30
36
  "for example `dg pip install <pkg>` or `dg npm install <pkg>`.\n";
31
- function setupHandler(args) {
37
+ async function setupHandler(args) {
32
38
  const parsed = parseSetupArgs(args);
33
39
  if ("error" in parsed) {
34
40
  return {
@@ -54,12 +60,16 @@ function setupHandler(args) {
54
60
  if (parsed.printOnly) {
55
61
  return {
56
62
  exitCode: 0,
57
- stdout: renderedPlan,
63
+ stdout: `${renderedPlan}${printableSurfaceOffers()}`,
58
64
  stderr: ""
59
65
  };
60
66
  }
61
67
  if (!parsed.yes) {
62
68
  if (isInteractive()) {
69
+ if (resolvePresentation().mode === "rich") {
70
+ const { runSetupWizard } = await import("../setup-ui/wizard.js");
71
+ return runSetupWizard(plan, { autoActivate: true });
72
+ }
63
73
  return runInteractiveSetup(plan);
64
74
  }
65
75
  return {
@@ -81,23 +91,34 @@ function setupHandler(args) {
81
91
  }
82
92
  throw error;
83
93
  }
94
+ const extra = await applyRequestedSurfaces(parsed);
95
+ const theme = createTheme(resolvePresentation().color);
84
96
  return {
85
97
  exitCode: 0,
86
- stdout: `${renderedPlan}\nSet up — active in new terminals. Activate this shell now: ${activationCommand(plan)}\n`,
98
+ stdout: `${theme.paint("pass", "✓")} Setup complete — active in new terminals ${theme.paint("muted", `(wrote ${tildify(plan.shimDir)} and ${tildify(plan.rcPath)}; details: dg doctor)`)}\n${extra}Activate this shell now: ${theme.paint("accent", activationCommand(plan))}\n`,
87
99
  stderr: ""
88
100
  };
89
101
  }
90
102
  function isInteractive() {
91
103
  return Boolean(process.stdin.isTTY && process.stderr.isTTY);
92
104
  }
93
- function runInteractiveSetup(plan) {
105
+ async function runInteractiveSetup(plan) {
94
106
  const theme = createTheme(resolvePresentation().color);
95
107
  const accent = (text) => theme.paint("accent", text);
96
108
  const muted = (text) => theme.paint("muted", text);
97
109
  const err = process.stderr;
98
- err.write(`\n Scans ${accent("npm")}/${accent("pip")}/${accent("yarn")}/${muted("…")} installs automatically — no ${accent("dg")} prefix.\n`);
99
- err.write(` ${muted("Writes")} ${accent(tildify(plan.rcPath))}${muted(".")} ${muted("Reversible with")} ${accent("dg uninstall")}${muted(".")}\n\n`);
100
- const answer = promptYesNoSync(` ${accent("Proceed?")}`, theme);
110
+ const agents = collectAgentOffers();
111
+ const repo = commitGuardOffer();
112
+ if (!securityNotesShown()) {
113
+ err.write(renderSecurityNotesText(theme));
114
+ err.write(` ${muted("Press Enter to agree")} `);
115
+ if (readLineFromTty() !== null) {
116
+ markSecurityNotesShown();
117
+ }
118
+ err.write("\n");
119
+ }
120
+ err.write(renderConsentScreen(consentSurfaces(plan, agents, repo !== null), theme));
121
+ const answer = promptYesNoSync(` ${accent("Protect installs on this machine?")}`, theme, true);
101
122
  if (answer === "no-tty") {
102
123
  return {
103
124
  exitCode: EXIT_USAGE,
@@ -121,21 +142,152 @@ function runInteractiveSetup(plan) {
121
142
  }
122
143
  throw error;
123
144
  }
124
- err.write(`\n ${theme.paint("pass", "✓ set up — active in new terminals")}\n`);
125
- err.write(` ${muted("Activate this shell now:")} ${accent(activationCommand(plan))}\n`);
145
+ err.write(`\n ${theme.paint("pass", "✓ shell installs protected — active in new terminals")}\n`);
146
+ await applyAgentOffers(agents, theme, err);
147
+ promptCommitSurface(repo, theme, err);
148
+ return finishWithActivation(plan, theme, err);
149
+ }
150
+ export function consentSurfaces(plan, agents, repoOffered) {
151
+ const surfaces = [
152
+ { label: "shell installs", detail: `writes ${tildify(plan.rcPath)}`, deferred: false }
153
+ ];
154
+ for (const offer of agents) {
155
+ surfaces.push({ label: `${offer.label} installs`, detail: `writes ${tildify(offer.ctx.settingsPath)}`, deferred: false });
156
+ }
157
+ if (repoOffered) {
158
+ surfaces.push({ label: "commits in this repo", detail: "asked separately", deferred: true });
159
+ }
160
+ return surfaces;
161
+ }
162
+ export function renderConsentScreen(surfaces, theme) {
163
+ const accent = (text) => theme.paint("accent", text);
164
+ const muted = (text) => theme.paint("muted", text);
165
+ const width = Math.max(...surfaces.map((surface) => surface.label.length));
166
+ const lines = [
167
+ "",
168
+ ` Scans ${accent("npm")}/${accent("pip")}/${accent("yarn")}/${muted("…")} installs automatically — no ${accent("dg")} prefix.`,
169
+ ` ${muted("Reversible with")} ${accent("dg uninstall")}${muted(".")}`,
170
+ "",
171
+ ` ${muted("Sets up:")}`
172
+ ];
173
+ for (const surface of surfaces) {
174
+ const label = surface.label.padEnd(width);
175
+ lines.push(surface.deferred ? ` ${muted(label)} ${muted(surface.detail)}` : ` ${accent(label)} ${muted(surface.detail)}`);
176
+ }
177
+ lines.push("", "");
178
+ return lines.join("\n");
179
+ }
180
+ function finishWithActivation(plan, theme, err) {
181
+ const offer = activationOffer();
182
+ if (offer === "none") {
183
+ return { exitCode: 0, stdout: "", stderr: "" };
184
+ }
185
+ const accent = (text) => theme.paint("accent", text);
186
+ const muted = (text) => theme.paint("muted", text);
187
+ if (offer === "prompt" && promptYesNoSync(`\n ${accent("Activate in this terminal now?")}`, theme, true) === "yes") {
188
+ err.write(` ${muted("Starting a protected shell — type")} ${accent("exit")} ${muted("to return to your previous one.")}\n`);
189
+ return { exitCode: activateShell(), stdout: "", stderr: "" };
190
+ }
191
+ err.write(`\n ${muted("Activate this shell now:")} ${accent(activationCommand(plan))}\n`);
126
192
  return { exitCode: 0, stdout: "", stderr: "" };
127
193
  }
194
+ async function applyAgentOffers(offers, theme, err) {
195
+ const muted = (text) => theme.paint("muted", text);
196
+ for (const offer of offers) {
197
+ try {
198
+ await applyAgentHook(offer.ctx);
199
+ err.write(` ${theme.paint("pass", `✓ ${offer.label} installs route through dg`)} ${muted(`(${tildify(offer.ctx.settingsPath)})`)}\n`);
200
+ }
201
+ catch (error) {
202
+ err.write(` ${theme.paint("warn", `✗ ${offer.label}`)} ${muted(`— ${error instanceof Error ? error.message : "unknown error"}`)}\n`);
203
+ }
204
+ }
205
+ }
206
+ function promptCommitSurface(repo, theme, err) {
207
+ if (!repo) {
208
+ return;
209
+ }
210
+ const accent = (text) => theme.paint("accent", text);
211
+ const muted = (text) => theme.paint("muted", text);
212
+ err.write(`\n ${muted("This is a git repo. Scan its commits before they land?")}\n`);
213
+ if (promptYesNoSync(` ${accent("Guard commits in this repo?")}`, theme, false) !== "yes") {
214
+ return;
215
+ }
216
+ try {
217
+ applyGitHook(repo);
218
+ err.write(` ${theme.paint("pass", "✓ commits in this repo are now scanned")}\n`);
219
+ }
220
+ catch (error) {
221
+ err.write(` ${theme.paint("warn", `✗ commits in this repo`)} ${muted(`— ${error instanceof Error ? error.message : "unknown error"}`)}\n`);
222
+ }
223
+ }
224
+ async function applyRequestedSurfaces(parsed) {
225
+ const lines = [];
226
+ if (parsed.agents) {
227
+ for (const offer of collectAgentOffers()) {
228
+ try {
229
+ await applyAgentHook(offer.ctx);
230
+ lines.push(`${offer.label} installs now route through dg (${tildify(offer.ctx.settingsPath)}).`);
231
+ }
232
+ catch (error) {
233
+ lines.push(`could not hook ${offer.label}: ${error instanceof Error ? error.message : "unknown error"}`);
234
+ }
235
+ }
236
+ }
237
+ if (parsed.guardCommit) {
238
+ const repo = commitGuardOffer();
239
+ if (repo) {
240
+ try {
241
+ applyGitHook(repo);
242
+ lines.push("commits in this repo are now scanned.");
243
+ }
244
+ catch (error) {
245
+ lines.push(`could not install the commit guard: ${error instanceof Error ? error.message : "unknown error"}`);
246
+ }
247
+ }
248
+ }
249
+ return lines.length > 0 ? `${lines.join("\n")}\n` : "";
250
+ }
251
+ function printableSurfaceOffers() {
252
+ const lines = [];
253
+ for (const offer of collectAgentOffers()) {
254
+ lines.push(`would offer: route ${offer.label} installs through dg (--agents)`);
255
+ }
256
+ if (commitGuardOffer()) {
257
+ lines.push("would offer: scan this repo's commits (--guard-commit)");
258
+ }
259
+ return lines.length > 0 ? `\n${lines.join("\n")}\n` : "";
260
+ }
128
261
  function activationCommand(plan) {
129
262
  return shellActivationCommand(plan.shell, tildify(plan.rcPath));
130
263
  }
131
- function promptYesNoSync(question, theme) {
132
- process.stderr.write(`${question} ${theme.paint("muted", "[y/N]")} `);
264
+ function renderSecurityNotesText(theme) {
265
+ const muted = (text) => theme.paint("muted", text);
266
+ const webBase = resolveWebBase(process.env);
267
+ return [
268
+ "",
269
+ ` ${theme.paint("accent", "Security notes")}`,
270
+ "",
271
+ " 1. dg can make mistakes.",
272
+ " A PASS verdict does not guarantee a package is safe. You are",
273
+ " responsible for what you install and should review new",
274
+ " dependencies.",
275
+ "",
276
+ " 2. By continuing you confirm you have read and understand the",
277
+ " Terms of Service and Privacy Policy.",
278
+ ` ${muted(`${webBase}/terms`)}`,
279
+ ` ${muted(`${webBase}/privacy`)}`,
280
+ "",
281
+ ""
282
+ ].join("\n");
283
+ }
284
+ function readLineFromTty() {
133
285
  let tty;
134
286
  try {
135
287
  tty = openSync("/dev/tty", "rs");
136
288
  }
137
289
  catch {
138
- return "no-tty";
290
+ return null;
139
291
  }
140
292
  try {
141
293
  const byte = Buffer.alloc(1);
@@ -160,22 +312,31 @@ function promptYesNoSync(question, theme) {
160
312
  }
161
313
  answer += char;
162
314
  }
163
- const normalized = answer.trim().toLowerCase();
164
- return normalized === "y" || normalized === "yes" ? "yes" : "no";
315
+ return answer;
165
316
  }
166
317
  finally {
167
318
  closeSync(tty);
168
319
  }
169
320
  }
170
- function tildify(path) {
171
- const home = homedir();
172
- return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
321
+ function promptYesNoSync(question, theme, defaultYes = false) {
322
+ process.stderr.write(`${question} ${theme.paint("muted", defaultYes ? "[Y/n]" : "[y/N]")} `);
323
+ const answer = readLineFromTty();
324
+ if (answer === null) {
325
+ return "no-tty";
326
+ }
327
+ const normalized = answer.trim().toLowerCase();
328
+ if (normalized === "") {
329
+ return defaultYes ? "yes" : "no";
330
+ }
331
+ return normalized === "y" || normalized === "yes" ? "yes" : "no";
173
332
  }
174
333
  function parseSetupArgs(args) {
175
334
  let printOnly = false;
176
335
  let yes = false;
177
336
  let shell = "auto";
178
337
  let service = false;
338
+ let agents = false;
339
+ let guardCommit = false;
179
340
  for (let index = 0; index < args.length; index += 1) {
180
341
  const arg = args[index];
181
342
  if (arg === "--print") {
@@ -187,6 +348,12 @@ function parseSetupArgs(args) {
187
348
  else if (arg === "--service") {
188
349
  service = true;
189
350
  }
351
+ else if (arg === "--agents") {
352
+ agents = true;
353
+ }
354
+ else if (arg === "--guard-commit") {
355
+ guardCommit = true;
356
+ }
190
357
  else if (arg === "--python-hook" || arg?.startsWith("--python-hook=")) {
191
358
  return {
192
359
  exitCode: EXIT_UNAVAILABLE,
@@ -251,7 +418,9 @@ function parseSetupArgs(args) {
251
418
  printOnly,
252
419
  yes,
253
420
  shell,
254
- service
421
+ service,
422
+ agents,
423
+ guardCommit
255
424
  };
256
425
  }
257
426
  function parseShell(value) {
@@ -270,10 +439,11 @@ function serviceSetupHandler(parsed) {
270
439
  };
271
440
  }
272
441
  if (!authStatus().authenticated) {
442
+ const theme = createTheme(resolvePresentation().color);
273
443
  return {
274
444
  exitCode: EXIT_UNAVAILABLE,
275
445
  stdout: "",
276
- stderr: "dg setup --service is a Pro or Team feature. Run 'dg login' to connect a paid account, then 'dg setup --service'. See https://westbayberry.com/pricing\n"
446
+ stderr: `${theme.paint("warn", "dg setup --service is a Pro or Team feature")} — ${theme.paint("accent", "dg login")} ${theme.paint("muted", "with a paid account, then re-run")} ${theme.paint("muted", westbayberry.com/pricing")}\n`
277
447
  };
278
448
  }
279
449
  if (!parsed.yes) {
@@ -1,5 +1,6 @@
1
1
  export const EXIT_USAGE = 2;
2
2
  export const EXIT_USAGE_VERDICT = 64;
3
+ export const EXIT_ANALYSIS_INCOMPLETE = 4;
3
4
  export const EXIT_NOTHING_TO_SCAN = 10;
4
5
  export const EXIT_UNAVAILABLE = 69;
5
6
  export const EXIT_TOOL_ERROR = 70;