@westbayberry/dg 2.0.8 → 2.0.11

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 (50) hide show
  1. package/README.md +17 -12
  2. package/dist/api/analyze.js +134 -34
  3. package/dist/audit-ui/export.js +3 -4
  4. package/dist/auth/device-login.js +13 -9
  5. package/dist/auth/store.js +43 -26
  6. package/dist/bin/dg.js +5 -0
  7. package/dist/commands/audit.js +14 -4
  8. package/dist/commands/config.js +3 -5
  9. package/dist/commands/doctor.js +3 -3
  10. package/dist/commands/explain.js +138 -6
  11. package/dist/commands/licenses.js +37 -24
  12. package/dist/commands/login.js +12 -3
  13. package/dist/commands/logout.js +15 -4
  14. package/dist/commands/scan.js +1 -1
  15. package/dist/commands/service.js +76 -24
  16. package/dist/commands/status.js +38 -4
  17. package/dist/commands/types.js +1 -0
  18. package/dist/config/settings.js +102 -22
  19. package/dist/launcher/install-preflight.js +81 -12
  20. package/dist/launcher/output-redaction.js +5 -3
  21. package/dist/launcher/preflight-prompt.js +31 -12
  22. package/dist/launcher/run.js +87 -8
  23. package/dist/proxy/ca.js +69 -29
  24. package/dist/proxy/enforcement.js +41 -3
  25. package/dist/proxy/worker.js +21 -9
  26. package/dist/runtime/first-run.js +33 -2
  27. package/dist/runtime/nudges.js +9 -2
  28. package/dist/scan/analyze-worker.js +18 -8
  29. package/dist/scan/collect.js +45 -32
  30. package/dist/scan/command.js +80 -40
  31. package/dist/scan/discovery.js +75 -7
  32. package/dist/scan/render.js +22 -6
  33. package/dist/scan/scanner-report.js +89 -12
  34. package/dist/scan/staged.js +69 -7
  35. package/dist/scan-ui/LegacyApp.js +10 -48
  36. package/dist/scan-ui/components/InteractiveResultsView.js +171 -111
  37. package/dist/scan-ui/components/ProjectSelector.js +3 -3
  38. package/dist/scan-ui/components/ScoreHeader.js +8 -4
  39. package/dist/scan-ui/hooks/useScan.js +74 -27
  40. package/dist/scan-ui/launch.js +21 -4
  41. package/dist/service/state.js +15 -4
  42. package/dist/service/trust-store.js +23 -2
  43. package/dist/setup/git-hook.js +28 -17
  44. package/dist/setup/plan.js +302 -18
  45. package/dist/state/cleanup-registry.js +65 -8
  46. package/dist/state/locks.js +95 -9
  47. package/dist/state/sessions.js +66 -2
  48. package/dist/verify/package-check.js +22 -3
  49. package/dist/verify/preflight.js +328 -170
  50. package/package.json +1 -1
@@ -1,11 +1,12 @@
1
1
  import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
- import { basename, delimiter, dirname, join, resolve } from "node:path";
2
+ import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
3
3
  import { chmodSync } from "node:fs";
4
4
  import { createTheme } from "../presentation/theme.js";
5
- import { acquireLockSync, findStaleSessionsSync, resolveDgPaths, sweepStaleSessionsSync, CLEANUP_REGISTRY_LOCK } from "../state/index.js";
5
+ import { acquireLockSync, findStaleSessionsSync, preserveCorruptCleanupRegistrySync, resolveDgPaths, sweepStaleSessionsSync, CLEANUP_REGISTRY_LOCK } from "../state/index.js";
6
6
  import { currentNodeVersion, isSupportedNode } from "../runtime/node-version.js";
7
7
  import { dgVersion } from "../commands/version.js";
8
- import { AuthError, authStatus, displayTier } from "../auth/store.js";
8
+ import { compareVersions, readLatestVersion } from "../commands/update.js";
9
+ import { AuthError, authStatus, displayTier, readAuthState } from "../auth/store.js";
9
10
  import { ConfigError, loadUserConfig } from "../config/settings.js";
10
11
  import { resolveRealBinary } from "../launcher/resolve-real-binary.js";
11
12
  import { packageManagerNames } from "../launcher/classify.js";
@@ -32,6 +33,7 @@ export const STALE_SESSION_OLDER_THAN_MS = 24 * 60 * 60 * 1000;
32
33
  const DOCTOR_GROUP_BY_NAME = {
33
34
  node: "environment",
34
35
  package: "environment",
36
+ update: "environment",
35
37
  "dg-binary-path": "environment",
36
38
  "real-binary-resolution": "environment",
37
39
  "recursive-shim-guard": "environment",
@@ -45,12 +47,16 @@ const DOCTOR_GROUP_BY_NAME = {
45
47
  "shell-rc": "setup",
46
48
  "python-hook-drift": "setup",
47
49
  path: "setup",
50
+ "path-noninteractive": "setup",
51
+ "commit-guard": "setup",
48
52
  "stale-sessions": "setup",
49
53
  service: "setup",
50
- auth: "account"
54
+ auth: "account",
55
+ api: "account"
51
56
  };
52
57
  const DOCTOR_FIX_BY_NAME = {
53
58
  node: "upgrade Node to >=22.14.0",
59
+ update: "dg update",
54
60
  "dg-binary-path": "put the dg bin directory first on PATH",
55
61
  "cleanup-registry": "re-run dg setup",
56
62
  "cleanup-registry-stale-entries": "re-run dg setup to refresh",
@@ -61,6 +67,7 @@ const DOCTOR_FIX_BY_NAME = {
61
67
  "shell-rc": "dg setup",
62
68
  "python-hook-drift": "dg uninstall, or re-run dg setup, to remove the stale pip hook",
63
69
  path: "reload your shell after setup",
70
+ "commit-guard": "dg guard-commit",
64
71
  "stale-sessions": "clears on the next protected run",
65
72
  auth: "dg login"
66
73
  };
@@ -187,7 +194,9 @@ export function uninstallSetup(options) {
187
194
  olderThanMs: STALE_SESSION_OLDER_THAN_MS
188
195
  }).removed;
189
196
  if (registryRead.malformed) {
190
- warnings.push(`cleanup registry is malformed: ${paths.cleanupRegistryPath}`);
197
+ warnings.push(registryRead.preservedPath
198
+ ? `cleanup registry was malformed: ${paths.cleanupRegistryPath} (preserved at ${registryRead.preservedPath})`
199
+ : `cleanup registry is malformed: ${paths.cleanupRegistryPath}`);
191
200
  }
192
201
  for (const entry of registryRead.registry.entries) {
193
202
  if (entry.owner !== "dg") {
@@ -290,6 +299,8 @@ export function doctorReport(options = {}) {
290
299
  : `Legacy dg pip hooks break pip in: ${staleHookSites.join(", ")}`
291
300
  });
292
301
  checks.push(pathPrecedenceCheck(env, shimDir, functionsPresent));
302
+ checks.push(nonInteractivePathCheck(env, shimDir));
303
+ checks.push(commitGuardCheck(registryRead.registry, env));
293
304
  checks.push({
294
305
  name: "stale-sessions",
295
306
  status: staleSessions.length === 0 ? "pass" : "warn",
@@ -315,6 +326,107 @@ export function doctorReport(options = {}) {
315
326
  checks: checks.map(enrichDoctorCheck)
316
327
  };
317
328
  }
329
+ const API_HEALTH_TIMEOUT_MS = 2000;
330
+ const UPDATE_LOOKUP_TIMEOUT_MS = 1500;
331
+ export async function doctorReportWithRemote(options = {}) {
332
+ const env = options.env ?? process.env;
333
+ const base = doctorReport({ env });
334
+ const checks = [...base.checks];
335
+ insertAfter(checks, "package", enrichDoctorCheck(updateFreshnessCheck()));
336
+ insertAfter(checks, "auth", enrichDoctorCheck(await apiHealthCheck(env, options.fetchImpl ?? fetch, options.apiTimeoutMs ?? API_HEALTH_TIMEOUT_MS)));
337
+ return { version: base.version, checks };
338
+ }
339
+ function insertAfter(checks, name, check) {
340
+ const index = checks.findIndex((candidate) => candidate.name === name);
341
+ checks.splice(index === -1 ? checks.length : index + 1, 0, check);
342
+ }
343
+ function updateFreshnessCheck() {
344
+ const latest = readLatestVersion(UPDATE_LOOKUP_TIMEOUT_MS);
345
+ if (!latest) {
346
+ return {
347
+ name: "update",
348
+ status: "unavailable",
349
+ message: "Latest published dg version is unknown (npm registry metadata unavailable). Run 'dg update' to retry."
350
+ };
351
+ }
352
+ if (compareVersions(latest, dgVersion()) > 0) {
353
+ return {
354
+ name: "update",
355
+ status: "warn",
356
+ message: `dg ${dgVersion()} is behind the latest published ${latest}`
357
+ };
358
+ }
359
+ return {
360
+ name: "update",
361
+ status: "pass",
362
+ message: `dg ${dgVersion()} is the latest published version`
363
+ };
364
+ }
365
+ async function apiHealthCheck(env, fetchImpl, timeoutMs) {
366
+ let baseUrl;
367
+ try {
368
+ baseUrl = doctorApiBaseUrl(env);
369
+ }
370
+ catch (error) {
371
+ return {
372
+ name: "api",
373
+ status: "fail",
374
+ message: error instanceof ConfigError ? error.message : "Unable to resolve api.baseUrl"
375
+ };
376
+ }
377
+ const controller = new AbortController();
378
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
379
+ const started = Date.now();
380
+ try {
381
+ const response = await fetchImpl(`${baseUrl}/health`, { method: "GET", signal: controller.signal });
382
+ const latencyMs = Date.now() - started;
383
+ if (response.ok) {
384
+ return {
385
+ name: "api",
386
+ status: "pass",
387
+ message: `${baseUrl}/health responded ${response.status} in ${latencyMs}ms`
388
+ };
389
+ }
390
+ return {
391
+ name: "api",
392
+ status: "warn",
393
+ message: `${baseUrl}/health responded ${response.status} after ${latencyMs}ms`,
394
+ fix: "check api.baseUrl (dg config get api.baseUrl) and the service status"
395
+ };
396
+ }
397
+ catch (error) {
398
+ return {
399
+ name: "api",
400
+ status: "warn",
401
+ message: `${baseUrl} is unreachable: ${describeFetchError(error, timeoutMs)}`,
402
+ fix: "check your network and api.baseUrl (dg config get api.baseUrl)"
403
+ };
404
+ }
405
+ finally {
406
+ clearTimeout(timer);
407
+ }
408
+ }
409
+ function doctorApiBaseUrl(env) {
410
+ try {
411
+ const apiBaseUrl = readAuthState(env)?.apiBaseUrl;
412
+ if (apiBaseUrl) {
413
+ return apiBaseUrl;
414
+ }
415
+ }
416
+ catch {
417
+ return loadUserConfig(env).api.baseUrl;
418
+ }
419
+ return loadUserConfig(env).api.baseUrl;
420
+ }
421
+ function describeFetchError(error, timeoutMs) {
422
+ if (error instanceof Error && error.name === "AbortError") {
423
+ return `no response within ${timeoutMs}ms`;
424
+ }
425
+ if (error instanceof Error && error.cause instanceof Error && error.cause.message) {
426
+ return error.cause.message;
427
+ }
428
+ return error instanceof Error ? error.message : "request failed";
429
+ }
318
430
  const DOCTOR_STATUS_ROLE = {
319
431
  pass: "pass",
320
432
  warn: "warn",
@@ -358,10 +470,10 @@ export function renderDoctorReport(report, theme = createTheme(false), verbose =
358
470
  }
359
471
  let nonPass = groupChecks.filter((check) => check.status !== "pass");
360
472
  if (key === "setup") {
361
- const trio = ["shims", "shell-rc", "path"];
362
- const allWarn = trio.every((name) => nonPass.some((check) => check.name === name && check.status === "warn"));
473
+ const setupCore = ["shims", "shell-rc", "path", "path-noninteractive"];
474
+ const allWarn = setupCore.every((name) => nonPass.some((check) => check.name === name && check.status === "warn"));
363
475
  if (allWarn) {
364
- nonPass = nonPass.filter((check) => !trio.includes(check.name) && !(check.name === "config" && check.status === "warn"));
476
+ nonPass = nonPass.filter((check) => !setupCore.includes(check.name) && !(check.name === "config" && check.status === "warn"));
365
477
  const notSetUp = "not set up — bare npm/pip installs aren't protected";
366
478
  if (nonPass.length === 0) {
367
479
  lines.push(rollupInlineLine(title, "warn", "⚠", notSetUp, "dg setup", theme));
@@ -483,7 +595,8 @@ export function shimSource(command) {
483
595
  `if [ -x "${dg}" ]; then`,
484
596
  ` DG_SHIM_ACTIVE=${nonce} exec "${dg}" ${command} "$@"`,
485
597
  "fi",
486
- `dg_path=$(printf '%s' "$PATH" | awk -v RS=':' -v ORS=':' '$0 != ENVIRON["HOME"] "/.dg/shims"' | sed 's/:$//')`,
598
+ `shim_dir=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd)`,
599
+ `dg_path=$(printf '%s' "$PATH" | awk -v RS=':' -v ORS=':' -v shim="$shim_dir" '$0 != shim && $0 != ENVIRON["HOME"] "/.dg/shims"' | sed 's/:$//')`,
487
600
  `dg_bin=$(PATH="$dg_path" command -v dg 2>/dev/null)`,
488
601
  `if [ -n "$dg_bin" ]; then`,
489
602
  ` DG_SHIM_ACTIVE=${nonce} exec "$dg_bin" ${command} "$@"`,
@@ -529,9 +642,34 @@ function fishRcBlock(shimDir) {
529
642
  return `${RC_BEGIN}\n# ${RC_SENTINEL}\n# ${RC_FUNCTIONS_SENTINEL}\nfish_add_path -p "${dir}"\n${fns}\n${RC_END}\n`;
530
643
  }
531
644
  function stripRcBlock(existing) {
532
- const pattern = new RegExp(`${escapeRegex(RC_BEGIN)}\\n[\\s\\S]*?${escapeRegex(RC_END)}\\n?`, "g");
533
- const unterminatedPattern = new RegExp(`${escapeRegex(RC_BEGIN)}\\n[\\s\\S]*$`, "g");
534
- return existing.replace(pattern, "").replace(unterminatedPattern, "");
645
+ return stripRcBlockDetailed(existing).content;
646
+ }
647
+ // An unterminated begin marker must never strip to EOF: only lines verifiably
648
+ // written by dg are removed, so user content below a stale marker survives.
649
+ function stripRcBlockDetailed(existing) {
650
+ let content = existing.replace(rcPairPattern(RC_BEGIN, RC_END), "");
651
+ const repairedLines = [];
652
+ for (;;) {
653
+ const lines = content.split("\n");
654
+ const begin = lines.indexOf(RC_BEGIN);
655
+ if (begin === -1) {
656
+ break;
657
+ }
658
+ let end = begin;
659
+ while (end + 1 < lines.length && isDgWrittenRcLine(lines[end + 1] ?? "")) {
660
+ end += 1;
661
+ }
662
+ repairedLines.push(begin === end ? `line ${begin + 1}` : `lines ${begin + 1}-${end + 1}`);
663
+ lines.splice(begin, end - begin + 1);
664
+ content = lines.join("\n");
665
+ }
666
+ return { content, repairedLines };
667
+ }
668
+ function rcPairPattern(begin, end) {
669
+ return new RegExp(`${escapeRegex(begin)}\\n(?:(?!${escapeRegex(begin)})[\\s\\S])*?${escapeRegex(end)}\\n?`, "g");
670
+ }
671
+ function isDgWrittenRcLine(line) {
672
+ return line === RC_END || line.startsWith("# dg-") || line.includes(RC_SHIM_HELPER) || line.includes(`${sep}.dg${sep}shims`);
535
673
  }
536
674
  function sweepLegacyRcBlocks(homeDir, removed, warnings) {
537
675
  for (const rel of LEGACY_RC_CANDIDATES) {
@@ -549,8 +687,7 @@ function sweepLegacyRcBlocks(homeDir, removed, warnings) {
549
687
  warnings.push(`legacy dg block in ${rcPath} is missing its end marker; left untouched`);
550
688
  continue;
551
689
  }
552
- const pattern = new RegExp(`${escapeRegex(marker.begin)}\\n[\\s\\S]*?${escapeRegex(marker.end)}\\n?`, "g");
553
- next = next.replace(pattern, "");
690
+ next = next.replace(rcPairPattern(marker.begin, marker.end), "");
554
691
  }
555
692
  if (next === existing) {
556
693
  continue;
@@ -567,7 +704,7 @@ function sweepLegacyRcBlocks(homeDir, removed, warnings) {
567
704
  export function legacyPythonHookSites(homeDir) {
568
705
  return candidateSitePackagesDirs(homeDir).filter((dir) => existsSync(join(dir, LEGACY_PYTHON_HOOK_PTH)) || readText(join(dir, LEGACY_PYTHON_HOOK_PY)).includes(LEGACY_PYTHON_HOOK_MARKER));
569
706
  }
570
- function sweepLegacyPythonHooks(homeDir, removed, warnings) {
707
+ export function sweepLegacyPythonHooks(homeDir, removed, warnings) {
571
708
  for (const dir of candidateSitePackagesDirs(homeDir)) {
572
709
  const pyPath = join(dir, LEGACY_PYTHON_HOOK_PY);
573
710
  const pthPath = join(dir, LEGACY_PYTHON_HOOK_PTH);
@@ -651,12 +788,14 @@ export function readRegistry(paths) {
651
788
  };
652
789
  }
653
790
  catch {
791
+ const preservedPath = preserveCorruptCleanupRegistrySync(paths);
654
792
  return {
655
793
  registry: {
656
794
  version: 1,
657
795
  entries: []
658
796
  },
659
- malformed: true
797
+ malformed: true,
798
+ ...(preservedPath ? { preservedPath } : {})
660
799
  };
661
800
  }
662
801
  }
@@ -690,7 +829,6 @@ function configCheck(paths, env) {
690
829
  }
691
830
  function unavailableDoctorChecks() {
692
831
  const unavailable = [
693
- ["api", "API connectivity is not checked until this machine is authenticated. Run 'dg login', then run 'dg doctor' again."],
694
832
  ["path-cache", "Shell command path cache checks are guidance only. After setup, run 'hash -r' for bash or 'rehash' for zsh."],
695
833
  ["proxy", "Per-command proxy health is checked when a protected prefix command runs, for example 'dg npm install <package>'."],
696
834
  ["ca", "CA health is checked during protected HTTPS artifact fetches or explicit service startup."],
@@ -844,6 +982,70 @@ function pathPrecedenceCheck(env, shimDir, functionsPresent) {
844
982
  message: `${shimDir} precedes the real package-manager directories on PATH`
845
983
  };
846
984
  }
985
+ // Shell functions only protect interactive shells; scripts, Makefiles,
986
+ // lifecycle scripts, and CI resolve commands by raw PATH order, so a version
987
+ // manager bin dir ahead of the shim dir bypasses dg there.
988
+ function nonInteractivePathCheck(env, shimDir) {
989
+ const pathEntries = (env.PATH ?? "").split(delimiter).filter(Boolean);
990
+ const shimIndex = pathEntries.findIndex((entry) => resolve(entry) === resolve(shimDir));
991
+ if (shimIndex === -1) {
992
+ return {
993
+ name: "path-noninteractive",
994
+ status: "warn",
995
+ message: `${shimDir} is not on PATH, so scripts, Makefiles, lifecycle scripts, and CI bypass dg`,
996
+ fix: "dg setup, then make sure the dg PATH block loads where non-interactive shells read it"
997
+ };
998
+ }
999
+ const offenders = new Map();
1000
+ for (const command of SHIM_COMMANDS) {
1001
+ const hit = firstExecutableOnPath(command, pathEntries);
1002
+ if (!hit || resolve(hit.dir) === resolve(shimDir) || isShimFile(hit.path)) {
1003
+ continue;
1004
+ }
1005
+ const offender = offenders.get(hit.dir) ?? { index: hit.index, commands: [] };
1006
+ offender.commands.push(command);
1007
+ offenders.set(hit.dir, offender);
1008
+ }
1009
+ if (offenders.size === 0) {
1010
+ return {
1011
+ name: "path-noninteractive",
1012
+ status: "pass",
1013
+ message: `Non-interactive shells resolve the dg shims in ${shimDir} first for every shimmed command`
1014
+ };
1015
+ }
1016
+ const details = [...offenders.entries()]
1017
+ .map(([dir, offender]) => `${offender.commands.join(", ")} from ${dir}${versionManagerLabel(dir)} at PATH position ${offender.index + 1}`)
1018
+ .join("; ");
1019
+ const firstDir = [...offenders.keys()][0] ?? "";
1020
+ return {
1021
+ name: "path-noninteractive",
1022
+ status: "warn",
1023
+ message: `Non-interactive shells (scripts, Makefiles, lifecycle scripts, CI) run ${details}, bypassing the dg shims at PATH position ${shimIndex + 1}`,
1024
+ fix: `put ${shimDir} before ${firstDir} on PATH for non-interactive shells (load the dg setup block after the version-manager init)`
1025
+ };
1026
+ }
1027
+ function firstExecutableOnPath(command, pathEntries) {
1028
+ for (const [index, dir] of pathEntries.entries()) {
1029
+ const candidate = join(dir, command);
1030
+ try {
1031
+ accessSync(candidate, constants.X_OK);
1032
+ return { path: candidate, dir, index };
1033
+ }
1034
+ catch {
1035
+ continue;
1036
+ }
1037
+ }
1038
+ return null;
1039
+ }
1040
+ function isShimFile(path) {
1041
+ return readText(path).slice(0, 160).includes(SHIM_SENTINEL);
1042
+ }
1043
+ const VERSION_MANAGER_DIR_NAMES = ["nvm", "asdf", "volta", "corepack", "fnm", "n"];
1044
+ function versionManagerLabel(dir) {
1045
+ const segments = dir.split(sep).map((segment) => segment.replace(/^\./, ""));
1046
+ const manager = VERSION_MANAGER_DIR_NAMES.find((name) => segments.includes(name));
1047
+ return manager ? ` (${manager})` : "";
1048
+ }
847
1049
  export function currentShellActivation(env = process.env) {
848
1050
  const shell = resolveShell("auto", env);
849
1051
  const homeDir = resolveDgPaths(env).homeDir;
@@ -966,9 +1168,91 @@ function removeRcBlock(entry, removed, missing, warnings) {
966
1168
  warnings.push(`refused to edit shell rc without dg sentinel: ${entry.path}`);
967
1169
  return;
968
1170
  }
969
- writeFileSync(entry.path, stripRcBlock(existing), "utf8");
1171
+ const stripped = stripRcBlockDetailed(existing);
1172
+ writeFileSync(entry.path, stripped.content, "utf8");
1173
+ if (stripped.repairedLines.length > 0) {
1174
+ warnings.push(`repaired an unterminated dg block in ${entry.path}: removed only dg-written lines (${stripped.repairedLines.join(", ")})`);
1175
+ }
970
1176
  removed.push(entry.path);
971
1177
  }
1178
+ // The command -v guard keeps the hook fail-open: a removed or moved dg prints
1179
+ // a one-line notice and lets the commit (and any chained hook) proceed instead
1180
+ // of blocking every commit with exit 127.
1181
+ export function guardHookScript(dgPath, chainedOriginal) {
1182
+ const lines = [
1183
+ "#!/bin/sh",
1184
+ `# ${GUARD_HOOK_SENTINEL}`,
1185
+ `if command -v "${dgPath}" >/dev/null 2>&1; then`,
1186
+ ` "${dgPath}" scan --staged --hook || exit $?`,
1187
+ "else",
1188
+ ` echo "dg: pre-commit scan skipped (dg not runnable at ${dgPath}); commit allowed" >&2`,
1189
+ "fi"
1190
+ ];
1191
+ if (chainedOriginal) {
1192
+ lines.push(`[ -x "${chainedOriginal}" ] && exec "${chainedOriginal}" "$@"`);
1193
+ }
1194
+ lines.push("exit 0");
1195
+ return `${lines.join("\n")}\n`;
1196
+ }
1197
+ export function chainedHookOriginal(content) {
1198
+ return content.match(/^\[ -x "(.+)" \] && exec "\1" "\$@"$/m)?.[1] ?? null;
1199
+ }
1200
+ export function guardHookDgPath(content) {
1201
+ return (content.match(/^if command -v "(.+)" >\/dev\/null 2>&1; then$/m)?.[1] ??
1202
+ content.match(/^"(.+)" scan --staged --hook/m)?.[1] ??
1203
+ null);
1204
+ }
1205
+ function commitGuardCheck(registry, env) {
1206
+ const hooks = registry.entries.filter((entry) => entry.owner === "dg" && entry.kind === "git-hook");
1207
+ if (hooks.length === 0) {
1208
+ return {
1209
+ name: "commit-guard",
1210
+ status: "pass",
1211
+ message: "No commit guard installed (optional; run dg guard-commit inside a repo)"
1212
+ };
1213
+ }
1214
+ const missing = hooks.filter((entry) => !hookOwnedByDg(entry));
1215
+ if (missing.length > 0) {
1216
+ return {
1217
+ name: "commit-guard",
1218
+ status: "warn",
1219
+ message: `Commit guard hook missing or replaced at: ${missing.map((entry) => entry.path).join(", ")}`,
1220
+ fix: "re-run dg guard-commit in that repo, or dg guard-commit off to forget it"
1221
+ };
1222
+ }
1223
+ const broken = hooks.filter((entry) => {
1224
+ const dgPath = guardHookDgPath(readText(entry.path));
1225
+ return dgPath !== null && !runnableDgPath(dgPath, env);
1226
+ });
1227
+ if (broken.length > 0) {
1228
+ return {
1229
+ name: "commit-guard",
1230
+ status: "warn",
1231
+ message: `Commit guard at ${broken.map((entry) => entry.path).join(", ")} points at a dg binary that is not runnable, so commits there fail open with a notice`,
1232
+ fix: "reinstall dg, then re-run dg guard-commit"
1233
+ };
1234
+ }
1235
+ return {
1236
+ name: "commit-guard",
1237
+ status: "pass",
1238
+ message: `Commit guard installed (${hooks.length} ${hooks.length === 1 ? "repo" : "repos"}); hook and dg binary resolve`
1239
+ };
1240
+ }
1241
+ function hookOwnedByDg(entry) {
1242
+ return readText(entry.path).split("\n", 2)[1]?.includes(entry.sentinel ?? GUARD_HOOK_SENTINEL) ?? false;
1243
+ }
1244
+ function runnableDgPath(dgPath, env) {
1245
+ if (dgPath.includes(sep)) {
1246
+ try {
1247
+ accessSync(dgPath, constants.X_OK);
1248
+ return true;
1249
+ }
1250
+ catch {
1251
+ return false;
1252
+ }
1253
+ }
1254
+ return findDgExecutables(env).length > 0;
1255
+ }
972
1256
  export function reverseGitHookEntry(entry, removed, missing, warnings) {
973
1257
  const sentinel = entry.sentinel ?? GUARD_HOOK_SENTINEL;
974
1258
  let ownsTarget = false;
@@ -1,4 +1,6 @@
1
- import { readJsonFile, writeJsonFileAtomic } from "./store.js";
1
+ import { renameSync } from "node:fs";
2
+ import { rename } from "node:fs/promises";
3
+ import { JsonStoreError, readJsonFile, writeJsonFileAtomic } from "./store.js";
2
4
  import { acquireLock, CLEANUP_REGISTRY_LOCK } from "./locks.js";
3
5
  export function emptyCleanupRegistry() {
4
6
  return {
@@ -6,12 +8,30 @@ export function emptyCleanupRegistry() {
6
8
  entries: []
7
9
  };
8
10
  }
9
- export async function readCleanupRegistry(paths) {
10
- const registry = await readJsonFile(paths.cleanupRegistryPath, emptyCleanupRegistry());
11
- if (registry.version !== 1 || !Array.isArray(registry.entries)) {
12
- throw new Error(`Unsupported cleanup registry at ${paths.cleanupRegistryPath}`);
11
+ export async function loadCleanupRegistry(paths, options = {}) {
12
+ let parsed;
13
+ try {
14
+ parsed = await readJsonFile(paths.cleanupRegistryPath, emptyCleanupRegistry());
15
+ }
16
+ catch (error) {
17
+ if (!(error instanceof JsonStoreError)) {
18
+ throw error;
19
+ }
20
+ parsed = undefined;
21
+ }
22
+ if (isCleanupRegistry(parsed)) {
23
+ return {
24
+ registry: parsed
25
+ };
13
26
  }
14
- return registry;
27
+ const preserved = await preserveCorruptRegistry(paths, options);
28
+ return {
29
+ registry: emptyCleanupRegistry(),
30
+ ...(preserved ? { corruptPreservedPath: preserved } : {})
31
+ };
32
+ }
33
+ export async function readCleanupRegistry(paths) {
34
+ return (await loadCleanupRegistry(paths)).registry;
15
35
  }
16
36
  export async function writeCleanupRegistry(paths, registry) {
17
37
  await writeJsonFileAtomic(paths.cleanupRegistryPath, registry);
@@ -19,7 +39,7 @@ export async function writeCleanupRegistry(paths, registry) {
19
39
  export async function recordCleanupEntry(paths, entry) {
20
40
  const lock = await acquireLock(paths, CLEANUP_REGISTRY_LOCK);
21
41
  try {
22
- const registry = await readCleanupRegistry(paths);
42
+ const { registry } = await loadCleanupRegistry(paths);
23
43
  const nextEntry = {
24
44
  ...entry,
25
45
  installedAt: entry.installedAt ?? new Date().toISOString(),
@@ -40,7 +60,7 @@ export async function recordCleanupEntry(paths, entry) {
40
60
  export async function removeCleanupEntry(paths, target) {
41
61
  const lock = await acquireLock(paths, CLEANUP_REGISTRY_LOCK);
42
62
  try {
43
- const registry = await readCleanupRegistry(paths);
63
+ const { registry } = await loadCleanupRegistry(paths);
44
64
  const next = {
45
65
  version: 1,
46
66
  entries: registry.entries.filter((candidate) => !sameRegistryTarget(candidate, target))
@@ -55,6 +75,43 @@ export async function removeCleanupEntry(paths, target) {
55
75
  export function ownedCleanupEntries(registry) {
56
76
  return registry.entries.filter((entry) => entry.owner === "dg");
57
77
  }
78
+ async function preserveCorruptRegistry(paths, options) {
79
+ const preservedPath = `${paths.cleanupRegistryPath}.corrupt-${new Date().toISOString().replace(/[:.]/g, "-")}`;
80
+ try {
81
+ await rename(paths.cleanupRegistryPath, preservedPath);
82
+ }
83
+ catch (error) {
84
+ if (isEnoent(error)) {
85
+ return undefined;
86
+ }
87
+ throw error;
88
+ }
89
+ const stderr = options.stderr ?? process.stderr;
90
+ stderr.write(`dg: cleanup registry at ${paths.cleanupRegistryPath} was unreadable; preserved it at ${preservedPath}. Previously registered entries may need 'dg doctor'.\n`);
91
+ return preservedPath;
92
+ }
93
+ export function preserveCorruptCleanupRegistrySync(paths, options = {}) {
94
+ const preservedPath = `${paths.cleanupRegistryPath}.corrupt-${new Date().toISOString().replace(/[:.]/g, "-")}`;
95
+ try {
96
+ renameSync(paths.cleanupRegistryPath, preservedPath);
97
+ }
98
+ catch {
99
+ return undefined;
100
+ }
101
+ const stderr = options.stderr ?? process.stderr;
102
+ stderr.write(`dg: cleanup registry at ${paths.cleanupRegistryPath} was unreadable; preserved it at ${preservedPath}. Previously registered entries may need 'dg doctor'.\n`);
103
+ return preservedPath;
104
+ }
105
+ function isCleanupRegistry(value) {
106
+ return (typeof value === "object" &&
107
+ value !== null &&
108
+ !Array.isArray(value) &&
109
+ value.version === 1 &&
110
+ Array.isArray(value.entries));
111
+ }
112
+ function isEnoent(error) {
113
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
114
+ }
58
115
  function sameRegistryTarget(left, right) {
59
116
  return left.kind === right.kind && left.path === right.path && left.sentinel === right.sentinel;
60
117
  }
@@ -1,4 +1,4 @@
1
- import { closeSync, mkdirSync, openSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
1
+ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import { mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  export const CLEANUP_REGISTRY_LOCK = "cleanup-registry";
@@ -91,7 +91,69 @@ export function acquireLockSync(paths, name, options = {}) {
91
91
  export async function readLockMetadata(path) {
92
92
  return JSON.parse(await readFile(path, "utf8"));
93
93
  }
94
+ const LOCK_RETRY_DELAY_MS = 25;
95
+ export function acquireLockSyncWithRetry(paths, name, options = {}) {
96
+ const deadline = Date.now() + (options.timeoutMs ?? 5_000);
97
+ const acquireOptions = {
98
+ ...(options.staleMs !== undefined ? { staleMs: options.staleMs } : {}),
99
+ ...(options.now !== undefined ? { now: options.now } : {})
100
+ };
101
+ for (;;) {
102
+ try {
103
+ return acquireLockSync(paths, name, acquireOptions);
104
+ }
105
+ catch (error) {
106
+ if (!(error instanceof LockBusyError) || Date.now() + LOCK_RETRY_DELAY_MS > deadline) {
107
+ throw error;
108
+ }
109
+ sleepSync(LOCK_RETRY_DELAY_MS);
110
+ }
111
+ }
112
+ }
113
+ function sleepSync(ms) {
114
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
115
+ }
116
+ export function isProcessAlive(pid) {
117
+ try {
118
+ process.kill(pid, 0);
119
+ return true;
120
+ }
121
+ catch (error) {
122
+ return !isErrno(error, "ESRCH");
123
+ }
124
+ }
125
+ function holderStateFromContent(content) {
126
+ let pid;
127
+ try {
128
+ pid = JSON.parse(content).pid;
129
+ }
130
+ catch {
131
+ return "unknown";
132
+ }
133
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) {
134
+ return "unknown";
135
+ }
136
+ return isProcessAlive(pid) ? "alive" : "dead";
137
+ }
94
138
  async function removeStaleLock(path, options) {
139
+ let content;
140
+ try {
141
+ content = await readFile(path, "utf8");
142
+ }
143
+ catch (error) {
144
+ if (isErrno(error, "ENOENT")) {
145
+ return;
146
+ }
147
+ content = null;
148
+ }
149
+ const holder = content === null ? "unknown" : holderStateFromContent(content);
150
+ if (holder === "alive") {
151
+ return;
152
+ }
153
+ if (holder === "dead") {
154
+ await takeoverLock(path);
155
+ return;
156
+ }
95
157
  if (!options.staleMs) {
96
158
  return;
97
159
  }
@@ -108,21 +170,27 @@ async function removeStaleLock(path, options) {
108
170
  if (now - details.mtimeMs < options.staleMs) {
109
171
  return;
110
172
  }
111
- const takeoverPath = `${path}.stale-${process.pid}-${++takeoverCounter}`;
173
+ await takeoverLock(path);
174
+ }
175
+ function removeStaleLockSync(path, options) {
176
+ let content;
112
177
  try {
113
- await rename(path, takeoverPath);
178
+ content = readFileSync(path, "utf8");
114
179
  }
115
180
  catch (error) {
116
181
  if (isErrno(error, "ENOENT")) {
117
182
  return;
118
183
  }
119
- throw error;
184
+ content = null;
185
+ }
186
+ const holder = content === null ? "unknown" : holderStateFromContent(content);
187
+ if (holder === "alive") {
188
+ return;
189
+ }
190
+ if (holder === "dead") {
191
+ takeoverLockSync(path);
192
+ return;
120
193
  }
121
- await rm(takeoverPath, {
122
- force: true
123
- });
124
- }
125
- function removeStaleLockSync(path, options) {
126
194
  if (!options.staleMs) {
127
195
  return;
128
196
  }
@@ -140,6 +208,24 @@ function removeStaleLockSync(path, options) {
140
208
  if (now - details.mtimeMs < options.staleMs) {
141
209
  return;
142
210
  }
211
+ takeoverLockSync(path);
212
+ }
213
+ async function takeoverLock(path) {
214
+ const takeoverPath = `${path}.stale-${process.pid}-${++takeoverCounter}`;
215
+ try {
216
+ await rename(path, takeoverPath);
217
+ }
218
+ catch (error) {
219
+ if (isErrno(error, "ENOENT")) {
220
+ return;
221
+ }
222
+ throw error;
223
+ }
224
+ await rm(takeoverPath, {
225
+ force: true
226
+ });
227
+ }
228
+ function takeoverLockSync(path) {
143
229
  const takeoverPath = `${path}.stale-${process.pid}-${++takeoverCounter}`;
144
230
  try {
145
231
  renameSync(path, takeoverPath);