@westbayberry/dg 1.0.52 → 1.0.56

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 (64) hide show
  1. package/README.md +5 -1
  2. package/dist/index.mjs +349 -168
  3. package/dist/packages/cli/src/alt-screen.js +36 -0
  4. package/dist/packages/cli/src/api.js +322 -0
  5. package/dist/packages/cli/src/auth.js +218 -0
  6. package/dist/packages/cli/src/bin.js +386 -0
  7. package/dist/packages/cli/src/config.js +228 -0
  8. package/dist/packages/cli/src/discover.js +126 -0
  9. package/dist/packages/cli/src/first-run.js +135 -0
  10. package/dist/packages/cli/src/hook.js +360 -0
  11. package/dist/packages/cli/src/lockfile.js +303 -0
  12. package/dist/packages/cli/src/npm-wrapper.js +218 -0
  13. package/dist/packages/cli/src/pip-wrapper.js +273 -0
  14. package/dist/packages/cli/src/sanitize.js +38 -0
  15. package/dist/packages/cli/src/scan-core.js +144 -0
  16. package/dist/packages/cli/src/setup-status.js +46 -0
  17. package/dist/packages/cli/src/static-output.js +625 -0
  18. package/dist/packages/cli/src/telemetry.js +141 -0
  19. package/dist/packages/cli/src/ui/App.js +137 -0
  20. package/dist/packages/cli/src/ui/InitApp.js +391 -0
  21. package/dist/packages/cli/src/ui/LoginApp.js +51 -0
  22. package/dist/packages/cli/src/ui/NpmWrapperApp.js +73 -0
  23. package/dist/packages/cli/src/ui/PipWrapperApp.js +72 -0
  24. package/dist/packages/cli/src/ui/components/ConfirmPrompt.js +24 -0
  25. package/dist/packages/cli/src/ui/components/DemoScanAnimation.js +26 -0
  26. package/dist/packages/cli/src/ui/components/DurationLine.js +7 -0
  27. package/dist/packages/cli/src/ui/components/ErrorView.js +30 -0
  28. package/dist/packages/cli/src/ui/components/FileSavePrompt.js +210 -0
  29. package/dist/packages/cli/src/ui/components/InteractiveResultsView.js +557 -0
  30. package/dist/packages/cli/src/ui/components/Mascot.js +33 -0
  31. package/dist/packages/cli/src/ui/components/ProgressBar.js +51 -0
  32. package/dist/packages/cli/src/ui/components/ProgressDots.js +35 -0
  33. package/dist/packages/cli/src/ui/components/ProjectSelector.js +60 -0
  34. package/dist/packages/cli/src/ui/components/ResultsView.js +105 -0
  35. package/dist/packages/cli/src/ui/components/ScanResultCard.js +54 -0
  36. package/dist/packages/cli/src/ui/components/ScoreHeader.js +142 -0
  37. package/dist/packages/cli/src/ui/components/SetupBanner.js +17 -0
  38. package/dist/packages/cli/src/ui/components/Spinner.js +11 -0
  39. package/dist/packages/cli/src/ui/hooks/useExpandAnimation.js +44 -0
  40. package/dist/packages/cli/src/ui/hooks/useInit.js +341 -0
  41. package/dist/packages/cli/src/ui/hooks/useLogin.js +121 -0
  42. package/dist/packages/cli/src/ui/hooks/useNpmWrapper.js +192 -0
  43. package/dist/packages/cli/src/ui/hooks/usePipWrapper.js +195 -0
  44. package/dist/packages/cli/src/ui/hooks/useScan.js +202 -0
  45. package/dist/packages/cli/src/ui/hooks/useTerminalSize.js +29 -0
  46. package/dist/packages/cli/src/update-check.js +152 -0
  47. package/dist/packages/cli/src/wizard-demo-data.js +63 -0
  48. package/dist/src/ecosystem.js +2 -0
  49. package/dist/src/lockfile/diff.js +38 -0
  50. package/dist/src/lockfile/parse_package_json.js +41 -0
  51. package/dist/src/lockfile/parse_package_lock.js +55 -0
  52. package/dist/src/lockfile/parse_pipfile_lock.js +69 -0
  53. package/dist/src/lockfile/parse_pnpm_lock.js +62 -0
  54. package/dist/src/lockfile/parse_poetry_lock.js +71 -0
  55. package/dist/src/lockfile/parse_requirements.js +83 -0
  56. package/dist/src/lockfile/parse_yarn_lock.js +66 -0
  57. package/dist/src/logger.js +21 -0
  58. package/dist/src/npm/h2pool.js +161 -0
  59. package/dist/src/npm/registry.js +299 -0
  60. package/dist/src/npm/tarball.js +274 -0
  61. package/dist/src/pypi/registry.js +299 -0
  62. package/dist/src/pypi/tarball.js +361 -0
  63. package/dist/src/types.js +2 -0
  64. package/package.json +6 -3
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.discoverProjects = discoverProjects;
4
+ const node_fs_1 = require("node:fs");
5
+ const node_path_1 = require("node:path");
6
+ const SKIP_DIRS = new Set([
7
+ "node_modules", ".venv", "venv", "__pycache__", ".git",
8
+ ".tox", ".eggs", "dist", "build", ".next", ".nuxt",
9
+ "coverage", ".cache", ".pytest_cache", ".mypy_cache",
10
+ "validation-results", "test-fixtures", "fixtures",
11
+ ]);
12
+ const NPM_LOCKFILES = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "npm-shrinkwrap.json"];
13
+ const PYTHON_DEPFILES = ["requirements.txt", "Pipfile.lock", "poetry.lock"];
14
+ const MAX_DEPTH = 8;
15
+ function discoverProjects(root) {
16
+ const projects = [];
17
+ walk(root, root, 0, projects);
18
+ return projects.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
19
+ }
20
+ function walk(dir, root, depth, out) {
21
+ if (depth > MAX_DEPTH)
22
+ return;
23
+ // Check for npm project
24
+ for (const lockfile of NPM_LOCKFILES) {
25
+ const lockPath = (0, node_path_1.join)(dir, lockfile);
26
+ if ((0, node_fs_1.existsSync)(lockPath)) {
27
+ const count = countNpmPackages(lockPath);
28
+ if (count > 0) {
29
+ out.push({
30
+ path: dir,
31
+ relativePath: (0, node_path_1.relative)(root, dir) || ".",
32
+ ecosystem: "npm",
33
+ depFile: lockfile,
34
+ packageCount: count,
35
+ });
36
+ }
37
+ break; // one npm project per directory
38
+ }
39
+ }
40
+ // Check for python project
41
+ for (const depFile of PYTHON_DEPFILES) {
42
+ const depPath = (0, node_path_1.join)(dir, depFile);
43
+ if ((0, node_fs_1.existsSync)(depPath)) {
44
+ const count = countPythonPackages(depPath, depFile);
45
+ if (count > 0) {
46
+ out.push({
47
+ path: dir,
48
+ relativePath: (0, node_path_1.relative)(root, dir) || ".",
49
+ ecosystem: "pypi",
50
+ depFile,
51
+ packageCount: count,
52
+ });
53
+ }
54
+ break; // one python project per directory
55
+ }
56
+ }
57
+ // Recurse into subdirectories
58
+ let entries;
59
+ try {
60
+ entries = (0, node_fs_1.readdirSync)(dir);
61
+ }
62
+ catch {
63
+ return;
64
+ }
65
+ for (const entry of entries) {
66
+ if (SKIP_DIRS.has(entry) || entry.startsWith("."))
67
+ continue;
68
+ const full = (0, node_path_1.join)(dir, entry);
69
+ try {
70
+ const st = (0, node_fs_1.lstatSync)(full);
71
+ if (st.isDirectory() && !st.isSymbolicLink()) {
72
+ walk(full, root, depth + 1, out);
73
+ }
74
+ }
75
+ catch { /* ignore — unreadable entry */ }
76
+ }
77
+ }
78
+ function countNpmPackages(lockPath) {
79
+ try {
80
+ const name = (0, node_path_1.basename)(lockPath);
81
+ const content = (0, node_fs_1.readFileSync)(lockPath, "utf-8");
82
+ if (name === "yarn.lock") {
83
+ // Count package header lines (non-indented, ending with :)
84
+ return (content.match(/^\S.*:$/gm) || []).length;
85
+ }
86
+ if (name === "pnpm-lock.yaml") {
87
+ // Count package entries
88
+ return (content.match(/^\s{2}'?[/@]/gm) || []).length || estimateLines(content, 200);
89
+ }
90
+ // package-lock.json / npm-shrinkwrap.json
91
+ const parsed = JSON.parse(content);
92
+ if (parsed.packages) {
93
+ return Object.keys(parsed.packages).filter(k => k !== "").length;
94
+ }
95
+ if (parsed.dependencies) {
96
+ return Object.keys(parsed.dependencies).length;
97
+ }
98
+ return 0;
99
+ }
100
+ catch {
101
+ return 0;
102
+ }
103
+ }
104
+ function countPythonPackages(depPath, depFile) {
105
+ try {
106
+ const content = (0, node_fs_1.readFileSync)(depPath, "utf-8");
107
+ if (depFile === "Pipfile.lock") {
108
+ const parsed = JSON.parse(content);
109
+ const defaultCount = Object.keys(parsed.default || {}).length;
110
+ const devCount = Object.keys(parsed.develop || {}).length;
111
+ return defaultCount + devCount;
112
+ }
113
+ if (depFile === "poetry.lock") {
114
+ return (content.match(/^\[\[package\]\]/gm) || []).length;
115
+ }
116
+ // requirements.txt — count non-empty, non-comment lines
117
+ return content.split("\n").filter(line => line.trim() && !line.trim().startsWith("#") && !line.trim().startsWith("-")).length;
118
+ }
119
+ catch {
120
+ return 0;
121
+ }
122
+ }
123
+ function estimateLines(content, fallback) {
124
+ const lines = content.split("\n").length;
125
+ return lines > 20 ? Math.floor(lines / 4) : fallback;
126
+ }
@@ -0,0 +1,135 @@
1
+ "use strict";
2
+ // First-run wizard orchestration.
3
+ //
4
+ // Called from bin.ts at the top of main(), before any command dispatches.
5
+ // Decides whether to mount the InitApp wizard based on:
6
+ // 1. Skip-list (commands like `update` and `logout` never trigger setup)
7
+ // 2. TTY (CI / piped output never gets the wizard)
8
+ // 3. Sentinel (`firstRunCompletedAt` in ~/.dgrc.json — sticky once set)
9
+ //
10
+ // Three contracts after the wizard mounts:
11
+ // - The sentinel is marked as soon as the wizard mounts successfully.
12
+ // Once the user has SEEN the offer they should never be re-prompted,
13
+ // regardless of whether they finished, said no, or Ctrl+C'd out.
14
+ // - If the wizard is dismissed before reaching the `done` phase
15
+ // (Ctrl+C, q, escape) we exit the whole process here. The user's
16
+ // original command does NOT run after a quit — surfacing `dg scan`'s
17
+ // UI right after they bailed on the wizard is jarring.
18
+ // - If the wizard ran a scan (the user said yes and walked through
19
+ // result_first_scan) AND the user's original command is `scan`, we
20
+ // exit cleanly. The wizard's scan already showed them what they came
21
+ // to see; running it again would force them through a project picker
22
+ // and double the work.
23
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
24
+ if (k2 === undefined) k2 = k;
25
+ var desc = Object.getOwnPropertyDescriptor(m, k);
26
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
27
+ desc = { enumerable: true, get: function() { return m[k]; } };
28
+ }
29
+ Object.defineProperty(o, k2, desc);
30
+ }) : (function(o, m, k, k2) {
31
+ if (k2 === undefined) k2 = k;
32
+ o[k2] = m[k];
33
+ }));
34
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
35
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
36
+ }) : function(o, v) {
37
+ o["default"] = v;
38
+ });
39
+ var __importStar = (this && this.__importStar) || (function () {
40
+ var ownKeys = function(o) {
41
+ ownKeys = Object.getOwnPropertyNames || function (o) {
42
+ var ar = [];
43
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
44
+ return ar;
45
+ };
46
+ return ownKeys(o);
47
+ };
48
+ return function (mod) {
49
+ if (mod && mod.__esModule) return mod;
50
+ var result = {};
51
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
52
+ __setModuleDefault(result, mod);
53
+ return result;
54
+ };
55
+ })();
56
+ Object.defineProperty(exports, "__esModule", { value: true });
57
+ exports.maybeOfferFirstRunWizard = maybeOfferFirstRunWizard;
58
+ const auth_1 = require("./auth");
59
+ // Commands that should never trigger the auto-wizard:
60
+ // update / logout — meta actions, see D17
61
+ // kitty — already mounts the wizard explicitly via bin.ts
62
+ // help / version — info queries, handled by their own early returns,
63
+ // listed here defensively in case dispatch order changes
64
+ // hook — meta-operation on setup itself (install / uninstall).
65
+ // A user running `dg hook install` or `dg hook uninstall`
66
+ // is already familiar enough with DG to know what hooks
67
+ // are; offering them the guided tour is incoherent and
68
+ // blocks the destructive `uninstall` they actually asked
69
+ // for. Sentinel stays unset so the next normal command
70
+ // still gets the wizard.
71
+ const SKIP_LIST = new Set([
72
+ "update",
73
+ "logout",
74
+ "kitty",
75
+ "help",
76
+ "version",
77
+ "hook",
78
+ ]);
79
+ async function maybeOfferFirstRunWizard(opts) {
80
+ if (SKIP_LIST.has(opts.rawCommand))
81
+ return;
82
+ if (!opts.isInteractive)
83
+ return;
84
+ if ((0, auth_1.getFirstRunCompletedAt)() !== null)
85
+ return;
86
+ let mounted = false;
87
+ let reachedDone = false;
88
+ let scanRanInWizard = false;
89
+ try {
90
+ const { render } = await Promise.resolve().then(() => __importStar(require("ink")));
91
+ const React = await Promise.resolve().then(() => __importStar(require("react")));
92
+ const { InitApp } = await Promise.resolve().then(() => __importStar(require("./ui/InitApp")));
93
+ const { enterAltScreen, leaveAltScreen } = await Promise.resolve().then(() => __importStar(require("./alt-screen")));
94
+ enterAltScreen();
95
+ try {
96
+ const { waitUntilExit } = render(React.createElement(InitApp, {
97
+ firstRun: true,
98
+ onReachedDone: () => {
99
+ reachedDone = true;
100
+ },
101
+ onScanRan: () => {
102
+ scanRanInWizard = true;
103
+ },
104
+ }));
105
+ mounted = true;
106
+ await waitUntilExit();
107
+ }
108
+ finally {
109
+ leaveAltScreen();
110
+ }
111
+ }
112
+ catch {
113
+ // Mount failed — let the original command run, don't mark the sentinel.
114
+ return;
115
+ }
116
+ if (mounted) {
117
+ try {
118
+ (0, auth_1.markFirstRunComplete)();
119
+ }
120
+ catch {
121
+ // Best-effort. Worst case: prompted again next invocation.
122
+ }
123
+ }
124
+ if (mounted && !reachedDone) {
125
+ // User dismissed the wizard before finishing. Don't run their original
126
+ // command — exit cleanly with the conventional Ctrl+C exit code.
127
+ process.exit(130);
128
+ }
129
+ // If the wizard ran a scan and the user's original command is `dg scan`,
130
+ // we'd otherwise re-run the same scan and force them through a project
131
+ // picker. Skip the duplicate cleanly.
132
+ if (mounted && reachedDone && scanRanInWizard && opts.rawCommand === "scan") {
133
+ process.exit(0);
134
+ }
135
+ }
@@ -0,0 +1,360 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.detectHookFramework = detectHookFramework;
4
+ exports.previewHookInstall = previewHookInstall;
5
+ exports.installHookForFramework = installHookForFramework;
6
+ exports.handleHookCommand = handleHookCommand;
7
+ const node_child_process_1 = require("node:child_process");
8
+ const node_fs_1 = require("node:fs");
9
+ const node_path_1 = require("node:path");
10
+ const HOOK_MARKER = "# dependency-guardian-hook";
11
+ const MARKER_START = "# dependency-guardian-hook-start";
12
+ const MARKER_END = "# dependency-guardian-hook-end";
13
+ const LEFTHOOK_MARKER = "dependency-guardian"; // used inside lefthook.yml command name
14
+ const HOOK_SCRIPT = `#!/bin/sh
15
+ ${HOOK_MARKER} — installed by \`dg hook install\`
16
+
17
+ # Check if any lockfiles are staged
18
+ STAGED=""
19
+ for f in package-lock.json npm-shrinkwrap.json yarn.lock pnpm-lock.yaml; do
20
+ if git diff --cached --name-only | grep -q "^\${f}$"; then
21
+ STAGED="yes"
22
+ break
23
+ fi
24
+ done
25
+
26
+ if [ -z "$STAGED" ]; then
27
+ exit 0
28
+ fi
29
+
30
+ # Find dg binary
31
+ DG_BIN=$(command -v dg 2>/dev/null || command -v dependency-guardian 2>/dev/null)
32
+ if [ -z "$DG_BIN" ]; then
33
+ echo "Warning: dg not found in PATH. Skipping dependency scan." >&2
34
+ exit 0
35
+ fi
36
+
37
+ echo "Dependency Guardian: lockfile change detected, scanning..." >&2
38
+ NO_COLOR=1 "$DG_BIN" scan --mode block
39
+ EXIT_CODE=$?
40
+
41
+ if [ "$EXIT_CODE" -eq 2 ]; then
42
+ echo "" >&2
43
+ echo "Commit blocked by Dependency Guardian (high-risk packages detected)." >&2
44
+ echo "Run \\\`dg scan --scan-all\\\` for details, or \\\`git commit --no-verify\\\` to bypass." >&2
45
+ exit 1
46
+ fi
47
+
48
+ if [ "$EXIT_CODE" -eq 3 ]; then
49
+ echo "Warning: dg scan encountered an error. Allowing commit." >&2
50
+ fi
51
+
52
+ exit 0
53
+ `;
54
+ const HOOK_SECTION = `
55
+ ${MARKER_START}
56
+ ${HOOK_SCRIPT.split("\n").slice(1).join("\n")}${MARKER_END}
57
+ `;
58
+ // One-line snippet appended to .husky/pre-commit. Husky pre-commit files are
59
+ // shell scripts (the v9+ format dropped the husky.sh shim — they're now plain
60
+ // scripts with no special header). We append a one-liner that invokes dg.
61
+ const HUSKY_SNIPPET = `
62
+ ${MARKER_START}
63
+ ${HOOK_MARKER} — installed by \`dg hook install\`
64
+ DG_BIN=$(command -v dg 2>/dev/null || command -v dependency-guardian 2>/dev/null)
65
+ if [ -n "$DG_BIN" ]; then
66
+ if git diff --cached --name-only | grep -qE '^(package-lock\\.json|npm-shrinkwrap\\.json|yarn\\.lock|pnpm-lock\\.yaml)$'; then
67
+ echo "Dependency Guardian: lockfile change detected, scanning..." >&2
68
+ NO_COLOR=1 "$DG_BIN" scan --mode block
69
+ DG_EXIT=$?
70
+ if [ "$DG_EXIT" -eq 2 ]; then
71
+ echo "Commit blocked by Dependency Guardian. Use 'git commit --no-verify' to bypass." >&2
72
+ exit 1
73
+ fi
74
+ fi
75
+ fi
76
+ ${MARKER_END}
77
+ `;
78
+ // One lefthook command entry appended to lefthook.yml's pre-commit.commands map.
79
+ // The key name embeds LEFTHOOK_MARKER so we can detect existing installs.
80
+ const LEFTHOOK_ENTRY = ` pre-commit:
81
+ commands:
82
+ ${LEFTHOOK_MARKER}:
83
+ glob: "{package-lock.json,yarn.lock,pnpm-lock.yaml,npm-shrinkwrap.json}"
84
+ run: dg scan --mode block
85
+ `;
86
+ const USAGE = `
87
+ dg hook — manage git pre-commit hook
88
+
89
+ Usage:
90
+ dg hook install Install pre-commit hook to scan lockfile changes
91
+ dg hook uninstall Remove the pre-commit hook
92
+
93
+ The hook runs \`dg scan --mode block\` when lockfile changes are committed.
94
+ If high-risk packages are detected, the commit is blocked.
95
+
96
+ Detection: dg hook install detects existing hook frameworks in your
97
+ project and integrates with them instead of fighting them:
98
+
99
+ .husky/pre-commit → appends a DG block (Husky v9+)
100
+ lefthook.yml → adds a pre-commit command entry
101
+ (none) → installs into .git/hooks/pre-commit directly
102
+ `;
103
+ function findGitDir() {
104
+ try {
105
+ return (0, node_child_process_1.execFileSync)("git", ["rev-parse", "--git-dir"], {
106
+ encoding: "utf-8",
107
+ stdio: ["pipe", "pipe", "pipe"],
108
+ }).trim();
109
+ }
110
+ catch {
111
+ throw new Error("Not a git repository. Run this from inside a git project.");
112
+ }
113
+ }
114
+ function findRepoRoot() {
115
+ try {
116
+ return (0, node_child_process_1.execFileSync)("git", ["rev-parse", "--show-toplevel"], {
117
+ encoding: "utf-8",
118
+ stdio: ["pipe", "pipe", "pipe"],
119
+ }).trim();
120
+ }
121
+ catch {
122
+ throw new Error("Not a git repository. Run this from inside a git project.");
123
+ }
124
+ }
125
+ /** Detect which pre-commit framework (if any) the project uses.
126
+ * Husky takes precedence over lefthook if both are present (rare). */
127
+ function detectHookFramework(repoRoot) {
128
+ const root = repoRoot ?? findRepoRoot();
129
+ const huskyHook = (0, node_path_1.join)(root, ".husky", "pre-commit");
130
+ const lefthookConfig = (0, node_path_1.join)(root, "lefthook.yml");
131
+ const lefthookConfigYaml = (0, node_path_1.join)(root, "lefthook.yaml");
132
+ const gitDir = findGitDir();
133
+ const bareHook = (0, node_path_1.join)(gitDir, "hooks", "pre-commit");
134
+ // Husky check — the file at .husky/pre-commit is the canonical husky setup
135
+ if ((0, node_fs_1.existsSync)(huskyHook)) {
136
+ const content = (0, node_fs_1.readFileSync)(huskyHook, "utf-8");
137
+ return {
138
+ framework: "husky",
139
+ targetFile: huskyHook,
140
+ alreadyInstalled: content.includes(HOOK_MARKER),
141
+ };
142
+ }
143
+ // Lefthook check — yml or yaml
144
+ for (const cfg of [lefthookConfig, lefthookConfigYaml]) {
145
+ if ((0, node_fs_1.existsSync)(cfg)) {
146
+ const content = (0, node_fs_1.readFileSync)(cfg, "utf-8");
147
+ // Match the dependency-guardian command name inside the pre-commit section.
148
+ // We look for the marker as a yaml key — must be at indent 6 (inside commands:)
149
+ const installed = /^\s+dependency-guardian\s*:/m.test(content);
150
+ return {
151
+ framework: "lefthook",
152
+ targetFile: cfg,
153
+ alreadyInstalled: installed,
154
+ };
155
+ }
156
+ }
157
+ // Bare git hook fallback
158
+ let installed = false;
159
+ if ((0, node_fs_1.existsSync)(bareHook)) {
160
+ installed = (0, node_fs_1.readFileSync)(bareHook, "utf-8").includes(HOOK_MARKER);
161
+ }
162
+ return {
163
+ framework: "bare",
164
+ targetFile: bareHook,
165
+ alreadyInstalled: installed,
166
+ };
167
+ }
168
+ /** Render the diff that will be applied for the given framework, without
169
+ * actually writing anything. Used by `dg init --dry-run` and the wizard's
170
+ * "show me the change" prompt. */
171
+ function previewHookInstall(info) {
172
+ if (info.alreadyInstalled) {
173
+ return `(already installed in ${info.targetFile} — no changes needed)`;
174
+ }
175
+ switch (info.framework) {
176
+ case "husky":
177
+ return `Will append to ${info.targetFile}:\n${HUSKY_SNIPPET}`;
178
+ case "lefthook":
179
+ return `Will add to ${info.targetFile}:\n\n${LEFTHOOK_ENTRY}`;
180
+ case "bare":
181
+ if ((0, node_fs_1.existsSync)(info.targetFile)) {
182
+ return `Will append to existing hook at ${info.targetFile}:\n${HOOK_SECTION}`;
183
+ }
184
+ return `Will create new hook at ${info.targetFile}:\n${HOOK_SCRIPT}`;
185
+ }
186
+ }
187
+ function installHuskyHook(targetFile) {
188
+ const existing = (0, node_fs_1.readFileSync)(targetFile, "utf-8");
189
+ if (existing.includes(HOOK_MARKER)) {
190
+ process.stderr.write(" Hook already installed in Husky.\n");
191
+ return;
192
+ }
193
+ (0, node_fs_1.writeFileSync)(targetFile, existing.trimEnd() + "\n" + HUSKY_SNIPPET);
194
+ // Husky hook files are executable; preserve that
195
+ try {
196
+ (0, node_fs_1.chmodSync)(targetFile, 0o755);
197
+ }
198
+ catch { /* ignore — chmod may fail on some FS */ }
199
+ process.stderr.write(` Appended Dependency Guardian to ${targetFile}\n`);
200
+ }
201
+ function installLefthookHook(targetFile) {
202
+ const existing = (0, node_fs_1.readFileSync)(targetFile, "utf-8");
203
+ if (/^\s+dependency-guardian\s*:/m.test(existing)) {
204
+ process.stderr.write(" Hook already installed in lefthook.\n");
205
+ return;
206
+ }
207
+ // Lefthook config is YAML. We need to add a pre-commit.commands.dependency-guardian
208
+ // entry. The simplest approach: detect whether `pre-commit:` already exists at
209
+ // the top level and either append our command under it, or add the whole block.
210
+ let updated;
211
+ const preCommitMatch = existing.match(/^pre-commit\s*:\s*$/m);
212
+ if (preCommitMatch) {
213
+ // pre-commit: already exists. Insert our command under its commands: subkey.
214
+ // We assume there's a `commands:` key under it. If not, this is too complex
215
+ // to merge automatically and we error out so the user can do it manually.
216
+ if (/^\s{2}commands\s*:\s*$/m.test(existing)) {
217
+ // Insert under the existing commands: line.
218
+ updated = existing.replace(/^(\s{2}commands\s*:\s*)$/m, `$1\n ${LEFTHOOK_MARKER}:\n glob: "{package-lock.json,yarn.lock,pnpm-lock.yaml,npm-shrinkwrap.json}"\n run: dg scan --mode block`);
219
+ }
220
+ else {
221
+ throw new Error(`Could not auto-merge into ${targetFile}: pre-commit: section exists but has no commands: subkey. ` +
222
+ `Add this manually under your pre-commit.commands:\n ${LEFTHOOK_MARKER}:\n glob: "{package-lock.json,...}"\n run: dg scan --mode block`);
223
+ }
224
+ }
225
+ else {
226
+ // No pre-commit: section. Append the whole block.
227
+ updated = existing.trimEnd() + "\n\n" + LEFTHOOK_ENTRY;
228
+ }
229
+ (0, node_fs_1.writeFileSync)(targetFile, updated);
230
+ process.stderr.write(` Added Dependency Guardian to ${targetFile}\n`);
231
+ }
232
+ function installBareHook(targetFile) {
233
+ const hooksDir = (0, node_path_1.dirname)(targetFile);
234
+ (0, node_fs_1.mkdirSync)(hooksDir, { recursive: true });
235
+ if ((0, node_fs_1.existsSync)(targetFile)) {
236
+ const existing = (0, node_fs_1.readFileSync)(targetFile, "utf-8");
237
+ if (existing.includes(HOOK_MARKER)) {
238
+ process.stderr.write(" Hook already installed.\n");
239
+ return;
240
+ }
241
+ (0, node_fs_1.writeFileSync)(targetFile, existing.trimEnd() + "\n" + HOOK_SECTION);
242
+ (0, node_fs_1.chmodSync)(targetFile, 0o755);
243
+ process.stderr.write(` Appended Dependency Guardian hook to existing ${targetFile}\n`);
244
+ return;
245
+ }
246
+ (0, node_fs_1.writeFileSync)(targetFile, HOOK_SCRIPT);
247
+ (0, node_fs_1.chmodSync)(targetFile, 0o755);
248
+ process.stderr.write(` Installed git pre-commit hook at ${targetFile}\n`);
249
+ }
250
+ /** Top-level installer that dispatches to the right framework-specific helper.
251
+ * Reused by `dg hook install` and the new `dg init` command. */
252
+ function installHookForFramework(info) {
253
+ switch (info.framework) {
254
+ case "husky":
255
+ installHuskyHook(info.targetFile);
256
+ return;
257
+ case "lefthook":
258
+ installLefthookHook(info.targetFile);
259
+ return;
260
+ case "bare":
261
+ installBareHook(info.targetFile);
262
+ return;
263
+ }
264
+ }
265
+ function installHook() {
266
+ const info = detectHookFramework();
267
+ installHookForFramework(info);
268
+ }
269
+ function uninstallHook() {
270
+ const gitDir = findGitDir();
271
+ const hookPath = (0, node_path_1.join)(gitDir, "hooks", "pre-commit");
272
+ // Try husky first
273
+ try {
274
+ const root = findRepoRoot();
275
+ const huskyPath = (0, node_path_1.join)(root, ".husky", "pre-commit");
276
+ if ((0, node_fs_1.existsSync)(huskyPath)) {
277
+ const content = (0, node_fs_1.readFileSync)(huskyPath, "utf-8");
278
+ if (content.includes(HOOK_MARKER)) {
279
+ const startIdx = content.indexOf(MARKER_START);
280
+ const endIdx = content.indexOf(MARKER_END);
281
+ if (startIdx !== -1 && endIdx !== -1) {
282
+ const before = content.slice(0, startIdx).trimEnd();
283
+ const after = content.slice(endIdx + MARKER_END.length).trimStart();
284
+ const remaining = (before + (after ? "\n" + after : "")).trimEnd() + "\n";
285
+ (0, node_fs_1.writeFileSync)(huskyPath, remaining);
286
+ process.stderr.write(` Removed Dependency Guardian section from ${huskyPath}\n`);
287
+ return;
288
+ }
289
+ }
290
+ }
291
+ // Try lefthook
292
+ for (const cfg of [(0, node_path_1.join)(root, "lefthook.yml"), (0, node_path_1.join)(root, "lefthook.yaml")]) {
293
+ if ((0, node_fs_1.existsSync)(cfg)) {
294
+ const content = (0, node_fs_1.readFileSync)(cfg, "utf-8");
295
+ if (/^\s+dependency-guardian\s*:/m.test(content)) {
296
+ // Strip the dependency-guardian command block — match key + 2 indented lines
297
+ const stripped = content.replace(/^\s+dependency-guardian\s*:\s*\n(?:\s{6,}.*\n)+/gm, "");
298
+ (0, node_fs_1.writeFileSync)(cfg, stripped);
299
+ process.stderr.write(` Removed Dependency Guardian section from ${cfg}\n`);
300
+ return;
301
+ }
302
+ }
303
+ }
304
+ }
305
+ catch { /* fall through to bare-hook handling */ }
306
+ if (!(0, node_fs_1.existsSync)(hookPath)) {
307
+ process.stderr.write(" No hook to remove.\n");
308
+ return;
309
+ }
310
+ const content = (0, node_fs_1.readFileSync)(hookPath, "utf-8");
311
+ if (!content.includes(HOOK_MARKER)) {
312
+ process.stderr.write(" No Dependency Guardian hook found in pre-commit.\n");
313
+ return;
314
+ }
315
+ // Check if the file is entirely the DG hook (starts with shebang + marker)
316
+ if (content.trimStart().startsWith("#!/bin/sh\n" + HOOK_MARKER)) {
317
+ (0, node_fs_1.unlinkSync)(hookPath);
318
+ process.stderr.write(` Removed pre-commit hook at ${hookPath}\n`);
319
+ return;
320
+ }
321
+ // Strip the DG section from a combined hook
322
+ const startIdx = content.indexOf(MARKER_START);
323
+ const endIdx = content.indexOf(MARKER_END);
324
+ if (startIdx !== -1 && endIdx !== -1) {
325
+ const before = content.slice(0, startIdx).trimEnd();
326
+ const after = content.slice(endIdx + MARKER_END.length).trimStart();
327
+ const remaining = (before + (after ? "\n" + after : "")).trimEnd() + "\n";
328
+ (0, node_fs_1.writeFileSync)(hookPath, remaining);
329
+ process.stderr.write(` Removed Dependency Guardian section from ${hookPath}\n`);
330
+ return;
331
+ }
332
+ // Marker found but no start/end pair (standalone install)
333
+ (0, node_fs_1.unlinkSync)(hookPath);
334
+ process.stderr.write(` Removed pre-commit hook at ${hookPath}\n`);
335
+ }
336
+ function handleHookCommand(args) {
337
+ const subcommand = args[0];
338
+ if (!subcommand || subcommand === "--help") {
339
+ process.stderr.write(USAGE);
340
+ return;
341
+ }
342
+ try {
343
+ if (subcommand === "install") {
344
+ installHook();
345
+ }
346
+ else if (subcommand === "uninstall") {
347
+ uninstallHook();
348
+ }
349
+ else {
350
+ process.stderr.write(` Unknown hook command: ${subcommand}\n`);
351
+ process.stderr.write(USAGE);
352
+ process.exit(1);
353
+ }
354
+ }
355
+ catch (err) {
356
+ const msg = err instanceof Error ? err.message : String(err);
357
+ process.stderr.write(` Error: ${msg}\n`);
358
+ process.exit(1);
359
+ }
360
+ }