@dalzoubi/dev-agents-sync 2.0.28 → 2.2.0
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 +53 -8
- package/package.json +1 -1
- package/src/commands/init.mjs +26 -2
- package/src/commands/update.mjs +45 -3
- package/src/hooks.mjs +52 -5
- package/src/settings.mjs +17 -0
- package/tests/e2e/steering-hooks-flow.test.mjs +27 -19
- package/tests/hooks-summary-line.test.mjs +595 -0
- package/tests/hooks-wave2-registration.test.mjs +523 -0
- package/tests/rules-injector-registration.test.mjs +17 -14
- package/tests/update-uptodate-wiring.test.mjs +484 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/hooks-summary-line.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* Spec: docs/specs/260805-hooks-wave-2/ — AC-8 (UX notes):
|
|
5
|
+
*
|
|
6
|
+
* "`init`/`update` extend the existing hooks summary line to the new count —
|
|
7
|
+
* `hooks/ installed (8 scripts)` after this wave, with the number derived
|
|
8
|
+
* from the installed set, never a hard-coded literal — and the existing
|
|
9
|
+
* wiring line; `--dry-run` and exit-code contracts unchanged."
|
|
10
|
+
*
|
|
11
|
+
* Behavior anchors (§7) — each would fail if the behavior broke:
|
|
12
|
+
*
|
|
13
|
+
* A1. init emits a hooks summary line whose count equals the number of hook
|
|
14
|
+
* scripts that run installed.
|
|
15
|
+
* A2. update emits the same line, same derivation.
|
|
16
|
+
* A3. The count TRACKS THE INSTALLED SET: three different fetchers shipping
|
|
17
|
+
* different hook counts produce three different numbers. This is the test
|
|
18
|
+
* that fails if anyone re-pins a literal into the line.
|
|
19
|
+
* A4. Singular is handled — one installed script reads "1 script", not
|
|
20
|
+
* "1 scripts".
|
|
21
|
+
* A5. A run that installs no hook scripts emits no hooks summary line.
|
|
22
|
+
* A6. The wiring line reports the settings.json merge, with a registration
|
|
23
|
+
* count derived from the CLI's own registration table (a script the table
|
|
24
|
+
* does not know about is installed but not counted as registered).
|
|
25
|
+
* A7. --no-hooks-wiring reports the SKIP instead of claiming a merge.
|
|
26
|
+
* A8. --dry-run emits neither line (planning is not doing) and still returns
|
|
27
|
+
* the plan — the dry-run contract is unchanged.
|
|
28
|
+
* A9. Neither line changes an exit code: the summary is informational only and
|
|
29
|
+
* a failing run still throws with its own exitCode.
|
|
30
|
+
*
|
|
31
|
+
* The lines go to stderr, the same stream as the sibling `steering/ …` and
|
|
32
|
+
* `pruned N retired hook registration(s)` lines, so stdout stays parseable.
|
|
33
|
+
*
|
|
34
|
+
* All tests use isolated tmp dirs and never touch the real repo.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
38
|
+
import assert from 'node:assert/strict';
|
|
39
|
+
import {
|
|
40
|
+
mkdtempSync,
|
|
41
|
+
mkdirSync,
|
|
42
|
+
writeFileSync,
|
|
43
|
+
readFileSync,
|
|
44
|
+
existsSync,
|
|
45
|
+
rmSync,
|
|
46
|
+
readdirSync,
|
|
47
|
+
} from 'node:fs';
|
|
48
|
+
import { tmpdir } from 'node:os';
|
|
49
|
+
import path from 'node:path';
|
|
50
|
+
import { fileURLToPath } from 'node:url';
|
|
51
|
+
|
|
52
|
+
import { runInit } from '../src/commands/init.mjs';
|
|
53
|
+
import { runUpdate } from '../src/commands/update.mjs';
|
|
54
|
+
import { writeLockfile } from '../src/lockfile.mjs';
|
|
55
|
+
import { DEFAULT_HOOK_SCRIPTS } from '../src/settings.mjs';
|
|
56
|
+
|
|
57
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
58
|
+
const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'release-v1.0.0');
|
|
59
|
+
const AVAILABLE_TAGS = ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'];
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Fixture helpers
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
function collectFiles(baseDir, currentDir, map, prefix) {
|
|
66
|
+
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
|
67
|
+
const full = path.join(currentDir, entry.name);
|
|
68
|
+
if (entry.isDirectory()) {
|
|
69
|
+
collectFiles(baseDir, full, map, prefix);
|
|
70
|
+
} else {
|
|
71
|
+
const rel = path.relative(baseDir, full).replace(/\\/g, '/');
|
|
72
|
+
map[`${prefix}/${rel}`] = readFileSync(full, 'utf8');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Every hook script name the CLI's registration table knows about. */
|
|
78
|
+
const REGISTERED_HOOK_NAMES = DEFAULT_HOOK_SCRIPTS.map((h) => path.basename(h.command));
|
|
79
|
+
|
|
80
|
+
function hookBody(name) {
|
|
81
|
+
return `#!/bin/sh\n# managed-by: dev-agents-sync v9.9.9\n# ${name}\nexit 0\n`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Builds a claude-target FileMap carrying exactly `hookNames` hook scripts.
|
|
86
|
+
* The hook set is a PARAMETER so the tests can vary it — that is what proves
|
|
87
|
+
* the reported count is derived rather than pinned.
|
|
88
|
+
*/
|
|
89
|
+
function fileMapWithHooks(hookNames) {
|
|
90
|
+
const map = {};
|
|
91
|
+
const targetDir = path.join(FIXTURE_DIR, 'claude');
|
|
92
|
+
if (existsSync(targetDir)) collectFiles(targetDir, targetDir, map, 'claude');
|
|
93
|
+
for (const name of hookNames) {
|
|
94
|
+
map[`claude/hooks/${name}`] = hookBody(name);
|
|
95
|
+
}
|
|
96
|
+
return map;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function fetcherWithHooks(hookNames) {
|
|
100
|
+
return async () => fileMapWithHooks(hookNames);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function makeTmpDir() {
|
|
104
|
+
return mkdtempSync(path.join(tmpdir(), 'das-hooks-summary-'));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Seeds a pre-initialized consumer repo so `update` has a lockfile to read. */
|
|
108
|
+
function setupInitializedRepo(consumerDir, { resolvedVersion = '1.0.0' } = {}) {
|
|
109
|
+
writeLockfile(consumerDir, {
|
|
110
|
+
source: 'github:dalzoubi/dev-agents',
|
|
111
|
+
range: '^1',
|
|
112
|
+
resolvedVersion,
|
|
113
|
+
targets: ['claude'],
|
|
114
|
+
lastUpdated: '2026-08-05T00:00:00Z',
|
|
115
|
+
});
|
|
116
|
+
const agentsDir = path.join(consumerDir, '.claude', 'agents');
|
|
117
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
118
|
+
writeFileSync(
|
|
119
|
+
path.join(agentsDir, 'define.md'),
|
|
120
|
+
readFileSync(path.join(FIXTURE_DIR, 'claude', 'agents', 'define.md'), 'utf8'),
|
|
121
|
+
'utf8',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Captures everything written to stderr while `fn` runs. */
|
|
126
|
+
async function captureStderr(fn) {
|
|
127
|
+
const origWrite = process.stderr.write.bind(process.stderr);
|
|
128
|
+
let captured = '';
|
|
129
|
+
process.stderr.write = (chunk) => {
|
|
130
|
+
captured += typeof chunk === 'string' ? chunk : chunk.toString();
|
|
131
|
+
return true;
|
|
132
|
+
};
|
|
133
|
+
try {
|
|
134
|
+
await fn();
|
|
135
|
+
} finally {
|
|
136
|
+
process.stderr.write = origWrite;
|
|
137
|
+
}
|
|
138
|
+
return captured;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Pulls the installed-count out of the hooks summary line, or null when the
|
|
143
|
+
* line is absent. Matching the number generically (not against an expected
|
|
144
|
+
* literal) is deliberate: the assertions compare it to a count the test
|
|
145
|
+
* DERIVES, so a wrong number fails with the real number in the message.
|
|
146
|
+
*/
|
|
147
|
+
function installedCountFrom(stderr) {
|
|
148
|
+
const m = /hooks\/ installed \((\d+) scripts?\)/.exec(stderr);
|
|
149
|
+
return m ? Number(m[1]) : null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// A1 / A2 — the line exists and its count equals the installed set
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
describe('AC-8 hooks summary line — count equals the installed set', () => {
|
|
157
|
+
let consumerDir;
|
|
158
|
+
beforeEach(() => { consumerDir = makeTmpDir(); });
|
|
159
|
+
afterEach(() => { rmSync(consumerDir, { recursive: true, force: true }); });
|
|
160
|
+
|
|
161
|
+
it('A1: init reports exactly the number of hook scripts it installed', async () => {
|
|
162
|
+
const stderr = await captureStderr(() =>
|
|
163
|
+
runInit(consumerDir, {
|
|
164
|
+
targets: ['claude'],
|
|
165
|
+
fetcher: fetcherWithHooks(REGISTERED_HOOK_NAMES),
|
|
166
|
+
availableTags: AVAILABLE_TAGS,
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const onDisk = readdirSync(path.join(consumerDir, '.claude', 'hooks'))
|
|
171
|
+
.filter((f) => f.endsWith('.sh'));
|
|
172
|
+
assert.equal(
|
|
173
|
+
installedCountFrom(stderr),
|
|
174
|
+
onDisk.length,
|
|
175
|
+
`init's hooks summary must report the number of scripts it wrote to ` +
|
|
176
|
+
`.claude/hooks/ (${onDisk.length}); stderr was:\n${stderr}`,
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('A2: update reports exactly the number of hook scripts it installed', async () => {
|
|
181
|
+
setupInitializedRepo(consumerDir);
|
|
182
|
+
|
|
183
|
+
const stderr = await captureStderr(() =>
|
|
184
|
+
runUpdate(consumerDir, {
|
|
185
|
+
fetcher: fetcherWithHooks(REGISTERED_HOOK_NAMES),
|
|
186
|
+
availableTags: AVAILABLE_TAGS,
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const onDisk = readdirSync(path.join(consumerDir, '.claude', 'hooks'))
|
|
191
|
+
.filter((f) => f.endsWith('.sh'));
|
|
192
|
+
assert.equal(
|
|
193
|
+
installedCountFrom(stderr),
|
|
194
|
+
onDisk.length,
|
|
195
|
+
`update's hooks summary must report the number of scripts it wrote to ` +
|
|
196
|
+
`.claude/hooks/ (${onDisk.length}); stderr was:\n${stderr}`,
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('A2b: the reported count equals the lockfile-recorded hooks list', async () => {
|
|
201
|
+
// Second derivation of the same truth: the lockfile records the installed
|
|
202
|
+
// hook paths, so the summary and the lockfile must agree by construction.
|
|
203
|
+
setupInitializedRepo(consumerDir);
|
|
204
|
+
let result;
|
|
205
|
+
const stderr = await captureStderr(async () => {
|
|
206
|
+
result = await runUpdate(consumerDir, {
|
|
207
|
+
fetcher: fetcherWithHooks(REGISTERED_HOOK_NAMES),
|
|
208
|
+
availableTags: AVAILABLE_TAGS,
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
assert.equal(
|
|
213
|
+
installedCountFrom(stderr),
|
|
214
|
+
result.hooks.length,
|
|
215
|
+
`summary count must equal the installed hook path list the run recorded; ` +
|
|
216
|
+
`stderr was:\n${stderr}`,
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// A3 — the count is DERIVED: vary the shipped set, the number follows
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
describe('AC-8 hooks summary line — count is derived, never a literal', () => {
|
|
226
|
+
let consumerDir;
|
|
227
|
+
beforeEach(() => { consumerDir = makeTmpDir(); });
|
|
228
|
+
afterEach(() => { rmSync(consumerDir, { recursive: true, force: true }); });
|
|
229
|
+
|
|
230
|
+
// Three shipped sets of DIFFERENT sizes. Any implementation that prints a
|
|
231
|
+
// pinned number fails at least two of these — which is the whole point: this
|
|
232
|
+
// wave already burned three fixes on hard-coded hook counts.
|
|
233
|
+
const shippedSets = [
|
|
234
|
+
REGISTERED_HOOK_NAMES.slice(0, 2),
|
|
235
|
+
REGISTERED_HOOK_NAMES.slice(0, 4),
|
|
236
|
+
REGISTERED_HOOK_NAMES,
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
for (const hookNames of shippedSets) {
|
|
240
|
+
it(`A3: a version shipping ${hookNames.length} hook scripts reports ${hookNames.length}`, async () => {
|
|
241
|
+
const stderr = await captureStderr(() =>
|
|
242
|
+
runInit(consumerDir, {
|
|
243
|
+
targets: ['claude'],
|
|
244
|
+
fetcher: fetcherWithHooks(hookNames),
|
|
245
|
+
availableTags: AVAILABLE_TAGS,
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
assert.equal(
|
|
250
|
+
installedCountFrom(stderr),
|
|
251
|
+
hookNames.length,
|
|
252
|
+
`the summary count must follow the shipped hook set, not a pinned ` +
|
|
253
|
+
`literal; stderr was:\n${stderr}`,
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
it('A3b: a hook script the registration table does not know about still counts as installed', async () => {
|
|
259
|
+
// The count comes from what was INSTALLED, not from the registration table.
|
|
260
|
+
const hookNames = [...REGISTERED_HOOK_NAMES, 'future-unregistered-hook.sh'];
|
|
261
|
+
const stderr = await captureStderr(() =>
|
|
262
|
+
runInit(consumerDir, {
|
|
263
|
+
targets: ['claude'],
|
|
264
|
+
fetcher: fetcherWithHooks(hookNames),
|
|
265
|
+
availableTags: AVAILABLE_TAGS,
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
assert.equal(
|
|
270
|
+
installedCountFrom(stderr),
|
|
271
|
+
hookNames.length,
|
|
272
|
+
`installed count must be derived from the installed scripts, not from ` +
|
|
273
|
+
`DEFAULT_HOOK_SCRIPTS; stderr was:\n${stderr}`,
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// A4 / A5 — singular, and silence when nothing was installed
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
describe('AC-8 hooks summary line — singular and empty cases', () => {
|
|
283
|
+
let consumerDir;
|
|
284
|
+
beforeEach(() => { consumerDir = makeTmpDir(); });
|
|
285
|
+
afterEach(() => { rmSync(consumerDir, { recursive: true, force: true }); });
|
|
286
|
+
|
|
287
|
+
it('A4: one installed script reads "1 script", not "1 scripts"', async () => {
|
|
288
|
+
const stderr = await captureStderr(() =>
|
|
289
|
+
runInit(consumerDir, {
|
|
290
|
+
targets: ['claude'],
|
|
291
|
+
fetcher: fetcherWithHooks([REGISTERED_HOOK_NAMES[0]]),
|
|
292
|
+
availableTags: AVAILABLE_TAGS,
|
|
293
|
+
}),
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
assert.match(
|
|
297
|
+
stderr,
|
|
298
|
+
/hooks\/ installed \(1 script\)/,
|
|
299
|
+
`singular must be handled; stderr was:\n${stderr}`,
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('A5: a run that installs no hook scripts emits no hooks summary line', async () => {
|
|
304
|
+
const stderr = await captureStderr(() =>
|
|
305
|
+
runInit(consumerDir, {
|
|
306
|
+
targets: ['claude'],
|
|
307
|
+
fetcher: fetcherWithHooks([]),
|
|
308
|
+
availableTags: AVAILABLE_TAGS,
|
|
309
|
+
}),
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
assert.equal(
|
|
313
|
+
installedCountFrom(stderr),
|
|
314
|
+
null,
|
|
315
|
+
`with no hook scripts shipped there is nothing to summarize; ` +
|
|
316
|
+
`stderr was:\n${stderr}`,
|
|
317
|
+
);
|
|
318
|
+
assert.ok(
|
|
319
|
+
!/hooks\/ (wired|settings\.json wiring skipped|nothing to wire)/.test(stderr),
|
|
320
|
+
`the wiring line is paired with the summary and must stay silent too; ` +
|
|
321
|
+
`stderr was:\n${stderr}`,
|
|
322
|
+
);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
// A6 / A7 — the wiring line
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
describe('AC-8 wiring line', () => {
|
|
331
|
+
let consumerDir;
|
|
332
|
+
beforeEach(() => { consumerDir = makeTmpDir(); });
|
|
333
|
+
afterEach(() => { rmSync(consumerDir, { recursive: true, force: true }); });
|
|
334
|
+
|
|
335
|
+
it('A6: init reports the settings.json merge with a derived registration count', async () => {
|
|
336
|
+
const stderr = await captureStderr(() =>
|
|
337
|
+
runInit(consumerDir, {
|
|
338
|
+
targets: ['claude'],
|
|
339
|
+
fetcher: fetcherWithHooks(REGISTERED_HOOK_NAMES),
|
|
340
|
+
availableTags: AVAILABLE_TAGS,
|
|
341
|
+
}),
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
assert.ok(
|
|
345
|
+
existsSync(path.join(consumerDir, '.claude', 'settings.json')),
|
|
346
|
+
'precondition: the wiring line only makes sense if settings.json was written',
|
|
347
|
+
);
|
|
348
|
+
assert.match(
|
|
349
|
+
stderr,
|
|
350
|
+
new RegExp(
|
|
351
|
+
`hooks/ wired into \\.claude/settings\\.json \\(${REGISTERED_HOOK_NAMES.length} registrations\\)`,
|
|
352
|
+
),
|
|
353
|
+
`wiring line must report the registrations the CLI actually made ` +
|
|
354
|
+
`(${REGISTERED_HOOK_NAMES.length}); stderr was:\n${stderr}`,
|
|
355
|
+
);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('A6b: an unregistered script is installed but not counted as a registration', async () => {
|
|
359
|
+
const stderr = await captureStderr(() =>
|
|
360
|
+
runInit(consumerDir, {
|
|
361
|
+
targets: ['claude'],
|
|
362
|
+
fetcher: fetcherWithHooks([...REGISTERED_HOOK_NAMES, 'future-unregistered-hook.sh']),
|
|
363
|
+
availableTags: AVAILABLE_TAGS,
|
|
364
|
+
}),
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
// Installed count includes it; registration count does not.
|
|
368
|
+
assert.equal(installedCountFrom(stderr), REGISTERED_HOOK_NAMES.length + 1);
|
|
369
|
+
assert.match(
|
|
370
|
+
stderr,
|
|
371
|
+
new RegExp(
|
|
372
|
+
`hooks/ wired into \\.claude/settings\\.json \\(${REGISTERED_HOOK_NAMES.length} registrations\\)`,
|
|
373
|
+
),
|
|
374
|
+
`registration count must come from the CLI's own table, so it can never ` +
|
|
375
|
+
`claim a registration the merge did not make; stderr was:\n${stderr}`,
|
|
376
|
+
);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('A6c: scripts the registration table knows nothing about report "nothing to wire"', async () => {
|
|
380
|
+
// A content tag can ship a hook the installed CLI predates. The scripts land,
|
|
381
|
+
// but the CLI registers nothing — and must not claim otherwise.
|
|
382
|
+
const stderr = await captureStderr(() =>
|
|
383
|
+
runInit(consumerDir, {
|
|
384
|
+
targets: ['claude'],
|
|
385
|
+
fetcher: fetcherWithHooks(['future-unregistered-hook.sh']),
|
|
386
|
+
availableTags: AVAILABLE_TAGS,
|
|
387
|
+
}),
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
assert.equal(installedCountFrom(stderr), 1, `stderr was:\n${stderr}`);
|
|
391
|
+
assert.match(
|
|
392
|
+
stderr,
|
|
393
|
+
/hooks\/ nothing to wire into \.claude\/settings\.json/,
|
|
394
|
+
`with no registrable script the wiring line must say so rather than ` +
|
|
395
|
+
`report a merge; stderr was:\n${stderr}`,
|
|
396
|
+
);
|
|
397
|
+
assert.ok(
|
|
398
|
+
!/hooks\/ wired into/.test(stderr),
|
|
399
|
+
`must not claim a merge it did not make; stderr was:\n${stderr}`,
|
|
400
|
+
);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('A7: --no-hooks-wiring reports the skip instead of claiming a merge', async () => {
|
|
404
|
+
const stderr = await captureStderr(() =>
|
|
405
|
+
runInit(consumerDir, {
|
|
406
|
+
targets: ['claude'],
|
|
407
|
+
noHooksWiring: true,
|
|
408
|
+
fetcher: fetcherWithHooks(REGISTERED_HOOK_NAMES),
|
|
409
|
+
availableTags: AVAILABLE_TAGS,
|
|
410
|
+
}),
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
assert.match(
|
|
414
|
+
stderr,
|
|
415
|
+
/hooks\/ settings\.json wiring skipped \(--no-hooks-wiring\)/,
|
|
416
|
+
`--no-hooks-wiring must be reported, so a consumer knows the scripts are ` +
|
|
417
|
+
`on disk but inert; stderr was:\n${stderr}`,
|
|
418
|
+
);
|
|
419
|
+
assert.ok(
|
|
420
|
+
!/hooks\/ wired into/.test(stderr),
|
|
421
|
+
`a skipped merge must never be reported as wired; stderr was:\n${stderr}`,
|
|
422
|
+
);
|
|
423
|
+
assert.ok(
|
|
424
|
+
!existsSync(path.join(consumerDir, '.claude', 'settings.json')),
|
|
425
|
+
'--no-hooks-wiring must not create settings.json',
|
|
426
|
+
);
|
|
427
|
+
// The install half still happened and is still reported.
|
|
428
|
+
assert.equal(installedCountFrom(stderr), REGISTERED_HOOK_NAMES.length);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('A7b: update --no-hooks-wiring reports the skip too', async () => {
|
|
432
|
+
setupInitializedRepo(consumerDir);
|
|
433
|
+
|
|
434
|
+
const stderr = await captureStderr(() =>
|
|
435
|
+
runUpdate(consumerDir, {
|
|
436
|
+
noHooksWiring: true,
|
|
437
|
+
fetcher: fetcherWithHooks(REGISTERED_HOOK_NAMES),
|
|
438
|
+
availableTags: AVAILABLE_TAGS,
|
|
439
|
+
}),
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
assert.match(
|
|
443
|
+
stderr,
|
|
444
|
+
/hooks\/ settings\.json wiring skipped \(--no-hooks-wiring\)/,
|
|
445
|
+
`update must report the skip as well as init; stderr was:\n${stderr}`,
|
|
446
|
+
);
|
|
447
|
+
assert.ok(!/hooks\/ wired into/.test(stderr));
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
// A8 / A9 — --dry-run and exit-code contracts unchanged
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
describe('AC-8 — --dry-run and exit-code contracts unchanged', () => {
|
|
456
|
+
let consumerDir;
|
|
457
|
+
beforeEach(() => { consumerDir = makeTmpDir(); });
|
|
458
|
+
afterEach(() => { rmSync(consumerDir, { recursive: true, force: true }); });
|
|
459
|
+
|
|
460
|
+
it('A8: init --dry-run emits neither line and still returns the plan', async () => {
|
|
461
|
+
let result;
|
|
462
|
+
const stderr = await captureStderr(async () => {
|
|
463
|
+
result = await runInit(consumerDir, {
|
|
464
|
+
targets: ['claude'],
|
|
465
|
+
dryRun: true,
|
|
466
|
+
fetcher: fetcherWithHooks(REGISTERED_HOOK_NAMES),
|
|
467
|
+
availableTags: AVAILABLE_TAGS,
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
assert.equal(
|
|
472
|
+
installedCountFrom(stderr),
|
|
473
|
+
null,
|
|
474
|
+
`--dry-run installs nothing, so it must not report an install ` +
|
|
475
|
+
`(planning is not doing); stderr was:\n${stderr}`,
|
|
476
|
+
);
|
|
477
|
+
assert.ok(
|
|
478
|
+
!/hooks\/ (wired into|settings\.json wiring skipped|nothing to wire)/.test(stderr),
|
|
479
|
+
`--dry-run must not report a wiring outcome; stderr was:\n${stderr}`,
|
|
480
|
+
);
|
|
481
|
+
assert.equal(result.dryRun, true, 'the dry-run result contract is unchanged');
|
|
482
|
+
assert.ok(
|
|
483
|
+
result.files.some((k) => k.startsWith('claude/hooks/')),
|
|
484
|
+
'the dry-run plan still lists the hook scripts it would install',
|
|
485
|
+
);
|
|
486
|
+
assert.ok(
|
|
487
|
+
!existsSync(path.join(consumerDir, '.claude', 'hooks')),
|
|
488
|
+
'--dry-run must write nothing',
|
|
489
|
+
);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('A8b: update --dry-run emits neither line and still returns the plan', async () => {
|
|
493
|
+
setupInitializedRepo(consumerDir);
|
|
494
|
+
|
|
495
|
+
let result;
|
|
496
|
+
const stderr = await captureStderr(async () => {
|
|
497
|
+
result = await runUpdate(consumerDir, {
|
|
498
|
+
dryRun: true,
|
|
499
|
+
fetcher: fetcherWithHooks(REGISTERED_HOOK_NAMES),
|
|
500
|
+
availableTags: AVAILABLE_TAGS,
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
assert.equal(installedCountFrom(stderr), null, `stderr was:\n${stderr}`);
|
|
505
|
+
assert.ok(
|
|
506
|
+
!/hooks\/ (wired into|settings\.json wiring skipped|nothing to wire)/.test(stderr),
|
|
507
|
+
`stderr was:\n${stderr}`,
|
|
508
|
+
);
|
|
509
|
+
assert.equal(result.dryRun, true, 'the dry-run result contract is unchanged');
|
|
510
|
+
assert.ok(
|
|
511
|
+
!existsSync(path.join(consumerDir, '.claude', 'hooks')),
|
|
512
|
+
'--dry-run must write nothing',
|
|
513
|
+
);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('A9: the summary lines are informational — a refused overwrite still fails with exit code 1', async () => {
|
|
517
|
+
// An unmanaged collision must still abort with its documented exitCode, and
|
|
518
|
+
// must abort BEFORE any hooks summary is printed (nothing was installed).
|
|
519
|
+
const hooksDir = path.join(consumerDir, '.claude', 'hooks');
|
|
520
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
521
|
+
writeFileSync(
|
|
522
|
+
path.join(hooksDir, REGISTERED_HOOK_NAMES[0]),
|
|
523
|
+
'#!/bin/sh\n# consumer-authored, no marker\nexit 0\n',
|
|
524
|
+
'utf8',
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
let thrown = null;
|
|
528
|
+
const stderr = await captureStderr(async () => {
|
|
529
|
+
try {
|
|
530
|
+
await runInit(consumerDir, {
|
|
531
|
+
targets: ['claude'],
|
|
532
|
+
fetcher: fetcherWithHooks(REGISTERED_HOOK_NAMES),
|
|
533
|
+
availableTags: AVAILABLE_TAGS,
|
|
534
|
+
});
|
|
535
|
+
} catch (err) {
|
|
536
|
+
thrown = err;
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
assert.ok(thrown, 'an unmanaged collision must still throw');
|
|
541
|
+
assert.equal(thrown.exitCode, 1, 'the documented exit code is unchanged');
|
|
542
|
+
assert.equal(
|
|
543
|
+
installedCountFrom(stderr),
|
|
544
|
+
null,
|
|
545
|
+
`a run that aborted before writing must not claim an install; ` +
|
|
546
|
+
`stderr was:\n${stderr}`,
|
|
547
|
+
);
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
// Stream discipline — the lines share the sibling informational stream
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
554
|
+
|
|
555
|
+
describe('AC-8 — summary lines go to stderr alongside their siblings', () => {
|
|
556
|
+
let consumerDir;
|
|
557
|
+
beforeEach(() => { consumerDir = makeTmpDir(); });
|
|
558
|
+
afterEach(() => { rmSync(consumerDir, { recursive: true, force: true }); });
|
|
559
|
+
|
|
560
|
+
it('both lines use the `info: dev-agents-sync:` stderr prefix, like steering/ and the prune line', async () => {
|
|
561
|
+
const stderr = await captureStderr(() =>
|
|
562
|
+
runInit(consumerDir, {
|
|
563
|
+
targets: ['claude'],
|
|
564
|
+
fetcher: fetcherWithHooks(REGISTERED_HOOK_NAMES),
|
|
565
|
+
availableTags: AVAILABLE_TAGS,
|
|
566
|
+
}),
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
const lines = stderr.split('\n');
|
|
570
|
+
const summaryLine = lines.find((l) => l.includes('hooks/ installed ('));
|
|
571
|
+
const wiringLine = lines.find((l) => l.includes('hooks/ wired into'));
|
|
572
|
+
|
|
573
|
+
assert.ok(summaryLine, `expected a hooks summary line; stderr was:\n${stderr}`);
|
|
574
|
+
assert.ok(wiringLine, `expected a hooks wiring line; stderr was:\n${stderr}`);
|
|
575
|
+
assert.match(summaryLine, /^info: dev-agents-sync: /);
|
|
576
|
+
assert.match(wiringLine, /^info: dev-agents-sync: /);
|
|
577
|
+
// Sanity: the sibling steering line shares the same prefix and stream.
|
|
578
|
+
assert.match(stderr, /^info: dev-agents-sync: steering\//m);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it('the install line precedes the wiring line (reported in the order it happened)', async () => {
|
|
582
|
+
const stderr = await captureStderr(() =>
|
|
583
|
+
runInit(consumerDir, {
|
|
584
|
+
targets: ['claude'],
|
|
585
|
+
fetcher: fetcherWithHooks(REGISTERED_HOOK_NAMES),
|
|
586
|
+
availableTags: AVAILABLE_TAGS,
|
|
587
|
+
}),
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
assert.ok(
|
|
591
|
+
stderr.indexOf('hooks/ installed (') < stderr.indexOf('hooks/ wired into'),
|
|
592
|
+
`scripts land on disk before they are registered; stderr was:\n${stderr}`,
|
|
593
|
+
);
|
|
594
|
+
});
|
|
595
|
+
});
|