@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
@@ -0,0 +1,389 @@
1
+ import { describeCooldownSettings, durationToDays, formatCooldownDuration } from "../policy/cooldown.js";
2
+ import { readHeldPackages } from "../state/index.js";
3
+ import { canonicalCooldownName } from "../policy/pypi-name.js";
4
+ import { ConfigError, loadUserConfig, setConfigValue, updateUserConfig } from "../config/settings.js";
5
+ import { appendCooldownExemptions, cooldownExemptionActive, CooldownExemptionCapError, findProjectRoot, loadDgFile, mutateDgFile, removeCooldownExemptions, resolveAcceptedBy } from "../project/dgfile.js";
6
+ import { EXIT_USAGE } from "./types.js";
7
+ export const cooldownCommand = {
8
+ name: "cooldown",
9
+ summary: "Hold new releases for a window before they can install, with per-package exemptions.",
10
+ usage: "dg cooldown [<window>|off] [--json] | dg cooldown exempt <name> [--reason <text>] [--expires <30d>] | dg cooldown rm <name>",
11
+ subcommands: [
12
+ { name: "list", summary: "Show the cooldown window and exemptions (default).", usage: "dg cooldown list [--json]", details: [], handler: () => unreachable() },
13
+ { name: "<window>", summary: "Set the window directly: dg cooldown 7d, dg cooldown 24h, dg cooldown off.", usage: "dg cooldown <7d|24h|off>", details: [], handler: () => unreachable() },
14
+ { name: "exempt", summary: "Always allow a package through cooldown.", usage: "dg cooldown exempt <name> [--reason <text>] [--expires <30d>] [--ecosystem npm|pypi]", details: [], handler: () => unreachable() },
15
+ { name: "rm", summary: "Drop an exemption so cooldown applies again.", usage: "dg cooldown rm <name> [--ecosystem npm|pypi]", details: [], handler: () => unreachable() },
16
+ { name: "prune", summary: "Remove expired exemptions.", usage: "dg cooldown prune", details: [], handler: () => unreachable() }
17
+ ],
18
+ flags: [
19
+ { flag: "--reason", value: "<text>", summary: "Why this package is exempt (stored in dg.json)." },
20
+ { flag: "--expires", value: "<30d>", summary: "Auto-expire the exemption after a duration (e.g. 14d, 90d)." },
21
+ { flag: "--ecosystem", value: "<npm|pypi>", summary: "Ecosystem when the name is not prefixed (default npm)." },
22
+ { flag: "--json", summary: "Machine-readable listing." }
23
+ ],
24
+ examples: [
25
+ "dg cooldown",
26
+ "dg cooldown 7d",
27
+ "dg cooldown off",
28
+ "dg cooldown exempt left-pad --reason 'vendored, pinned'",
29
+ "dg cooldown exempt pypi:requests --expires 30d",
30
+ "dg cooldown rm left-pad"
31
+ ],
32
+ details: [
33
+ "Cooldown quarantines releases younger than your configured window so a freshly-published compromised version can't install before anyone notices. An exemption opts one package out — use it for internal packages you publish and install immediately, or a dependency you've already vetted. Exemptions live in dg.json at the git root; an --expires window makes them self-revoke so the quarantine comes back on its own."
34
+ ],
35
+ handler: (context) => runCooldownCommand(context)
36
+ };
37
+ function unreachable() {
38
+ throw new Error("subcommand handled by the cooldown router");
39
+ }
40
+ export function runCooldownCommand(context, cwd = process.cwd(), env = process.env, now = new Date()) {
41
+ const [first, ...rest] = context.args;
42
+ if (first === undefined || first === "list" || first === "--json") {
43
+ const json = first === "--json" || rest.includes("--json");
44
+ const extras = rest.filter((arg) => arg !== "--json");
45
+ if (extras.length > 0) {
46
+ return usage(`unexpected argument '${extras[0]}'`);
47
+ }
48
+ return listExemptions(cwd, env, json, now);
49
+ }
50
+ if (first === "exempt") {
51
+ return addExemption(rest, cwd, env, now);
52
+ }
53
+ if (first === "rm" || first === "remove") {
54
+ return removeExemption(rest, cwd, env);
55
+ }
56
+ if (first === "prune") {
57
+ return pruneExemptions(rest, cwd, env, now);
58
+ }
59
+ if (/^(0|off|[1-9]\d{0,3}[hd])$/i.test(first)) {
60
+ return setWindow(first.toLowerCase(), rest, cwd, env, now);
61
+ }
62
+ return usage(`unknown subcommand '${first}'`);
63
+ }
64
+ function setWindow(window, rest, cwd, env, now) {
65
+ if (rest.length > 0) {
66
+ return usage(`unexpected argument '${rest[0]}'`);
67
+ }
68
+ try {
69
+ updateUserConfig((current) => setConfigValue(current, "cooldown.age", window), env);
70
+ }
71
+ catch (error) {
72
+ if (error instanceof ConfigError) {
73
+ return usage(error.message);
74
+ }
75
+ throw error;
76
+ }
77
+ return listExemptions(cwd, env, false, now);
78
+ }
79
+ function parseFlags(args) {
80
+ const positionals = [];
81
+ let reason;
82
+ let expires;
83
+ let ecosystem;
84
+ for (let i = 0; i < args.length; i += 1) {
85
+ const arg = args[i];
86
+ if (arg === undefined) {
87
+ continue;
88
+ }
89
+ if (arg === "--reason" || arg === "--expires" || arg === "--ecosystem") {
90
+ const value = args[i + 1];
91
+ if (value === undefined || value.startsWith("--")) {
92
+ return { error: `${arg} needs a value` };
93
+ }
94
+ if (arg === "--reason")
95
+ reason = value;
96
+ else if (arg === "--expires")
97
+ expires = value;
98
+ else
99
+ ecosystem = value;
100
+ i += 1;
101
+ continue;
102
+ }
103
+ if (arg.startsWith("--reason="))
104
+ reason = arg.slice("--reason=".length);
105
+ else if (arg.startsWith("--expires="))
106
+ expires = arg.slice("--expires=".length);
107
+ else if (arg.startsWith("--ecosystem="))
108
+ ecosystem = arg.slice("--ecosystem=".length);
109
+ else if (arg.startsWith("--"))
110
+ return { error: `unknown flag '${arg}'` };
111
+ else
112
+ positionals.push(arg);
113
+ }
114
+ return {
115
+ positionals,
116
+ ...(reason !== undefined ? { reason } : {}),
117
+ ...(expires !== undefined ? { expires } : {}),
118
+ ...(ecosystem !== undefined ? { ecosystem } : {})
119
+ };
120
+ }
121
+ const EXEMPTION_NAME_RE = /^[@A-Za-z0-9][@A-Za-z0-9._/-]*$/u;
122
+ function resolveTarget(token, ecosystemFlag) {
123
+ const colon = token.indexOf(":");
124
+ if (colon > 0) {
125
+ const prefix = token.slice(0, colon);
126
+ if (prefix === "npm" || prefix === "pypi" || prefix === "cargo") {
127
+ return validatedTarget(prefix, token.slice(colon + 1), token);
128
+ }
129
+ return { error: `unknown ecosystem prefix '${prefix}:' (only npm:, pypi:, and cargo: are supported)` };
130
+ }
131
+ const eco = ecosystemFlag ?? "npm";
132
+ if (eco !== "npm" && eco !== "pypi" && eco !== "cargo") {
133
+ return { error: `--ecosystem must be npm, pypi, or cargo, got '${eco}'` };
134
+ }
135
+ return validatedTarget(eco, token, token);
136
+ }
137
+ function validatedTarget(ecosystem, name, token) {
138
+ if (name.length === 0) {
139
+ return { error: `'${token}' has no package name` };
140
+ }
141
+ if (!EXEMPTION_NAME_RE.test(name)) {
142
+ return { error: `'${name}' is not a valid package name (no spaces, control characters, or '*' globs; use config cooldown.exempt for globs)` };
143
+ }
144
+ return { ecosystem, name: canonicalName(ecosystem, name) };
145
+ }
146
+ function canonicalName(ecosystem, name) {
147
+ return canonicalCooldownName(ecosystem, name);
148
+ }
149
+ function expiresAtFrom(expires, now) {
150
+ if (expires === undefined || expires === "off" || expires === "never") {
151
+ return {};
152
+ }
153
+ const days = durationToDays(expires);
154
+ if (days <= 0) {
155
+ if (/^[1-9]\d*(h|d)$/.test(expires)) {
156
+ return { error: `--expires '${expires}' is too large (maximum is 9999d or 9999h)` };
157
+ }
158
+ return { error: `--expires must be a positive duration like 30d or 12h, got '${expires}'` };
159
+ }
160
+ return { value: new Date(now.getTime() + days * 24 * 60 * 60 * 1000).toISOString() };
161
+ }
162
+ function formatExpiryDisplay(expiresAt, acceptedAt) {
163
+ const expiry = Date.parse(expiresAt);
164
+ const accepted = Date.parse(acceptedAt);
165
+ if (Number.isFinite(expiry) && Number.isFinite(accepted) && expiry - accepted < 24 * 60 * 60 * 1000) {
166
+ return `${expiresAt.slice(0, 16).replace("T", " ")}Z`;
167
+ }
168
+ return expiresAt.slice(0, 10);
169
+ }
170
+ function addExemption(args, cwd, env, now) {
171
+ const parsed = parseFlags(args);
172
+ if ("error" in parsed) {
173
+ return usage(parsed.error);
174
+ }
175
+ if (parsed.positionals.length !== 1 || parsed.positionals[0] === undefined) {
176
+ return usage("exempt takes exactly one <name>");
177
+ }
178
+ const target = resolveTarget(parsed.positionals[0], parsed.ecosystem);
179
+ if ("error" in target) {
180
+ return usage(target.error);
181
+ }
182
+ const expiry = expiresAtFrom(parsed.expires, now);
183
+ if ("error" in expiry) {
184
+ return usage(expiry.error);
185
+ }
186
+ const located = locateWritableDgFile(cwd, env);
187
+ if ("error" in located) {
188
+ return located.error;
189
+ }
190
+ const exemption = {
191
+ ecosystem: target.ecosystem,
192
+ name: target.name,
193
+ reason: parsed.reason ?? "",
194
+ acceptedBy: resolveAcceptedBy(located.root, env),
195
+ ...(expiry.value ? { expiresAt: expiry.value } : {})
196
+ };
197
+ try {
198
+ mutateDgFile(located.root, env, (file) => appendCooldownExemptions(file, [exemption], now));
199
+ }
200
+ catch (error) {
201
+ if (error instanceof CooldownExemptionCapError) {
202
+ return { exitCode: 1, stdout: "", stderr: `dg cooldown: ${error.message}.\n` };
203
+ }
204
+ throw error;
205
+ }
206
+ const until = expiry.value ? ` until ${formatExpiryDisplay(expiry.value, now.toISOString())}` : "";
207
+ return {
208
+ exitCode: 0,
209
+ stdout: `Exempted ${target.ecosystem}:${target.name} from cooldown${until}.\n`,
210
+ stderr: ""
211
+ };
212
+ }
213
+ function removeExemption(args, cwd, env) {
214
+ const parsed = parseFlags(args);
215
+ if ("error" in parsed) {
216
+ return usage(parsed.error);
217
+ }
218
+ if (parsed.positionals.length !== 1 || parsed.positionals[0] === undefined) {
219
+ return usage("rm takes exactly one <name>");
220
+ }
221
+ const target = resolveTarget(parsed.positionals[0], parsed.ecosystem);
222
+ if ("error" in target) {
223
+ return usage(target.error);
224
+ }
225
+ const located = locateWritableDgFile(cwd, env);
226
+ if ("error" in located) {
227
+ return located.error;
228
+ }
229
+ const matches = (e) => e.ecosystem === target.ecosystem && e.name === target.name;
230
+ let removed = false;
231
+ mutateDgFile(located.root, env, (file) => {
232
+ if (!file.cooldownExemptions.some(matches)) {
233
+ return file;
234
+ }
235
+ removed = true;
236
+ return removeCooldownExemptions(file, matches);
237
+ });
238
+ if (!removed) {
239
+ return { exitCode: 1, stdout: "", stderr: `dg cooldown: ${target.ecosystem}:${target.name} is not exempt in ${located.file.path}.\n` };
240
+ }
241
+ return { exitCode: 0, stdout: `Removed cooldown exemption for ${target.ecosystem}:${target.name} — cooldown applies again.\n`, stderr: "" };
242
+ }
243
+ function pruneExemptions(args, cwd, env, now) {
244
+ if (args.length > 0) {
245
+ return usage(`unexpected argument '${args[0]}'`);
246
+ }
247
+ const located = locateWritableDgFile(cwd, env);
248
+ if ("error" in located) {
249
+ return located.error;
250
+ }
251
+ let pruned = 0;
252
+ mutateDgFile(located.root, env, (file) => {
253
+ const expired = (e) => !cooldownExemptionActive(e, now);
254
+ pruned = file.cooldownExemptions.filter(expired).length;
255
+ return pruned === 0 ? file : removeCooldownExemptions(file, expired);
256
+ });
257
+ return { exitCode: 0, stdout: `Pruned ${pruned} expired cooldown exemption${pruned === 1 ? "" : "s"}.\n`, stderr: "" };
258
+ }
259
+ function windowStatusLines(env) {
260
+ const config = loadUserConfig(env);
261
+ const window = describeCooldownSettings(config, env);
262
+ const windowLine = window === "off"
263
+ ? "off — new releases install immediately"
264
+ : `${window} set: dg cooldown 3d`;
265
+ const unknownLine = config.cooldown.onUnknown === "block"
266
+ ? "block (no known publish date → held back)"
267
+ : "allow (no known publish date → not held back)";
268
+ return [
269
+ "Cooldown — versions published less than your window ago wait before installing.",
270
+ "",
271
+ ` window ${windowLine}`,
272
+ ` unknown ${unknownLine}`,
273
+ ""
274
+ ];
275
+ }
276
+ const EXEMPTION_FOOTER = [
277
+ " add: dg cooldown exempt <name> [--expires 30d] · remove: dg cooldown rm <name>",
278
+ " An exemption waives only the wait — the package is still scanned. No CVE fast-track yet."
279
+ ];
280
+ function renderTable(columns, rows) {
281
+ const widths = columns.map((label, column) => Math.max(label.length, ...rows.map((row) => row[column]?.length ?? 0)));
282
+ const renderRow = (row) => ` ${row.map((cell, column) => cell.padEnd(widths[column] ?? 0)).join(" ").trimEnd()}`;
283
+ return [renderRow(columns), ...rows.map(renderRow)];
284
+ }
285
+ function eligibleDisplay(entry, now) {
286
+ if (!entry.eligibleAt) {
287
+ return "unknown";
288
+ }
289
+ const eligible = Date.parse(entry.eligibleAt);
290
+ if (!Number.isFinite(eligible)) {
291
+ return "unknown";
292
+ }
293
+ const relative = formatCooldownDuration(Math.max(0, (eligible - now.getTime()) / 86_400_000));
294
+ return `${entry.eligibleAt.slice(0, 10)} (in ${relative})`;
295
+ }
296
+ function heldSectionLines(env, now) {
297
+ const held = readHeldPackages(env, now);
298
+ if (held.length === 0) {
299
+ return ["Currently held: none.", ""];
300
+ }
301
+ const rows = held.map((entry) => [
302
+ `${entry.ecosystem}:${entry.name}@${entry.version}`,
303
+ entry.publishedAt ? entry.publishedAt.slice(0, 10) : "-",
304
+ eligibleDisplay(entry, now)
305
+ ]);
306
+ return [
307
+ "Currently held:",
308
+ "",
309
+ ...renderTable(["PACKAGE", "PUBLISHED", "ELIGIBLE"], rows),
310
+ "",
311
+ " release now: dg cooldown exempt <name>",
312
+ ""
313
+ ];
314
+ }
315
+ function listExemptions(cwd, env, json, now) {
316
+ const root = findProjectRoot(cwd, env);
317
+ if (!root) {
318
+ return { exitCode: EXIT_USAGE, stdout: "", stderr: "dg cooldown: not inside a git repository.\n" };
319
+ }
320
+ const file = loadDgFile(root);
321
+ if (file.exists && !file.readable) {
322
+ return { exitCode: 1, stdout: "", stderr: `dg cooldown: cannot use ${file.path} — ${file.failure ?? "unreadable"}.\n` };
323
+ }
324
+ const config = loadUserConfig(env);
325
+ if (json) {
326
+ return {
327
+ exitCode: 0,
328
+ stdout: `${JSON.stringify({
329
+ schemaVersion: 1,
330
+ path: file.path,
331
+ window: { effective: describeCooldownSettings(config, env), onUnknown: config.cooldown.onUnknown },
332
+ held: readHeldPackages(env, now),
333
+ cooldownExemptions: file.cooldownExemptions.map((e) => exemptionJson(e, now))
334
+ }, null, 2)}\n`,
335
+ stderr: ""
336
+ };
337
+ }
338
+ const header = [...windowStatusLines(env), ...heldSectionLines(env, now)];
339
+ if (file.cooldownExemptions.length === 0) {
340
+ const note = file.exists ? `No exemptions in ${file.path}.` : "No exemptions yet — add one with the command below.";
341
+ return { exitCode: 0, stdout: `${[...header, note, ...EXEMPTION_FOOTER].join("\n")}\n`, stderr: "" };
342
+ }
343
+ const rows = file.cooldownExemptions.map((e) => [
344
+ `${e.ecosystem}:${e.name}`,
345
+ e.reason || "-",
346
+ e.acceptedBy || "-",
347
+ e.acceptedAt.slice(0, 10) || "-",
348
+ e.expiresAt ? formatExpiryDisplay(e.expiresAt, e.acceptedAt) : "-",
349
+ cooldownExemptionActive(e, now) ? "active" : "expired"
350
+ ]);
351
+ const lines = [
352
+ ...header,
353
+ `Exemptions in ${file.path}:`,
354
+ "",
355
+ ...renderTable(["PACKAGE", "REASON", "BY", "WHEN", "EXPIRES", "STATUS"], rows),
356
+ "",
357
+ ...EXEMPTION_FOOTER
358
+ ];
359
+ return { exitCode: 0, stdout: `${lines.join("\n")}\n`, stderr: "" };
360
+ }
361
+ function locateWritableDgFile(cwd, env) {
362
+ const root = findProjectRoot(cwd, env);
363
+ if (!root) {
364
+ return { error: { exitCode: EXIT_USAGE, stdout: "", stderr: "dg cooldown: not inside a git repository.\n" } };
365
+ }
366
+ const file = loadDgFile(root);
367
+ if (!file.readable) {
368
+ return { error: { exitCode: 1, stdout: "", stderr: `dg cooldown: cannot write ${file.path} — ${file.failure ?? "unreadable"}.\n` } };
369
+ }
370
+ return { root, file };
371
+ }
372
+ function exemptionJson(e, now) {
373
+ return {
374
+ ecosystem: e.ecosystem,
375
+ name: e.name,
376
+ reason: e.reason,
377
+ acceptedBy: e.acceptedBy,
378
+ acceptedAt: e.acceptedAt,
379
+ ...(e.expiresAt ? { expiresAt: e.expiresAt } : {}),
380
+ status: cooldownExemptionActive(e, now) ? "active" : "expired"
381
+ };
382
+ }
383
+ function usage(message) {
384
+ return {
385
+ exitCode: EXIT_USAGE,
386
+ stdout: "",
387
+ stderr: `dg cooldown: ${message}. Usage: ${cooldownCommand.usage}\n`
388
+ };
389
+ }
@@ -0,0 +1,169 @@
1
+ import { recordDecisionEvents } from "../decisions/remember-prompt.js";
2
+ import { packageKey } from "../decisions/apply.js";
3
+ import { findProjectRoot, loadDgFile, mutateDgFile, removeDecisions } from "../project/dgfile.js";
4
+ import { EXIT_USAGE } from "./types.js";
5
+ export const decisionsCommand = {
6
+ name: "decisions",
7
+ summary: "List or revoke remembered warn acceptances stored in dg.json.",
8
+ usage: "dg decisions [list] [--json] | dg decisions revoke <id|name[@version]>",
9
+ subcommands: [
10
+ { name: "list", summary: "Show every remembered acceptance (default).", usage: "dg decisions list [--json]", details: [], handler: () => unreachable() },
11
+ { name: "revoke", summary: "Remove an acceptance by id prefix, name, or name@version.", usage: "dg decisions revoke <id|name[@version]>", details: [], handler: () => unreachable() }
12
+ ],
13
+ flags: [{ flag: "--json", summary: "Machine-readable listing." }],
14
+ examples: ["dg decisions", "dg decisions list --json", "dg decisions revoke left-pad@1.3.0"],
15
+ details: [
16
+ "Acceptances live in dg.json at the git root and only ever soften how an acknowledged warn is presented — a block verdict is never suppressible. Revoking makes the warn surface again on the next scan."
17
+ ],
18
+ handler: (context) => runDecisionsCommand(context)
19
+ };
20
+ function unreachable() {
21
+ throw new Error("subcommand handled by the decisions router");
22
+ }
23
+ export function runDecisionsCommand(context, cwd = process.cwd(), env = process.env) {
24
+ const [first, ...rest] = context.args;
25
+ if (first === undefined || first === "list" || first === "--json") {
26
+ const json = first === "--json" || rest.includes("--json");
27
+ const extras = rest.filter((arg) => arg !== "--json");
28
+ if (extras.length > 0) {
29
+ return usage(`unexpected argument '${extras[0]}'`);
30
+ }
31
+ return listDecisions(cwd, env, json);
32
+ }
33
+ if (first === "revoke") {
34
+ const [selector, ...extra] = rest;
35
+ if (!selector || extra.length > 0) {
36
+ return usage("revoke takes exactly one <id|name[@version]>");
37
+ }
38
+ return revokeDecisions(selector, cwd, env);
39
+ }
40
+ return usage(`unknown subcommand '${first}'`);
41
+ }
42
+ function listDecisions(cwd, env, json) {
43
+ const located = locateDgFile(cwd, env);
44
+ if ("error" in located) {
45
+ return located.error;
46
+ }
47
+ if ("missing" in located) {
48
+ if (json) {
49
+ return { exitCode: 0, stdout: `${JSON.stringify({ schemaVersion: 1, path: null, decisions: [] }, null, 2)}\n`, stderr: "" };
50
+ }
51
+ return { exitCode: 0, stdout: `No dg.json at ${located.missing} — nothing remembered yet.\n`, stderr: "" };
52
+ }
53
+ const file = located.file;
54
+ if (json) {
55
+ return {
56
+ exitCode: 0,
57
+ stdout: `${JSON.stringify({ schemaVersion: 1, path: file.path, decisions: file.decisions.map((entry) => entryJson(entry)) }, null, 2)}\n`,
58
+ stderr: ""
59
+ };
60
+ }
61
+ if (file.decisions.length === 0) {
62
+ return { exitCode: 0, stdout: `No remembered acceptances in ${file.path}.\n`, stderr: "" };
63
+ }
64
+ const rows = file.decisions.map((entry) => [
65
+ entry.id.slice(0, 8),
66
+ `${entry.ecosystem}:${entry.name}@${scopeLabel(entry)}`,
67
+ findingsLabel(entry),
68
+ entry.acceptedBy,
69
+ entry.acceptedAt.slice(0, 10) || "-",
70
+ entry.expiresAt ? entry.expiresAt.slice(0, 10) : "-",
71
+ isExpired(entry) ? "expired" : "active"
72
+ ]);
73
+ const header = ["ID", "PACKAGE", "ACCEPTED FINDINGS", "BY", "WHEN", "EXPIRES", "STATUS"];
74
+ const widths = header.map((label, column) => Math.max(label.length, ...rows.map((row) => row[column]?.length ?? 0)));
75
+ const renderRow = (row) => row.map((cell, column) => cell.padEnd(widths[column] ?? 0)).join(" ").trimEnd();
76
+ const lines = [
77
+ `Remembered acceptances in ${file.path} (warns only — blocks are never suppressible):`,
78
+ "",
79
+ renderRow(header),
80
+ ...rows.map(renderRow),
81
+ "",
82
+ `Revoke with: dg decisions revoke <id|name[@version]>`
83
+ ];
84
+ return { exitCode: 0, stdout: `${lines.join("\n")}\n`, stderr: "" };
85
+ }
86
+ function revokeDecisions(selector, cwd, env) {
87
+ const located = locateDgFile(cwd, env);
88
+ if ("error" in located) {
89
+ return located.error;
90
+ }
91
+ if ("missing" in located) {
92
+ return { exitCode: 1, stdout: "", stderr: `dg decisions: nothing to revoke — no dg.json at ${located.missing} yet.\n` };
93
+ }
94
+ let matched = [];
95
+ mutateDgFile(located.root, env, (file) => {
96
+ matched = file.decisions.filter((entry) => matchesSelector(entry, selector));
97
+ if (matched.length === 0) {
98
+ return file;
99
+ }
100
+ return removeDecisions(file, new Set(matched.map((entry) => entry.id)));
101
+ });
102
+ if (matched.length === 0) {
103
+ return { exitCode: 1, stdout: "", stderr: `dg decisions: nothing matches '${selector}' in ${located.file.path}.\n` };
104
+ }
105
+ recordDecisionEvents("decision.revoked", matched.map((entry) => `${entry.ecosystem}:${packageKey(entry.name, scopeLabel(entry))}`), `revoked via dg decisions (${selector})`, env);
106
+ const lines = matched.map((entry) => `Revoked ${entry.ecosystem}:${entry.name}@${scopeLabel(entry)} (${entry.id.slice(0, 8)}) — the warn will surface again.`);
107
+ return { exitCode: 0, stdout: `${lines.join("\n")}\n`, stderr: "" };
108
+ }
109
+ function locateDgFile(cwd, env) {
110
+ const root = findProjectRoot(cwd, env);
111
+ if (!root) {
112
+ return { error: { exitCode: EXIT_USAGE, stdout: "", stderr: "dg decisions: not inside a git repository.\n" } };
113
+ }
114
+ const file = loadDgFile(root);
115
+ if (!file.exists) {
116
+ return { missing: root };
117
+ }
118
+ if (!file.readable) {
119
+ return { error: { exitCode: 1, stdout: "", stderr: `dg decisions: cannot use ${file.path} — ${file.failure ?? "unreadable"}.\n` } };
120
+ }
121
+ return { root, file };
122
+ }
123
+ function matchesSelector(entry, selector) {
124
+ if (selector.length >= 4 && entry.id.startsWith(selector)) {
125
+ return true;
126
+ }
127
+ const at = selector.lastIndexOf("@");
128
+ if (at > 0) {
129
+ const name = selector.slice(0, at);
130
+ const version = selector.slice(at + 1);
131
+ return entry.name === name && entry.scope.kind === "exact" && entry.scope.version === version;
132
+ }
133
+ return entry.name === selector;
134
+ }
135
+ function scopeLabel(entry) {
136
+ return entry.scope.kind === "exact" ? entry.scope.version : "*";
137
+ }
138
+ function findingsLabel(entry) {
139
+ const pairs = Object.entries(entry.findings).map(([category, severity]) => `${category}:${severity}`);
140
+ return pairs.length > 0 ? pairs.sort().join(",") : "(action-only warn)";
141
+ }
142
+ function isExpired(entry) {
143
+ if (!entry.expiresAt) {
144
+ return false;
145
+ }
146
+ const expiry = Date.parse(entry.expiresAt);
147
+ return !Number.isFinite(expiry) || expiry <= Date.now();
148
+ }
149
+ function entryJson(entry) {
150
+ return {
151
+ id: entry.id,
152
+ ecosystem: entry.ecosystem,
153
+ name: entry.name,
154
+ scope: entry.scope,
155
+ findings: entry.findings,
156
+ reason: entry.reason,
157
+ acceptedBy: entry.acceptedBy,
158
+ acceptedAt: entry.acceptedAt,
159
+ ...(entry.expiresAt ? { expiresAt: entry.expiresAt } : {}),
160
+ status: isExpired(entry) ? "expired" : "active"
161
+ };
162
+ }
163
+ function usage(message) {
164
+ return {
165
+ exitCode: EXIT_USAGE,
166
+ stdout: "",
167
+ stderr: `dg decisions: ${message}. Usage: ${decisionsCommand.usage}\n`
168
+ };
169
+ }
@@ -33,7 +33,7 @@ async function doctorHandler(args) {
33
33
  const theme = createTheme(resolvePresentation().color);
34
34
  return {
35
35
  exitCode: hasFailure ? 1 : 0,
36
- stdout: json ? `${JSON.stringify(report, null, 2)}\n` : renderDoctorReport(report, theme, verbose),
36
+ stdout: json ? `${JSON.stringify({ schemaVersion: 1, ...report }, null, 2)}\n` : renderDoctorReport(report, theme, verbose),
37
37
  stderr: ""
38
38
  };
39
39
  }
@@ -1,4 +1,4 @@
1
- const PRODUCT_DESCRIPTION = "Dependency Guardian supply-chain firewall CLI.";
1
+ const PRODUCT_DESCRIPTION = "Dependency Guardian";
2
2
  const COMMON_COMMANDS = ["scan", "verify", "audit", "licenses", "setup", "guard-commit", "doctor", "login"];
3
3
  const GLOBAL_FLAGS_FOOTER = "Global: --help/-h · --version/-v · --json (where supported) · --no-color / --force-color (also NO_COLOR, FORCE_COLOR).";
4
4
  function isWrapper(command) {
@@ -1,9 +1,10 @@
1
- import { mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
1
+ import { readdirSync, statSync } from "node:fs";
2
2
  import { launchScanTui, shouldLaunchScanTui } from "../scan-ui/launch.js";
3
+ import { writeReportAtomic } from "../util/report-writer.js";
3
4
  import { basename, dirname, relative, resolve, sep } from "node:path";
4
5
  import { scanProject } from "../scan/discovery.js";
5
6
  import { isSupportedLockfilePath, verifyLockfile } from "../verify/preflight.js";
6
- import { EXIT_USAGE_VERDICT } from "./types.js";
7
+ import { EXIT_ANALYSIS_INCOMPLETE, EXIT_USAGE_VERDICT } from "./types.js";
7
8
  const LICENSE_RISKS = [
8
9
  "network-copyleft",
9
10
  "no-license",
@@ -39,7 +40,7 @@ export const licensesCommand = {
39
40
  examples: ["dg licenses", "dg licenses --markdown -o licenses.md", "dg licenses --fail-on strong-copyleft,network-copyleft"],
40
41
  details: [
41
42
  "Reports project and lockfile license metadata without running package managers or package code.",
42
- "License exports support text, JSON, CSV, and Markdown with stable exit codes for policy gates."
43
+ "License exports support text, JSON, CSV, and Markdown. Exit codes: 0 pass, 2 policy block, 4 report error, 64 usage error."
43
44
  ],
44
45
  handler: (context) => runLicensesCommand(context.args)
45
46
  };
@@ -60,13 +61,13 @@ async function runLicensesCommand(args) {
60
61
  }
61
62
  catch (error) {
62
63
  return {
63
- exitCode: 1,
64
+ exitCode: EXIT_ANALYSIS_INCOMPLETE,
64
65
  stdout: "",
65
66
  stderr: `dg licenses TUI failed: ${error instanceof Error ? error.message : "unknown error"}\n`
66
67
  };
67
68
  }
68
69
  return {
69
- exitCode: 0,
70
+ exitCode: typeof process.exitCode === "number" ? process.exitCode : 0,
70
71
  stdout: "",
71
72
  stderr: ""
72
73
  };
@@ -78,7 +79,7 @@ async function runLicensesCommand(args) {
78
79
  }
79
80
  catch (error) {
80
81
  return {
81
- exitCode: 1,
82
+ exitCode: EXIT_ANALYSIS_INCOMPLETE,
82
83
  stdout: "",
83
84
  stderr: `dg licenses failed: ${error instanceof Error ? error.message : "unknown license error"}\n`
84
85
  };
@@ -91,7 +92,7 @@ async function runLicensesCommand(args) {
91
92
  }
92
93
  catch (error) {
93
94
  return {
94
- exitCode: 1,
95
+ exitCode: EXIT_ANALYSIS_INCOMPLETE,
95
96
  stdout: "",
96
97
  stderr: `dg licenses could not write ${parsed.outputPath}: ${error instanceof Error ? error.message : "unknown write error"}\n`
97
98
  };
@@ -329,7 +330,7 @@ function classifyLicense(license) {
329
330
  }
330
331
  function renderLicenseReport(report, format) {
331
332
  if (format === "json") {
332
- return `${JSON.stringify(report, null, 2)}\n`;
333
+ return `${JSON.stringify({ schemaVersion: 1, ...report }, null, 2)}\n`;
333
334
  }
334
335
  if (format === "csv") {
335
336
  return renderCsv(report);
@@ -392,7 +393,7 @@ function renderMarkdown(report) {
392
393
  return `${lines.join("\n")}\n`;
393
394
  }
394
395
  function exitCodeForReport(report) {
395
- return report.status === "block" ? 1 : 0;
396
+ return report.status === "block" ? 2 : 0;
396
397
  }
397
398
  function usageError(message) {
398
399
  return {
@@ -401,19 +402,6 @@ function usageError(message) {
401
402
  stderr: `dg licenses: ${message}. Usage: dg licenses [path] [--json|--csv|--markdown] [--output <path>] [--fail-on <risk[,risk...]>] [--deny-license <id>]\n`
402
403
  };
403
404
  }
404
- function writeReportAtomic(outputPath, contents) {
405
- const directory = dirname(outputPath);
406
- mkdirSync(directory, { recursive: true });
407
- const temporaryPath = `${outputPath}.${process.pid}.${Date.now()}.tmp`;
408
- try {
409
- writeFileSync(temporaryPath, contents, "utf8");
410
- renameSync(temporaryPath, outputPath);
411
- }
412
- catch (error) {
413
- rmSync(temporaryPath, { force: true });
414
- throw error;
415
- }
416
- }
417
405
  function dedupeEntries(entries) {
418
406
  const seen = new Set();
419
407
  const deduped = [];