@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
@@ -1,8 +1,9 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
3
- import { isAbsolute, join, resolve, sep } from "node:path";
2
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
4
4
  import { randomBytes } from "node:crypto";
5
5
  import { acquireLockSync, CLEANUP_REGISTRY_LOCK, resolveDgPaths } from "../state/index.js";
6
+ import { resolveToolPath } from "../util/external-tool.js";
6
7
  import { gitTrimmed } from "../util/git.js";
7
8
  import { GUARD_HOOK_SENTINEL, SETUP_UNINSTALL_LOCK, SETUP_UNINSTALL_LOCK_STALE_MS, chainedHookOriginal, guardHookScript, mergeRegistry, readRegistry, reverseGitHookEntry, writeRegistry } from "./plan.js";
8
9
  export { GUARD_HOOK_SENTINEL } from "./plan.js";
@@ -84,7 +85,40 @@ function registryOriginal(context) {
84
85
  const entry = readRegistry(context.paths).registry.entries.find((candidate) => candidate.kind === "git-hook" && candidate.owner === "dg" && candidate.path === context.hookTarget);
85
86
  return entry?.original ?? null;
86
87
  }
88
+ function isSymlinkPath(path) {
89
+ try {
90
+ return lstatSync(path).isSymbolicLink();
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ }
96
+ function writeHookAtomic(target, content) {
97
+ const tmp = join(dirname(target), `.pre-commit.dg-${randomBytes(6).toString("hex")}.tmp`);
98
+ writeFileSync(tmp, content, { encoding: "utf8", mode: 0o755, flag: "wx" });
99
+ try {
100
+ renameSync(tmp, target);
101
+ }
102
+ catch (error) {
103
+ rmSync(tmp, { force: true });
104
+ throw error;
105
+ }
106
+ }
87
107
  export function applyGitHook(context, now = new Date()) {
108
+ if (gitHookState(context) !== "foreign" && isSymlinkPath(context.hookTarget)) {
109
+ return {
110
+ hookTarget: context.hookTarget,
111
+ chainedOriginal: null,
112
+ checks: [
113
+ {
114
+ name: "hook-writable",
115
+ ok: false,
116
+ detail: `${context.hookTarget} is a symlink; dg refuses to replace it — remove the link or point it at a real file, then re-run dg guard-commit`
117
+ }
118
+ ],
119
+ active: false
120
+ };
121
+ }
88
122
  const lock = acquireLockSync(context.paths, SETUP_UNINSTALL_LOCK, { staleMs: SETUP_UNINSTALL_LOCK_STALE_MS });
89
123
  let chainedOriginal = null;
90
124
  try {
@@ -98,7 +132,7 @@ export function applyGitHook(context, now = new Date()) {
98
132
  else if (state === "managed") {
99
133
  chainedOriginal = chainedHookOriginal(hookText(context.hookTarget)) ?? registryOriginal(context);
100
134
  }
101
- writeFileSync(context.hookTarget, guardHookScript(context.dgPath, chainedOriginal), { encoding: "utf8", mode: 0o755 });
135
+ writeHookAtomic(context.hookTarget, guardHookScript(context.dgPath, chainedOriginal));
102
136
  chmodSync(context.hookTarget, 0o755);
103
137
  const entry = {
104
138
  kind: "git-hook",
@@ -176,7 +210,11 @@ function runSelfTest(context) {
176
210
  if (!existsSync(context.hookTarget) || !isManaged(context.hookTarget)) {
177
211
  return { ok: false, detail: "no dg hook to exercise" };
178
212
  }
179
- const result = spawnSync("sh", [context.hookTarget], {
213
+ const sh = resolveToolPath("sh", context.env);
214
+ if (!sh) {
215
+ return { ok: false, detail: "sh not found on PATH; hook not exercised" };
216
+ }
217
+ const result = spawnSync(sh, [context.hookTarget], {
180
218
  cwd: context.root,
181
219
  env: { ...context.env, [GUARD_SELFTEST_ENV]: "1" },
182
220
  encoding: "utf8",
@@ -219,6 +257,13 @@ export function gitHookStatusState(options = {}) {
219
257
  const installedHere = registry.entries.some((entry) => entry.kind === "git-hook" && entry.owner === "dg" && isUnderRoot(entry.path, context.root));
220
258
  return installedHere ? "dead" : "off";
221
259
  }
260
+ export function commitGuardOffer(options = {}) {
261
+ const resolved = resolveGitRepo(options);
262
+ if ("error" in resolved) {
263
+ return null;
264
+ }
265
+ return gitHookStatusState(options) === "off" ? resolved : null;
266
+ }
222
267
  export function removeGitHookForRepo(context) {
223
268
  const lock = acquireLockSync(context.paths, SETUP_UNINSTALL_LOCK, { staleMs: SETUP_UNINSTALL_LOCK_STALE_MS });
224
269
  const removed = [];
@@ -1,7 +1,10 @@
1
- import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
1
+ import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
3
3
  import { chmodSync } from "node:fs";
4
+ import { randomBytes } from "node:crypto";
5
+ import { homedir } from "node:os";
4
6
  import { createTheme } from "../presentation/theme.js";
7
+ import { reverseAgentHookEntry } from "../agents/registry.js";
5
8
  import { acquireLockSync, findStaleSessionsSync, preserveCorruptCleanupRegistrySync, resolveDgPaths, sweepStaleSessionsSync, CLEANUP_REGISTRY_LOCK } from "../state/index.js";
6
9
  import { currentNodeVersion, isSupportedNode } from "../runtime/node-version.js";
7
10
  import { dgVersion } from "../commands/version.js";
@@ -28,6 +31,7 @@ const LEGACY_RC_CANDIDATES = [".zshrc", ".bashrc", ".bash_profile", ".profile",
28
31
  const LEGACY_PYTHON_HOOK_PY = "dg_pip_hook.py";
29
32
  const LEGACY_PYTHON_HOOK_PTH = "dg_pip_hook.pth";
30
33
  const LEGACY_PYTHON_HOOK_MARKER = "Dependency Guardian pip-install interceptor";
34
+ const LEGACY_PYTHON_HOOK_PTH_MARKER = "import dg_pip_hook";
31
35
  export const SETUP_UNINSTALL_LOCK = "setup-uninstall";
32
36
  export const SETUP_UNINSTALL_LOCK_STALE_MS = 30 * 60 * 1000;
33
37
  export const STALE_SESSION_OLDER_THAN_MS = 24 * 60 * 60 * 1000;
@@ -44,7 +48,6 @@ const DOCTOR_GROUP_BY_NAME = {
44
48
  config: "setup",
45
49
  policy: "setup",
46
50
  "script-gate": "setup",
47
- telemetry: "setup",
48
51
  shims: "setup",
49
52
  "shell-rc": "setup",
50
53
  "python-hook-drift": "setup",
@@ -64,7 +67,6 @@ const DOCTOR_FIX_BY_NAME = {
64
67
  "cleanup-registry-stale-entries": "re-run dg setup to refresh",
65
68
  config: "fix or remove ~/.dg, then re-run dg setup",
66
69
  policy: "fix ~/.dg config",
67
- telemetry: "fix ~/.dg config",
68
70
  shims: "dg setup",
69
71
  "shell-rc": "dg setup",
70
72
  "python-hook-drift": "dg uninstall, or re-run dg setup, to remove the stale pip hook",
@@ -156,7 +158,7 @@ export function applySetupPlan(plan, now = new Date()) {
156
158
  recursive: true,
157
159
  mode: 0o700
158
160
  });
159
- writeFileSync(plan.rcPath, withRcBlock(readText(plan.rcPath), plan), "utf8");
161
+ writeRcFileAtomic(plan.rcPath, withRcBlock(readText(plan.rcPath), plan));
160
162
  entries.push(cleanupEntry("rc", plan.rcPath, "mode1", now, RC_SENTINEL));
161
163
  sweepLegacyPythonHooks(plan.paths.homeDir, [], []);
162
164
  const registry = withRegistryLock(plan.paths, () => {
@@ -213,6 +215,9 @@ export function uninstallSetup(options) {
213
215
  else if (entry.kind === "git-hook") {
214
216
  reverseGitHookEntry(entry, removed, missing, warnings);
215
217
  }
218
+ else if (entry.kind === "agent-hook") {
219
+ reverseAgentHookEntry(entry, removed, missing, warnings);
220
+ }
216
221
  }
217
222
  sweepLegacyRcBlocks(paths.homeDir, removed, warnings);
218
223
  sweepLegacyPythonHooks(paths.homeDir, removed, warnings);
@@ -278,7 +283,6 @@ export function doctorReport(options = {}) {
278
283
  checks.push(authCheck(env));
279
284
  checks.push(policyCheck(env));
280
285
  checks.push(scriptGateCheck(env));
281
- checks.push(telemetryCheck(env));
282
286
  const missingShims = SHIM_COMMANDS.filter((command) => !validShim(join(shimDir, command), command));
283
287
  checks.push({
284
288
  name: "shims",
@@ -568,6 +572,9 @@ function shellRcPath(homeDir, shell) {
568
572
  }
569
573
  return join(homeDir, ".zshrc");
570
574
  }
575
+ export function tildifyPath(path, home = homedir()) {
576
+ return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
577
+ }
571
578
  export function activationCommand(shell, rcDisplay) {
572
579
  if (shell === "fish") {
573
580
  return `source ${rcDisplay}`;
@@ -647,6 +654,26 @@ function fishRcBlock(shimDir) {
647
654
  function stripRcBlock(existing) {
648
655
  return stripRcBlockDetailed(existing).content;
649
656
  }
657
+ function writeRcFileAtomic(rcPath, content) {
658
+ let target = rcPath;
659
+ let mode = 0o644;
660
+ try {
661
+ target = realpathSync(rcPath);
662
+ mode = statSync(target).mode & 0o7777;
663
+ }
664
+ catch {
665
+ // no existing file: write a fresh rc at the requested path
666
+ }
667
+ const tempPath = join(dirname(target), `.${basename(target)}.dg-${randomBytes(6).toString("hex")}.tmp`);
668
+ writeFileSync(tempPath, content, { encoding: "utf8", mode, flag: "wx" });
669
+ try {
670
+ renameSync(tempPath, target);
671
+ }
672
+ catch (error) {
673
+ rmSync(tempPath, { force: true });
674
+ throw error;
675
+ }
676
+ }
650
677
  // An unterminated begin marker must never strip to EOF: only lines verifiably
651
678
  // written by dg are removed, so user content below a stale marker survives.
652
679
  function stripRcBlockDetailed(existing) {
@@ -696,7 +723,7 @@ function sweepLegacyRcBlocks(homeDir, removed, warnings) {
696
723
  continue;
697
724
  }
698
725
  try {
699
- writeFileSync(rcPath, next, "utf8");
726
+ writeRcFileAtomic(rcPath, next);
700
727
  removed.push(`${rcPath} (legacy dg block)`);
701
728
  }
702
729
  catch (error) {
@@ -704,19 +731,23 @@ function sweepLegacyRcBlocks(homeDir, removed, warnings) {
704
731
  }
705
732
  }
706
733
  }
734
+ function isLegacyPthHook(pthPath) {
735
+ const content = readText(pthPath);
736
+ return content.includes(LEGACY_PYTHON_HOOK_PTH_MARKER) || content.includes(LEGACY_PYTHON_HOOK_MARKER);
737
+ }
707
738
  export function legacyPythonHookSites(homeDir) {
708
- return candidateSitePackagesDirs(homeDir).filter((dir) => existsSync(join(dir, LEGACY_PYTHON_HOOK_PTH)) || readText(join(dir, LEGACY_PYTHON_HOOK_PY)).includes(LEGACY_PYTHON_HOOK_MARKER));
739
+ return candidateSitePackagesDirs(homeDir).filter((dir) => isLegacyPthHook(join(dir, LEGACY_PYTHON_HOOK_PTH)) || readText(join(dir, LEGACY_PYTHON_HOOK_PY)).includes(LEGACY_PYTHON_HOOK_MARKER));
709
740
  }
710
741
  export function sweepLegacyPythonHooks(homeDir, removed, warnings) {
711
742
  for (const dir of candidateSitePackagesDirs(homeDir)) {
712
743
  const pyPath = join(dir, LEGACY_PYTHON_HOOK_PY);
713
744
  const pthPath = join(dir, LEGACY_PYTHON_HOOK_PTH);
714
745
  const pyIsHook = readText(pyPath).includes(LEGACY_PYTHON_HOOK_MARKER);
715
- const pthPresent = existsSync(pthPath);
716
- if (!pyIsHook && !pthPresent) {
746
+ const pthIsHook = isLegacyPthHook(pthPath);
747
+ if (!pyIsHook && !pthIsHook) {
717
748
  continue;
718
749
  }
719
- if (pthPresent) {
750
+ if (pthIsHook) {
720
751
  removePythonHookFile(pthPath, removed, warnings);
721
752
  }
722
753
  if (pyIsHook) {
@@ -873,7 +904,7 @@ function dgPathCheck(env) {
873
904
  };
874
905
  }
875
906
  function currentDgBinaryPath(env) {
876
- const explicit = env.DG_TEST_CURRENT_DG_PATH;
907
+ const explicit = env.NODE_ENV === "test" ? env.DG_TEST_CURRENT_DG_PATH : undefined;
877
908
  if (explicit) {
878
909
  return explicit;
879
910
  }
@@ -1136,34 +1167,24 @@ function scriptGateCheck(env) {
1136
1167
  };
1137
1168
  }
1138
1169
  }
1139
- function telemetryCheck(env) {
1140
- try {
1141
- const config = loadUserConfig(env);
1142
- return {
1143
- name: "telemetry",
1144
- status: "pass",
1145
- message: config.telemetry.enabled ? "Telemetry is enabled with minimized event fields" : "Telemetry is disabled"
1146
- };
1147
- }
1148
- catch (error) {
1149
- return {
1150
- name: "telemetry",
1151
- status: "fail",
1152
- message: error instanceof ConfigError ? error.message : "Unable to read telemetry config"
1153
- };
1154
- }
1155
- }
1156
1170
  export function writeRegistry(paths, registry) {
1157
1171
  mkdirSync(dirname(paths.cleanupRegistryPath), {
1158
1172
  recursive: true,
1159
1173
  mode: 0o700
1160
1174
  });
1161
- const tempPath = `${paths.cleanupRegistryPath}.${process.pid}.tmp`;
1175
+ const tempPath = `${paths.cleanupRegistryPath}.${randomBytes(6).toString("hex")}.tmp`;
1162
1176
  writeFileSync(tempPath, `${JSON.stringify(registry, null, 2)}\n`, {
1163
1177
  encoding: "utf8",
1164
- mode: 0o600
1178
+ mode: 0o600,
1179
+ flag: "wx"
1165
1180
  });
1166
- renameSync(tempPath, paths.cleanupRegistryPath);
1181
+ try {
1182
+ renameSync(tempPath, paths.cleanupRegistryPath);
1183
+ }
1184
+ catch (error) {
1185
+ rmSync(tempPath, { force: true });
1186
+ throw error;
1187
+ }
1167
1188
  }
1168
1189
  function withRegistryLock(paths, action) {
1169
1190
  const lock = acquireLockSync(paths, CLEANUP_REGISTRY_LOCK, {
@@ -1204,7 +1225,13 @@ function removeRcBlock(entry, removed, missing, warnings) {
1204
1225
  return;
1205
1226
  }
1206
1227
  const stripped = stripRcBlockDetailed(existing);
1207
- writeFileSync(entry.path, stripped.content, "utf8");
1228
+ try {
1229
+ writeRcFileAtomic(entry.path, stripped.content);
1230
+ }
1231
+ catch (error) {
1232
+ warnings.push(`could not rewrite shell rc ${entry.path}: ${error instanceof Error ? error.message : "write error"}`);
1233
+ return;
1234
+ }
1208
1235
  if (stripped.repairedLines.length > 0) {
1209
1236
  warnings.push(`repaired an unterminated dg block in ${entry.path}: removed only dg-written lines (${stripped.repairedLines.join(", ")})`);
1210
1237
  }
@@ -1214,28 +1241,35 @@ function removeRcBlock(entry, removed, missing, warnings) {
1214
1241
  // a one-line notice and lets the commit (and any chained hook) proceed instead
1215
1242
  // of blocking every commit with exit 127.
1216
1243
  export function guardHookScript(dgPath, chainedOriginal) {
1244
+ const dg = escapeDoubleQuotedSh(dgPath);
1217
1245
  const lines = [
1218
1246
  "#!/bin/sh",
1219
1247
  `# ${GUARD_HOOK_SENTINEL}`,
1220
- `if command -v "${dgPath}" >/dev/null 2>&1; then`,
1221
- ` "${dgPath}" scan --staged --hook || exit $?`,
1248
+ `if command -v "${dg}" >/dev/null 2>&1; then`,
1249
+ ` "${dg}" scan --staged --hook || exit $?`,
1222
1250
  "else",
1223
- ` echo "dg: pre-commit scan skipped (dg not runnable at ${dgPath}); commit allowed" >&2`,
1251
+ ` echo "dg: pre-commit scan skipped (dg not runnable at ${dg}); commit allowed" >&2`,
1224
1252
  "fi"
1225
1253
  ];
1226
1254
  if (chainedOriginal) {
1227
- lines.push(`[ -x "${chainedOriginal}" ] && exec "${chainedOriginal}" "$@"`);
1255
+ const chained = escapeDoubleQuotedSh(chainedOriginal);
1256
+ lines.push(`[ -x "${chained}" ] && exec "${chained}" "$@"`);
1228
1257
  }
1229
1258
  lines.push("exit 0");
1230
1259
  return `${lines.join("\n")}\n`;
1231
1260
  }
1261
+ function unescapeDoubleQuotedSh(value) {
1262
+ return value.replace(/\\([\\"$`])/g, "$1");
1263
+ }
1232
1264
  export function chainedHookOriginal(content) {
1233
- return content.match(/^\[ -x "(.+)" \] && exec "\1" "\$@"$/m)?.[1] ?? null;
1265
+ const matched = content.match(/^\[ -x "(.+)" \] && exec "\1" "\$@"$/m)?.[1];
1266
+ return matched ? unescapeDoubleQuotedSh(matched) : null;
1234
1267
  }
1235
1268
  export function guardHookDgPath(content) {
1236
- return (content.match(/^if command -v "(.+)" >\/dev\/null 2>&1; then$/m)?.[1] ??
1269
+ const matched = content.match(/^if command -v "(.+)" >\/dev\/null 2>&1; then$/m)?.[1] ??
1237
1270
  content.match(/^"(.+)" scan --staged --hook/m)?.[1] ??
1238
- null);
1271
+ null;
1272
+ return matched ? unescapeDoubleQuotedSh(matched) : null;
1239
1273
  }
1240
1274
  function commitGuardCheck(registry, env) {
1241
1275
  const hooks = registry.entries.filter((entry) => entry.owner === "dg" && entry.kind === "git-hook");
@@ -0,0 +1,39 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { isCiEnv } from "../presentation/mode.js";
4
+ import { resolveDgPaths } from "../state/index.js";
5
+ export function setupApplied(env = process.env) {
6
+ return existsSync(join(resolveDgPaths(env).homeDir, ".dg", "shims", "npm"));
7
+ }
8
+ export function securityNotesMarkerPath(env = process.env) {
9
+ return join(resolveDgPaths(env).stateDir, "security-notes-shown");
10
+ }
11
+ export function wizardSkippedMarkerPath(env = process.env) {
12
+ return join(resolveDgPaths(env).stateDir, "setup-wizard-skipped");
13
+ }
14
+ export function securityNotesShown(env = process.env) {
15
+ return existsSync(securityNotesMarkerPath(env));
16
+ }
17
+ export function markSecurityNotesShown(env = process.env, now = new Date()) {
18
+ writeMarker(securityNotesMarkerPath(env), now);
19
+ }
20
+ export function markWizardSkipped(env = process.env, now = new Date()) {
21
+ writeMarker(wizardSkippedMarkerPath(env), now);
22
+ }
23
+ export function shouldOfferSetupWizard(env = process.env, stdin = process.stdin, stderr = process.stderr) {
24
+ return (process.platform !== "win32" &&
25
+ Boolean(stdin.isTTY) &&
26
+ Boolean(stderr.isTTY) &&
27
+ !isCiEnv(env) &&
28
+ !setupApplied(env) &&
29
+ !existsSync(wizardSkippedMarkerPath(env)));
30
+ }
31
+ function writeMarker(path, now) {
32
+ try {
33
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
34
+ writeFileSync(path, `${now.toISOString()}\n`, { encoding: "utf8", mode: 0o600 });
35
+ }
36
+ catch {
37
+ return;
38
+ }
39
+ }
@@ -0,0 +1,42 @@
1
+ import { resolvePresentation } from "../presentation/mode.js";
2
+ import { MACHINE_OUTPUT_FLAGS } from "../runtime/first-run.js";
3
+ import { buildSetupPlan } from "../setup/plan.js";
4
+ import { shouldOfferSetupWizard } from "./gate.js";
5
+ const SKIP_COMMANDS = new Set([
6
+ "help",
7
+ "--help",
8
+ "-h",
9
+ "--help-all",
10
+ "help-all",
11
+ "version",
12
+ "--version",
13
+ "-v",
14
+ "login",
15
+ "logout",
16
+ "setup",
17
+ "update",
18
+ "upgrade",
19
+ "uninstall"
20
+ ]);
21
+ const NOT_RUN = { handled: false, result: { exitCode: 0, stdout: "", stderr: "" } };
22
+ export async function maybeOfferSetupWizard(args, options = {}) {
23
+ const env = options.env ?? process.env;
24
+ const command = args[0] ?? "";
25
+ if (command && SKIP_COMMANDS.has(command)) {
26
+ return NOT_RUN;
27
+ }
28
+ if (args.some((arg) => MACHINE_OUTPUT_FLAGS.has(arg))) {
29
+ return NOT_RUN;
30
+ }
31
+ if (!shouldOfferSetupWizard(env, options.stdin ?? process.stdin, options.stderr ?? process.stderr)) {
32
+ return NOT_RUN;
33
+ }
34
+ const rich = options.richMode ?? resolvePresentation().mode === "rich";
35
+ if (!rich) {
36
+ return NOT_RUN;
37
+ }
38
+ const plan = buildSetupPlan({ shell: "auto" });
39
+ const runWizard = options.runWizard ?? (await import("./wizard.js")).runSetupWizard;
40
+ const result = await runWizard(plan, { env, autoActivate: args.length === 0 });
41
+ return { handled: args.length === 0, result };
42
+ }
@@ -0,0 +1,27 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ export const Selector = ({ options, onSelect, onCancel }) => {
5
+ const [cursor, setCursor] = useState(0);
6
+ useInput((input, key) => {
7
+ if (key.upArrow || input === "k") {
8
+ setCursor((c) => Math.max(0, c - 1));
9
+ }
10
+ else if (key.downArrow || input === "j") {
11
+ setCursor((c) => Math.min(options.length - 1, c + 1));
12
+ }
13
+ else if (key.return) {
14
+ onSelect(cursor);
15
+ }
16
+ else if (key.escape) {
17
+ onCancel();
18
+ }
19
+ else if (/^[1-9]$/.test(input)) {
20
+ const index = Number(input) - 1;
21
+ if (index < options.length) {
22
+ onSelect(index);
23
+ }
24
+ }
25
+ });
26
+ return (_jsxs(Box, { flexDirection: "column", children: [options.map((option, i) => (_jsxs(Text, { ...(i === cursor ? { color: "cyan" } : {}), children: [i === cursor ? "❯" : " ", " ", i + 1, ". ", option.label] }, i))), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "Enter to confirm \u00B7 arrows or number keys to choose \u00B7 Esc to skip" })] }));
27
+ };
@@ -0,0 +1,56 @@
1
+ import { LockBusyError } from "../state/index.js";
2
+ import { applyGitHook } from "../setup/git-hook.js";
3
+ import { applySetupPlanWithLock } from "../setup/plan.js";
4
+ import { applyAgentHook } from "../agents/registry.js";
5
+ export function buildWizardTasks(plan, agents) {
6
+ const tasks = [
7
+ {
8
+ label: "shell installs route through dg",
9
+ run: async () => {
10
+ try {
11
+ applySetupPlanWithLock(plan);
12
+ return { ok: true };
13
+ }
14
+ catch (error) {
15
+ if (error instanceof LockBusyError) {
16
+ return {
17
+ ok: false,
18
+ fatal: true,
19
+ detail: `dg setup cannot apply while another setup or uninstall is running: ${error.path}`
20
+ };
21
+ }
22
+ throw error;
23
+ }
24
+ }
25
+ }
26
+ ];
27
+ for (const offer of agents) {
28
+ tasks.push({
29
+ label: `${offer.label} installs route through dg`,
30
+ run: async () => {
31
+ try {
32
+ await applyAgentHook(offer.ctx);
33
+ return { ok: true };
34
+ }
35
+ catch (error) {
36
+ return { ok: false, detail: error instanceof Error ? error.message : "unknown error" };
37
+ }
38
+ }
39
+ });
40
+ }
41
+ return tasks;
42
+ }
43
+ export function buildCommitGuardTask(repo) {
44
+ return {
45
+ label: "commits in this repo are scanned",
46
+ run: async () => {
47
+ try {
48
+ applyGitHook(repo);
49
+ return { ok: true };
50
+ }
51
+ catch (error) {
52
+ return { ok: false, detail: error instanceof Error ? error.message : "unknown error" };
53
+ }
54
+ }
55
+ };
56
+ }