@westbayberry/dg 2.1.0 → 2.3.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 (148) hide show
  1. package/README.md +216 -226
  2. package/dist/agents/claude-code.js +123 -0
  3. package/dist/agents/codex.js +65 -0
  4. package/dist/agents/copilot-cli.js +115 -0
  5. package/dist/agents/cursor.js +118 -0
  6. package/dist/agents/gate-posture.js +21 -0
  7. package/dist/agents/gemini.js +107 -0
  8. package/dist/agents/persistence.js +351 -0
  9. package/dist/agents/registry.js +128 -0
  10. package/dist/agents/routing.js +118 -0
  11. package/dist/agents/types.js +1 -0
  12. package/dist/agents/windsurf.js +93 -0
  13. package/dist/api/analyze.js +1 -1
  14. package/dist/audit/detectors.js +0 -11
  15. package/dist/audit/events.js +5 -21
  16. package/dist/audit/rules.js +6 -2
  17. package/dist/audit-ui/AuditApp.js +2 -0
  18. package/dist/audit-ui/components/AuditResultsView.js +55 -92
  19. package/dist/audit-ui/export.js +0 -4
  20. package/dist/audit-ui/format.js +0 -3
  21. package/dist/audit-ui/launch.js +10 -1
  22. package/dist/auth/device-login.js +4 -5
  23. package/dist/auth/login-app.js +7 -7
  24. package/dist/auth/store.js +17 -5
  25. package/dist/bin/dg.js +59 -52
  26. package/dist/commands/agents.js +275 -0
  27. package/dist/commands/audit.js +28 -3
  28. package/dist/commands/config.js +15 -7
  29. package/dist/commands/cooldown.js +389 -0
  30. package/dist/commands/decisions.js +23 -9
  31. package/dist/commands/doctor.js +1 -1
  32. package/dist/commands/help.js +1 -1
  33. package/dist/commands/licenses.js +10 -22
  34. package/dist/commands/logout.js +4 -11
  35. package/dist/commands/router.js +6 -4
  36. package/dist/commands/sbom.js +206 -0
  37. package/dist/commands/scan.js +1 -1
  38. package/dist/commands/service.js +32 -13
  39. package/dist/commands/setup.js +196 -27
  40. package/dist/commands/types.js +1 -0
  41. package/dist/commands/uninstall.js +2 -1
  42. package/dist/commands/update.js +17 -8
  43. package/dist/commands/verify.js +8 -5
  44. package/dist/config/settings.js +49 -48
  45. package/dist/decisions/remember-prompt.js +5 -10
  46. package/dist/export-ui/ExportDialog.js +198 -0
  47. package/dist/install-ui/LiveInstall.js +5 -4
  48. package/dist/install-ui/block-render.js +18 -14
  49. package/dist/install-ui/prep-spinner.js +32 -0
  50. package/dist/launcher/agent-check.js +896 -0
  51. package/dist/launcher/agent-hook-exec.js +70 -0
  52. package/dist/launcher/agent-hook-io.js +39 -0
  53. package/dist/launcher/cargo-cache.js +40 -0
  54. package/dist/launcher/classify.js +44 -7
  55. package/dist/launcher/env.js +105 -26
  56. package/dist/launcher/install-preflight.js +64 -18
  57. package/dist/launcher/live-install.js +29 -8
  58. package/dist/launcher/manifest-screen.js +171 -0
  59. package/dist/launcher/output-redaction.js +10 -4
  60. package/dist/launcher/preflight-prompt.js +18 -4
  61. package/dist/launcher/run.js +178 -103
  62. package/dist/launcher/spawn-invocation.js +21 -0
  63. package/dist/policy/cooldown.js +18 -5
  64. package/dist/policy/evaluate.js +5 -6
  65. package/dist/policy/pypi-name.js +17 -0
  66. package/dist/presentation/mode.js +3 -2
  67. package/dist/presentation/package-page.js +9 -0
  68. package/dist/presentation/theme.js +7 -7
  69. package/dist/project/dgfile.js +146 -6
  70. package/dist/project/override-trust.js +0 -0
  71. package/dist/proxy/auth.js +42 -0
  72. package/dist/proxy/ca.js +29 -9
  73. package/dist/proxy/cooldown-exemptions-file.js +33 -0
  74. package/dist/proxy/enforcement.js +55 -16
  75. package/dist/proxy/metadata-map.js +66 -12
  76. package/dist/proxy/preverified.js +55 -0
  77. package/dist/proxy/server.js +568 -53
  78. package/dist/proxy/worker.js +16 -1
  79. package/dist/publish-set/collect.js +1 -4
  80. package/dist/publish-set/npm.js +8 -5
  81. package/dist/publish-set/pack.js +9 -3
  82. package/dist/runtime/cli.js +0 -4
  83. package/dist/runtime/fatal.js +31 -0
  84. package/dist/runtime/first-run.js +14 -12
  85. package/dist/runtime/node-version.js +43 -6
  86. package/dist/runtime/nudges.js +35 -2
  87. package/dist/sbom/cyclonedx.js +211 -0
  88. package/dist/sbom-ui/SbomApp.js +158 -0
  89. package/dist/sbom-ui/components/SbomHeader.js +32 -0
  90. package/dist/sbom-ui/components/SbomList.js +52 -0
  91. package/dist/sbom-ui/inventory.js +132 -0
  92. package/dist/sbom-ui/launch.js +51 -0
  93. package/dist/sbom-ui/run.js +55 -0
  94. package/dist/sbom-ui/store.js +26 -0
  95. package/dist/scan/collect.js +10 -6
  96. package/dist/scan/command.js +17 -10
  97. package/dist/scan/discovery.js +11 -2
  98. package/dist/scan/render.js +28 -4
  99. package/dist/scan/scanner-report.js +15 -9
  100. package/dist/scan/staged.js +33 -9
  101. package/dist/scan-ui/LegacyApp.js +9 -13
  102. package/dist/scan-ui/alt-screen.js +5 -8
  103. package/dist/scan-ui/components/InteractiveResultsView.js +133 -120
  104. package/dist/scan-ui/components/ProgressBar.js +3 -14
  105. package/dist/scan-ui/components/ProjectSelector.js +1 -1
  106. package/dist/scan-ui/components/ScoreHeader.js +2 -3
  107. package/dist/scan-ui/components/SetupBanner.js +0 -6
  108. package/dist/scan-ui/format-helpers.js +61 -5
  109. package/dist/scan-ui/hooks/useResizeRepaint.js +25 -0
  110. package/dist/scan-ui/hooks/useScan.js +19 -2
  111. package/dist/scan-ui/launch.js +7 -4
  112. package/dist/scan-ui/shims.js +11 -4
  113. package/dist/scripts/detect.js +7 -2
  114. package/dist/scripts/gate.js +1 -1
  115. package/dist/security/sanitize.js +8 -4
  116. package/dist/service/state.js +28 -9
  117. package/dist/service/trust-refresh.js +92 -0
  118. package/dist/service/trust-store.js +5 -9
  119. package/dist/service/worker.js +23 -1
  120. package/dist/setup/activate-shell.js +28 -0
  121. package/dist/setup/git-hook.js +49 -4
  122. package/dist/setup/plan.js +227 -49
  123. package/dist/setup/uninstall-standalone.js +25 -0
  124. package/dist/setup-ui/gate.js +39 -0
  125. package/dist/setup-ui/offer.js +42 -0
  126. package/dist/setup-ui/selector.js +27 -0
  127. package/dist/setup-ui/tasks.js +56 -0
  128. package/dist/setup-ui/wizard.js +233 -0
  129. package/dist/standalone/uninstall.mjs +2123 -0
  130. package/dist/state/cooldown-held.js +66 -0
  131. package/dist/state/index.js +1 -0
  132. package/dist/state/locks.js +4 -2
  133. package/dist/state/store.js +2 -1
  134. package/dist/util/external-tool.js +25 -0
  135. package/dist/util/git.js +10 -3
  136. package/dist/util/report-writer.js +57 -0
  137. package/dist/verify/local.js +242 -44
  138. package/dist/verify/package-check.js +87 -29
  139. package/dist/verify/preflight.js +242 -49
  140. package/dist/verify/render.js +15 -1
  141. package/npm-shrinkwrap.json +2383 -0
  142. package/package.json +7 -8
  143. package/NOTICE +0 -5
  144. package/dist/commands/completion.js +0 -117
  145. package/dist/commands/explain.js +0 -236
  146. package/dist/commands/unavailable.js +0 -11
  147. package/dist/scripts/rebuild.js +0 -28
  148. package/dist/telemetry/events.js +0 -40
@@ -1,8 +1,9 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { npmExecutable } from "../publish-set/npm.js";
2
+ import { toolInvocation } from "../util/external-tool.js";
3
3
  import { EXIT_USAGE } from "./types.js";
4
4
  import { dgVersion } from "./version.js";
5
5
  const PACKAGE_NAME = "@westbayberry/dg";
6
+ const VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/u;
6
7
  export const updateCommand = {
7
8
  name: "update",
8
9
  summary: "Check for dg CLI updates.",
@@ -26,7 +27,7 @@ function runUpdateCommand(args, commandName) {
26
27
  if (parsed.format === "json") {
27
28
  return {
28
29
  exitCode: report.status === "unknown" ? 1 : 0,
29
- stdout: `${JSON.stringify(report, null, 2)}\n`,
30
+ stdout: `${JSON.stringify({ schemaVersion: 1, ...report }, null, 2)}\n`,
30
31
  stderr: ""
31
32
  };
32
33
  }
@@ -62,17 +63,22 @@ function parseUpdateArgs(args) {
62
63
  };
63
64
  }
64
65
  export function readLatestVersion(timeoutMs = 5000) {
65
- const injected = process.env.DG_UPDATE_LATEST_VERSION;
66
+ const injected = process.env.NODE_ENV === "test" ? process.env.DG_UPDATE_LATEST_VERSION : undefined;
66
67
  if (injected) {
67
- return injected;
68
+ return validVersion(injected);
68
69
  }
69
- const result = spawnSync(npmExecutable(), ["view", PACKAGE_NAME, "version", "--json"], {
70
+ const invocation = toolInvocation("npm", ["view", PACKAGE_NAME, "version", "--json"]);
71
+ if (!invocation) {
72
+ return null;
73
+ }
74
+ const result = spawnSync(invocation.command, [...invocation.args], {
70
75
  encoding: "utf8",
71
76
  env: {
72
77
  ...process.env,
73
78
  npm_config_ignore_scripts: "true"
74
79
  },
75
- timeout: timeoutMs
80
+ timeout: timeoutMs,
81
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments
76
82
  });
77
83
  if (result.status !== 0) {
78
84
  return null;
@@ -83,12 +89,15 @@ export function readLatestVersion(timeoutMs = 5000) {
83
89
  }
84
90
  try {
85
91
  const parsed = JSON.parse(raw);
86
- return typeof parsed === "string" ? parsed : null;
92
+ return typeof parsed === "string" ? validVersion(parsed) : null;
87
93
  }
88
94
  catch {
89
- return raw.replace(/^"|"$/gu, "");
95
+ return validVersion(raw.replace(/^"|"$/gu, ""));
90
96
  }
91
97
  }
98
+ function validVersion(value) {
99
+ return VERSION_PATTERN.test(value) ? value : null;
100
+ }
92
101
  function buildUpdateReport(latestVersion) {
93
102
  if (!latestVersion) {
94
103
  return {
@@ -1,4 +1,4 @@
1
- import { EXIT_UNAVAILABLE, EXIT_USAGE } from "./types.js";
1
+ import { EXIT_ANALYSIS_INCOMPLETE, EXIT_UNAVAILABLE, EXIT_USAGE_VERDICT } from "./types.js";
2
2
  import { writeFileSync } from "node:fs";
3
3
  import { existsSync } from "node:fs";
4
4
  import { resolve } from "node:path";
@@ -22,14 +22,14 @@ export const verifyCommand = {
22
22
  ],
23
23
  examples: ["dg verify npm:react", "dg verify pypi:requests@2.31.0", "dg verify ./pkg.tgz --verbose", "dg verify package-lock.json --json"],
24
24
  details: [
25
- "dg verify npm:react (or pypi:requests, with an optional @version — defaults to latest) runs a real scanner check on a published package before you install it. This is a Pro/Team feature and requires dg login.",
25
+ "dg verify npm:react (or pypi:requests, with an optional @version — defaults to latest) runs a real scanner check on a published package before you install it. Signed-out runs show the verdict and top reasons; dg login unlocks full findings, license info, --json, and --output.",
26
26
  "Local paths, workspaces, tgz/zip/wheel artifacts, and lockfiles are verified offline as advisory preflight (free); proxy enforcement remains authoritative for network artifact fetches."
27
27
  ],
28
28
  handler: (context) => {
29
29
  const parsed = parseVerifyArgs(context.args);
30
30
  if ("error" in parsed) {
31
31
  return {
32
- exitCode: EXIT_USAGE,
32
+ exitCode: EXIT_USAGE_VERDICT,
33
33
  stdout: "",
34
34
  stderr: `dg verify: ${parsed.error}. Usage: dg verify <spec|path|lockfile> [--verbose] [--json|--sarif] [--output <path>]\n`
35
35
  };
@@ -52,7 +52,7 @@ export const verifyCommand = {
52
52
  }
53
53
  catch (error) {
54
54
  return {
55
- exitCode: 1,
55
+ exitCode: EXIT_ANALYSIS_INCOMPLETE,
56
56
  stdout: "",
57
57
  stderr: `dg verify could not write ${parsed.outputPath}: ${error instanceof Error ? error.message : "unknown write error"}\n`
58
58
  };
@@ -147,5 +147,8 @@ function exitCodeForReport(report) {
147
147
  if (report.status === "block") {
148
148
  return 2;
149
149
  }
150
- return report.status === "warn" || report.status === "error" ? 1 : 0;
150
+ if (report.status === "error") {
151
+ return EXIT_ANALYSIS_INCOMPLETE;
152
+ }
153
+ return report.status === "warn" ? 1 : 0;
151
154
  }
@@ -9,6 +9,8 @@ export const CONFIG_KEYS = Object.freeze([
9
9
  "policy.trustProjectAllowlists",
10
10
  "policy.allowForceOverride",
11
11
  "policy.scriptHardening",
12
+ "policy.shimFailClosed",
13
+ "policy.strictEgress",
12
14
  "scriptGate.mode",
13
15
  "scriptGate.observe",
14
16
  "gitHook.onWarn",
@@ -19,9 +21,7 @@ export const CONFIG_KEYS = Object.freeze([
19
21
  "cooldown.cargo.age",
20
22
  "cooldown.onUnknown",
21
23
  "cooldown.exempt",
22
- "audit.upload",
23
- "telemetry.enabled",
24
- "webhooks.enabled"
24
+ "audit.upload"
25
25
  ]);
26
26
  export const DEFAULT_CONFIG = Object.freeze({
27
27
  version: 1,
@@ -35,7 +35,9 @@ export const DEFAULT_CONFIG = Object.freeze({
35
35
  mode: "block",
36
36
  trustProjectAllowlists: false,
37
37
  allowForceOverride: true,
38
- scriptHardening: false
38
+ scriptHardening: false,
39
+ shimFailClosed: false,
40
+ strictEgress: false
39
41
  },
40
42
  scriptGate: {
41
43
  mode: "observe",
@@ -50,17 +52,11 @@ export const DEFAULT_CONFIG = Object.freeze({
50
52
  npmAge: "",
51
53
  pypiAge: "",
52
54
  cargoAge: "",
53
- onUnknown: "allow",
55
+ onUnknown: "block",
54
56
  exempt: ""
55
57
  },
56
58
  audit: {
57
59
  upload: false
58
- },
59
- telemetry: {
60
- enabled: false
61
- },
62
- webhooks: {
63
- enabled: true
64
60
  }
65
61
  });
66
62
  export class ConfigError extends Error {
@@ -114,6 +110,17 @@ export function updateUserConfig(apply, env = process.env) {
114
110
  return next;
115
111
  });
116
112
  }
113
+ // Whether repo-supplied project overrides (allowlists, cooldown exemptions,
114
+ // warn-decisions) are trusted wholesale. A corrupt config fails closed to
115
+ // untrusted so a hostile repo can never relax screening by breaking the config.
116
+ export function trustsProjectOverrides(env = process.env) {
117
+ try {
118
+ return loadUserConfig(env).policy.trustProjectAllowlists;
119
+ }
120
+ catch {
121
+ return false;
122
+ }
123
+ }
117
124
  export function getConfigValue(config, key) {
118
125
  if (key === "api.baseUrl") {
119
126
  return config.api.baseUrl;
@@ -133,6 +140,12 @@ export function getConfigValue(config, key) {
133
140
  if (key === "policy.scriptHardening") {
134
141
  return String(config.policy.scriptHardening);
135
142
  }
143
+ if (key === "policy.shimFailClosed") {
144
+ return String(config.policy.shimFailClosed);
145
+ }
146
+ if (key === "policy.strictEgress") {
147
+ return String(config.policy.strictEgress);
148
+ }
136
149
  if (key === "scriptGate.mode") {
137
150
  return config.scriptGate.mode;
138
151
  }
@@ -163,16 +176,20 @@ export function getConfigValue(config, key) {
163
176
  if (key === "cooldown.exempt") {
164
177
  return config.cooldown.exempt;
165
178
  }
166
- if (key === "audit.upload") {
167
- return String(config.audit.upload);
168
- }
169
- if (key === "telemetry.enabled") {
170
- return String(config.telemetry.enabled);
171
- }
172
- return String(config.webhooks.enabled);
179
+ return String(config.audit.upload);
173
180
  }
174
- export function listConfig(config) {
175
- return CONFIG_KEYS.map((key) => ({
181
+ export const ADVANCED_CONFIG_KEYS = new Set([
182
+ "org.id",
183
+ "policy.scriptHardening",
184
+ "policy.shimFailClosed",
185
+ "policy.strictEgress",
186
+ "scriptGate.observe",
187
+ "cooldown.npm.age",
188
+ "cooldown.pypi.age",
189
+ "cooldown.cargo.age"
190
+ ]);
191
+ export function listConfig(config, includeAdvanced = false) {
192
+ return CONFIG_KEYS.filter((key) => includeAdvanced || !ADVANCED_CONFIG_KEYS.has(key)).map((key) => ({
176
193
  key,
177
194
  value: getConfigValue(config, key)
178
195
  }));
@@ -212,6 +229,12 @@ export function setConfigValue(config, key, rawValue) {
212
229
  if (key === "policy.scriptHardening") {
213
230
  return withPolicyBoolean(config, "scriptHardening", rawValue);
214
231
  }
232
+ if (key === "policy.shimFailClosed") {
233
+ return withPolicyBoolean(config, "shimFailClosed", rawValue);
234
+ }
235
+ if (key === "policy.strictEgress") {
236
+ return withPolicyBoolean(config, "strictEgress", rawValue);
237
+ }
215
238
  if (key === "scriptGate.mode") {
216
239
  return {
217
240
  ...config,
@@ -266,26 +289,10 @@ export function setConfigValue(config, key, rawValue) {
266
289
  if (key === "cooldown.exempt") {
267
290
  return withCooldown(config, { exempt: parseCooldownExempt(rawValue) });
268
291
  }
269
- if (key === "audit.upload") {
270
- return {
271
- ...config,
272
- audit: {
273
- upload: parseBoolean(rawValue, key)
274
- }
275
- };
276
- }
277
- if (key === "telemetry.enabled") {
278
- return {
279
- ...config,
280
- telemetry: {
281
- enabled: parseBoolean(rawValue, key)
282
- }
283
- };
284
- }
285
292
  return {
286
293
  ...config,
287
- webhooks: {
288
- enabled: parseBoolean(rawValue, key)
294
+ audit: {
295
+ upload: parseBoolean(rawValue, key)
289
296
  }
290
297
  };
291
298
  }
@@ -309,8 +316,6 @@ function normalizeConfig(raw) {
309
316
  const gitHook = fieldObject(raw, "gitHook");
310
317
  const cooldown = fieldObject(raw, "cooldown");
311
318
  const audit = fieldObject(raw, "audit");
312
- const telemetry = fieldObject(raw, "telemetry");
313
- const webhooks = fieldObject(raw, "webhooks");
314
319
  const scriptHardening = fieldBoolean(policy, "policy.scriptHardening", "scriptHardening") ?? DEFAULT_CONFIG.policy.scriptHardening;
315
320
  return {
316
321
  version: 1,
@@ -324,7 +329,9 @@ function normalizeConfig(raw) {
324
329
  mode: parsePolicyMode(fieldString(policy, "policy.mode", "mode") ?? DEFAULT_CONFIG.policy.mode),
325
330
  trustProjectAllowlists: fieldBoolean(policy, "policy.trustProjectAllowlists", "trustProjectAllowlists") ?? DEFAULT_CONFIG.policy.trustProjectAllowlists,
326
331
  allowForceOverride: fieldBoolean(policy, "policy.allowForceOverride", "allowForceOverride") ?? DEFAULT_CONFIG.policy.allowForceOverride,
327
- scriptHardening
332
+ scriptHardening,
333
+ shimFailClosed: fieldBoolean(policy, "policy.shimFailClosed", "shimFailClosed") ?? DEFAULT_CONFIG.policy.shimFailClosed,
334
+ strictEgress: fieldBoolean(policy, "policy.strictEgress", "strictEgress") ?? DEFAULT_CONFIG.policy.strictEgress
328
335
  },
329
336
  scriptGate: {
330
337
  mode: parseScriptGateMode(fieldString(scriptGate, "scriptGate.mode", "mode") ?? (scriptHardening ? "enforce" : DEFAULT_CONFIG.scriptGate.mode)),
@@ -344,12 +351,6 @@ function normalizeConfig(raw) {
344
351
  },
345
352
  audit: {
346
353
  upload: fieldBoolean(audit, "audit.upload", "upload") ?? DEFAULT_CONFIG.audit.upload
347
- },
348
- telemetry: {
349
- enabled: fieldBoolean(telemetry, "telemetry.enabled", "enabled") ?? DEFAULT_CONFIG.telemetry.enabled
350
- },
351
- webhooks: {
352
- enabled: fieldBoolean(webhooks, "webhooks.enabled", "enabled") ?? DEFAULT_CONFIG.webhooks.enabled
353
354
  }
354
355
  };
355
356
  }
@@ -482,7 +483,7 @@ function parseBoolean(value, field) {
482
483
  }
483
484
  throw new ConfigError(`${field} must be true or false`);
484
485
  }
485
- function parseUrl(value) {
486
+ export function parseUrl(value) {
486
487
  const trimmed = value.trim();
487
488
  try {
488
489
  const url = new URL(trimmed);
@@ -1,7 +1,8 @@
1
- import { emitWebhookEvent, recordAuditEvent } from "../audit/events.js";
1
+ import { recordAuditEvent } from "../audit/events.js";
2
2
  import { loadUserConfig } from "../config/settings.js";
3
3
  import { promptText, promptYesNo } from "../install-ui/prompt.js";
4
- import { appendDecisions, saveDgFile } from "../project/dgfile.js";
4
+ import { dirname } from "node:path";
5
+ import { appendDecisions, mutateDgFile } from "../project/dgfile.js";
5
6
  import { promptLine as ttyPromptLine, promptYesNo as ttyPromptYesNo } from "../util/tty-prompt.js";
6
7
  import { findingFingerprint, packageKey } from "./apply.js";
7
8
  const defaultSyncPrompts = {
@@ -67,7 +68,7 @@ export function persistRemembered(file, packages, options) {
67
68
  reason: options.reason,
68
69
  acceptedBy: options.acceptedBy
69
70
  }));
70
- saveDgFile(appendDecisions(file, additions));
71
+ mutateDgFile(dirname(file.path), options.env, (current) => appendDecisions(current, additions));
71
72
  recordDecisionEvents("decision.accepted", packages.map((pkg) => `${pkg.ecosystem}:${packageKey(pkg.name, pkg.version)}`), options.reason, options.env);
72
73
  }
73
74
  export function recordDecisionEvents(type, packageNames, reason, env) {
@@ -86,12 +87,6 @@ export function recordDecisionEvents(type, packageNames, reason, env) {
86
87
  policyMode,
87
88
  createdAt: new Date().toISOString()
88
89
  };
89
- try {
90
- recordAuditEvent(event, env);
91
- emitWebhookEvent(event, env);
92
- }
93
- catch {
94
- // audit trail is best-effort; the decision write already succeeded
95
- }
90
+ recordAuditEvent(event, env);
96
91
  }
97
92
  }
@@ -0,0 +1,198 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useMemo, useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import chalk from "chalk";
5
+ import { existsSync, readdirSync } from "node:fs";
6
+ import { basename, join, resolve, sep } from "node:path";
7
+ import { exportDestinations, resolveExportPath, userHomeDir, writeReportAtomic } from "../util/report-writer.js";
8
+ export function loginRequiredToast() {
9
+ return `${chalk.yellow("Sign in to export:")} ${chalk.cyan.bold("dg login")} ${chalk.dim("(free account)")}`;
10
+ }
11
+ const PATH_DISPLAY_MAX = 48;
12
+ function cursorBeforeExtension(name) {
13
+ const slash = name.lastIndexOf("/");
14
+ const dot = name.indexOf(".", slash + 2);
15
+ return dot > slash + 1 ? dot : name.length;
16
+ }
17
+ function fieldFor(name) {
18
+ return { value: name, cursor: cursorBeforeExtension(name) };
19
+ }
20
+ function abbreviateHome(dir, home) {
21
+ if (dir === home)
22
+ return "~";
23
+ if (dir.startsWith(home + sep))
24
+ return `~${dir.slice(home.length)}`;
25
+ return dir;
26
+ }
27
+ function truncatePath(text) {
28
+ if (text.length <= PATH_DISPLAY_MAX)
29
+ return text;
30
+ return `…${text.slice(text.length - PATH_DISPLAY_MAX + 1)}`;
31
+ }
32
+ function prefillFor(dir, cwd, home, filename) {
33
+ if (resolve(dir) === resolve(cwd))
34
+ return filename;
35
+ if (dir === home)
36
+ return `~/${filename}`;
37
+ if (dir.startsWith(home + sep))
38
+ return `~${dir.slice(home.length)}/${filename}`;
39
+ return join(dir, filename);
40
+ }
41
+ function completePath(value, cwd) {
42
+ const slash = value.lastIndexOf("/");
43
+ const dir = slash < 0 ? "" : value.slice(0, slash + 1);
44
+ const fragment = slash < 0 ? value : value.slice(slash + 1);
45
+ let entries;
46
+ try {
47
+ entries = readdirSync(resolveExportPath(dir === "" ? "." : dir, cwd), { withFileTypes: true })
48
+ .map((entry) => ({ name: entry.name, isDir: entry.isDirectory() }));
49
+ }
50
+ catch {
51
+ return value;
52
+ }
53
+ const match = entries
54
+ .filter((entry) => entry.name.toLowerCase().startsWith(fragment.toLowerCase()))
55
+ .sort((a, b) => (a.isDir === b.isDir ? a.name.localeCompare(b.name) : a.isDir ? -1 : 1))[0];
56
+ if (!match)
57
+ return value;
58
+ return `${dir}${match.name}${match.isDir ? "/" : ""}`;
59
+ }
60
+ export const ExportDialog = ({ options, theme, cwd, onDone, env = process.env }) => {
61
+ const multi = options.length > 1;
62
+ const [stage, setStage] = useState(multi ? "format" : "dest");
63
+ const [selected, setSelected] = useState(0);
64
+ const [destIndex, setDestIndex] = useState(0);
65
+ const [field, setField] = useState({ value: "", cursor: 0 });
66
+ const home = useMemo(() => userHomeDir(env), [env]);
67
+ const destinations = useMemo(() => exportDestinations(cwd, env), [cwd, env]);
68
+ const option = options[selected];
69
+ const filename = basename(option?.defaultName ?? "");
70
+ const rowCount = destinations.length + 1;
71
+ const writeTo = (target) => {
72
+ if (!option)
73
+ return;
74
+ try {
75
+ writeReportAtomic(target, option.render());
76
+ onDone({ path: target });
77
+ }
78
+ catch (error) {
79
+ onDone({ error: error instanceof Error ? error.message : "write failed" });
80
+ }
81
+ };
82
+ const openEditor = (dest) => {
83
+ const prefill = dest ? prefillFor(dest.dir, cwd, home, filename) : filename;
84
+ setField(fieldFor(prefill));
85
+ setStage("path");
86
+ };
87
+ const insertAtCursor = (text) => {
88
+ setField((f) => ({ value: f.value.slice(0, f.cursor) + text + f.value.slice(f.cursor), cursor: f.cursor + text.length }));
89
+ };
90
+ const moveCursor = (to) => {
91
+ setField((f) => ({ ...f, cursor: Math.max(0, Math.min(f.value.length, to(f))) }));
92
+ };
93
+ useInput((input, key) => {
94
+ if (stage === "format") {
95
+ if (key.escape || input === "q") {
96
+ onDone(null);
97
+ return;
98
+ }
99
+ if (key.return) {
100
+ setStage("dest");
101
+ return;
102
+ }
103
+ if (key.upArrow || input === "k") {
104
+ setSelected((s) => Math.max(0, s - 1));
105
+ return;
106
+ }
107
+ if (key.downArrow || input === "j") {
108
+ setSelected((s) => Math.min(options.length - 1, s + 1));
109
+ return;
110
+ }
111
+ return;
112
+ }
113
+ if (stage === "dest") {
114
+ if (key.escape) {
115
+ if (multi)
116
+ setStage("format");
117
+ else
118
+ onDone(null);
119
+ return;
120
+ }
121
+ if (input === "t") {
122
+ openEditor(destinations[destIndex]);
123
+ return;
124
+ }
125
+ if (key.upArrow || input === "k") {
126
+ setDestIndex((d) => Math.max(0, d - 1));
127
+ return;
128
+ }
129
+ if (key.downArrow || input === "j") {
130
+ setDestIndex((d) => Math.min(rowCount - 1, d + 1));
131
+ return;
132
+ }
133
+ if (key.return) {
134
+ const dest = destinations[destIndex];
135
+ if (dest)
136
+ writeTo(join(dest.dir, filename));
137
+ else
138
+ openEditor(destinations[destIndex - 1] ?? destinations[0]);
139
+ }
140
+ return;
141
+ }
142
+ if (key.escape) {
143
+ setStage("dest");
144
+ return;
145
+ }
146
+ if (key.return) {
147
+ const trimmed = field.value.trim();
148
+ if (trimmed.length > 0)
149
+ writeTo(resolveExportPath(trimmed, cwd));
150
+ return;
151
+ }
152
+ if (key.tab) {
153
+ setField((f) => fieldFor(completePath(f.value, cwd)));
154
+ return;
155
+ }
156
+ if (key.leftArrow) {
157
+ moveCursor((f) => f.cursor - 1);
158
+ return;
159
+ }
160
+ if (key.rightArrow) {
161
+ moveCursor((f) => f.cursor + 1);
162
+ return;
163
+ }
164
+ if (key.ctrl && input === "a") {
165
+ moveCursor(() => 0);
166
+ return;
167
+ }
168
+ if (key.ctrl && input === "e") {
169
+ moveCursor((f) => f.value.length);
170
+ return;
171
+ }
172
+ if (key.delete || key.backspace) {
173
+ setField((f) => (f.cursor === 0 ? f : { value: f.value.slice(0, f.cursor - 1) + f.value.slice(f.cursor), cursor: f.cursor - 1 }));
174
+ return;
175
+ }
176
+ if (input && !key.ctrl && !key.meta && /^[\x20-\x7e]+$/.test(input)) {
177
+ insertAtCursor(input);
178
+ }
179
+ });
180
+ const target = field.value.trim().length > 0 ? resolveExportPath(field.value, cwd) : null;
181
+ const targetExists = target !== null && existsSync(target);
182
+ const beforeCursor = field.value.slice(0, field.cursor);
183
+ const atCursor = field.value.charAt(field.cursor);
184
+ const afterCursor = field.value.slice(field.cursor + 1);
185
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingLeft: 2, paddingRight: 2, children: [_jsx(Text, { bold: true, children: "Export" }), _jsx(Text, { children: "" }), stage === "format" && options.map((opt, idx) => {
186
+ const isSelected = idx === selected;
187
+ return (_jsxs(Box, { children: [_jsx(Box, { width: 5, flexShrink: 0, children: _jsxs(Text, { children: [" ", isSelected ? chalk.cyan("●") : chalk.dim("○")] }) }), _jsx(Box, { flexShrink: 1, children: _jsx(Text, { wrap: "truncate-end", children: isSelected ? chalk.bold(opt.label) : opt.label }) })] }, opt.label));
188
+ }), stage === "dest" && (_jsxs(_Fragment, { children: [destinations.map((dest, idx) => {
189
+ const isSelected = idx === destIndex;
190
+ const replaces = existsSync(join(dest.dir, filename));
191
+ const shown = truncatePath(`${abbreviateHome(dest.dir, home)}/${filename}`.replace(/\/+/g, "/"));
192
+ return (_jsxs(Box, { children: [_jsx(Box, { width: 3, flexShrink: 0, children: _jsxs(Text, { children: [" ", isSelected ? chalk.cyan("▸") : " "] }) }), _jsx(Box, { width: 14, flexShrink: 0, children: _jsx(Text, { children: isSelected ? chalk.bold(dest.label) : dest.label }) }), _jsx(Box, { flexShrink: 1, children: _jsxs(Text, { wrap: "truncate-start", children: [theme.paint("muted", shown), replaces ? theme.paint("warn", " (replaces)") : ""] }) })] }, dest.label));
193
+ }), _jsxs(Box, { children: [_jsx(Box, { width: 3, flexShrink: 0, children: _jsxs(Text, { children: [" ", destIndex === destinations.length ? chalk.cyan("▸") : " "] }) }), _jsx(Box, { flexShrink: 1, children: _jsx(Text, { children: destIndex === destinations.length ? chalk.bold("Type a path…") : theme.paint("muted", "Type a path…") }) })] })] })), stage === "path" && (_jsxs(_Fragment, { children: [option && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: option.label }), _jsxs(Text, { wrap: "truncate-start", children: [chalk.cyan("▌ "), chalk.bold("Save as"), " ", beforeCursor, atCursor === "" ? chalk.cyan("█") : chalk.inverse(atCursor) + afterCursor] }), _jsx(Text, { wrap: "truncate-start", children: target === null
194
+ ? theme.paint("muted", "enter a file name")
195
+ : targetExists
196
+ ? theme.paint("warn", `${target} exists — Enter overwrites`)
197
+ : theme.paint("muted", `→ ${target}`) })] }))] }), _jsxs(Text, { children: [" ", stage === "format" ? (_jsxs(_Fragment, { children: [chalk.bold.cyan("↑↓"), " ", chalk.dim("choose"), " ", chalk.bold.cyan("⏎"), " ", chalk.dim("next"), " ", chalk.bold.cyan("Esc"), " ", chalk.dim("cancel")] })) : stage === "dest" ? (_jsxs(_Fragment, { children: [chalk.bold.cyan("↑↓"), " ", chalk.dim("choose"), " ", chalk.bold.cyan("⏎"), " ", chalk.dim(`save ${filename}`), " ", chalk.bold.cyan("t"), " ", chalk.dim("type path"), " ", chalk.bold.cyan("Esc"), " ", chalk.dim(multi ? "back" : "cancel")] })) : (_jsxs(_Fragment, { children: [chalk.bold.cyan("⏎"), " ", chalk.dim("save"), " ", chalk.bold.cyan("Tab"), " ", chalk.dim("complete"), " ", chalk.bold.cyan("Esc"), " ", chalk.dim("back")] }))] })] }));
198
+ };
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import InkSpinner from "ink-spinner";
4
4
  import { formatResetDate } from "./block-render.js";
5
+ import { sanitizeLine } from "../security/sanitize.js";
5
6
  function packageCount(n) {
6
7
  return n === 1 ? "1 package" : `${n} packages`;
7
8
  }
@@ -13,9 +14,9 @@ export const LiveInstall = ({ view }) => {
13
14
  if (view.phase === "scanning") {
14
15
  return (_jsxs(Box, { paddingX: 1, children: [_jsx(Text, { color: "cyan", children: _jsx(InkSpinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", view.total === 0
15
16
  ? "DG starting protection…"
16
- : view.resolvedTotal !== undefined
17
- ? `DG verifying ${view.total}/${Math.max(view.total, view.resolvedTotal)}…`
18
- : `DG verifying ${packageCount(view.total)}…`] }), view.current ? _jsxs(Text, { dimColor: true, children: [" ", view.current] }) : null] }));
17
+ : view.resolvedTotal !== undefined && view.total <= view.resolvedTotal
18
+ ? `DG verifying ${view.total}/${view.resolvedTotal}…`
19
+ : `DG verifying ${packageCount(view.total)}…`] }), view.current ? _jsxs(Text, { dimColor: true, children: [" ", sanitizeLine(view.current)] }) : null] }));
19
20
  }
20
21
  if (view.total === 0 && !view.blocked) {
21
22
  return null;
@@ -30,7 +31,7 @@ export const LiveInstall = ({ view }) => {
30
31
  }
31
32
  const total = view.verified + view.flagged;
32
33
  if (view.flagged > 0) {
33
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { color: "yellow", children: ["\u26A0 DG verified ", packageCount(total), " \u2014 ", view.flagged, " flagged"] }), (view.flaggedItems ?? []).map((item, index) => (_jsxs(Text, { dimColor: true, children: [" ", item.packageName, " ", item.reason] }, `${item.packageName}-${index}`)))] }));
34
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { color: "yellow", children: ["\u26A0 DG verified ", packageCount(total), " \u2014 ", view.flagged, " flagged"] }), (view.flaggedItems ?? []).map((item, index) => (_jsxs(Text, { dimColor: true, children: [" ", sanitizeLine(item.packageName), " ", sanitizeLine(item.reason)] }, `${item.packageName}-${index}`)))] }));
34
35
  }
35
36
  return (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: "green", children: ["\u2713 DG verified ", packageCount(total), " \u2014 clean"] }) }));
36
37
  };
@@ -1,4 +1,5 @@
1
1
  import { formatCooldownDuration, formatPackageAge } from "../policy/cooldown.js";
2
+ import { sanitizeLine } from "../security/sanitize.js";
2
3
  const VERIFIED_BAD = new Set([
3
4
  "malware",
4
5
  "policy",
@@ -32,7 +33,7 @@ const NEXT_STEP = {
32
33
  "private-upload-disabled": "Enable private artifact scanning to verify this package.",
33
34
  "needs-login": "Run 'dg login' (free) to check packages from the registry before they install.",
34
35
  "quota-exceeded": "Upgrade your plan or wait for your monthly limit to reset. See westbayberry.com/pricing.",
35
- cooldown: "Wait for the cooldown, pin an older version, or exempt it: dg config set cooldown.exempt <name>"
36
+ cooldown: "Wait it out (see holds: dg cooldown), pin an older version, or exempt it: dg cooldown exempt <name>"
36
37
  };
37
38
  function cooldownDetailLine(cooldown) {
38
39
  const window = formatCooldownDuration(cooldown.requiredDays);
@@ -53,9 +54,9 @@ export function describeBlockedInstall(decision) {
53
54
  : "Re-check later with 'dg verify', or override if you accept the risk.";
54
55
  return {
55
56
  kind: verifiedBad ? "blocked" : "unverified",
56
- packageName: decision.packageName,
57
+ packageName: sanitizeLine(decision.packageName),
57
58
  headline: HEADLINES[decision.cause],
58
- reason: decision.reason,
59
+ reason: sanitizeLine(decision.reason),
59
60
  cause: decision.cause,
60
61
  ...(decision.resetsAt ? { resetsAt: decision.resetsAt } : {}),
61
62
  ...(nextStep ? { nextStep } : {}),
@@ -63,27 +64,30 @@ export function describeBlockedInstall(decision) {
63
64
  };
64
65
  }
65
66
  export function describeFlaggedWarn(decision) {
67
+ const packageName = sanitizeLine(decision.packageName);
66
68
  if (decision.cause === "quota-exceeded") {
67
- return { packageName: decision.packageName, reason: "installed unverified (over quota)" };
69
+ return { packageName, reason: "installed unverified (over quota)" };
68
70
  }
69
71
  if (decision.forceOverride?.allowed) {
70
- return { packageName: decision.packageName, reason: "installed despite block (--dg-force-install)" };
72
+ return { packageName, reason: "installed despite block (--dg-force-install)" };
71
73
  }
72
- return { packageName: decision.packageName, reason: decision.reason };
74
+ return { packageName, reason: sanitizeLine(decision.reason) };
73
75
  }
74
76
  export function renderInstallDecision(decision) {
77
+ const packageName = sanitizeLine(decision.packageName);
78
+ const reason = sanitizeLine(decision.reason);
75
79
  if (decision.action === "pass") {
76
- return `✓ DG verified ${decision.packageName} — clean\n`;
80
+ return `✓ DG verified ${packageName} — clean\n`;
77
81
  }
78
82
  if (decision.action === "warn") {
79
83
  if (decision.cause === "quota-exceeded") {
80
84
  const reset = formatResetDate(decision.resetsAt);
81
- return `⚠ Over quota — installed ${decision.packageName} unverified${reset ? ` (resets ${reset})` : ""}\n`;
85
+ return `⚠ Over quota — installed ${packageName} unverified${reset ? ` (resets ${reset})` : ""}\n`;
82
86
  }
83
87
  if (decision.forceOverride?.allowed) {
84
- return `⚠ DG override — installing ${decision.packageName} despite block (--dg-force-install)\n`;
88
+ return `⚠ DG override — installing ${packageName} despite block (--dg-force-install)\n`;
85
89
  }
86
- return `⚠ DG flagged ${decision.packageName} (warn) — ${decision.reason}\n`;
90
+ return `⚠ DG flagged ${packageName} (warn) — ${reason}\n`;
87
91
  }
88
92
  if (decision.cause === "quota-exceeded") {
89
93
  const reset = formatResetDate(decision.resetsAt);
@@ -98,15 +102,15 @@ export function renderInstallDecision(decision) {
98
102
  verifiedBad
99
103
  ? `✘ DG blocked install — ${headline}`
100
104
  : decision.cause === "cooldown"
101
- ? `? DG quarantined ${decision.packageName} — ${headline}`
102
- : `? DG could not verify ${decision.packageName} — ${headline}`,
103
- ` ${decision.packageName} ${decision.reason}`
105
+ ? `? DG quarantined ${packageName} — ${headline}`
106
+ : `? DG could not verify ${packageName} — ${headline}`,
107
+ ` ${packageName} ${reason}`
104
108
  ];
105
109
  if (decision.cause === "cooldown" && decision.cooldown) {
106
110
  lines.push(` ${cooldownDetailLine(decision.cooldown)}`);
107
111
  }
108
112
  if (decision.dashboardUrl) {
109
- lines.push(` Evidence: ${decision.dashboardUrl}`);
113
+ lines.push(` Evidence: ${sanitizeLine(decision.dashboardUrl)}`);
110
114
  }
111
115
  if (decision.unauthenticated && decision.cause !== "needs-login") {
112
116
  lines.push(" Auth: local policy only (run 'dg login' for full coverage)");