@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
|
-
/**
|
|
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).
|
|
11
|
-
*
|
|
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
|
|
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
|
-
|
|
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).
|
|
110
|
-
*
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
309
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
339
|
-
|
|
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:
|
|
499
|
+
return { success: true, message: `No ${relHookPath} to remove` };
|
|
342
500
|
}
|
|
343
|
-
|
|
501
|
+
const content = fs_1.default.readFileSync(hookPath, 'utf-8');
|
|
344
502
|
if (!content.includes('vault-guard')) {
|
|
345
|
-
return { success: true, message:
|
|
503
|
+
return { success: true, message: `No vault-guard stanza in ${relHookPath}` };
|
|
346
504
|
}
|
|
347
|
-
//
|
|
348
|
-
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
|
|
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:
|
|
519
|
+
return { success: true, message: `Removed ${relHookPath}` };
|
|
354
520
|
}
|
|
355
|
-
fs_1.default.writeFileSync(hookPath,
|
|
356
|
-
|
|
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
|
-
|
|
359
|
-
|
|
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.
|
|
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",
|