baldart 3.19.0 → 3.21.2
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/CHANGELOG.md +180 -0
- package/README.md +2 -2
- package/VERSION +1 -1
- package/framework/.claude/agents/REGISTRY.md +2 -0
- package/framework/.claude/hooks/agent-discovery-gate.js +167 -0
- package/framework/.claude/hooks/agent-discovery-info.sh +104 -0
- package/framework/.claude/skills/baldart-update/SKILL.md +315 -0
- package/framework/AGENTS.md +1 -0
- package/package.json +1 -1
- package/src/commands/add.js +16 -11
- package/src/commands/doctor.js +138 -13
- package/src/commands/update.js +18 -6
- package/src/utils/hooks.js +357 -86
package/src/utils/hooks.js
CHANGED
|
@@ -1,28 +1,105 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Consumer-side hook registration for `.claude/settings.json`.
|
|
3
3
|
*
|
|
4
|
-
* BALDART ships
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* content contains project-specific tokens (Neo-Brutalism, merchant,
|
|
8
|
-
* Recharts, secrets, hardcoded paths, …).
|
|
4
|
+
* BALDART ships one or more Claude Code hooks (PreToolUse, Stop, SessionStart,
|
|
5
|
+
* …) that are auto-registered in the consumer's `.claude/settings.json` on
|
|
6
|
+
* `baldart add` and re-checked on `baldart update` / `baldart doctor`.
|
|
9
7
|
*
|
|
10
|
-
*
|
|
11
|
-
* `baldart update` / `baldart doctor`. The user can remove the entry from
|
|
12
|
-
* `.claude/settings.json` if they want to disable it.
|
|
8
|
+
* **Multi-hook architecture (since v3.18.0)**
|
|
13
9
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
10
|
+
* Hook definitions live in `HOOK_REGISTRY` below — adding a hook is a matter
|
|
11
|
+
* of appending one entry. Every register/unregister/status operation iterates
|
|
12
|
+
* the registry, so call sites don't change when we ship a new hook.
|
|
13
|
+
*
|
|
14
|
+
* Each hook entry carries:
|
|
15
|
+
* - `id` stable marker stored in settings.json under `hooks[].id`
|
|
16
|
+
* - `event` Claude Code event (`PreToolUse`, `Stop`, `SessionStart`, …)
|
|
17
|
+
* - `matcher` tool-name regex (use '*' for events that don't take one)
|
|
18
|
+
* - `command` shell command Claude Code runs
|
|
19
|
+
* - `order` sort key when this hook coexists with other BALDART hooks
|
|
20
|
+
* on the same event (lower runs first)
|
|
21
|
+
* - `legacyMatch` (optional) substring that identifies pre-v3.18 entries
|
|
22
|
+
* when migrating consumers from the single-hook layout
|
|
23
|
+
*
|
|
24
|
+
* **Drift policy**
|
|
25
|
+
*
|
|
26
|
+
* When an existing entry matches our `id` but its `command` differs, we don't
|
|
27
|
+
* silently rewrite it — that would obliterate user customizations. Instead we
|
|
28
|
+
* call back to `onDrift({ id, event, matcher, expected, actual })` and expect
|
|
29
|
+
* `'keep'` or `'replace'`. Callers wire `onDrift` to an inquirer prompt
|
|
30
|
+
* (with a `'diff'` option that shows the diff and re-prompts). The default
|
|
31
|
+
* when `onDrift` is not provided is `'keep'` — safe for non-interactive runs.
|
|
32
|
+
*
|
|
33
|
+
* **Failure isolation**
|
|
34
|
+
*
|
|
35
|
+
* Claude Code's runtime executes hooks on the same event in array order and
|
|
36
|
+
* isolates failures itself (a non-zero exit from a Stop/SessionStart hook
|
|
37
|
+
* does not block the others). Our only responsibility is to never corrupt
|
|
38
|
+
* the array of hooks the user has set up themselves — entries we don't own
|
|
39
|
+
* (no `id` of ours, no `legacyMatch` of ours) are preserved verbatim and
|
|
40
|
+
* stay in their original position.
|
|
17
41
|
*/
|
|
18
42
|
|
|
19
43
|
const fs = require('fs');
|
|
20
44
|
const path = require('path');
|
|
21
45
|
|
|
22
46
|
const SETTINGS_FILE = path.join('.claude', 'settings.json');
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
47
|
+
|
|
48
|
+
/** @typedef {Object} HookDef
|
|
49
|
+
* @property {string} id
|
|
50
|
+
* @property {string} event Claude Code event name
|
|
51
|
+
* @property {string} matcher tool-name regex, or '*' for matcherless events
|
|
52
|
+
* @property {string} command shell command
|
|
53
|
+
* @property {number} order ascending sort key among BALDART hooks on same event
|
|
54
|
+
* @property {string} [legacyMatch] substring marker for pre-v3.18 entries to migrate
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/** @type {HookDef[]} */
|
|
58
|
+
const HOOK_REGISTRY = [
|
|
59
|
+
{
|
|
60
|
+
id: 'baldart-framework-edit-gate',
|
|
61
|
+
event: 'PreToolUse',
|
|
62
|
+
matcher: 'Edit|Write|MultiEdit|NotebookEdit',
|
|
63
|
+
command: 'node .framework/framework/.claude/hooks/framework-edit-gate.js',
|
|
64
|
+
order: 100,
|
|
65
|
+
legacyMatch: 'framework-edit-gate.js',
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
// Denies `Agent` tool calls whose subagent_type matches a BALDART agent
|
|
69
|
+
// name but the consumer's .claude/agents/<name>.md is missing or its
|
|
70
|
+
// symlink is broken. Mitigates anthropics/claude-code#56869 (silent
|
|
71
|
+
// sub-agent fallback) and #20931 (broken file-based discovery) for the
|
|
72
|
+
// filesystem-detectable subset.
|
|
73
|
+
id: 'baldart-agent-discovery-gate',
|
|
74
|
+
event: 'PreToolUse',
|
|
75
|
+
matcher: 'Agent',
|
|
76
|
+
command: 'node .framework/framework/.claude/hooks/agent-discovery-gate.js',
|
|
77
|
+
order: 200,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
// Injects an additionalContext warning at session start listing BALDART
|
|
81
|
+
// agents whose .claude/agents/<name>.md is missing. Best-effort early
|
|
82
|
+
// signal (Claude Code #10373 may suppress injection on new
|
|
83
|
+
// conversations) — the blocking counterpart is agent-discovery-gate.
|
|
84
|
+
id: 'baldart-agent-discovery-info',
|
|
85
|
+
event: 'SessionStart',
|
|
86
|
+
matcher: '*',
|
|
87
|
+
command: 'bash .framework/framework/.claude/hooks/agent-discovery-info.sh',
|
|
88
|
+
order: 100,
|
|
89
|
+
},
|
|
90
|
+
// Future hooks (T1.3, T1.4, T2.3) will be appended here, e.g.:
|
|
91
|
+
// {
|
|
92
|
+
// id: 'baldart-capture-detect',
|
|
93
|
+
// event: 'Stop',
|
|
94
|
+
// matcher: '*',
|
|
95
|
+
// command: 'bash .framework/framework/.claude/hooks/capture-detect.sh',
|
|
96
|
+
// order: 100,
|
|
97
|
+
// },
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// settings.json I/O
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
26
103
|
|
|
27
104
|
function readSettings(cwd = process.cwd()) {
|
|
28
105
|
const full = path.join(cwd, SETTINGS_FILE);
|
|
@@ -41,112 +118,306 @@ function writeSettings(settingsPath, settings) {
|
|
|
41
118
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
42
119
|
}
|
|
43
120
|
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Internal: locate an entry that owns a given hook definition
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
44
125
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
126
|
+
* Walks `settings.hooks[event]` and returns the first entry/hook pair whose
|
|
127
|
+
* `id` matches, or whose `command` contains `legacyMatch` (pre-v3.18 layout).
|
|
128
|
+
* Returns `null` when the hook is not yet registered.
|
|
48
129
|
*/
|
|
49
|
-
function
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
130
|
+
function findOwnedEntry(eventList, def) {
|
|
131
|
+
if (!Array.isArray(eventList)) return null;
|
|
132
|
+
for (let i = 0; i < eventList.length; i++) {
|
|
133
|
+
const entry = eventList[i];
|
|
53
134
|
if (!entry || !Array.isArray(entry.hooks)) continue;
|
|
54
|
-
for (
|
|
135
|
+
for (let j = 0; j < entry.hooks.length; j++) {
|
|
136
|
+
const h = entry.hooks[j];
|
|
55
137
|
if (!h) continue;
|
|
56
|
-
if (h.id ===
|
|
57
|
-
if (
|
|
58
|
-
|
|
138
|
+
if (h.id === def.id) return { entryIdx: i, hookIdx: j, entry, hook: h, byLegacy: false };
|
|
139
|
+
if (def.legacyMatch && h.type === 'command'
|
|
140
|
+
&& typeof h.command === 'string' && h.command.includes(def.legacyMatch)) {
|
|
141
|
+
return { entryIdx: i, hookIdx: j, entry, hook: h, byLegacy: true };
|
|
59
142
|
}
|
|
60
143
|
}
|
|
61
144
|
}
|
|
62
|
-
return
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Status — read-only inspection of every hook in the registry
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @typedef {Object} HookStatus
|
|
154
|
+
* @property {string} id
|
|
155
|
+
* @property {string} event
|
|
156
|
+
* @property {boolean} registered hook with this id exists in settings.json
|
|
157
|
+
* @property {boolean} drift registered but command differs from expected
|
|
158
|
+
* @property {boolean} legacy matched only by legacyMatch (id is missing)
|
|
159
|
+
* @property {string} expected command BALDART wants
|
|
160
|
+
* @property {string} [actual] command currently in settings.json (when registered)
|
|
161
|
+
*/
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Returns the status of every hook in the registry. Pure read; never writes.
|
|
165
|
+
*
|
|
166
|
+
* @param {string} cwd
|
|
167
|
+
* @returns {{ malformed: boolean, path: string, hooks: HookStatus[] }}
|
|
168
|
+
*/
|
|
169
|
+
function getStatus(cwd = process.cwd()) {
|
|
170
|
+
const { path: settingsPath, settings, malformed } = readSettings(cwd);
|
|
171
|
+
if (malformed) {
|
|
172
|
+
return { malformed: true, path: settingsPath, hooks: [] };
|
|
173
|
+
}
|
|
174
|
+
const allHooks = (settings && settings.hooks) || {};
|
|
175
|
+
const hooks = HOOK_REGISTRY.map((def) => {
|
|
176
|
+
const found = findOwnedEntry(allHooks[def.event], def);
|
|
177
|
+
if (!found) {
|
|
178
|
+
return {
|
|
179
|
+
id: def.id, event: def.event,
|
|
180
|
+
registered: false, drift: false, legacy: false,
|
|
181
|
+
expected: def.command,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const actual = found.hook.command;
|
|
185
|
+
return {
|
|
186
|
+
id: def.id, event: def.event,
|
|
187
|
+
registered: true,
|
|
188
|
+
drift: actual !== def.command,
|
|
189
|
+
legacy: found.byLegacy,
|
|
190
|
+
expected: def.command,
|
|
191
|
+
actual,
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
return { malformed: false, path: settingsPath, hooks };
|
|
63
195
|
}
|
|
64
196
|
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// Register — write path with drift callback
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
|
|
65
201
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
202
|
+
* Idempotently register every hook in the registry.
|
|
203
|
+
*
|
|
204
|
+
* @param {string} [cwd]
|
|
205
|
+
* @param {Object} [options]
|
|
206
|
+
* @param {(info: { id: string, event: string, matcher: string,
|
|
207
|
+
* expected: string, actual: string })
|
|
208
|
+
* => Promise<'keep'|'replace'>|('keep'|'replace')} [options.onDrift]
|
|
209
|
+
* Called when a hook with our `id` exists but command differs.
|
|
210
|
+
* Default: `'keep'` (safe for non-interactive / CI).
|
|
211
|
+
* @returns {Promise<{ malformed: boolean, path: string,
|
|
212
|
+
* created: boolean, updated: boolean,
|
|
213
|
+
* results: Array<{ id: string, status:
|
|
214
|
+
* 'created'|'already'|'drift-replaced'|'drift-kept'|'legacy-migrated',
|
|
215
|
+
* previousCommand?: string }> }>}
|
|
71
216
|
*/
|
|
72
|
-
function
|
|
217
|
+
async function registerAll(cwd = process.cwd(), options = {}) {
|
|
218
|
+
const onDrift = typeof options.onDrift === 'function'
|
|
219
|
+
? options.onDrift
|
|
220
|
+
: () => 'keep';
|
|
221
|
+
|
|
73
222
|
const { path: settingsPath, settings: existing, malformed } = readSettings(cwd);
|
|
74
|
-
if (malformed)
|
|
223
|
+
if (malformed) {
|
|
224
|
+
return { malformed: true, path: settingsPath, created: false, updated: false, results: [] };
|
|
225
|
+
}
|
|
75
226
|
|
|
76
|
-
const
|
|
227
|
+
const wasNew = !existing;
|
|
228
|
+
const settings = existing && typeof existing === 'object' ? structuredClone(existing) : {};
|
|
77
229
|
settings.hooks = settings.hooks && typeof settings.hooks === 'object'
|
|
78
|
-
?
|
|
230
|
+
? settings.hooks
|
|
79
231
|
: {};
|
|
80
232
|
|
|
81
|
-
const
|
|
233
|
+
const results = [];
|
|
234
|
+
let modified = false;
|
|
82
235
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
236
|
+
for (const def of HOOK_REGISTRY) {
|
|
237
|
+
const list = Array.isArray(settings.hooks[def.event])
|
|
238
|
+
? settings.hooks[def.event]
|
|
239
|
+
: [];
|
|
240
|
+
|
|
241
|
+
const found = findOwnedEntry(list, def);
|
|
242
|
+
|
|
243
|
+
if (!found) {
|
|
244
|
+
list.push({
|
|
245
|
+
matcher: def.matcher,
|
|
246
|
+
hooks: [{ id: def.id, type: 'command', command: def.command }],
|
|
247
|
+
});
|
|
248
|
+
settings.hooks[def.event] = list;
|
|
249
|
+
results.push({ id: def.id, status: 'created' });
|
|
250
|
+
modified = true;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (found.byLegacy) {
|
|
255
|
+
// Pre-v3.18 entry: stamp our id on it and align the command.
|
|
256
|
+
const previousCommand = found.hook.command;
|
|
257
|
+
found.hook.id = def.id;
|
|
258
|
+
found.hook.command = def.command;
|
|
259
|
+
results.push({ id: def.id, status: 'legacy-migrated', previousCommand });
|
|
260
|
+
modified = true;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (found.hook.command === def.command) {
|
|
265
|
+
results.push({ id: def.id, status: 'already' });
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Drift: same id, different command. Ask the caller.
|
|
270
|
+
const decision = await onDrift({
|
|
271
|
+
id: def.id,
|
|
272
|
+
event: def.event,
|
|
273
|
+
matcher: def.matcher,
|
|
274
|
+
expected: def.command,
|
|
275
|
+
actual: found.hook.command,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
if (decision === 'replace') {
|
|
279
|
+
const previousCommand = found.hook.command;
|
|
280
|
+
found.hook.command = def.command;
|
|
281
|
+
results.push({ id: def.id, status: 'drift-replaced', previousCommand });
|
|
282
|
+
modified = true;
|
|
283
|
+
} else {
|
|
284
|
+
results.push({
|
|
285
|
+
id: def.id, status: 'drift-kept', previousCommand: found.hook.command,
|
|
286
|
+
});
|
|
93
287
|
}
|
|
94
288
|
}
|
|
95
289
|
|
|
96
|
-
|
|
97
|
-
matcher: HOOK_MATCHER,
|
|
98
|
-
hooks: [{
|
|
99
|
-
id: HOOK_ID,
|
|
100
|
-
type: 'command',
|
|
101
|
-
command: HOOK_COMMAND,
|
|
102
|
-
}],
|
|
103
|
-
});
|
|
290
|
+
if (modified) writeSettings(settingsPath, settings);
|
|
104
291
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
292
|
+
return {
|
|
293
|
+
malformed: false,
|
|
294
|
+
path: settingsPath,
|
|
295
|
+
created: modified && wasNew,
|
|
296
|
+
updated: modified && !wasNew,
|
|
297
|
+
results,
|
|
298
|
+
};
|
|
108
299
|
}
|
|
109
300
|
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// Unregister — remove every BALDART hook from settings.json
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
|
|
110
305
|
/**
|
|
111
|
-
* Remove
|
|
306
|
+
* Remove every hook defined in the registry from settings.json. Idempotent.
|
|
307
|
+
*
|
|
308
|
+
* @param {string} [cwd]
|
|
309
|
+
* @returns {{ malformed: boolean, path: string,
|
|
310
|
+
* results: Array<{ id: string, status: 'removed'|'not-found' }> }}
|
|
112
311
|
*/
|
|
113
|
-
function
|
|
312
|
+
function unregisterAll(cwd = process.cwd()) {
|
|
114
313
|
const { path: settingsPath, settings, malformed } = readSettings(cwd);
|
|
115
|
-
if (malformed || !settings || !settings.hooks
|
|
116
|
-
return {
|
|
314
|
+
if (malformed || !settings || !settings.hooks) {
|
|
315
|
+
return {
|
|
316
|
+
malformed,
|
|
317
|
+
path: settingsPath,
|
|
318
|
+
results: HOOK_REGISTRY.map((d) => ({ id: d.id, status: 'not-found' })),
|
|
319
|
+
};
|
|
117
320
|
}
|
|
118
321
|
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const innerKept = entry.hooks.filter((h) => {
|
|
123
|
-
if (!h) return true;
|
|
124
|
-
if (h.id === HOOK_ID) return false;
|
|
125
|
-
if (h.type === 'command' && typeof h.command === 'string'
|
|
126
|
-
&& h.command.includes('framework-edit-gate.js')) return false;
|
|
127
|
-
return true;
|
|
128
|
-
});
|
|
129
|
-
if (innerKept.length === 0) return null;
|
|
130
|
-
return { ...entry, hooks: innerKept };
|
|
131
|
-
})
|
|
132
|
-
.filter(Boolean);
|
|
322
|
+
const next = structuredClone(settings);
|
|
323
|
+
const results = [];
|
|
324
|
+
let modified = false;
|
|
133
325
|
|
|
134
|
-
|
|
135
|
-
|
|
326
|
+
for (const def of HOOK_REGISTRY) {
|
|
327
|
+
const list = Array.isArray(next.hooks[def.event]) ? next.hooks[def.event] : null;
|
|
328
|
+
if (!list) {
|
|
329
|
+
results.push({ id: def.id, status: 'not-found' });
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
let removed = false;
|
|
333
|
+
const filtered = list
|
|
334
|
+
.map((entry) => {
|
|
335
|
+
if (!entry || !Array.isArray(entry.hooks)) return entry;
|
|
336
|
+
const innerKept = entry.hooks.filter((h) => {
|
|
337
|
+
if (!h) return true;
|
|
338
|
+
if (h.id === def.id) { removed = true; return false; }
|
|
339
|
+
if (def.legacyMatch && h.type === 'command'
|
|
340
|
+
&& typeof h.command === 'string' && h.command.includes(def.legacyMatch)) {
|
|
341
|
+
removed = true;
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
return true;
|
|
345
|
+
});
|
|
346
|
+
if (innerKept.length === 0) return null;
|
|
347
|
+
return { ...entry, hooks: innerKept };
|
|
348
|
+
})
|
|
349
|
+
.filter(Boolean);
|
|
350
|
+
|
|
351
|
+
if (removed) {
|
|
352
|
+
if (filtered.length === 0) {
|
|
353
|
+
delete next.hooks[def.event];
|
|
354
|
+
} else {
|
|
355
|
+
next.hooks[def.event] = filtered;
|
|
356
|
+
}
|
|
357
|
+
modified = true;
|
|
358
|
+
results.push({ id: def.id, status: 'removed' });
|
|
359
|
+
} else {
|
|
360
|
+
results.push({ id: def.id, status: 'not-found' });
|
|
361
|
+
}
|
|
136
362
|
}
|
|
137
363
|
|
|
138
|
-
const next = { ...settings, hooks: { ...settings.hooks, PreToolUse: filtered } };
|
|
139
|
-
if (next.hooks.PreToolUse.length === 0) delete next.hooks.PreToolUse;
|
|
140
364
|
if (Object.keys(next.hooks).length === 0) delete next.hooks;
|
|
141
|
-
writeSettings(settingsPath, next);
|
|
142
|
-
|
|
365
|
+
if (modified) writeSettings(settingsPath, next);
|
|
366
|
+
|
|
367
|
+
return { malformed: false, path: settingsPath, results };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
// Drift prompt helper (interactive Keep / Replace / Show diff)
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Build an `onDrift` callback suitable for `registerAll`. Wraps inquirer via
|
|
376
|
+
* the project's UI helper so the CLI output stays consistent.
|
|
377
|
+
*
|
|
378
|
+
* @param {Object} UI the `src/utils/ui.js` module (avoids a
|
|
379
|
+
* circular require by accepting it from
|
|
380
|
+
* the caller)
|
|
381
|
+
* @param {Object} [options]
|
|
382
|
+
* @param {boolean} [options.autoYes] non-interactive mode: skip prompt and
|
|
383
|
+
* always return 'keep' (safe default)
|
|
384
|
+
* @returns {(info: {id:string, event:string, expected:string, actual:string})
|
|
385
|
+
* => Promise<'keep'|'replace'>}
|
|
386
|
+
*/
|
|
387
|
+
function createDriftPrompt(UI, { autoYes = false } = {}) {
|
|
388
|
+
if (autoYes) {
|
|
389
|
+
return async ({ id }) => {
|
|
390
|
+
UI.warning(`Hook drift detected for \`${id}\` — keeping current command (auto mode). Run \`baldart doctor\` interactively to resolve.`);
|
|
391
|
+
return 'keep';
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const truncate = (s, n = 70) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
|
396
|
+
|
|
397
|
+
return async function onDrift({ id, event, expected, actual }) {
|
|
398
|
+
UI.warning(`Hook \`${id}\` (${event}) is registered with a non-default command.`);
|
|
399
|
+
// Loop while the user keeps choosing 'diff'
|
|
400
|
+
for (;;) {
|
|
401
|
+
const choice = await UI.select(
|
|
402
|
+
`What do you want to do with \`${id}\`?`,
|
|
403
|
+
[
|
|
404
|
+
{ name: `Keep current — leave: ${truncate(actual)}`, value: 'keep' },
|
|
405
|
+
{ name: `Replace with BALDART — set: ${truncate(expected)}`, value: 'replace' },
|
|
406
|
+
{ name: `Show full diff and decide`, value: 'diff' },
|
|
407
|
+
],
|
|
408
|
+
);
|
|
409
|
+
if (choice !== 'diff') return choice;
|
|
410
|
+
console.log(` expected: ${expected}`);
|
|
411
|
+
console.log(` actual: ${actual}`);
|
|
412
|
+
}
|
|
413
|
+
};
|
|
143
414
|
}
|
|
144
415
|
|
|
145
416
|
module.exports = {
|
|
146
|
-
|
|
147
|
-
register,
|
|
148
|
-
unregister,
|
|
149
|
-
HOOK_ID,
|
|
150
|
-
HOOK_COMMAND,
|
|
417
|
+
HOOK_REGISTRY,
|
|
151
418
|
SETTINGS_FILE,
|
|
419
|
+
getStatus,
|
|
420
|
+
registerAll,
|
|
421
|
+
unregisterAll,
|
|
422
|
+
createDriftPrompt,
|
|
152
423
|
};
|