toolfunnel 0.7.0 → 0.7.1
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 +228 -235
- package/SECURITY.md +84 -84
- package/bin/toolfunnel.js +451 -451
- package/docs/KNOWN_BUGS.md +80 -80
- package/docs/design.md +84 -84
- package/docs/packaging.md +115 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +39 -39
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -147
- package/mcp/servers/mock-upstream/server.js +380 -380
- package/package.json +67 -67
- package/src/auth/config.js +174 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +353 -353
- package/src/core/child-reaper.js +61 -61
- package/src/core/config-home.js +134 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +329 -329
- package/src/core/hook-loader.js +824 -824
- package/src/core/hook-runner.js +566 -566
- package/src/core/logger.js +297 -297
- package/src/core/matcher.js +100 -100
- package/src/core/metrics.js +75 -75
- package/src/core/rename-migration.js +163 -163
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -98
- package/src/extend/add-hook.md +152 -152
- package/src/extend/add-mcp.md +119 -119
- package/src/extend/configure.md +88 -88
- package/src/extend/create-tool.md +168 -168
- package/src/extend/howto.js +102 -102
- package/src/extend/package.md +190 -190
- package/src/extend/wrap.md +99 -99
- package/src/mcp/aggregator.js +1719 -1674
- package/src/mcp/expose-store.js +577 -577
- package/src/mcp/gated-run.js +210 -210
- package/src/mcp/http-transport.js +1563 -1563
- package/src/mcp/mcp-client.js +1070 -1070
- package/src/mcp/modern.js +406 -406
- package/src/mcp/protocol.js +468 -468
- package/src/mcp/server.js +3112 -3112
- package/src/tools/registry.js +661 -661
- package/src/tools/tool-state.js +170 -170
- package/src/ui/public/app.js +1187 -1187
- package/src/ui/public/index.html +286 -286
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1616 -1616
- package/tools/scripts/base64.js +139 -139
- package/tools/scripts/danger.js +61 -61
- package/tools/scripts/echo.js +39 -39
- package/tools/scripts/hash.js +101 -101
- package/tools/scripts/json.js +109 -109
- package/tools/scripts/text-stats.js +131 -131
- package/tools/scripts/tf-env.js +43 -43
- package/tools/scripts/tf-hook-add.js +151 -151
- package/tools/scripts/tf-hook-set.js +120 -120
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +117 -117
- package/tools/scripts/tf-mcp-set.js +121 -121
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -134
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -103
- package/tools/scripts/uuid.js +102 -102
package/src/core/hook-loader.js
CHANGED
|
@@ -1,824 +1,824 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* hook-loader.js - load and persist the hook manifest, resolve runnable specs.
|
|
5
|
-
*
|
|
6
|
-
* Contract: the manifest shape and the loader API are both specified below.
|
|
7
|
-
*
|
|
8
|
-
* Responsibilities:
|
|
9
|
-
* - loadManifest(path): read hooks.manifest.json, expand ${HOOKS_DIR} (the absolute
|
|
10
|
-
* src/hooks dir) inside every command string, return { version, hooksDir, hooks: Spec[] }.
|
|
11
|
-
* - enabledHooksFor(event): the enabled specs for a lifecycle event.
|
|
12
|
-
* - setEnabled(id, bool): flip a hook's enabled flag and persist ATOMICALLY (temp + rename).
|
|
13
|
-
* - readScript(id): the hook's source text (for the manager's "open").
|
|
14
|
-
* - writeScript(id, text): save to the COPY under src/hooks/scripts - and REFUSE any
|
|
15
|
-
* resolved path outside src/hooks/scripts (path-traversal guard, isolation rule).
|
|
16
|
-
*
|
|
17
|
-
* Hook Manager v2 additions - all ADDITIVE; the API above
|
|
18
|
-
* keeps its exact behaviour so the existing loader tests still pass:
|
|
19
|
-
* - AUTO-DETECT: autodetect() scans <hooksDir>/scripts for hook scripts and reconciles
|
|
20
|
-
* them with the manifest. The FOLDER is the source of truth for what EXISTS; the
|
|
21
|
-
* manifest is CONFIG. Scripts on disk with no manifest entry surface as detected
|
|
22
|
-
* entries (enabled:false, detected:true, event/matcher unknown). Manifest entries
|
|
23
|
-
* whose script is missing on disk are flagged (present:false). Aliased as
|
|
24
|
-
* detect/reconcile/scan/inventory/reconcileScripts.
|
|
25
|
-
* - PERSIST STATE: enabled-state lives in <hooksDir>/hooks.state.json, an overlay keyed
|
|
26
|
-
* by hook id ({ "<id>": bool }). On load it is applied OVER the manifest's enabled
|
|
27
|
-
* flags (overlay WINS - documented precedence). setEnabled writes the overlay
|
|
28
|
-
* atomically (temp+rename) IN ADDITION to mutating the manifest, so toggles survive
|
|
29
|
-
* restarts and stay separate from the inventory.
|
|
30
|
-
*
|
|
31
|
-
* CommonJS only. Node built-ins only.
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
const fs = require('node:fs');
|
|
35
|
-
const path = require('node:path');
|
|
36
|
-
|
|
37
|
-
// Lifecycle event names (frozen) - used to validate addEntry specs against the
|
|
38
|
-
// six supported events. events.js has zero host imports, so this cannot cycle.
|
|
39
|
-
const { EVENT_NAMES } = require('./events');
|
|
40
|
-
|
|
41
|
-
// The matcher-compile discriminator - addEntry refuses a matcher that can never be
|
|
42
|
-
// evaluated (matcher.js has zero host imports, so this cannot cycle either).
|
|
43
|
-
const { matcherError } = require('./matcher');
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Extensions we treat as hook scripts during auto-detect. Everything else in the
|
|
47
|
-
* scripts dir (e.g. *.conf files, READMEs) is config/data, not a hook.
|
|
48
|
-
*/
|
|
49
|
-
const HOOK_SCRIPT_EXTS = /\.(sh|js|cjs|mjs|cmd|bat|ps1|py)$/i;
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Resolve the absolute src/hooks directory from the manifest's own location and its
|
|
53
|
-
* declared (possibly relative) hooksDir. The manifest lives at <hooks>/hooks.manifest.json,
|
|
54
|
-
* so its directory IS the hooks dir; we honor an explicit absolute hooksDir if given but
|
|
55
|
-
* otherwise anchor to the manifest file's location (robust against cwd differences).
|
|
56
|
-
*
|
|
57
|
-
* @param {string} manifestPath absolute path to hooks.manifest.json
|
|
58
|
-
* @param {string|undefined} declaredHooksDir the manifest's "hooksDir" field
|
|
59
|
-
* @returns {string} absolute path to the hooks directory
|
|
60
|
-
*/
|
|
61
|
-
function resolveHooksDir(manifestPath, declaredHooksDir) {
|
|
62
|
-
const manifestDir = path.dirname(path.resolve(manifestPath));
|
|
63
|
-
if (!declaredHooksDir) return manifestDir;
|
|
64
|
-
if (path.isAbsolute(declaredHooksDir)) return path.resolve(declaredHooksDir);
|
|
65
|
-
// Relative hooksDir is interpreted relative to the manifest's directory's parent only
|
|
66
|
-
// when it does not simply re-point at the manifest dir; in practice the manifest sits
|
|
67
|
-
// inside the hooks dir, so the manifest directory is the source of truth.
|
|
68
|
-
return manifestDir;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Expand ${HOOKS_DIR} occurrences in a string with the absolute hooks dir.
|
|
73
|
-
* Defensive: returns the input unchanged for non-strings.
|
|
74
|
-
*
|
|
75
|
-
* @param {string} str
|
|
76
|
-
* @param {string} hooksDir absolute hooks directory
|
|
77
|
-
* @returns {string}
|
|
78
|
-
*/
|
|
79
|
-
function expandHooksDir(str, hooksDir) {
|
|
80
|
-
if (typeof str !== 'string') return str;
|
|
81
|
-
// Replace every literal occurrence of ${HOOKS_DIR}. We avoid regex special-char issues
|
|
82
|
-
// by using split/join on the literal token.
|
|
83
|
-
return str.split('${HOOKS_DIR}').join(hooksDir);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
class HookLoader {
|
|
87
|
-
/**
|
|
88
|
-
* @param {string} manifestPath absolute path to hooks.manifest.json
|
|
89
|
-
* @param {object} manifest the parsed + expanded manifest object
|
|
90
|
-
* @param {string} hooksDir absolute hooks directory
|
|
91
|
-
*/
|
|
92
|
-
constructor(manifestPath, manifest, hooksDir) {
|
|
93
|
-
this.manifestPath = manifestPath;
|
|
94
|
-
this.manifest = manifest;
|
|
95
|
-
this.hooksDir = hooksDir;
|
|
96
|
-
// The scripts copy directory - the ONLY place writeScript may touch.
|
|
97
|
-
this.scriptsDir = path.join(hooksDir, 'scripts');
|
|
98
|
-
// v2: the persisted enabled-state overlay (keyed by hook id), kept separate
|
|
99
|
-
// from the manifest inventory. Lives next to the manifest in the hooks dir.
|
|
100
|
-
this.statePath = path.join(hooksDir, 'hooks.state.json');
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** @returns {number} manifest schema version */
|
|
104
|
-
get version() {
|
|
105
|
-
return this.manifest && this.manifest.version;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** @returns {Array<object>} all hook specs (expanded) */
|
|
109
|
-
get hooks() {
|
|
110
|
-
return (this.manifest && Array.isArray(this.manifest.hooks)) ? this.manifest.hooks : [];
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Enabled specs for a given lifecycle event, in manifest order.
|
|
115
|
-
* @param {string} event one of EVENTS
|
|
116
|
-
* @returns {Array<object>}
|
|
117
|
-
*/
|
|
118
|
-
enabledHooksFor(event) {
|
|
119
|
-
return this.hooks.filter((h) => h && h.event === event && h.enabled === true);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Look up a single spec by id.
|
|
124
|
-
* @param {string} id
|
|
125
|
-
* @returns {object|undefined}
|
|
126
|
-
*/
|
|
127
|
-
getSpec(id) {
|
|
128
|
-
return this.hooks.find((h) => h && h.id === id);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// ── v2: persisted enabled-state overlay ──────────────────────────────────
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Read the enabled-state overlay from hooks.state.json. The overlay is a flat
|
|
135
|
-
* map of hook id -> boolean. Missing/unreadable/malformed file -> empty overlay
|
|
136
|
-
* (the manifest's own enabled flags stand). Never throws.
|
|
137
|
-
*
|
|
138
|
-
* @returns {Object<string, boolean>}
|
|
139
|
-
*/
|
|
140
|
-
readState() {
|
|
141
|
-
let raw;
|
|
142
|
-
try {
|
|
143
|
-
raw = JSON.parse(fs.readFileSync(this.statePath, 'utf8'));
|
|
144
|
-
} catch (_) {
|
|
145
|
-
return {};
|
|
146
|
-
}
|
|
147
|
-
return normalizeState(raw);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Persist the enabled-state overlay atomically (temp + rename). Confined to the
|
|
152
|
-
* hooks dir. The overlay carries ONLY booleans keyed by hook id.
|
|
153
|
-
*
|
|
154
|
-
* @param {Object<string, boolean>} overlay
|
|
155
|
-
* @returns {void}
|
|
156
|
-
*/
|
|
157
|
-
writeState(overlay) {
|
|
158
|
-
const clean = normalizeState(overlay);
|
|
159
|
-
atomicWriteJson(this.statePath, clean);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Apply the on-disk state overlay over the in-memory manifest specs. The overlay
|
|
164
|
-
* WINS over the manifest's own enabled flag (documented precedence). Called on
|
|
165
|
-
* load so enabledHooksFor reflects the persisted toggles. Idempotent.
|
|
166
|
-
*
|
|
167
|
-
* @returns {Object<string, boolean>} the overlay that was applied
|
|
168
|
-
*/
|
|
169
|
-
applyStateOverlay() {
|
|
170
|
-
const overlay = this.readState();
|
|
171
|
-
for (const h of this.hooks) {
|
|
172
|
-
if (!h || typeof h.id !== 'string') continue;
|
|
173
|
-
if (Object.prototype.hasOwnProperty.call(overlay, h.id)) {
|
|
174
|
-
h.enabled = overlay[h.id] === true;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
return overlay;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// ── v2: auto-detect / reconcile inventory ────────────────────────────────
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Scan <hooksDir>/scripts for hook scripts and reconcile with the manifest.
|
|
184
|
-
*
|
|
185
|
-
* The FOLDER is the source of truth for what EXISTS; the manifest is CONFIG.
|
|
186
|
-
* - Manifest entries are returned with `present` reflecting whether their
|
|
187
|
-
* `script` file exists on disk (manifest entries with no script - inline
|
|
188
|
-
* commands - are treated as present:true, since there is no file to miss).
|
|
189
|
-
* - Scripts present on disk but absent from the manifest are surfaced as
|
|
190
|
-
* `detected` entries: enabled:false, detected:true, event/matcher unknown.
|
|
191
|
-
*
|
|
192
|
-
* Returns a normalised inventory. `all` is the union (configured first, in
|
|
193
|
-
* manifest order, then detected). Pure read - does not mutate the manifest or
|
|
194
|
-
* touch disk beyond reading the scripts dir.
|
|
195
|
-
*
|
|
196
|
-
* @returns {{ all: object[], configured: object[], detected: object[], orphans: object[] }}
|
|
197
|
-
*/
|
|
198
|
-
autodetect() {
|
|
199
|
-
const scriptFiles = this._scanScripts(); // Set of basenames present on disk
|
|
200
|
-
const referenced = new Set();
|
|
201
|
-
|
|
202
|
-
const configured = [];
|
|
203
|
-
const orphans = [];
|
|
204
|
-
|
|
205
|
-
for (const h of this.hooks) {
|
|
206
|
-
if (!h || typeof h !== 'object') continue;
|
|
207
|
-
const base = scriptBasename(h.script);
|
|
208
|
-
if (base) referenced.add(base);
|
|
209
|
-
|
|
210
|
-
// Inline-command hooks (script:null) have no file to verify -> present.
|
|
211
|
-
const present = base ? scriptFiles.has(base) : true;
|
|
212
|
-
const entry = Object.assign({}, h, {
|
|
213
|
-
detected: false,
|
|
214
|
-
configured: true,
|
|
215
|
-
present,
|
|
216
|
-
});
|
|
217
|
-
if (!present) {
|
|
218
|
-
// Orphan: manifest references a script that does not exist on disk.
|
|
219
|
-
entry.missing = true;
|
|
220
|
-
entry.scriptMissing = true;
|
|
221
|
-
entry.exists = false;
|
|
222
|
-
entry.status = 'missing';
|
|
223
|
-
orphans.push(entry);
|
|
224
|
-
}
|
|
225
|
-
configured.push(entry);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Scripts on disk with no manifest entry -> detected, unconfigured.
|
|
229
|
-
const detected = [];
|
|
230
|
-
for (const base of scriptFiles) {
|
|
231
|
-
if (referenced.has(base)) continue;
|
|
232
|
-
detected.push({
|
|
233
|
-
id: `detected/${base.replace(/\.[^.]+$/, '')}`,
|
|
234
|
-
event: null, // unknown - not yet configured
|
|
235
|
-
matcher: null,
|
|
236
|
-
type: 'command',
|
|
237
|
-
script: path.posix.join('scripts', base),
|
|
238
|
-
command: null,
|
|
239
|
-
timeout: null,
|
|
240
|
-
enabled: false, // detected entries are never auto-enabled
|
|
241
|
-
detected: true,
|
|
242
|
-
configured: false,
|
|
243
|
-
unconfigured: true,
|
|
244
|
-
present: true,
|
|
245
|
-
source: 'disk-scan',
|
|
246
|
-
description: `Detected on disk; not yet configured in the manifest (${base}).`,
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
return {
|
|
251
|
-
all: configured.concat(detected),
|
|
252
|
-
configured,
|
|
253
|
-
detected,
|
|
254
|
-
orphans,
|
|
255
|
-
};
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/** @returns {object} alias of {@link HookLoader#autodetect} (compatibility naming). */
|
|
259
|
-
reconcile() { return this.autodetect(); }
|
|
260
|
-
/** @returns {object} alias of {@link HookLoader#autodetect}. */
|
|
261
|
-
reconcileScripts() { return this.autodetect(); }
|
|
262
|
-
/** @returns {object} alias of {@link HookLoader#autodetect}. */
|
|
263
|
-
inventory() { return this.autodetect(); }
|
|
264
|
-
/** @returns {object} alias of {@link HookLoader#autodetect}. */
|
|
265
|
-
detect() { return this.autodetect(); }
|
|
266
|
-
/** @returns {object} alias of {@link HookLoader#autodetect}. */
|
|
267
|
-
scan() { return this.autodetect(); }
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* Scan the scripts dir for candidate hook script files. Hook scripts are
|
|
271
|
-
* executable shell/node scripts; we treat .sh/.js/.cmd/.bat/.ps1/.py as hook
|
|
272
|
-
* scripts and ignore everything else (e.g. a *.conf file is config, not a
|
|
273
|
-
* hook). Missing dir -> empty set. Never throws.
|
|
274
|
-
*
|
|
275
|
-
* @returns {Set<string>} basenames of script files present on disk
|
|
276
|
-
* @private
|
|
277
|
-
*/
|
|
278
|
-
_scanScripts() {
|
|
279
|
-
const out = new Set();
|
|
280
|
-
let entries;
|
|
281
|
-
try {
|
|
282
|
-
entries = fs.readdirSync(this.scriptsDir, { withFileTypes: true });
|
|
283
|
-
} catch (_) {
|
|
284
|
-
return out; // no scripts dir yet
|
|
285
|
-
}
|
|
286
|
-
for (const ent of entries) {
|
|
287
|
-
if (!ent.isFile || !ent.isFile()) continue;
|
|
288
|
-
if (HOOK_SCRIPT_EXTS.test(ent.name)) out.add(ent.name);
|
|
289
|
-
}
|
|
290
|
-
return out;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* Toggle a hook's enabled flag and persist the manifest atomically.
|
|
295
|
-
*
|
|
296
|
-
* v2 precedence: the authoritative persisted toggle lives in the hooks.state.json
|
|
297
|
-
* OVERLAY (keyed by hook id), which WINS over the manifest's own enabled flag at
|
|
298
|
-
* load time. setEnabled writes that overlay atomically so the toggle survives a
|
|
299
|
-
* restart and stays separate from the auto-detected inventory. For backward
|
|
300
|
-
* compatibility (and so the manifest stays a faithful default), it ALSO mirrors
|
|
301
|
-
* the flag into the manifest - writing the ORIGINAL (unexpanded) command strings
|
|
302
|
-
* so the absolute ${HOOKS_DIR} expansion never leaks into the portable on-disk
|
|
303
|
-
* manifest. We re-read the raw manifest from disk to do this.
|
|
304
|
-
*
|
|
305
|
-
* If the hook is a v2 DETECTED entry (on disk, not in the manifest), there is no
|
|
306
|
-
* manifest row to mirror into - the overlay alone carries its state.
|
|
307
|
-
*
|
|
308
|
-
* @param {string} id hook id
|
|
309
|
-
* @param {boolean} bool desired enabled state
|
|
310
|
-
* @returns {boolean} true if the toggle was applied (manifest hook OR detected hook)
|
|
311
|
-
*/
|
|
312
|
-
setEnabled(id, bool) {
|
|
313
|
-
const desired = !!bool;
|
|
314
|
-
|
|
315
|
-
// Update the in-memory (expanded) spec so the live engine sees the change
|
|
316
|
-
// immediately - if this id corresponds to a configured manifest hook.
|
|
317
|
-
const liveSpec = this.getSpec(id);
|
|
318
|
-
if (liveSpec) liveSpec.enabled = desired;
|
|
319
|
-
|
|
320
|
-
// (1) Mirror into the manifest when a matching row exists (keeps the manifest a
|
|
321
|
-
// faithful default and preserves the v1 setEnabled contract). Skipped for
|
|
322
|
-
// detected-only ids that have no manifest row.
|
|
323
|
-
let raw;
|
|
324
|
-
try {
|
|
325
|
-
raw = JSON.parse(fs.readFileSync(this.manifestPath, 'utf8'));
|
|
326
|
-
} catch (err) {
|
|
327
|
-
// If the on-disk file is unreadable, fall back to persisting a re-collapsed copy
|
|
328
|
-
// of the in-memory manifest (best effort - still atomic).
|
|
329
|
-
raw = collapseManifest(this.manifest, this.hooksDir);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
let manifestRow = false;
|
|
333
|
-
if (raw && Array.isArray(raw.hooks)) {
|
|
334
|
-
const rawSpec = raw.hooks.find((h) => h && h.id === id);
|
|
335
|
-
if (rawSpec) {
|
|
336
|
-
manifestRow = true;
|
|
337
|
-
rawSpec.enabled = desired;
|
|
338
|
-
atomicWriteJson(this.manifestPath, raw);
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// (2) Persist the overlay - the authoritative, precedence-winning store - ONLY for an id
|
|
343
|
-
// that matched something. The first correction here fixed the constant-true return
|
|
344
|
-
// (`!!liveSpec || true`) but left this write unconditional, so a typo'd id still left a
|
|
345
|
-
// stray key in hooks.state.json forever. Strays are not inert: applyState() lays the
|
|
346
|
-
// overlay over the manifest at every load, so a future hook adopting the id would boot
|
|
347
|
-
// silently in the stale recorded state - a pre-disabled gate.
|
|
348
|
-
if (liveSpec || manifestRow) {
|
|
349
|
-
const overlay = this.readState();
|
|
350
|
-
overlay[id] = desired;
|
|
351
|
-
this.writeState(overlay);
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// True if we toggled a known manifest hook; also true for a detected (manifest-less) hook
|
|
355
|
-
// so the manager can persist its state. An id matching NEITHER is a miss: report false,
|
|
356
|
-
// write nothing.
|
|
357
|
-
return !!liveSpec || manifestRow;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/**
|
|
361
|
-
* Add a new hook entry to the manifest AND the live in-memory engine view.
|
|
362
|
-
*
|
|
363
|
-
* Mirrors setEnabled's persistence pattern: the RAW manifest JSON is re-read from
|
|
364
|
-
* disk (so the portable ${HOOKS_DIR} token inside command strings stays UNCOLLAPSED
|
|
365
|
-
* on disk), the validated spec is appended VERBATIM, and the file is rewritten
|
|
366
|
-
* atomically (temp + rename). In ADDITION an EXPANDED copy - ${HOOKS_DIR} resolved in
|
|
367
|
-
* the command - is pushed onto this.manifest.hooks so enabledHooksFor / the live
|
|
368
|
-
* engine observe the new hook WITHOUT a reload.
|
|
369
|
-
*
|
|
370
|
-
* @param {object} spec
|
|
371
|
-
* - id: non-empty string; must not already exist in the manifest (rejected if it does)
|
|
372
|
-
* - event: one of the lifecycle EVENTS (EVENT_NAMES)
|
|
373
|
-
* - command: non-empty string
|
|
374
|
-
* - matcher: optional string; must COMPILE under matcher.js's anchoring (rejected
|
|
375
|
-
* otherwise - an uncompilable matcher can never be evaluated)
|
|
376
|
-
* - script/timeout/description: optional, stored verbatim
|
|
377
|
-
* - enabled: defaults to (spec.enabled === true)
|
|
378
|
-
* @returns {object} the stored spec (exactly as written to the manifest)
|
|
379
|
-
* @throws TypeError/Error if validation fails or the id already exists
|
|
380
|
-
*/
|
|
381
|
-
addEntry(spec) {
|
|
382
|
-
if (!spec || typeof spec !== 'object') {
|
|
383
|
-
throw new TypeError('addEntry: spec must be an object');
|
|
384
|
-
}
|
|
385
|
-
if (typeof spec.id !== 'string' || spec.id.length === 0) {
|
|
386
|
-
throw new Error('addEntry: spec.id must be a non-empty string');
|
|
387
|
-
}
|
|
388
|
-
if (!EVENT_NAMES.includes(spec.event)) {
|
|
389
|
-
throw new Error(
|
|
390
|
-
`addEntry: spec.event must be one of ${EVENT_NAMES.join(', ')} (got ${JSON.stringify(spec.event)})`
|
|
391
|
-
);
|
|
392
|
-
}
|
|
393
|
-
if (typeof spec.command !== 'string' || spec.command.length === 0) {
|
|
394
|
-
throw new Error('addEntry: spec.command must be a non-empty string');
|
|
395
|
-
}
|
|
396
|
-
// A matcher that does not compile can never be evaluated - storing it would author a
|
|
397
|
-
// hook whose gate the engine must refuse on every tool-bearing call (hook-engine's
|
|
398
|
-
// unreadable-gate rule). Refuse HERE so the UI's POST /api/hooks/add and tf_hook_add
|
|
399
|
-
// both answer with the problem instead of 200/ok. (0.7.0; matcher-compile.test.js.)
|
|
400
|
-
if (spec.matcher !== undefined && spec.matcher !== null && typeof spec.matcher !== 'string') {
|
|
401
|
-
throw new Error('addEntry: spec.matcher must be a string when provided');
|
|
402
|
-
}
|
|
403
|
-
{
|
|
404
|
-
const compileProblem = matcherError(spec.matcher);
|
|
405
|
-
if (compileProblem) {
|
|
406
|
-
throw new Error(
|
|
407
|
-
`addEntry: spec.matcher ${JSON.stringify(spec.matcher)} does not compile as a ` +
|
|
408
|
-
`regular expression (${compileProblem}) - matchers are anchored regexes ` +
|
|
409
|
-
'(e.g. "Bash|Write"), not globs'
|
|
410
|
-
);
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// The stored spec: every supplied field VERBATIM, with enabled normalized to a
|
|
415
|
-
// strict boolean. The command is left untouched so a portable ${HOOKS_DIR} token
|
|
416
|
-
// (if the caller used one) stays UNCOLLAPSED on disk.
|
|
417
|
-
const stored = Object.assign({}, spec, { enabled: spec.enabled === true });
|
|
418
|
-
|
|
419
|
-
// Re-read the RAW manifest from disk (commands uncollapsed). Fall back to a
|
|
420
|
-
// re-collapsed copy of the in-memory manifest if the file is unreadable - same
|
|
421
|
-
// resilience as setEnabled, and the collapse keeps ${HOOKS_DIR} uncollapsed too.
|
|
422
|
-
let raw;
|
|
423
|
-
try {
|
|
424
|
-
raw = JSON.parse(fs.readFileSync(this.manifestPath, 'utf8'));
|
|
425
|
-
} catch (_) {
|
|
426
|
-
raw = collapseManifest(this.manifest, this.hooksDir);
|
|
427
|
-
}
|
|
428
|
-
if (!raw || typeof raw !== 'object') raw = { version: this.version, hooks: [] };
|
|
429
|
-
if (!Array.isArray(raw.hooks)) raw.hooks = [];
|
|
430
|
-
|
|
431
|
-
if (raw.hooks.some((h) => h && h.id === spec.id)) {
|
|
432
|
-
throw new Error(`addEntry: a hook with id "${spec.id}" already exists`);
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
// Persist the spec VERBATIM, atomically.
|
|
436
|
-
raw.hooks.push(stored);
|
|
437
|
-
atomicWriteJson(this.manifestPath, raw);
|
|
438
|
-
|
|
439
|
-
// Mirror an EXPANDED copy into the live manifest so the engine sees it NOW.
|
|
440
|
-
if (!Array.isArray(this.manifest.hooks)) this.manifest.hooks = [];
|
|
441
|
-
const expanded = Object.assign({}, stored, {
|
|
442
|
-
command: expandHooksDir(stored.command, this.hooksDir),
|
|
443
|
-
});
|
|
444
|
-
this.manifest.hooks.push(expanded);
|
|
445
|
-
|
|
446
|
-
return stored;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
/**
|
|
450
|
-
* Remove a hook entry by id from the manifest, the live engine view, and the
|
|
451
|
-
* persisted enabled-state overlay. Does NOT delete the script file on disk -
|
|
452
|
-
* scripts are inventory (auto-detect's source of truth); unconfiguring a hook
|
|
453
|
-
* only drops its manifest row, leaving any file for re-detection.
|
|
454
|
-
*
|
|
455
|
-
* Mirrors setEnabled's persistence pattern: re-read the RAW manifest (commands
|
|
456
|
-
* stay UNCOLLAPSED), filter out the id, and rewrite atomically ONLY if something
|
|
457
|
-
* changed. The in-memory expanded spec is spliced out so the live engine stops
|
|
458
|
-
* firing it WITHOUT a reload, and the overlay key (when present) is purged.
|
|
459
|
-
*
|
|
460
|
-
* @param {string} id hook id to remove
|
|
461
|
-
* @returns {boolean} true if a manifest row OR a live spec was removed
|
|
462
|
-
* @throws Error if id is not a non-empty string
|
|
463
|
-
*/
|
|
464
|
-
removeEntry(id) {
|
|
465
|
-
if (typeof id !== 'string' || id.length === 0) {
|
|
466
|
-
throw new Error('removeEntry: id must be a non-empty string');
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
// (1) Re-read the RAW manifest and drop the matching row (commands uncollapsed).
|
|
470
|
-
let raw;
|
|
471
|
-
try {
|
|
472
|
-
raw = JSON.parse(fs.readFileSync(this.manifestPath, 'utf8'));
|
|
473
|
-
} catch (_) {
|
|
474
|
-
raw = collapseManifest(this.manifest, this.hooksDir);
|
|
475
|
-
}
|
|
476
|
-
let removedFromManifest = false;
|
|
477
|
-
if (raw && Array.isArray(raw.hooks)) {
|
|
478
|
-
const before = raw.hooks.length;
|
|
479
|
-
raw.hooks = raw.hooks.filter((h) => !(h && h.id === id));
|
|
480
|
-
removedFromManifest = raw.hooks.length !== before;
|
|
481
|
-
if (removedFromManifest) atomicWriteJson(this.manifestPath, raw); // write only if changed
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
// (2) Splice the live expanded spec(s) so the engine stops seeing it immediately.
|
|
485
|
-
let removedFromMemory = false;
|
|
486
|
-
if (Array.isArray(this.manifest.hooks)) {
|
|
487
|
-
for (let i = this.manifest.hooks.length - 1; i >= 0; i--) {
|
|
488
|
-
const h = this.manifest.hooks[i];
|
|
489
|
-
if (h && h.id === id) {
|
|
490
|
-
this.manifest.hooks.splice(i, 1);
|
|
491
|
-
removedFromMemory = true;
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
// (3) Purge the overlay key. Only rewrite the overlay when it actually carried
|
|
497
|
-
// this id, so we never create a stray state file for a never-toggled hook.
|
|
498
|
-
const overlay = this.readState();
|
|
499
|
-
if (Object.prototype.hasOwnProperty.call(overlay, id)) {
|
|
500
|
-
delete overlay[id];
|
|
501
|
-
this.writeState(overlay);
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
return removedFromManifest || removedFromMemory;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/**
|
|
508
|
-
* Read a hook's script source. Resolves the spec's "script" (relative to hooksDir) and
|
|
509
|
-
* reads it. Falls back to deriving the path from the script basename under scripts/.
|
|
510
|
-
*
|
|
511
|
-
* @param {string} id hook id
|
|
512
|
-
* @returns {string} file contents
|
|
513
|
-
* @throws if the hook id is unknown or the file cannot be read
|
|
514
|
-
*/
|
|
515
|
-
readScript(id) {
|
|
516
|
-
const spec = this.getSpec(id);
|
|
517
|
-
if (!spec) throw new Error(`readScript: unknown hook id "${id}"`);
|
|
518
|
-
const scriptPath = this._scriptPathForSpec(spec);
|
|
519
|
-
return fs.readFileSync(scriptPath, 'utf8');
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
/**
|
|
523
|
-
* Write a hook's script source to the COPY under src/hooks/scripts. Refuses any path that
|
|
524
|
-
* resolves outside src/hooks/scripts (defense-in-depth for the isolation rule).
|
|
525
|
-
*
|
|
526
|
-
* @param {string} id hook id
|
|
527
|
-
* @param {string} text new source
|
|
528
|
-
* @returns {void}
|
|
529
|
-
* @throws if the id is unknown, or the resolved path escapes scripts/
|
|
530
|
-
*/
|
|
531
|
-
writeScript(id, text) {
|
|
532
|
-
const spec = this.getSpec(id);
|
|
533
|
-
if (!spec) throw new Error(`writeScript: unknown hook id "${id}"`);
|
|
534
|
-
if (typeof text !== 'string') {
|
|
535
|
-
throw new TypeError('writeScript: text must be a string');
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
const scriptPath = this._scriptPathForSpec(spec);
|
|
539
|
-
this._assertInsideScriptsDir(scriptPath);
|
|
540
|
-
|
|
541
|
-
// Atomic write within the scripts dir (temp + rename), preserving any line endings
|
|
542
|
-
// the caller supplied (do not normalize - the shell scripts may be CRLF-sensitive).
|
|
543
|
-
atomicWriteText(scriptPath, text, this.scriptsDir);
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
/**
|
|
547
|
-
* Resolve the absolute filesystem path for a spec's script, anchored to hooksDir, and
|
|
548
|
-
* guard it against escaping scripts/.
|
|
549
|
-
*
|
|
550
|
-
* @param {object} spec
|
|
551
|
-
* @returns {string} absolute path inside scripts/
|
|
552
|
-
* @private
|
|
553
|
-
*/
|
|
554
|
-
_scriptPathForSpec(spec) {
|
|
555
|
-
let rel = spec.script;
|
|
556
|
-
if (typeof rel !== 'string' || rel.length === 0) {
|
|
557
|
-
// Fall back to deriving from the id's basename if "script" is missing.
|
|
558
|
-
rel = path.join('scripts', `${path.basename(spec.id)}`);
|
|
559
|
-
}
|
|
560
|
-
// spec.script is documented relative to hooksDir (e.g. "scripts/on-prompt.sh").
|
|
561
|
-
const resolved = path.resolve(this.hooksDir, rel);
|
|
562
|
-
this._assertInsideScriptsDir(resolved);
|
|
563
|
-
return resolved;
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
/**
|
|
567
|
-
* Throw unless `target` resolves strictly inside this.scriptsDir.
|
|
568
|
-
* @param {string} target absolute candidate path
|
|
569
|
-
* @private
|
|
570
|
-
*/
|
|
571
|
-
_assertInsideScriptsDir(target) {
|
|
572
|
-
const resolved = path.resolve(target);
|
|
573
|
-
const base = path.resolve(this.scriptsDir);
|
|
574
|
-
// Compare with a trailing separator so "<base>extra" cannot pass as "<base>".
|
|
575
|
-
const baseWithSep = base.endsWith(path.sep) ? base : base + path.sep;
|
|
576
|
-
const isInside = resolved === base || resolved.startsWith(baseWithSep);
|
|
577
|
-
if (!isInside) {
|
|
578
|
-
throw new Error(
|
|
579
|
-
`path-traversal blocked: "${resolved}" is outside the scripts dir "${base}"`
|
|
580
|
-
);
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
/**
|
|
586
|
-
* Coerce an arbitrary parsed value into a clean { id: boolean } overlay map. Accepts
|
|
587
|
-
* either a flat map ({ "<id>": bool }) or a wrapped form ({ enabled: { ... } }) for
|
|
588
|
-
* forward-tolerance; non-boolean values are coerced to strict booleans; non-object
|
|
589
|
-
* input yields an empty overlay. Never throws.
|
|
590
|
-
*
|
|
591
|
-
* @param {*} raw
|
|
592
|
-
* @returns {Object<string, boolean>}
|
|
593
|
-
*/
|
|
594
|
-
function normalizeState(raw) {
|
|
595
|
-
const src =
|
|
596
|
-
raw && typeof raw === 'object' && raw.enabled && typeof raw.enabled === 'object'
|
|
597
|
-
? raw.enabled
|
|
598
|
-
: raw;
|
|
599
|
-
const out = {};
|
|
600
|
-
if (!src || typeof src !== 'object' || Array.isArray(src)) return out;
|
|
601
|
-
for (const key of Object.keys(src)) {
|
|
602
|
-
out[key] = src[key] === true;
|
|
603
|
-
}
|
|
604
|
-
return out;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
/**
|
|
608
|
-
* The basename of a spec's script field (e.g. "scripts/on-prompt.sh" -> "on-prompt.sh").
|
|
609
|
-
* Returns null for inline-command hooks (script null/empty). Tolerates either path
|
|
610
|
-
* separator since manifests use forward slashes.
|
|
611
|
-
*
|
|
612
|
-
* @param {*} script the spec.script field
|
|
613
|
-
* @returns {string|null}
|
|
614
|
-
*/
|
|
615
|
-
function scriptBasename(script) {
|
|
616
|
-
if (typeof script !== 'string' || script.length === 0) return null;
|
|
617
|
-
const norm = script.split('\\').join('/');
|
|
618
|
-
const base = norm.slice(norm.lastIndexOf('/') + 1);
|
|
619
|
-
return base.length ? base : null;
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
/**
|
|
623
|
-
* Produce a manifest object with command strings collapsed back to ${HOOKS_DIR} so the
|
|
624
|
-
* on-disk form stays machine-portable. Used only as a fallback when the raw file cannot
|
|
625
|
-
* be re-read during persistence.
|
|
626
|
-
*
|
|
627
|
-
* @param {object} manifest expanded manifest
|
|
628
|
-
* @param {string} hooksDir absolute hooks dir to collapse out of command strings
|
|
629
|
-
* @returns {object} a deep-enough copy with collapsed commands
|
|
630
|
-
*/
|
|
631
|
-
function collapseManifest(manifest, hooksDir) {
|
|
632
|
-
const copy = JSON.parse(JSON.stringify(manifest));
|
|
633
|
-
if (Array.isArray(copy.hooks)) {
|
|
634
|
-
for (const h of copy.hooks) {
|
|
635
|
-
if (h && typeof h.command === 'string' && hooksDir) {
|
|
636
|
-
h.command = h.command.split(hooksDir).join('${HOOKS_DIR}');
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
return copy;
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
/**
|
|
644
|
-
* Atomically write a JS object as pretty JSON: write to a temp file in the SAME directory
|
|
645
|
-
* as the target (so rename is atomic on the same filesystem), then rename over the target.
|
|
646
|
-
*
|
|
647
|
-
* @param {string} targetPath absolute destination
|
|
648
|
-
* @param {object} obj
|
|
649
|
-
*/
|
|
650
|
-
function atomicWriteJson(targetPath, obj) {
|
|
651
|
-
const data = JSON.stringify(obj, null, 2) + '\n';
|
|
652
|
-
atomicWriteText(targetPath, data, path.dirname(targetPath));
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
/**
|
|
656
|
-
* Atomically write text: temp file in `tmpDir` (must be on the same volume as the target),
|
|
657
|
-
* fsync, then rename over the target. Cleans up the temp file on failure.
|
|
658
|
-
*
|
|
659
|
-
* @param {string} targetPath absolute destination
|
|
660
|
-
* @param {string} data
|
|
661
|
-
* @param {string} tmpDir directory to place the temp file (same FS as target)
|
|
662
|
-
*/
|
|
663
|
-
function atomicWriteText(targetPath, data, tmpDir) {
|
|
664
|
-
const dir = tmpDir || path.dirname(targetPath);
|
|
665
|
-
const tmpName = `.${path.basename(targetPath)}.${process.pid}.${Date.now()}.${Math.random()
|
|
666
|
-
.toString(36)
|
|
667
|
-
.slice(2)}.tmp`;
|
|
668
|
-
const tmpPath = path.join(dir, tmpName);
|
|
669
|
-
|
|
670
|
-
let fd;
|
|
671
|
-
try {
|
|
672
|
-
fd = fs.openSync(tmpPath, 'w');
|
|
673
|
-
fs.writeFileSync(fd, data);
|
|
674
|
-
try {
|
|
675
|
-
fs.fsyncSync(fd);
|
|
676
|
-
} catch (_) {
|
|
677
|
-
// fsync may fail on some platforms/filesystems; the rename is still ordered.
|
|
678
|
-
}
|
|
679
|
-
} finally {
|
|
680
|
-
if (fd !== undefined) {
|
|
681
|
-
try {
|
|
682
|
-
fs.closeSync(fd);
|
|
683
|
-
} catch (_) {
|
|
684
|
-
/* ignore */
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
try {
|
|
690
|
-
// rename is atomic on the same filesystem and overwrites the destination on POSIX;
|
|
691
|
-
// on Windows it overwrites too as long as the target isn't locked.
|
|
692
|
-
fs.renameSync(tmpPath, targetPath);
|
|
693
|
-
} catch (err) {
|
|
694
|
-
// Best-effort cleanup so we don't leave temp files behind.
|
|
695
|
-
try {
|
|
696
|
-
fs.unlinkSync(tmpPath);
|
|
697
|
-
} catch (_) {
|
|
698
|
-
/* ignore */
|
|
699
|
-
}
|
|
700
|
-
throw err;
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
/**
|
|
705
|
-
* Load a manifest from disk, expand ${HOOKS_DIR} in every command string, and return a
|
|
706
|
-
* HookLoader holding it.
|
|
707
|
-
*
|
|
708
|
-
* @param {string} manifestPath absolute path to hooks.manifest.json
|
|
709
|
-
* @returns {HookLoader}
|
|
710
|
-
*/
|
|
711
|
-
function loadManifest(manifestPath) {
|
|
712
|
-
if (typeof manifestPath !== 'string' || manifestPath.length === 0) {
|
|
713
|
-
throw new TypeError('loadManifest: manifestPath must be a non-empty string');
|
|
714
|
-
}
|
|
715
|
-
const absManifest = path.resolve(manifestPath);
|
|
716
|
-
const raw = JSON.parse(fs.readFileSync(absManifest, 'utf8'));
|
|
717
|
-
|
|
718
|
-
const hooksDir = resolveHooksDir(absManifest, raw && raw.hooksDir);
|
|
719
|
-
|
|
720
|
-
// Build the expanded manifest (do not mutate the raw parse beyond this copy).
|
|
721
|
-
const expanded = {
|
|
722
|
-
version: raw && raw.version,
|
|
723
|
-
hooksDir, // store the resolved ABSOLUTE hooks dir for downstream consumers
|
|
724
|
-
hooks: [],
|
|
725
|
-
};
|
|
726
|
-
|
|
727
|
-
const srcHooks = (raw && Array.isArray(raw.hooks)) ? raw.hooks : [];
|
|
728
|
-
for (const h of srcHooks) {
|
|
729
|
-
if (!h || typeof h !== 'object') continue;
|
|
730
|
-
const spec = Object.assign({}, h);
|
|
731
|
-
// Expand ${HOOKS_DIR} inside the command (and, defensively, any string field that
|
|
732
|
-
// might reference it - but command is the documented carrier).
|
|
733
|
-
if (typeof spec.command === 'string') {
|
|
734
|
-
spec.command = expandHooksDir(spec.command, hooksDir);
|
|
735
|
-
}
|
|
736
|
-
// Normalize enabled to a strict boolean (manifest authors may omit it).
|
|
737
|
-
spec.enabled = spec.enabled === true;
|
|
738
|
-
expanded.hooks.push(spec);
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
const loader = new HookLoader(absManifest, expanded, hooksDir);
|
|
742
|
-
// v2: apply the persisted enabled-state overlay OVER the manifest flags so the
|
|
743
|
-
// returned loader (and enabledHooksFor) reflects the durable toggles. The overlay
|
|
744
|
-
// wins; absent/unreadable overlay leaves the manifest defaults intact.
|
|
745
|
-
loader.applyStateOverlay();
|
|
746
|
-
return loader;
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
/**
|
|
750
|
-
* Tiny inline self-check: add then remove a temp entry and confirm it round-trips
|
|
751
|
-
* with the manifest byte-for-byte RESTORED. Runs only under `node hook-loader.js`
|
|
752
|
-
* (never on import) and uses a THROWAWAY manifest in the OS temp dir, so the live
|
|
753
|
-
* project manifest is never touched. Exits non-zero on any failure.
|
|
754
|
-
*/
|
|
755
|
-
function _selfCheck() {
|
|
756
|
-
const os = require('node:os');
|
|
757
|
-
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hookloader-selfcheck-'));
|
|
758
|
-
const manifestPath = path.join(tmpRoot, 'hooks.manifest.json');
|
|
759
|
-
const assert = (cond, msg) => { if (!cond) throw new Error('self-check FAILED: ' + msg); };
|
|
760
|
-
try {
|
|
761
|
-
// A manifest whose command uses the ${HOOKS_DIR} token, so we can prove it stays
|
|
762
|
-
// UNCOLLAPSED on disk while being EXPANDED in the live view.
|
|
763
|
-
const original =
|
|
764
|
-
JSON.stringify(
|
|
765
|
-
{ version: 1, hooks: [{ id: 'existing', event: 'PreToolUse', command: '${HOOKS_DIR}/scripts/x.sh', enabled: true }] },
|
|
766
|
-
null,
|
|
767
|
-
2
|
|
768
|
-
) + '\n';
|
|
769
|
-
fs.writeFileSync(manifestPath, original);
|
|
770
|
-
|
|
771
|
-
const loader = loadManifest(manifestPath);
|
|
772
|
-
const tmpSpec = { id: 'selfcheck/tmp', event: 'PostToolUse', command: '${HOOKS_DIR}/scripts/tmp.sh' };
|
|
773
|
-
|
|
774
|
-
const stored = loader.addEntry(tmpSpec);
|
|
775
|
-
assert(stored.enabled === false, 'enabled should default to false');
|
|
776
|
-
// On disk: VERBATIM, token uncollapsed.
|
|
777
|
-
const afterAdd = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
778
|
-
const diskRow = afterAdd.hooks.find((h) => h.id === 'selfcheck/tmp');
|
|
779
|
-
assert(!!diskRow, 'temp entry must be persisted to the manifest');
|
|
780
|
-
assert(diskRow.command === '${HOOKS_DIR}/scripts/tmp.sh', 'on-disk command must keep ${HOOKS_DIR} uncollapsed');
|
|
781
|
-
// Live: EXPANDED so the engine sees it without reload.
|
|
782
|
-
const liveRow = loader.getSpec('selfcheck/tmp');
|
|
783
|
-
assert(!!liveRow, 'temp entry must be live in this.manifest.hooks');
|
|
784
|
-
assert(liveRow.command === path.join(loader.hooksDir, 'scripts/tmp.sh') ||
|
|
785
|
-
liveRow.command === loader.hooksDir + '/scripts/tmp.sh',
|
|
786
|
-
'live command must be expanded: ' + liveRow.command);
|
|
787
|
-
|
|
788
|
-
// Duplicate id must be rejected.
|
|
789
|
-
let threw = false;
|
|
790
|
-
try { loader.addEntry(tmpSpec); } catch (_) { threw = true; }
|
|
791
|
-
assert(threw, 'adding a duplicate id must throw');
|
|
792
|
-
|
|
793
|
-
const removed = loader.removeEntry('selfcheck/tmp');
|
|
794
|
-
assert(removed === true, 'removeEntry should report a removal');
|
|
795
|
-
assert(!loader.getSpec('selfcheck/tmp'), 'temp entry must be gone from the live view');
|
|
796
|
-
|
|
797
|
-
// Manifest must be RESTORED byte-for-byte (the only other row is "existing").
|
|
798
|
-
const afterRemove = fs.readFileSync(manifestPath, 'utf8');
|
|
799
|
-
assert(afterRemove === original, 'manifest must be restored byte-for-byte after add+remove');
|
|
800
|
-
|
|
801
|
-
process.stdout.write('hook-loader self-check OK (add+remove round-trip; manifest restored)\n');
|
|
802
|
-
} finally {
|
|
803
|
-
try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch (_) { /* ignore */ }
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
if (require.main === module) {
|
|
808
|
-
try {
|
|
809
|
-
_selfCheck();
|
|
810
|
-
} catch (err) {
|
|
811
|
-
process.stderr.write(((err && err.stack) || String(err)) + '\n');
|
|
812
|
-
process.exit(1);
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
module.exports = {
|
|
817
|
-
loadManifest,
|
|
818
|
-
HookLoader,
|
|
819
|
-
// Exported for unit tests / reuse:
|
|
820
|
-
expandHooksDir,
|
|
821
|
-
resolveHooksDir,
|
|
822
|
-
normalizeState,
|
|
823
|
-
scriptBasename,
|
|
824
|
-
};
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* hook-loader.js - load and persist the hook manifest, resolve runnable specs.
|
|
5
|
+
*
|
|
6
|
+
* Contract: the manifest shape and the loader API are both specified below.
|
|
7
|
+
*
|
|
8
|
+
* Responsibilities:
|
|
9
|
+
* - loadManifest(path): read hooks.manifest.json, expand ${HOOKS_DIR} (the absolute
|
|
10
|
+
* src/hooks dir) inside every command string, return { version, hooksDir, hooks: Spec[] }.
|
|
11
|
+
* - enabledHooksFor(event): the enabled specs for a lifecycle event.
|
|
12
|
+
* - setEnabled(id, bool): flip a hook's enabled flag and persist ATOMICALLY (temp + rename).
|
|
13
|
+
* - readScript(id): the hook's source text (for the manager's "open").
|
|
14
|
+
* - writeScript(id, text): save to the COPY under src/hooks/scripts - and REFUSE any
|
|
15
|
+
* resolved path outside src/hooks/scripts (path-traversal guard, isolation rule).
|
|
16
|
+
*
|
|
17
|
+
* Hook Manager v2 additions - all ADDITIVE; the API above
|
|
18
|
+
* keeps its exact behaviour so the existing loader tests still pass:
|
|
19
|
+
* - AUTO-DETECT: autodetect() scans <hooksDir>/scripts for hook scripts and reconciles
|
|
20
|
+
* them with the manifest. The FOLDER is the source of truth for what EXISTS; the
|
|
21
|
+
* manifest is CONFIG. Scripts on disk with no manifest entry surface as detected
|
|
22
|
+
* entries (enabled:false, detected:true, event/matcher unknown). Manifest entries
|
|
23
|
+
* whose script is missing on disk are flagged (present:false). Aliased as
|
|
24
|
+
* detect/reconcile/scan/inventory/reconcileScripts.
|
|
25
|
+
* - PERSIST STATE: enabled-state lives in <hooksDir>/hooks.state.json, an overlay keyed
|
|
26
|
+
* by hook id ({ "<id>": bool }). On load it is applied OVER the manifest's enabled
|
|
27
|
+
* flags (overlay WINS - documented precedence). setEnabled writes the overlay
|
|
28
|
+
* atomically (temp+rename) IN ADDITION to mutating the manifest, so toggles survive
|
|
29
|
+
* restarts and stay separate from the inventory.
|
|
30
|
+
*
|
|
31
|
+
* CommonJS only. Node built-ins only.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const fs = require('node:fs');
|
|
35
|
+
const path = require('node:path');
|
|
36
|
+
|
|
37
|
+
// Lifecycle event names (frozen) - used to validate addEntry specs against the
|
|
38
|
+
// six supported events. events.js has zero host imports, so this cannot cycle.
|
|
39
|
+
const { EVENT_NAMES } = require('./events');
|
|
40
|
+
|
|
41
|
+
// The matcher-compile discriminator - addEntry refuses a matcher that can never be
|
|
42
|
+
// evaluated (matcher.js has zero host imports, so this cannot cycle either).
|
|
43
|
+
const { matcherError } = require('./matcher');
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Extensions we treat as hook scripts during auto-detect. Everything else in the
|
|
47
|
+
* scripts dir (e.g. *.conf files, READMEs) is config/data, not a hook.
|
|
48
|
+
*/
|
|
49
|
+
const HOOK_SCRIPT_EXTS = /\.(sh|js|cjs|mjs|cmd|bat|ps1|py)$/i;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the absolute src/hooks directory from the manifest's own location and its
|
|
53
|
+
* declared (possibly relative) hooksDir. The manifest lives at <hooks>/hooks.manifest.json,
|
|
54
|
+
* so its directory IS the hooks dir; we honor an explicit absolute hooksDir if given but
|
|
55
|
+
* otherwise anchor to the manifest file's location (robust against cwd differences).
|
|
56
|
+
*
|
|
57
|
+
* @param {string} manifestPath absolute path to hooks.manifest.json
|
|
58
|
+
* @param {string|undefined} declaredHooksDir the manifest's "hooksDir" field
|
|
59
|
+
* @returns {string} absolute path to the hooks directory
|
|
60
|
+
*/
|
|
61
|
+
function resolveHooksDir(manifestPath, declaredHooksDir) {
|
|
62
|
+
const manifestDir = path.dirname(path.resolve(manifestPath));
|
|
63
|
+
if (!declaredHooksDir) return manifestDir;
|
|
64
|
+
if (path.isAbsolute(declaredHooksDir)) return path.resolve(declaredHooksDir);
|
|
65
|
+
// Relative hooksDir is interpreted relative to the manifest's directory's parent only
|
|
66
|
+
// when it does not simply re-point at the manifest dir; in practice the manifest sits
|
|
67
|
+
// inside the hooks dir, so the manifest directory is the source of truth.
|
|
68
|
+
return manifestDir;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Expand ${HOOKS_DIR} occurrences in a string with the absolute hooks dir.
|
|
73
|
+
* Defensive: returns the input unchanged for non-strings.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} str
|
|
76
|
+
* @param {string} hooksDir absolute hooks directory
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
function expandHooksDir(str, hooksDir) {
|
|
80
|
+
if (typeof str !== 'string') return str;
|
|
81
|
+
// Replace every literal occurrence of ${HOOKS_DIR}. We avoid regex special-char issues
|
|
82
|
+
// by using split/join on the literal token.
|
|
83
|
+
return str.split('${HOOKS_DIR}').join(hooksDir);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
class HookLoader {
|
|
87
|
+
/**
|
|
88
|
+
* @param {string} manifestPath absolute path to hooks.manifest.json
|
|
89
|
+
* @param {object} manifest the parsed + expanded manifest object
|
|
90
|
+
* @param {string} hooksDir absolute hooks directory
|
|
91
|
+
*/
|
|
92
|
+
constructor(manifestPath, manifest, hooksDir) {
|
|
93
|
+
this.manifestPath = manifestPath;
|
|
94
|
+
this.manifest = manifest;
|
|
95
|
+
this.hooksDir = hooksDir;
|
|
96
|
+
// The scripts copy directory - the ONLY place writeScript may touch.
|
|
97
|
+
this.scriptsDir = path.join(hooksDir, 'scripts');
|
|
98
|
+
// v2: the persisted enabled-state overlay (keyed by hook id), kept separate
|
|
99
|
+
// from the manifest inventory. Lives next to the manifest in the hooks dir.
|
|
100
|
+
this.statePath = path.join(hooksDir, 'hooks.state.json');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** @returns {number} manifest schema version */
|
|
104
|
+
get version() {
|
|
105
|
+
return this.manifest && this.manifest.version;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** @returns {Array<object>} all hook specs (expanded) */
|
|
109
|
+
get hooks() {
|
|
110
|
+
return (this.manifest && Array.isArray(this.manifest.hooks)) ? this.manifest.hooks : [];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Enabled specs for a given lifecycle event, in manifest order.
|
|
115
|
+
* @param {string} event one of EVENTS
|
|
116
|
+
* @returns {Array<object>}
|
|
117
|
+
*/
|
|
118
|
+
enabledHooksFor(event) {
|
|
119
|
+
return this.hooks.filter((h) => h && h.event === event && h.enabled === true);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Look up a single spec by id.
|
|
124
|
+
* @param {string} id
|
|
125
|
+
* @returns {object|undefined}
|
|
126
|
+
*/
|
|
127
|
+
getSpec(id) {
|
|
128
|
+
return this.hooks.find((h) => h && h.id === id);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── v2: persisted enabled-state overlay ──────────────────────────────────
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Read the enabled-state overlay from hooks.state.json. The overlay is a flat
|
|
135
|
+
* map of hook id -> boolean. Missing/unreadable/malformed file -> empty overlay
|
|
136
|
+
* (the manifest's own enabled flags stand). Never throws.
|
|
137
|
+
*
|
|
138
|
+
* @returns {Object<string, boolean>}
|
|
139
|
+
*/
|
|
140
|
+
readState() {
|
|
141
|
+
let raw;
|
|
142
|
+
try {
|
|
143
|
+
raw = JSON.parse(fs.readFileSync(this.statePath, 'utf8'));
|
|
144
|
+
} catch (_) {
|
|
145
|
+
return {};
|
|
146
|
+
}
|
|
147
|
+
return normalizeState(raw);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Persist the enabled-state overlay atomically (temp + rename). Confined to the
|
|
152
|
+
* hooks dir. The overlay carries ONLY booleans keyed by hook id.
|
|
153
|
+
*
|
|
154
|
+
* @param {Object<string, boolean>} overlay
|
|
155
|
+
* @returns {void}
|
|
156
|
+
*/
|
|
157
|
+
writeState(overlay) {
|
|
158
|
+
const clean = normalizeState(overlay);
|
|
159
|
+
atomicWriteJson(this.statePath, clean);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Apply the on-disk state overlay over the in-memory manifest specs. The overlay
|
|
164
|
+
* WINS over the manifest's own enabled flag (documented precedence). Called on
|
|
165
|
+
* load so enabledHooksFor reflects the persisted toggles. Idempotent.
|
|
166
|
+
*
|
|
167
|
+
* @returns {Object<string, boolean>} the overlay that was applied
|
|
168
|
+
*/
|
|
169
|
+
applyStateOverlay() {
|
|
170
|
+
const overlay = this.readState();
|
|
171
|
+
for (const h of this.hooks) {
|
|
172
|
+
if (!h || typeof h.id !== 'string') continue;
|
|
173
|
+
if (Object.prototype.hasOwnProperty.call(overlay, h.id)) {
|
|
174
|
+
h.enabled = overlay[h.id] === true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return overlay;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── v2: auto-detect / reconcile inventory ────────────────────────────────
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Scan <hooksDir>/scripts for hook scripts and reconcile with the manifest.
|
|
184
|
+
*
|
|
185
|
+
* The FOLDER is the source of truth for what EXISTS; the manifest is CONFIG.
|
|
186
|
+
* - Manifest entries are returned with `present` reflecting whether their
|
|
187
|
+
* `script` file exists on disk (manifest entries with no script - inline
|
|
188
|
+
* commands - are treated as present:true, since there is no file to miss).
|
|
189
|
+
* - Scripts present on disk but absent from the manifest are surfaced as
|
|
190
|
+
* `detected` entries: enabled:false, detected:true, event/matcher unknown.
|
|
191
|
+
*
|
|
192
|
+
* Returns a normalised inventory. `all` is the union (configured first, in
|
|
193
|
+
* manifest order, then detected). Pure read - does not mutate the manifest or
|
|
194
|
+
* touch disk beyond reading the scripts dir.
|
|
195
|
+
*
|
|
196
|
+
* @returns {{ all: object[], configured: object[], detected: object[], orphans: object[] }}
|
|
197
|
+
*/
|
|
198
|
+
autodetect() {
|
|
199
|
+
const scriptFiles = this._scanScripts(); // Set of basenames present on disk
|
|
200
|
+
const referenced = new Set();
|
|
201
|
+
|
|
202
|
+
const configured = [];
|
|
203
|
+
const orphans = [];
|
|
204
|
+
|
|
205
|
+
for (const h of this.hooks) {
|
|
206
|
+
if (!h || typeof h !== 'object') continue;
|
|
207
|
+
const base = scriptBasename(h.script);
|
|
208
|
+
if (base) referenced.add(base);
|
|
209
|
+
|
|
210
|
+
// Inline-command hooks (script:null) have no file to verify -> present.
|
|
211
|
+
const present = base ? scriptFiles.has(base) : true;
|
|
212
|
+
const entry = Object.assign({}, h, {
|
|
213
|
+
detected: false,
|
|
214
|
+
configured: true,
|
|
215
|
+
present,
|
|
216
|
+
});
|
|
217
|
+
if (!present) {
|
|
218
|
+
// Orphan: manifest references a script that does not exist on disk.
|
|
219
|
+
entry.missing = true;
|
|
220
|
+
entry.scriptMissing = true;
|
|
221
|
+
entry.exists = false;
|
|
222
|
+
entry.status = 'missing';
|
|
223
|
+
orphans.push(entry);
|
|
224
|
+
}
|
|
225
|
+
configured.push(entry);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Scripts on disk with no manifest entry -> detected, unconfigured.
|
|
229
|
+
const detected = [];
|
|
230
|
+
for (const base of scriptFiles) {
|
|
231
|
+
if (referenced.has(base)) continue;
|
|
232
|
+
detected.push({
|
|
233
|
+
id: `detected/${base.replace(/\.[^.]+$/, '')}`,
|
|
234
|
+
event: null, // unknown - not yet configured
|
|
235
|
+
matcher: null,
|
|
236
|
+
type: 'command',
|
|
237
|
+
script: path.posix.join('scripts', base),
|
|
238
|
+
command: null,
|
|
239
|
+
timeout: null,
|
|
240
|
+
enabled: false, // detected entries are never auto-enabled
|
|
241
|
+
detected: true,
|
|
242
|
+
configured: false,
|
|
243
|
+
unconfigured: true,
|
|
244
|
+
present: true,
|
|
245
|
+
source: 'disk-scan',
|
|
246
|
+
description: `Detected on disk; not yet configured in the manifest (${base}).`,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
all: configured.concat(detected),
|
|
252
|
+
configured,
|
|
253
|
+
detected,
|
|
254
|
+
orphans,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** @returns {object} alias of {@link HookLoader#autodetect} (compatibility naming). */
|
|
259
|
+
reconcile() { return this.autodetect(); }
|
|
260
|
+
/** @returns {object} alias of {@link HookLoader#autodetect}. */
|
|
261
|
+
reconcileScripts() { return this.autodetect(); }
|
|
262
|
+
/** @returns {object} alias of {@link HookLoader#autodetect}. */
|
|
263
|
+
inventory() { return this.autodetect(); }
|
|
264
|
+
/** @returns {object} alias of {@link HookLoader#autodetect}. */
|
|
265
|
+
detect() { return this.autodetect(); }
|
|
266
|
+
/** @returns {object} alias of {@link HookLoader#autodetect}. */
|
|
267
|
+
scan() { return this.autodetect(); }
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Scan the scripts dir for candidate hook script files. Hook scripts are
|
|
271
|
+
* executable shell/node scripts; we treat .sh/.js/.cmd/.bat/.ps1/.py as hook
|
|
272
|
+
* scripts and ignore everything else (e.g. a *.conf file is config, not a
|
|
273
|
+
* hook). Missing dir -> empty set. Never throws.
|
|
274
|
+
*
|
|
275
|
+
* @returns {Set<string>} basenames of script files present on disk
|
|
276
|
+
* @private
|
|
277
|
+
*/
|
|
278
|
+
_scanScripts() {
|
|
279
|
+
const out = new Set();
|
|
280
|
+
let entries;
|
|
281
|
+
try {
|
|
282
|
+
entries = fs.readdirSync(this.scriptsDir, { withFileTypes: true });
|
|
283
|
+
} catch (_) {
|
|
284
|
+
return out; // no scripts dir yet
|
|
285
|
+
}
|
|
286
|
+
for (const ent of entries) {
|
|
287
|
+
if (!ent.isFile || !ent.isFile()) continue;
|
|
288
|
+
if (HOOK_SCRIPT_EXTS.test(ent.name)) out.add(ent.name);
|
|
289
|
+
}
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Toggle a hook's enabled flag and persist the manifest atomically.
|
|
295
|
+
*
|
|
296
|
+
* v2 precedence: the authoritative persisted toggle lives in the hooks.state.json
|
|
297
|
+
* OVERLAY (keyed by hook id), which WINS over the manifest's own enabled flag at
|
|
298
|
+
* load time. setEnabled writes that overlay atomically so the toggle survives a
|
|
299
|
+
* restart and stays separate from the auto-detected inventory. For backward
|
|
300
|
+
* compatibility (and so the manifest stays a faithful default), it ALSO mirrors
|
|
301
|
+
* the flag into the manifest - writing the ORIGINAL (unexpanded) command strings
|
|
302
|
+
* so the absolute ${HOOKS_DIR} expansion never leaks into the portable on-disk
|
|
303
|
+
* manifest. We re-read the raw manifest from disk to do this.
|
|
304
|
+
*
|
|
305
|
+
* If the hook is a v2 DETECTED entry (on disk, not in the manifest), there is no
|
|
306
|
+
* manifest row to mirror into - the overlay alone carries its state.
|
|
307
|
+
*
|
|
308
|
+
* @param {string} id hook id
|
|
309
|
+
* @param {boolean} bool desired enabled state
|
|
310
|
+
* @returns {boolean} true if the toggle was applied (manifest hook OR detected hook)
|
|
311
|
+
*/
|
|
312
|
+
setEnabled(id, bool) {
|
|
313
|
+
const desired = !!bool;
|
|
314
|
+
|
|
315
|
+
// Update the in-memory (expanded) spec so the live engine sees the change
|
|
316
|
+
// immediately - if this id corresponds to a configured manifest hook.
|
|
317
|
+
const liveSpec = this.getSpec(id);
|
|
318
|
+
if (liveSpec) liveSpec.enabled = desired;
|
|
319
|
+
|
|
320
|
+
// (1) Mirror into the manifest when a matching row exists (keeps the manifest a
|
|
321
|
+
// faithful default and preserves the v1 setEnabled contract). Skipped for
|
|
322
|
+
// detected-only ids that have no manifest row.
|
|
323
|
+
let raw;
|
|
324
|
+
try {
|
|
325
|
+
raw = JSON.parse(fs.readFileSync(this.manifestPath, 'utf8'));
|
|
326
|
+
} catch (err) {
|
|
327
|
+
// If the on-disk file is unreadable, fall back to persisting a re-collapsed copy
|
|
328
|
+
// of the in-memory manifest (best effort - still atomic).
|
|
329
|
+
raw = collapseManifest(this.manifest, this.hooksDir);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
let manifestRow = false;
|
|
333
|
+
if (raw && Array.isArray(raw.hooks)) {
|
|
334
|
+
const rawSpec = raw.hooks.find((h) => h && h.id === id);
|
|
335
|
+
if (rawSpec) {
|
|
336
|
+
manifestRow = true;
|
|
337
|
+
rawSpec.enabled = desired;
|
|
338
|
+
atomicWriteJson(this.manifestPath, raw);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// (2) Persist the overlay - the authoritative, precedence-winning store - ONLY for an id
|
|
343
|
+
// that matched something. The first correction here fixed the constant-true return
|
|
344
|
+
// (`!!liveSpec || true`) but left this write unconditional, so a typo'd id still left a
|
|
345
|
+
// stray key in hooks.state.json forever. Strays are not inert: applyState() lays the
|
|
346
|
+
// overlay over the manifest at every load, so a future hook adopting the id would boot
|
|
347
|
+
// silently in the stale recorded state - a pre-disabled gate.
|
|
348
|
+
if (liveSpec || manifestRow) {
|
|
349
|
+
const overlay = this.readState();
|
|
350
|
+
overlay[id] = desired;
|
|
351
|
+
this.writeState(overlay);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// True if we toggled a known manifest hook; also true for a detected (manifest-less) hook
|
|
355
|
+
// so the manager can persist its state. An id matching NEITHER is a miss: report false,
|
|
356
|
+
// write nothing.
|
|
357
|
+
return !!liveSpec || manifestRow;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Add a new hook entry to the manifest AND the live in-memory engine view.
|
|
362
|
+
*
|
|
363
|
+
* Mirrors setEnabled's persistence pattern: the RAW manifest JSON is re-read from
|
|
364
|
+
* disk (so the portable ${HOOKS_DIR} token inside command strings stays UNCOLLAPSED
|
|
365
|
+
* on disk), the validated spec is appended VERBATIM, and the file is rewritten
|
|
366
|
+
* atomically (temp + rename). In ADDITION an EXPANDED copy - ${HOOKS_DIR} resolved in
|
|
367
|
+
* the command - is pushed onto this.manifest.hooks so enabledHooksFor / the live
|
|
368
|
+
* engine observe the new hook WITHOUT a reload.
|
|
369
|
+
*
|
|
370
|
+
* @param {object} spec
|
|
371
|
+
* - id: non-empty string; must not already exist in the manifest (rejected if it does)
|
|
372
|
+
* - event: one of the lifecycle EVENTS (EVENT_NAMES)
|
|
373
|
+
* - command: non-empty string
|
|
374
|
+
* - matcher: optional string; must COMPILE under matcher.js's anchoring (rejected
|
|
375
|
+
* otherwise - an uncompilable matcher can never be evaluated)
|
|
376
|
+
* - script/timeout/description: optional, stored verbatim
|
|
377
|
+
* - enabled: defaults to (spec.enabled === true)
|
|
378
|
+
* @returns {object} the stored spec (exactly as written to the manifest)
|
|
379
|
+
* @throws TypeError/Error if validation fails or the id already exists
|
|
380
|
+
*/
|
|
381
|
+
addEntry(spec) {
|
|
382
|
+
if (!spec || typeof spec !== 'object') {
|
|
383
|
+
throw new TypeError('addEntry: spec must be an object');
|
|
384
|
+
}
|
|
385
|
+
if (typeof spec.id !== 'string' || spec.id.length === 0) {
|
|
386
|
+
throw new Error('addEntry: spec.id must be a non-empty string');
|
|
387
|
+
}
|
|
388
|
+
if (!EVENT_NAMES.includes(spec.event)) {
|
|
389
|
+
throw new Error(
|
|
390
|
+
`addEntry: spec.event must be one of ${EVENT_NAMES.join(', ')} (got ${JSON.stringify(spec.event)})`
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
if (typeof spec.command !== 'string' || spec.command.length === 0) {
|
|
394
|
+
throw new Error('addEntry: spec.command must be a non-empty string');
|
|
395
|
+
}
|
|
396
|
+
// A matcher that does not compile can never be evaluated - storing it would author a
|
|
397
|
+
// hook whose gate the engine must refuse on every tool-bearing call (hook-engine's
|
|
398
|
+
// unreadable-gate rule). Refuse HERE so the UI's POST /api/hooks/add and tf_hook_add
|
|
399
|
+
// both answer with the problem instead of 200/ok. (0.7.0; matcher-compile.test.js.)
|
|
400
|
+
if (spec.matcher !== undefined && spec.matcher !== null && typeof spec.matcher !== 'string') {
|
|
401
|
+
throw new Error('addEntry: spec.matcher must be a string when provided');
|
|
402
|
+
}
|
|
403
|
+
{
|
|
404
|
+
const compileProblem = matcherError(spec.matcher);
|
|
405
|
+
if (compileProblem) {
|
|
406
|
+
throw new Error(
|
|
407
|
+
`addEntry: spec.matcher ${JSON.stringify(spec.matcher)} does not compile as a ` +
|
|
408
|
+
`regular expression (${compileProblem}) - matchers are anchored regexes ` +
|
|
409
|
+
'(e.g. "Bash|Write"), not globs'
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// The stored spec: every supplied field VERBATIM, with enabled normalized to a
|
|
415
|
+
// strict boolean. The command is left untouched so a portable ${HOOKS_DIR} token
|
|
416
|
+
// (if the caller used one) stays UNCOLLAPSED on disk.
|
|
417
|
+
const stored = Object.assign({}, spec, { enabled: spec.enabled === true });
|
|
418
|
+
|
|
419
|
+
// Re-read the RAW manifest from disk (commands uncollapsed). Fall back to a
|
|
420
|
+
// re-collapsed copy of the in-memory manifest if the file is unreadable - same
|
|
421
|
+
// resilience as setEnabled, and the collapse keeps ${HOOKS_DIR} uncollapsed too.
|
|
422
|
+
let raw;
|
|
423
|
+
try {
|
|
424
|
+
raw = JSON.parse(fs.readFileSync(this.manifestPath, 'utf8'));
|
|
425
|
+
} catch (_) {
|
|
426
|
+
raw = collapseManifest(this.manifest, this.hooksDir);
|
|
427
|
+
}
|
|
428
|
+
if (!raw || typeof raw !== 'object') raw = { version: this.version, hooks: [] };
|
|
429
|
+
if (!Array.isArray(raw.hooks)) raw.hooks = [];
|
|
430
|
+
|
|
431
|
+
if (raw.hooks.some((h) => h && h.id === spec.id)) {
|
|
432
|
+
throw new Error(`addEntry: a hook with id "${spec.id}" already exists`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Persist the spec VERBATIM, atomically.
|
|
436
|
+
raw.hooks.push(stored);
|
|
437
|
+
atomicWriteJson(this.manifestPath, raw);
|
|
438
|
+
|
|
439
|
+
// Mirror an EXPANDED copy into the live manifest so the engine sees it NOW.
|
|
440
|
+
if (!Array.isArray(this.manifest.hooks)) this.manifest.hooks = [];
|
|
441
|
+
const expanded = Object.assign({}, stored, {
|
|
442
|
+
command: expandHooksDir(stored.command, this.hooksDir),
|
|
443
|
+
});
|
|
444
|
+
this.manifest.hooks.push(expanded);
|
|
445
|
+
|
|
446
|
+
return stored;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Remove a hook entry by id from the manifest, the live engine view, and the
|
|
451
|
+
* persisted enabled-state overlay. Does NOT delete the script file on disk -
|
|
452
|
+
* scripts are inventory (auto-detect's source of truth); unconfiguring a hook
|
|
453
|
+
* only drops its manifest row, leaving any file for re-detection.
|
|
454
|
+
*
|
|
455
|
+
* Mirrors setEnabled's persistence pattern: re-read the RAW manifest (commands
|
|
456
|
+
* stay UNCOLLAPSED), filter out the id, and rewrite atomically ONLY if something
|
|
457
|
+
* changed. The in-memory expanded spec is spliced out so the live engine stops
|
|
458
|
+
* firing it WITHOUT a reload, and the overlay key (when present) is purged.
|
|
459
|
+
*
|
|
460
|
+
* @param {string} id hook id to remove
|
|
461
|
+
* @returns {boolean} true if a manifest row OR a live spec was removed
|
|
462
|
+
* @throws Error if id is not a non-empty string
|
|
463
|
+
*/
|
|
464
|
+
removeEntry(id) {
|
|
465
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
466
|
+
throw new Error('removeEntry: id must be a non-empty string');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// (1) Re-read the RAW manifest and drop the matching row (commands uncollapsed).
|
|
470
|
+
let raw;
|
|
471
|
+
try {
|
|
472
|
+
raw = JSON.parse(fs.readFileSync(this.manifestPath, 'utf8'));
|
|
473
|
+
} catch (_) {
|
|
474
|
+
raw = collapseManifest(this.manifest, this.hooksDir);
|
|
475
|
+
}
|
|
476
|
+
let removedFromManifest = false;
|
|
477
|
+
if (raw && Array.isArray(raw.hooks)) {
|
|
478
|
+
const before = raw.hooks.length;
|
|
479
|
+
raw.hooks = raw.hooks.filter((h) => !(h && h.id === id));
|
|
480
|
+
removedFromManifest = raw.hooks.length !== before;
|
|
481
|
+
if (removedFromManifest) atomicWriteJson(this.manifestPath, raw); // write only if changed
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// (2) Splice the live expanded spec(s) so the engine stops seeing it immediately.
|
|
485
|
+
let removedFromMemory = false;
|
|
486
|
+
if (Array.isArray(this.manifest.hooks)) {
|
|
487
|
+
for (let i = this.manifest.hooks.length - 1; i >= 0; i--) {
|
|
488
|
+
const h = this.manifest.hooks[i];
|
|
489
|
+
if (h && h.id === id) {
|
|
490
|
+
this.manifest.hooks.splice(i, 1);
|
|
491
|
+
removedFromMemory = true;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// (3) Purge the overlay key. Only rewrite the overlay when it actually carried
|
|
497
|
+
// this id, so we never create a stray state file for a never-toggled hook.
|
|
498
|
+
const overlay = this.readState();
|
|
499
|
+
if (Object.prototype.hasOwnProperty.call(overlay, id)) {
|
|
500
|
+
delete overlay[id];
|
|
501
|
+
this.writeState(overlay);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return removedFromManifest || removedFromMemory;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Read a hook's script source. Resolves the spec's "script" (relative to hooksDir) and
|
|
509
|
+
* reads it. Falls back to deriving the path from the script basename under scripts/.
|
|
510
|
+
*
|
|
511
|
+
* @param {string} id hook id
|
|
512
|
+
* @returns {string} file contents
|
|
513
|
+
* @throws if the hook id is unknown or the file cannot be read
|
|
514
|
+
*/
|
|
515
|
+
readScript(id) {
|
|
516
|
+
const spec = this.getSpec(id);
|
|
517
|
+
if (!spec) throw new Error(`readScript: unknown hook id "${id}"`);
|
|
518
|
+
const scriptPath = this._scriptPathForSpec(spec);
|
|
519
|
+
return fs.readFileSync(scriptPath, 'utf8');
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Write a hook's script source to the COPY under src/hooks/scripts. Refuses any path that
|
|
524
|
+
* resolves outside src/hooks/scripts (defense-in-depth for the isolation rule).
|
|
525
|
+
*
|
|
526
|
+
* @param {string} id hook id
|
|
527
|
+
* @param {string} text new source
|
|
528
|
+
* @returns {void}
|
|
529
|
+
* @throws if the id is unknown, or the resolved path escapes scripts/
|
|
530
|
+
*/
|
|
531
|
+
writeScript(id, text) {
|
|
532
|
+
const spec = this.getSpec(id);
|
|
533
|
+
if (!spec) throw new Error(`writeScript: unknown hook id "${id}"`);
|
|
534
|
+
if (typeof text !== 'string') {
|
|
535
|
+
throw new TypeError('writeScript: text must be a string');
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const scriptPath = this._scriptPathForSpec(spec);
|
|
539
|
+
this._assertInsideScriptsDir(scriptPath);
|
|
540
|
+
|
|
541
|
+
// Atomic write within the scripts dir (temp + rename), preserving any line endings
|
|
542
|
+
// the caller supplied (do not normalize - the shell scripts may be CRLF-sensitive).
|
|
543
|
+
atomicWriteText(scriptPath, text, this.scriptsDir);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Resolve the absolute filesystem path for a spec's script, anchored to hooksDir, and
|
|
548
|
+
* guard it against escaping scripts/.
|
|
549
|
+
*
|
|
550
|
+
* @param {object} spec
|
|
551
|
+
* @returns {string} absolute path inside scripts/
|
|
552
|
+
* @private
|
|
553
|
+
*/
|
|
554
|
+
_scriptPathForSpec(spec) {
|
|
555
|
+
let rel = spec.script;
|
|
556
|
+
if (typeof rel !== 'string' || rel.length === 0) {
|
|
557
|
+
// Fall back to deriving from the id's basename if "script" is missing.
|
|
558
|
+
rel = path.join('scripts', `${path.basename(spec.id)}`);
|
|
559
|
+
}
|
|
560
|
+
// spec.script is documented relative to hooksDir (e.g. "scripts/on-prompt.sh").
|
|
561
|
+
const resolved = path.resolve(this.hooksDir, rel);
|
|
562
|
+
this._assertInsideScriptsDir(resolved);
|
|
563
|
+
return resolved;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Throw unless `target` resolves strictly inside this.scriptsDir.
|
|
568
|
+
* @param {string} target absolute candidate path
|
|
569
|
+
* @private
|
|
570
|
+
*/
|
|
571
|
+
_assertInsideScriptsDir(target) {
|
|
572
|
+
const resolved = path.resolve(target);
|
|
573
|
+
const base = path.resolve(this.scriptsDir);
|
|
574
|
+
// Compare with a trailing separator so "<base>extra" cannot pass as "<base>".
|
|
575
|
+
const baseWithSep = base.endsWith(path.sep) ? base : base + path.sep;
|
|
576
|
+
const isInside = resolved === base || resolved.startsWith(baseWithSep);
|
|
577
|
+
if (!isInside) {
|
|
578
|
+
throw new Error(
|
|
579
|
+
`path-traversal blocked: "${resolved}" is outside the scripts dir "${base}"`
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Coerce an arbitrary parsed value into a clean { id: boolean } overlay map. Accepts
|
|
587
|
+
* either a flat map ({ "<id>": bool }) or a wrapped form ({ enabled: { ... } }) for
|
|
588
|
+
* forward-tolerance; non-boolean values are coerced to strict booleans; non-object
|
|
589
|
+
* input yields an empty overlay. Never throws.
|
|
590
|
+
*
|
|
591
|
+
* @param {*} raw
|
|
592
|
+
* @returns {Object<string, boolean>}
|
|
593
|
+
*/
|
|
594
|
+
function normalizeState(raw) {
|
|
595
|
+
const src =
|
|
596
|
+
raw && typeof raw === 'object' && raw.enabled && typeof raw.enabled === 'object'
|
|
597
|
+
? raw.enabled
|
|
598
|
+
: raw;
|
|
599
|
+
const out = {};
|
|
600
|
+
if (!src || typeof src !== 'object' || Array.isArray(src)) return out;
|
|
601
|
+
for (const key of Object.keys(src)) {
|
|
602
|
+
out[key] = src[key] === true;
|
|
603
|
+
}
|
|
604
|
+
return out;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* The basename of a spec's script field (e.g. "scripts/on-prompt.sh" -> "on-prompt.sh").
|
|
609
|
+
* Returns null for inline-command hooks (script null/empty). Tolerates either path
|
|
610
|
+
* separator since manifests use forward slashes.
|
|
611
|
+
*
|
|
612
|
+
* @param {*} script the spec.script field
|
|
613
|
+
* @returns {string|null}
|
|
614
|
+
*/
|
|
615
|
+
function scriptBasename(script) {
|
|
616
|
+
if (typeof script !== 'string' || script.length === 0) return null;
|
|
617
|
+
const norm = script.split('\\').join('/');
|
|
618
|
+
const base = norm.slice(norm.lastIndexOf('/') + 1);
|
|
619
|
+
return base.length ? base : null;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Produce a manifest object with command strings collapsed back to ${HOOKS_DIR} so the
|
|
624
|
+
* on-disk form stays machine-portable. Used only as a fallback when the raw file cannot
|
|
625
|
+
* be re-read during persistence.
|
|
626
|
+
*
|
|
627
|
+
* @param {object} manifest expanded manifest
|
|
628
|
+
* @param {string} hooksDir absolute hooks dir to collapse out of command strings
|
|
629
|
+
* @returns {object} a deep-enough copy with collapsed commands
|
|
630
|
+
*/
|
|
631
|
+
function collapseManifest(manifest, hooksDir) {
|
|
632
|
+
const copy = JSON.parse(JSON.stringify(manifest));
|
|
633
|
+
if (Array.isArray(copy.hooks)) {
|
|
634
|
+
for (const h of copy.hooks) {
|
|
635
|
+
if (h && typeof h.command === 'string' && hooksDir) {
|
|
636
|
+
h.command = h.command.split(hooksDir).join('${HOOKS_DIR}');
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return copy;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Atomically write a JS object as pretty JSON: write to a temp file in the SAME directory
|
|
645
|
+
* as the target (so rename is atomic on the same filesystem), then rename over the target.
|
|
646
|
+
*
|
|
647
|
+
* @param {string} targetPath absolute destination
|
|
648
|
+
* @param {object} obj
|
|
649
|
+
*/
|
|
650
|
+
function atomicWriteJson(targetPath, obj) {
|
|
651
|
+
const data = JSON.stringify(obj, null, 2) + '\n';
|
|
652
|
+
atomicWriteText(targetPath, data, path.dirname(targetPath));
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Atomically write text: temp file in `tmpDir` (must be on the same volume as the target),
|
|
657
|
+
* fsync, then rename over the target. Cleans up the temp file on failure.
|
|
658
|
+
*
|
|
659
|
+
* @param {string} targetPath absolute destination
|
|
660
|
+
* @param {string} data
|
|
661
|
+
* @param {string} tmpDir directory to place the temp file (same FS as target)
|
|
662
|
+
*/
|
|
663
|
+
function atomicWriteText(targetPath, data, tmpDir) {
|
|
664
|
+
const dir = tmpDir || path.dirname(targetPath);
|
|
665
|
+
const tmpName = `.${path.basename(targetPath)}.${process.pid}.${Date.now()}.${Math.random()
|
|
666
|
+
.toString(36)
|
|
667
|
+
.slice(2)}.tmp`;
|
|
668
|
+
const tmpPath = path.join(dir, tmpName);
|
|
669
|
+
|
|
670
|
+
let fd;
|
|
671
|
+
try {
|
|
672
|
+
fd = fs.openSync(tmpPath, 'w');
|
|
673
|
+
fs.writeFileSync(fd, data);
|
|
674
|
+
try {
|
|
675
|
+
fs.fsyncSync(fd);
|
|
676
|
+
} catch (_) {
|
|
677
|
+
// fsync may fail on some platforms/filesystems; the rename is still ordered.
|
|
678
|
+
}
|
|
679
|
+
} finally {
|
|
680
|
+
if (fd !== undefined) {
|
|
681
|
+
try {
|
|
682
|
+
fs.closeSync(fd);
|
|
683
|
+
} catch (_) {
|
|
684
|
+
/* ignore */
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
try {
|
|
690
|
+
// rename is atomic on the same filesystem and overwrites the destination on POSIX;
|
|
691
|
+
// on Windows it overwrites too as long as the target isn't locked.
|
|
692
|
+
fs.renameSync(tmpPath, targetPath);
|
|
693
|
+
} catch (err) {
|
|
694
|
+
// Best-effort cleanup so we don't leave temp files behind.
|
|
695
|
+
try {
|
|
696
|
+
fs.unlinkSync(tmpPath);
|
|
697
|
+
} catch (_) {
|
|
698
|
+
/* ignore */
|
|
699
|
+
}
|
|
700
|
+
throw err;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Load a manifest from disk, expand ${HOOKS_DIR} in every command string, and return a
|
|
706
|
+
* HookLoader holding it.
|
|
707
|
+
*
|
|
708
|
+
* @param {string} manifestPath absolute path to hooks.manifest.json
|
|
709
|
+
* @returns {HookLoader}
|
|
710
|
+
*/
|
|
711
|
+
function loadManifest(manifestPath) {
|
|
712
|
+
if (typeof manifestPath !== 'string' || manifestPath.length === 0) {
|
|
713
|
+
throw new TypeError('loadManifest: manifestPath must be a non-empty string');
|
|
714
|
+
}
|
|
715
|
+
const absManifest = path.resolve(manifestPath);
|
|
716
|
+
const raw = JSON.parse(fs.readFileSync(absManifest, 'utf8'));
|
|
717
|
+
|
|
718
|
+
const hooksDir = resolveHooksDir(absManifest, raw && raw.hooksDir);
|
|
719
|
+
|
|
720
|
+
// Build the expanded manifest (do not mutate the raw parse beyond this copy).
|
|
721
|
+
const expanded = {
|
|
722
|
+
version: raw && raw.version,
|
|
723
|
+
hooksDir, // store the resolved ABSOLUTE hooks dir for downstream consumers
|
|
724
|
+
hooks: [],
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
const srcHooks = (raw && Array.isArray(raw.hooks)) ? raw.hooks : [];
|
|
728
|
+
for (const h of srcHooks) {
|
|
729
|
+
if (!h || typeof h !== 'object') continue;
|
|
730
|
+
const spec = Object.assign({}, h);
|
|
731
|
+
// Expand ${HOOKS_DIR} inside the command (and, defensively, any string field that
|
|
732
|
+
// might reference it - but command is the documented carrier).
|
|
733
|
+
if (typeof spec.command === 'string') {
|
|
734
|
+
spec.command = expandHooksDir(spec.command, hooksDir);
|
|
735
|
+
}
|
|
736
|
+
// Normalize enabled to a strict boolean (manifest authors may omit it).
|
|
737
|
+
spec.enabled = spec.enabled === true;
|
|
738
|
+
expanded.hooks.push(spec);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const loader = new HookLoader(absManifest, expanded, hooksDir);
|
|
742
|
+
// v2: apply the persisted enabled-state overlay OVER the manifest flags so the
|
|
743
|
+
// returned loader (and enabledHooksFor) reflects the durable toggles. The overlay
|
|
744
|
+
// wins; absent/unreadable overlay leaves the manifest defaults intact.
|
|
745
|
+
loader.applyStateOverlay();
|
|
746
|
+
return loader;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Tiny inline self-check: add then remove a temp entry and confirm it round-trips
|
|
751
|
+
* with the manifest byte-for-byte RESTORED. Runs only under `node hook-loader.js`
|
|
752
|
+
* (never on import) and uses a THROWAWAY manifest in the OS temp dir, so the live
|
|
753
|
+
* project manifest is never touched. Exits non-zero on any failure.
|
|
754
|
+
*/
|
|
755
|
+
function _selfCheck() {
|
|
756
|
+
const os = require('node:os');
|
|
757
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hookloader-selfcheck-'));
|
|
758
|
+
const manifestPath = path.join(tmpRoot, 'hooks.manifest.json');
|
|
759
|
+
const assert = (cond, msg) => { if (!cond) throw new Error('self-check FAILED: ' + msg); };
|
|
760
|
+
try {
|
|
761
|
+
// A manifest whose command uses the ${HOOKS_DIR} token, so we can prove it stays
|
|
762
|
+
// UNCOLLAPSED on disk while being EXPANDED in the live view.
|
|
763
|
+
const original =
|
|
764
|
+
JSON.stringify(
|
|
765
|
+
{ version: 1, hooks: [{ id: 'existing', event: 'PreToolUse', command: '${HOOKS_DIR}/scripts/x.sh', enabled: true }] },
|
|
766
|
+
null,
|
|
767
|
+
2
|
|
768
|
+
) + '\n';
|
|
769
|
+
fs.writeFileSync(manifestPath, original);
|
|
770
|
+
|
|
771
|
+
const loader = loadManifest(manifestPath);
|
|
772
|
+
const tmpSpec = { id: 'selfcheck/tmp', event: 'PostToolUse', command: '${HOOKS_DIR}/scripts/tmp.sh' };
|
|
773
|
+
|
|
774
|
+
const stored = loader.addEntry(tmpSpec);
|
|
775
|
+
assert(stored.enabled === false, 'enabled should default to false');
|
|
776
|
+
// On disk: VERBATIM, token uncollapsed.
|
|
777
|
+
const afterAdd = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
778
|
+
const diskRow = afterAdd.hooks.find((h) => h.id === 'selfcheck/tmp');
|
|
779
|
+
assert(!!diskRow, 'temp entry must be persisted to the manifest');
|
|
780
|
+
assert(diskRow.command === '${HOOKS_DIR}/scripts/tmp.sh', 'on-disk command must keep ${HOOKS_DIR} uncollapsed');
|
|
781
|
+
// Live: EXPANDED so the engine sees it without reload.
|
|
782
|
+
const liveRow = loader.getSpec('selfcheck/tmp');
|
|
783
|
+
assert(!!liveRow, 'temp entry must be live in this.manifest.hooks');
|
|
784
|
+
assert(liveRow.command === path.join(loader.hooksDir, 'scripts/tmp.sh') ||
|
|
785
|
+
liveRow.command === loader.hooksDir + '/scripts/tmp.sh',
|
|
786
|
+
'live command must be expanded: ' + liveRow.command);
|
|
787
|
+
|
|
788
|
+
// Duplicate id must be rejected.
|
|
789
|
+
let threw = false;
|
|
790
|
+
try { loader.addEntry(tmpSpec); } catch (_) { threw = true; }
|
|
791
|
+
assert(threw, 'adding a duplicate id must throw');
|
|
792
|
+
|
|
793
|
+
const removed = loader.removeEntry('selfcheck/tmp');
|
|
794
|
+
assert(removed === true, 'removeEntry should report a removal');
|
|
795
|
+
assert(!loader.getSpec('selfcheck/tmp'), 'temp entry must be gone from the live view');
|
|
796
|
+
|
|
797
|
+
// Manifest must be RESTORED byte-for-byte (the only other row is "existing").
|
|
798
|
+
const afterRemove = fs.readFileSync(manifestPath, 'utf8');
|
|
799
|
+
assert(afterRemove === original, 'manifest must be restored byte-for-byte after add+remove');
|
|
800
|
+
|
|
801
|
+
process.stdout.write('hook-loader self-check OK (add+remove round-trip; manifest restored)\n');
|
|
802
|
+
} finally {
|
|
803
|
+
try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch (_) { /* ignore */ }
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
if (require.main === module) {
|
|
808
|
+
try {
|
|
809
|
+
_selfCheck();
|
|
810
|
+
} catch (err) {
|
|
811
|
+
process.stderr.write(((err && err.stack) || String(err)) + '\n');
|
|
812
|
+
process.exit(1);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
module.exports = {
|
|
817
|
+
loadManifest,
|
|
818
|
+
HookLoader,
|
|
819
|
+
// Exported for unit tests / reuse:
|
|
820
|
+
expandHooksDir,
|
|
821
|
+
resolveHooksDir,
|
|
822
|
+
normalizeState,
|
|
823
|
+
scriptBasename,
|
|
824
|
+
};
|