@westbayberry/dg 2.0.7 → 2.0.10
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.
- package/README.md +17 -12
- package/dist/api/analyze.js +134 -34
- package/dist/audit-ui/export.js +3 -4
- package/dist/auth/device-login.js +13 -9
- package/dist/auth/store.js +43 -26
- package/dist/bin/dg.js +5 -0
- package/dist/commands/audit.js +14 -4
- package/dist/commands/config.js +3 -5
- package/dist/commands/doctor.js +3 -3
- package/dist/commands/explain.js +138 -6
- package/dist/commands/licenses.js +37 -24
- package/dist/commands/login.js +12 -3
- package/dist/commands/logout.js +15 -4
- package/dist/commands/scan.js +1 -1
- package/dist/commands/service.js +76 -24
- package/dist/commands/status.js +38 -4
- package/dist/commands/types.js +1 -0
- package/dist/config/settings.js +102 -22
- package/dist/install-ui/prompt.js +5 -2
- package/dist/launcher/install-preflight.js +158 -0
- package/dist/launcher/live-install.js +11 -2
- package/dist/launcher/output-redaction.js +5 -3
- package/dist/launcher/pip-report.js +18 -2
- package/dist/launcher/preflight-prompt.js +31 -12
- package/dist/launcher/run.js +87 -8
- package/dist/proxy/ca.js +69 -29
- package/dist/proxy/enforcement.js +41 -3
- package/dist/proxy/worker.js +21 -9
- package/dist/runtime/first-run.js +33 -2
- package/dist/runtime/nudges.js +9 -2
- package/dist/scan/analyze-worker.js +18 -8
- package/dist/scan/collect.js +35 -28
- package/dist/scan/command.js +80 -40
- package/dist/scan/discovery.js +9 -3
- package/dist/scan/render.js +22 -6
- package/dist/scan/scanner-report.js +89 -12
- package/dist/scan/staged.js +69 -7
- package/dist/scan-ui/LegacyApp.js +10 -48
- package/dist/scan-ui/components/InteractiveResultsView.js +171 -111
- package/dist/scan-ui/components/ProjectSelector.js +3 -3
- package/dist/scan-ui/components/ScoreHeader.js +8 -4
- package/dist/scan-ui/hooks/useScan.js +74 -27
- package/dist/scan-ui/launch.js +18 -4
- package/dist/service/state.js +15 -4
- package/dist/service/trust-store.js +23 -2
- package/dist/setup/git-hook.js +28 -17
- package/dist/setup/plan.js +302 -18
- package/dist/state/cleanup-registry.js +65 -8
- package/dist/state/locks.js +95 -9
- package/dist/state/sessions.js +66 -2
- package/dist/verify/package-check.js +22 -3
- package/dist/verify/preflight.js +328 -170
- package/package.json +1 -1
package/dist/setup/plan.js
CHANGED
|
@@ -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 {
|
|
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(
|
|
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
|
|
362
|
-
const allWarn =
|
|
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) => !
|
|
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
|
-
`
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|
package/dist/state/locks.js
CHANGED
|
@@ -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
|
-
|
|
173
|
+
await takeoverLock(path);
|
|
174
|
+
}
|
|
175
|
+
function removeStaleLockSync(path, options) {
|
|
176
|
+
let content;
|
|
112
177
|
try {
|
|
113
|
-
|
|
178
|
+
content = readFileSync(path, "utf8");
|
|
114
179
|
}
|
|
115
180
|
catch (error) {
|
|
116
181
|
if (isErrno(error, "ENOENT")) {
|
|
117
182
|
return;
|
|
118
183
|
}
|
|
119
|
-
|
|
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);
|