@vaultcompass/vault-guard-core 1.4.2 → 1.4.4

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.
@@ -1,27 +1,110 @@
1
1
  export type HookManager = 'native' | 'husky' | 'lefthook' | 'precommit';
2
2
  export interface InstallHookOptions {
3
3
  manager?: HookManager;
4
- /** Working directory (git repo root). Defaults to `process.cwd()`. */
4
+ /**
5
+ * Working directory. MUST be the git repository root -- the directory
6
+ * containing `.git` -- not a package subdirectory in a monorepo, even
7
+ * one that owns its own `.husky`. `install()`/`uninstall()` refuse
8
+ * outright when `.git` is not directly present (see the check at the
9
+ * top of each), and core.hooksPath is resolved relative to the
10
+ * worktree root regardless of where a nested `.husky` lives, so running
11
+ * from anywhere else either fails fast or resolves the wrong hooks
12
+ * directory entirely. Defaults to `process.cwd()`.
13
+ */
5
14
  cwd?: string;
6
15
  }
7
16
  export declare class PreCommitHook {
8
17
  /**
9
18
  * Resolve the directory where Git expects the \`pre-commit\` executable.
10
- * Honors \`core.hooksPath\` (local then global). Relative paths are resolved
11
- * against the **.git** directory, per Git documentation.
19
+ * Honors \`core.hooksPath\` (local then global).
20
+ *
21
+ * A RELATIVE \`core.hooksPath\` resolves against the WORKING-TREE ROOT,
22
+ * not against the .git directory. This resolved against the .git
23
+ * directory until a review caught it: husky 9 sets exactly this shape
24
+ * (core.hooksPath=.husky/_), so a repository using husky 9 had its hook
25
+ * written to a path git never reads, reported as installed, and never
26
+ * ran. Verified by driving a real commit rather than by re-reading the
27
+ * documentation that was misread the first time: with
28
+ * core.hooksPath=.husky/_ and an executable hook planted at BOTH
29
+ * .husky/_/pre-commit and .git/.husky/_/pre-commit, a commit runs the
30
+ * former. pre-commit-hook.test.ts pins it the same way.
31
+ *
32
+ * An ABSOLUTE \`core.hooksPath\` is used exactly as given, and no
33
+ * \`core.hooksPath\` at all still resolves against the git directory
34
+ * (never the working-tree root) -- both of those are unaffected by the
35
+ * bug above and must stay that way: a linked worktree or a submodule
36
+ * has its own git directory that is not its working-tree root, and that
37
+ * is precisely where an unset \`core.hooksPath\` needs to keep pointing.
12
38
  */
13
39
  getEffectiveHooksDir(cwd: string): {
14
40
  hooksDir: string;
15
41
  viaHooksPath: boolean;
16
42
  };
43
+ /**
44
+ * Whether the resolved hooks directory is husky 9's GENERATED,
45
+ * gitignored directory (\`.husky/_\` by default) rather than a real
46
+ * hooks directory. Husky's own prepare script rewrites this directory on
47
+ * every \`pnpm install\`, so nothing vault-guard writes there survives;
48
+ * the durable, tracked hook lives one directory up at
49
+ * \`.husky/<hookname>\`.
50
+ *
51
+ * This is deliberately narrow: the ONLY signal that may trigger the
52
+ * redirect is the directory SHAPE -- a resolved basename of \`_\` under a
53
+ * directory literally named \`.husky\`. Two false-positive shapes were
54
+ * caught by review before shipping and must never redirect:
55
+ * - core.hooksPath pointing at an unrelated directory (e.g. .githooks)
56
+ * that happens to contain a file literally named \`h\` -- an \`h\` file
57
+ * is not evidence of husky on its own, only the directory shape is;
58
+ * - a dispatcher-shaped pre-commit file (the same two-line shebang
59
+ * body husky 9 writes) sitting somewhere that is NOT \`.husky/_\`
60
+ * (e.g. plain \`.git/hooks/pre-commit\`) -- content shape alone is not
61
+ * evidence either, since a foreign hook can coincidentally look like
62
+ * this.
63
+ * Neither the \`h\` shim nor dispatcher-shaped content is checked at all
64
+ * here; they would only ever have been used to confirm a shape match,
65
+ * never to trigger one on their own, and the shape check alone is both
66
+ * necessary and sufficient for every case this fix needs to handle.
67
+ */
68
+ isHuskyGeneratedHooksDir(hooksDir: string): boolean;
69
+ /**
70
+ * Where husky's own \`h\` shim actually resolves and executes the tracked
71
+ * hook, given a husky-generated hooksDir (isHuskyGeneratedHooksDir(hooksDir)
72
+ * must already be true). Husky computes this as the PARENT of the
73
+ * generated \`_\` directory -- fixed relative to hooksDir, never a fixed
74
+ * \`<cwd>/.husky\`, because core.hooksPath can point at a NESTED
75
+ * \`.husky/_\` (e.g. \`packages/app/.husky/_\`, the ordinary shape for a
76
+ * monorepo package that owns husky's "prepare" script but is not itself
77
+ * the git root). In that case husky's shim genuinely runs
78
+ * \`packages/app/.husky/<hookname>\`, not \`<cwd>/.husky/<hookname>\` --
79
+ * proven wrong by independent review with a functional shim and a real
80
+ * commit before this was fixed. getPreCommitHookPath, installNative,
81
+ * uninstallNative, and getPreCommitCmdPath all resolve through this one
82
+ * place so the redirect target can never drift between them.
83
+ */
84
+ private resolveHuskyDir;
85
+ /** \`absPath\`, relative to \`cwd\`, with forward slashes on every platform. */
86
+ private relFromCwd;
17
87
  /**
18
88
  * Absolute path to the \`pre-commit\` hook file for the given manager.
89
+ *
90
+ * For the \`native\` manager, when the resolved hooks directory is
91
+ * husky 9's generated directory (see isHuskyGeneratedHooksDir), this
92
+ * resolves to the TRACKED hook file husky's own \`h\` shim actually runs
93
+ * -- see resolveHuskyDir -- because nothing written under the generated
94
+ * directory survives husky's prepare script. See install()'s
95
+ * husky-delegation in installNative for the write side of this.
19
96
  */
20
97
  getPreCommitHookPath(cwd: string, manager?: HookManager): string;
21
98
  /**
22
- * Absolute path to the Windows \`pre-commit.cmd\` companion (native manager only).
99
+ * Absolute path to the Windows \`pre-commit.cmd\` companion (native manager
100
+ * only). \`undefined\` under a husky-generated hooks directory: the .cmd
101
+ * companion is native-only and installNative's husky-redirect never
102
+ * writes one there (see installNative), so there is no meaningful path
103
+ * to report. Callers that used to guard this getter with their own
104
+ * isHuskyGeneratedHooksDir check (the CLI's foreignHookConflict did)
105
+ * can drop that guard now that it lives here instead.
23
106
  */
24
- getPreCommitCmdPath(cwd: string): string;
107
+ getPreCommitCmdPath(cwd: string): string | undefined;
25
108
  install(options?: InstallHookOptions): {
26
109
  success: boolean;
27
110
  message: string;
@@ -40,11 +123,45 @@ export declare class PreCommitHook {
40
123
  private removeNativeCmdCompanion;
41
124
  private installNative;
42
125
  private uninstallNative;
126
+ /**
127
+ * @param huskyDir Directory holding the tracked hook. Defaults to
128
+ * \`<cwd>/.husky\`, correct for the explicit \`husky\` manager (it never
129
+ * consults core.hooksPath). installNative's redirect passes the
130
+ * ACTUAL directory resolveHuskyDir computed instead, which can be
131
+ * nested (e.g. \`packages/app/.husky\`) -- see resolveHuskyDir.
132
+ */
43
133
  private installHusky;
134
+ /**
135
+ * @param huskyDir See installHusky.
136
+ *
137
+ * Fix for a defect the reviewer found while checking uninstall after
138
+ * the redirect started routing every husky 9 repo through this
139
+ * function (previously only reachable via the explicit `husky`
140
+ * manager): when installHusky wrote the WHOLE file from
141
+ * HUSKY_HOOK_SCRIPT (the fresh-install path -- what both the redirect
142
+ * and a from-scratch \`--manager husky\` install take), there is no
143
+ * "# --- vault-guard ---" appended-block marker to strip, so the old
144
+ * logic here matched nothing, rewrote the file byte-identical, and
145
+ * reported success:true with isInstalled still true. Distinguishing
146
+ * the two shapes vault-guard itself ever produces -- the whole-file
147
+ * header vs. the appended-stanza marker -- fixes this: a whole-file
148
+ * hook is removed entirely; an appended stanza is stripped, keeping
149
+ * the pre-existing foreign content; anything else that merely mentions
150
+ * "vault-guard" in neither recognized shape is left untouched, with an
151
+ * honest message and success only if it happens not to still read as
152
+ * installed.
153
+ */
44
154
  private uninstallHusky;
45
155
  private installLefthook;
46
156
  private uninstallLefthook;
47
157
  private installPreCommitFramework;
48
158
  private uninstallPreCommitFramework;
49
159
  private resolveGitDir;
160
+ /**
161
+ * The working-tree root, or null when it cannot be determined. Asked of
162
+ * git rather than assumed to be \`cwd\`, so a relative \`core.hooksPath\`
163
+ * resolves correctly when \`install\`/\`getEffectiveHooksDir\` is called
164
+ * from a subdirectory of the repository.
165
+ */
166
+ private resolveWorktreeRoot;
50
167
  }
@@ -8,12 +8,23 @@ const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const child_process_1 = require("child_process");
10
10
  const errors_1 = require("../errors");
11
+ /**
12
+ * Marks a hook file vault-guard wrote WHOLE, from a template -- as
13
+ * opposed to a stanza vault-guard appended to a pre-existing (foreign)
14
+ * hook it does not own. uninstallHusky uses this to decide whether it is
15
+ * safe to delete the file outright: present means the whole file is
16
+ * vault-guard's, so removing it entirely is correct; absent (even when
17
+ * the file mentions "vault-guard" some other way) means the file
18
+ * predates vault-guard or was never fully vault-guard's, so it must not
19
+ * be deleted wholesale.
20
+ */
21
+ const VAULT_GUARD_HOOK_HEADER = '# vault-guard pre-commit (installed by @vaultcompass/vault-guard)';
11
22
  /**
12
23
  * Shell hook body for **native** Git hooks (`core.hooksPath` or `.git/hooks`).
13
24
  * Scans **staged files only** — fast and matches what will actually be committed.
14
25
  */
15
26
  const NATIVE_HOOK_SCRIPT = `#!/bin/sh
16
- # vault-guard pre-commit (installed by @vaultcompass/vault-guard)
27
+ ${VAULT_GUARD_HOOK_HEADER}
17
28
  set -e
18
29
 
19
30
  # Re-attach stdin for GUI git clients when possible.
@@ -69,6 +80,7 @@ exit /b 0
69
80
  `;
70
81
  /** Husky-friendly hook (sources \`_/husky.sh\` when present). */
71
82
  const HUSKY_HOOK_SCRIPT = `#!/usr/bin/env sh
83
+ ${VAULT_GUARD_HOOK_HEADER}
72
84
  if [ -f "$(dirname "$0")/_/husky.sh" ]; then
73
85
  . "$(dirname "$0")/_/husky.sh"
74
86
  fi
@@ -106,8 +118,25 @@ repos:
106
118
  class PreCommitHook {
107
119
  /**
108
120
  * Resolve the directory where Git expects the \`pre-commit\` executable.
109
- * Honors \`core.hooksPath\` (local then global). Relative paths are resolved
110
- * against the **.git** directory, per Git documentation.
121
+ * Honors \`core.hooksPath\` (local then global).
122
+ *
123
+ * A RELATIVE \`core.hooksPath\` resolves against the WORKING-TREE ROOT,
124
+ * not against the .git directory. This resolved against the .git
125
+ * directory until a review caught it: husky 9 sets exactly this shape
126
+ * (core.hooksPath=.husky/_), so a repository using husky 9 had its hook
127
+ * written to a path git never reads, reported as installed, and never
128
+ * ran. Verified by driving a real commit rather than by re-reading the
129
+ * documentation that was misread the first time: with
130
+ * core.hooksPath=.husky/_ and an executable hook planted at BOTH
131
+ * .husky/_/pre-commit and .git/.husky/_/pre-commit, a commit runs the
132
+ * former. pre-commit-hook.test.ts pins it the same way.
133
+ *
134
+ * An ABSOLUTE \`core.hooksPath\` is used exactly as given, and no
135
+ * \`core.hooksPath\` at all still resolves against the git directory
136
+ * (never the working-tree root) -- both of those are unaffected by the
137
+ * bug above and must stay that way: a linked worktree or a submodule
138
+ * has its own git directory that is not its working-tree root, and that
139
+ * is precisely where an unset \`core.hooksPath\` needs to keep pointing.
111
140
  */
112
141
  getEffectiveHooksDir(cwd) {
113
142
  const gitDirAbs = this.resolveGitDir(cwd);
@@ -128,25 +157,99 @@ class PreCommitHook {
128
157
  if (!hooksPath) {
129
158
  return { hooksDir: path_1.default.join(gitDirAbs, 'hooks'), viaHooksPath: false };
130
159
  }
131
- const hooksDir = path_1.default.isAbsolute(hooksPath)
132
- ? hooksPath
133
- : path_1.default.join(gitDirAbs, hooksPath);
134
- return { hooksDir, viaHooksPath: true };
160
+ if (path_1.default.isAbsolute(hooksPath)) {
161
+ return { hooksDir: hooksPath, viaHooksPath: true };
162
+ }
163
+ const worktreeRoot = this.resolveWorktreeRoot(cwd) ?? cwd;
164
+ return { hooksDir: path_1.default.join(worktreeRoot, hooksPath), viaHooksPath: true };
165
+ }
166
+ /**
167
+ * Whether the resolved hooks directory is husky 9's GENERATED,
168
+ * gitignored directory (\`.husky/_\` by default) rather than a real
169
+ * hooks directory. Husky's own prepare script rewrites this directory on
170
+ * every \`pnpm install\`, so nothing vault-guard writes there survives;
171
+ * the durable, tracked hook lives one directory up at
172
+ * \`.husky/<hookname>\`.
173
+ *
174
+ * This is deliberately narrow: the ONLY signal that may trigger the
175
+ * redirect is the directory SHAPE -- a resolved basename of \`_\` under a
176
+ * directory literally named \`.husky\`. Two false-positive shapes were
177
+ * caught by review before shipping and must never redirect:
178
+ * - core.hooksPath pointing at an unrelated directory (e.g. .githooks)
179
+ * that happens to contain a file literally named \`h\` -- an \`h\` file
180
+ * is not evidence of husky on its own, only the directory shape is;
181
+ * - a dispatcher-shaped pre-commit file (the same two-line shebang
182
+ * body husky 9 writes) sitting somewhere that is NOT \`.husky/_\`
183
+ * (e.g. plain \`.git/hooks/pre-commit\`) -- content shape alone is not
184
+ * evidence either, since a foreign hook can coincidentally look like
185
+ * this.
186
+ * Neither the \`h\` shim nor dispatcher-shaped content is checked at all
187
+ * here; they would only ever have been used to confirm a shape match,
188
+ * never to trigger one on their own, and the shape check alone is both
189
+ * necessary and sufficient for every case this fix needs to handle.
190
+ */
191
+ isHuskyGeneratedHooksDir(hooksDir) {
192
+ const base = path_1.default.basename(hooksDir);
193
+ const parentBase = path_1.default.basename(path_1.default.dirname(hooksDir));
194
+ return base === '_' && parentBase === '.husky';
195
+ }
196
+ /**
197
+ * Where husky's own \`h\` shim actually resolves and executes the tracked
198
+ * hook, given a husky-generated hooksDir (isHuskyGeneratedHooksDir(hooksDir)
199
+ * must already be true). Husky computes this as the PARENT of the
200
+ * generated \`_\` directory -- fixed relative to hooksDir, never a fixed
201
+ * \`<cwd>/.husky\`, because core.hooksPath can point at a NESTED
202
+ * \`.husky/_\` (e.g. \`packages/app/.husky/_\`, the ordinary shape for a
203
+ * monorepo package that owns husky's "prepare" script but is not itself
204
+ * the git root). In that case husky's shim genuinely runs
205
+ * \`packages/app/.husky/<hookname>\`, not \`<cwd>/.husky/<hookname>\` --
206
+ * proven wrong by independent review with a functional shim and a real
207
+ * commit before this was fixed. getPreCommitHookPath, installNative,
208
+ * uninstallNative, and getPreCommitCmdPath all resolve through this one
209
+ * place so the redirect target can never drift between them.
210
+ */
211
+ resolveHuskyDir(hooksDir) {
212
+ return path_1.default.dirname(hooksDir);
213
+ }
214
+ /** \`absPath\`, relative to \`cwd\`, with forward slashes on every platform. */
215
+ relFromCwd(cwd, absPath) {
216
+ return path_1.default.relative(cwd, absPath).split(path_1.default.sep).join('/');
135
217
  }
136
218
  /**
137
219
  * Absolute path to the \`pre-commit\` hook file for the given manager.
220
+ *
221
+ * For the \`native\` manager, when the resolved hooks directory is
222
+ * husky 9's generated directory (see isHuskyGeneratedHooksDir), this
223
+ * resolves to the TRACKED hook file husky's own \`h\` shim actually runs
224
+ * -- see resolveHuskyDir -- because nothing written under the generated
225
+ * directory survives husky's prepare script. See install()'s
226
+ * husky-delegation in installNative for the write side of this.
138
227
  */
139
228
  getPreCommitHookPath(cwd, manager = 'native') {
140
229
  if (manager === 'husky') {
141
230
  return path_1.default.join(cwd, '.husky', 'pre-commit');
142
231
  }
143
- return path_1.default.join(this.getEffectiveHooksDir(cwd).hooksDir, 'pre-commit');
232
+ const { hooksDir } = this.getEffectiveHooksDir(cwd);
233
+ if (this.isHuskyGeneratedHooksDir(hooksDir)) {
234
+ return path_1.default.join(this.resolveHuskyDir(hooksDir), 'pre-commit');
235
+ }
236
+ return path_1.default.join(hooksDir, 'pre-commit');
144
237
  }
145
238
  /**
146
- * Absolute path to the Windows \`pre-commit.cmd\` companion (native manager only).
239
+ * Absolute path to the Windows \`pre-commit.cmd\` companion (native manager
240
+ * only). \`undefined\` under a husky-generated hooks directory: the .cmd
241
+ * companion is native-only and installNative's husky-redirect never
242
+ * writes one there (see installNative), so there is no meaningful path
243
+ * to report. Callers that used to guard this getter with their own
244
+ * isHuskyGeneratedHooksDir check (the CLI's foreignHookConflict did)
245
+ * can drop that guard now that it lives here instead.
147
246
  */
148
247
  getPreCommitCmdPath(cwd) {
149
- return path_1.default.join(this.getEffectiveHooksDir(cwd).hooksDir, 'pre-commit.cmd');
248
+ const { hooksDir } = this.getEffectiveHooksDir(cwd);
249
+ if (this.isHuskyGeneratedHooksDir(hooksDir)) {
250
+ return undefined;
251
+ }
252
+ return path_1.default.join(hooksDir, 'pre-commit.cmd');
150
253
  }
151
254
  install(options = {}) {
152
255
  const cwd = options.cwd ?? process.cwd();
@@ -227,6 +330,25 @@ class PreCommitHook {
227
330
  }
228
331
  installNative(cwd) {
229
332
  const { hooksDir, viaHooksPath } = this.getEffectiveHooksDir(cwd);
333
+ // core.hooksPath points at husky 9's generated, gitignored directory
334
+ // (typically .husky/_, but see resolveHuskyDir for the nested case).
335
+ // Writing there is pointless -- husky's prepare script rewrites it on
336
+ // every `pnpm install` -- so install into the same tracked hook file
337
+ // the husky manager uses, and say so. Never write under the generated
338
+ // directory in this branch.
339
+ if (this.isHuskyGeneratedHooksDir(hooksDir)) {
340
+ const huskyDir = this.resolveHuskyDir(hooksDir);
341
+ const result = this.installHusky(cwd, huskyDir);
342
+ if (!result.success) {
343
+ return result;
344
+ }
345
+ const relHookPath = this.relFromCwd(cwd, result.hookPath ?? path_1.default.join(huskyDir, 'pre-commit'));
346
+ return {
347
+ success: true,
348
+ message: `Hooks are managed by husky; installing into ${relHookPath}. ${result.message}`,
349
+ hookPath: result.hookPath,
350
+ };
351
+ }
230
352
  const hookPath = path_1.default.join(hooksDir, 'pre-commit');
231
353
  const cmdPath = path_1.default.join(hooksDir, 'pre-commit.cmd');
232
354
  try {
@@ -269,6 +391,14 @@ class PreCommitHook {
269
391
  }
270
392
  uninstallNative(cwd) {
271
393
  const { hooksDir } = this.getEffectiveHooksDir(cwd);
394
+ if (this.isHuskyGeneratedHooksDir(hooksDir)) {
395
+ const huskyDir = this.resolveHuskyDir(hooksDir);
396
+ const result = this.uninstallHusky(cwd, huskyDir);
397
+ return {
398
+ success: result.success,
399
+ message: `Hooks are managed by husky; ${result.message}`,
400
+ };
401
+ }
272
402
  const hookPath = path_1.default.join(hooksDir, 'pre-commit');
273
403
  const cmdRemoved = this.removeNativeCmdCompanion(hooksDir);
274
404
  if (!fs_1.default.existsSync(hookPath)) {
@@ -305,9 +435,16 @@ class PreCommitHook {
305
435
  // -------------------------------------------------------------------------
306
436
  // Husky — .husky/pre-commit
307
437
  // -------------------------------------------------------------------------
308
- installHusky(cwd) {
309
- const huskyDir = path_1.default.join(cwd, '.husky');
438
+ /**
439
+ * @param huskyDir Directory holding the tracked hook. Defaults to
440
+ * \`<cwd>/.husky\`, correct for the explicit \`husky\` manager (it never
441
+ * consults core.hooksPath). installNative's redirect passes the
442
+ * ACTUAL directory resolveHuskyDir computed instead, which can be
443
+ * nested (e.g. \`packages/app/.husky\`) -- see resolveHuskyDir.
444
+ */
445
+ installHusky(cwd, huskyDir = path_1.default.join(cwd, '.husky')) {
310
446
  const hookPath = path_1.default.join(huskyDir, 'pre-commit');
447
+ const relHookPath = this.relFromCwd(cwd, hookPath);
311
448
  try {
312
449
  if (!fs_1.default.existsSync(huskyDir)) {
313
450
  fs_1.default.mkdirSync(huskyDir, { recursive: true });
@@ -321,12 +458,12 @@ class PreCommitHook {
321
458
  return { success: true, message: 'Husky hook already contains vault-guard block', hookPath };
322
459
  }
323
460
  fs_1.default.appendFileSync(hookPath, `\n# --- vault-guard ---\nvault-guard scan --staged || {\n echo "❌ vault-guard blocked commit"\n exit 1\n}\n`, { encoding: 'utf-8' });
324
- return { success: true, message: 'Appended vault-guard to existing .husky/pre-commit', hookPath };
461
+ return { success: true, message: `Appended vault-guard to existing ${relHookPath}`, hookPath };
325
462
  }
326
463
  fs_1.default.writeFileSync(hookPath, HUSKY_HOOK_SCRIPT, { mode: 0o755 });
327
464
  return {
328
465
  success: true,
329
- message: 'Created .husky/pre-commit (run `npx husky init` first if _/husky.sh is missing)',
466
+ message: `Created ${relHookPath} (run \`npx husky init\` first if _/husky.sh is missing)`,
330
467
  hookPath,
331
468
  };
332
469
  }
@@ -335,28 +472,72 @@ class PreCommitHook {
335
472
  return { success: false, message: hookError.message };
336
473
  }
337
474
  }
338
- uninstallHusky(cwd) {
339
- const hookPath = path_1.default.join(cwd, '.husky', 'pre-commit');
475
+ /**
476
+ * @param huskyDir See installHusky.
477
+ *
478
+ * Fix for a defect the reviewer found while checking uninstall after
479
+ * the redirect started routing every husky 9 repo through this
480
+ * function (previously only reachable via the explicit `husky`
481
+ * manager): when installHusky wrote the WHOLE file from
482
+ * HUSKY_HOOK_SCRIPT (the fresh-install path -- what both the redirect
483
+ * and a from-scratch \`--manager husky\` install take), there is no
484
+ * "# --- vault-guard ---" appended-block marker to strip, so the old
485
+ * logic here matched nothing, rewrote the file byte-identical, and
486
+ * reported success:true with isInstalled still true. Distinguishing
487
+ * the two shapes vault-guard itself ever produces -- the whole-file
488
+ * header vs. the appended-stanza marker -- fixes this: a whole-file
489
+ * hook is removed entirely; an appended stanza is stripped, keeping
490
+ * the pre-existing foreign content; anything else that merely mentions
491
+ * "vault-guard" in neither recognized shape is left untouched, with an
492
+ * honest message and success only if it happens not to still read as
493
+ * installed.
494
+ */
495
+ uninstallHusky(cwd, huskyDir = path_1.default.join(cwd, '.husky')) {
496
+ const hookPath = path_1.default.join(huskyDir, 'pre-commit');
497
+ const relHookPath = this.relFromCwd(cwd, hookPath);
340
498
  if (!fs_1.default.existsSync(hookPath)) {
341
- return { success: true, message: 'No .husky/pre-commit to remove' };
499
+ return { success: true, message: `No ${relHookPath} to remove` };
342
500
  }
343
- let content = fs_1.default.readFileSync(hookPath, 'utf-8');
501
+ const content = fs_1.default.readFileSync(hookPath, 'utf-8');
344
502
  if (!content.includes('vault-guard')) {
345
- return { success: true, message: 'No vault-guard stanza in .husky/pre-commit' };
503
+ return { success: true, message: `No vault-guard stanza in ${relHookPath}` };
346
504
  }
347
- // Remove appended block if present.
348
- content = content.replace(/\n# --- vault-guard ---[\s\S]*$/m, '');
349
- // If entire file is only our husky template, delete file.
350
- if (!content.includes('vault-guard')) {
351
- if (content.trim().length === 0) {
505
+ // vault-guard wrote the ENTIRE file from HUSKY_HOOK_SCRIPT (whether
506
+ // via a fresh --manager husky install or the native husky-redirect):
507
+ // it is safe, and correct, to remove the file outright rather than
508
+ // try to strip individual lines from a template vault-guard owns.
509
+ if (content.includes(VAULT_GUARD_HOOK_HEADER)) {
510
+ fs_1.default.unlinkSync(hookPath);
511
+ return { success: true, message: `Removed ${relHookPath}` };
512
+ }
513
+ // vault-guard appended a stanza to a pre-existing (foreign) hook: strip
514
+ // just that stanza, preserving whatever the file had before.
515
+ if (content.includes('# --- vault-guard ---')) {
516
+ const stripped = content.replace(/\n# --- vault-guard ---[\s\S]*$/m, '');
517
+ if (stripped.trim().length === 0) {
352
518
  fs_1.default.unlinkSync(hookPath);
353
- return { success: true, message: 'Removed .husky/pre-commit' };
519
+ return { success: true, message: `Removed ${relHookPath}` };
354
520
  }
355
- fs_1.default.writeFileSync(hookPath, content, { mode: 0o755 });
356
- return { success: true, message: 'Removed vault-guard stanza from .husky/pre-commit' };
521
+ fs_1.default.writeFileSync(hookPath, stripped, { mode: 0o755 });
522
+ const stillInstalled = stripped.includes('vault-guard') && stripped.includes('scan --staged');
523
+ return {
524
+ success: !stillInstalled,
525
+ message: stillInstalled
526
+ ? `Removed the appended stanza from ${relHookPath}, but it still references vault-guard -- review manually`
527
+ : `Removed vault-guard stanza from ${relHookPath}`,
528
+ };
357
529
  }
358
- fs_1.default.writeFileSync(hookPath, content, { mode: 0o755 });
359
- return { success: true, message: 'Updated .husky/pre-commit (review manually if needed)' };
530
+ // Mentions "vault-guard" somewhere, but in NEITHER shape vault-guard
531
+ // itself ever writes (no whole-file header, no appended-stanza
532
+ // marker) -- do not guess at what to remove from a file this code did
533
+ // not write; leave it untouched and say so honestly. Report success
534
+ // only if it does not still read as installed (isInstalled uses the
535
+ // same two-substring check).
536
+ const stillInstalled = content.includes('vault-guard') && content.includes('scan --staged');
537
+ return {
538
+ success: !stillInstalled,
539
+ message: `${relHookPath} mentions vault-guard but does not match the shape this version writes; it may have been written by an older vault-guard, or by hand. Leaving it unchanged. Review and remove the vault-guard reference manually if needed.`,
540
+ };
360
541
  }
361
542
  // -------------------------------------------------------------------------
362
543
  // Lefthook — lefthook-local.yml (merged with lefthook.yml)
@@ -480,5 +661,24 @@ class PreCommitHook {
480
661
  return null;
481
662
  }
482
663
  }
664
+ /**
665
+ * The working-tree root, or null when it cannot be determined. Asked of
666
+ * git rather than assumed to be \`cwd\`, so a relative \`core.hooksPath\`
667
+ * resolves correctly when \`install\`/\`getEffectiveHooksDir\` is called
668
+ * from a subdirectory of the repository.
669
+ */
670
+ resolveWorktreeRoot(cwd) {
671
+ try {
672
+ const rel = (0, child_process_1.execSync)('git rev-parse --show-toplevel', {
673
+ cwd,
674
+ encoding: 'utf-8',
675
+ stdio: ['ignore', 'pipe', 'pipe'],
676
+ }).trim();
677
+ return path_1.default.resolve(cwd, rel);
678
+ }
679
+ catch {
680
+ return null;
681
+ }
682
+ }
483
683
  }
484
684
  exports.PreCommitHook = PreCommitHook;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vaultcompass/vault-guard-core",
3
- "version": "1.4.2",
3
+ "version": "1.4.4",
4
4
  "description": "Secret-scanning engine: vendor-anchored patterns, entropy gating, baselines, SARIF/JSON, hook helpers.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",