@dalzoubi/dev-agents-sync 2.0.27 → 2.1.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 +56 -9
- package/package.json +1 -1
- package/src/commands/init.mjs +26 -2
- package/src/commands/update.mjs +45 -3
- package/src/hooks.mjs +62 -8
- package/src/settings.mjs +17 -0
- package/src/steering.mjs +17 -5
- 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/steering-seed.test.mjs +10 -9
- package/tests/update-uptodate-wiring.test.mjs +484 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/update-uptodate-wiring.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* Finding M5 (gate-3 re-run, hooks wave 2): `update`'s up-to-date short-circuit
|
|
5
|
+
* deliberately performs the settings.json hook merge (so hook adoption is not
|
|
6
|
+
* gated on a version bump) but reported NOTHING — for a consumer already pinned
|
|
7
|
+
* to the latest tag who had never had hooks wired, that is a genuine first-time
|
|
8
|
+
* write to consumer-owned state with zero output, contradicting hooks.mjs's own
|
|
9
|
+
* "wiring edits consumer-owned state, so it is never silent (§6)".
|
|
10
|
+
*
|
|
11
|
+
* Behavior anchors (§7) — each would fail if the behavior broke:
|
|
12
|
+
*
|
|
13
|
+
* U1. A first-time wiring on the up-to-date path reports the wiring line, and
|
|
14
|
+
* the registration count is DERIVED from the hook scripts on disk.
|
|
15
|
+
* U1b. Vary the scripts on disk, the number follows (never a pinned literal).
|
|
16
|
+
* U1c. The branch does NOT claim an install: it installs nothing, so
|
|
17
|
+
* `hooks/ installed (N scripts)` must stay absent (AC-8).
|
|
18
|
+
* U2. A repeat `update` on the same path is an idempotent no-op and stays
|
|
19
|
+
* SILENT — the line reports what this run actually did, not standing state.
|
|
20
|
+
* U2b. Nothing on disk and nothing to prune → no write, no line.
|
|
21
|
+
* U3. --dry-run emits nothing and writes nothing (planning is not doing).
|
|
22
|
+
* U4. --no-hooks-wiring performs no merge on this path, so it claims nothing;
|
|
23
|
+
* U4b. and the install path still reports the skip (unchanged).
|
|
24
|
+
* U5. Exit-code contracts unchanged: the happy path returns `upToDate` without
|
|
25
|
+
* throwing, and a malformed settings.json still fails loud with
|
|
26
|
+
* ESETTINGS_PARSE / exitCode 1, byte-identical file, no wiring line.
|
|
27
|
+
* U6. The short-circuit still performs no fetch.
|
|
28
|
+
*
|
|
29
|
+
* No assertion pins a hook COUNT: every expected number is derived from
|
|
30
|
+
* DEFAULT_HOOK_SCRIPTS and the scripts the test itself seeded (AC-8).
|
|
31
|
+
*
|
|
32
|
+
* All tests use isolated tmp dirs and never touch the real repo.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
36
|
+
import assert from 'node:assert/strict';
|
|
37
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
|
|
38
|
+
import { tmpdir } from 'node:os';
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
|
|
41
|
+
import { runUpdate } from '../src/commands/update.mjs';
|
|
42
|
+
import { writeLockfile } from '../src/lockfile.mjs';
|
|
43
|
+
import { DEFAULT_HOOK_SCRIPTS } from '../src/settings.mjs';
|
|
44
|
+
|
|
45
|
+
const AVAILABLE_TAGS = ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'];
|
|
46
|
+
// The version `^1` resolves to out of AVAILABLE_TAGS. Pinning the lockfile here
|
|
47
|
+
// is what puts `update` on the up-to-date short-circuit instead of a fetch.
|
|
48
|
+
const LATEST_IN_RANGE = '1.2.0';
|
|
49
|
+
|
|
50
|
+
function makeTmpDir() {
|
|
51
|
+
return mkdtempSync(path.join(tmpdir(), 'das-uptodate-wiring-'));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A fetcher that fails the test if it is ever called. The short-circuit's whole
|
|
56
|
+
* point is to avoid a fetch, so this doubles as the U6 anchor.
|
|
57
|
+
*/
|
|
58
|
+
function noFetchFetcher() {
|
|
59
|
+
return async () => {
|
|
60
|
+
throw new Error('the up-to-date short-circuit must not fetch');
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Minimal claude-target FileMap carrying only hook scripts, for the install path. */
|
|
65
|
+
function fetcherWithHooks(registrations) {
|
|
66
|
+
return async () => {
|
|
67
|
+
const map = {};
|
|
68
|
+
for (const { command } of registrations) {
|
|
69
|
+
map[command.replace(/^\.claude\//, 'claude/')] = hookBody(path.basename(command));
|
|
70
|
+
}
|
|
71
|
+
return map;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function hookBody(name) {
|
|
76
|
+
return `#!/bin/sh\n# managed-by: dev-agents-sync v9.9.9\n# ${name}\nexit 0\n`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Seeds a lockfile already pinned to `resolvedVersion` (default: the latest in range). */
|
|
80
|
+
function setupPinnedRepo(consumerDir, { resolvedVersion = LATEST_IN_RANGE } = {}) {
|
|
81
|
+
writeLockfile(consumerDir, {
|
|
82
|
+
source: 'github:dalzoubi/dev-agents',
|
|
83
|
+
range: '^1',
|
|
84
|
+
resolvedVersion,
|
|
85
|
+
targets: ['claude'],
|
|
86
|
+
lastUpdated: '2026-08-05T00:00:00Z',
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Writes the hook scripts for `registrations` into .claude/hooks/ — simulating a
|
|
92
|
+
* consumer whose scripts an earlier `update` already installed but whose
|
|
93
|
+
* settings.json was never wired.
|
|
94
|
+
*
|
|
95
|
+
* Returns the number of registrations the CLI's own table yields for the scripts
|
|
96
|
+
* now on disk. DERIVED from DEFAULT_HOOK_SCRIPTS, never a literal (AC-8): one
|
|
97
|
+
* command registered under two events would count twice, and the expected number
|
|
98
|
+
* follows the table the moment a hook is added or retired.
|
|
99
|
+
*/
|
|
100
|
+
function seedHookScripts(consumerDir, registrations) {
|
|
101
|
+
const commands = new Set(registrations.map((h) => h.command));
|
|
102
|
+
for (const command of commands) {
|
|
103
|
+
const abs = path.join(consumerDir, command);
|
|
104
|
+
mkdirSync(path.dirname(abs), { recursive: true });
|
|
105
|
+
writeFileSync(abs, hookBody(path.basename(command)), 'utf8');
|
|
106
|
+
}
|
|
107
|
+
return DEFAULT_HOOK_SCRIPTS.filter((h) => commands.has(h.command)).length;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Captures everything written to stderr while `fn` runs. */
|
|
111
|
+
async function captureStderr(fn) {
|
|
112
|
+
const origWrite = process.stderr.write.bind(process.stderr);
|
|
113
|
+
let captured = '';
|
|
114
|
+
process.stderr.write = (chunk) => {
|
|
115
|
+
captured += typeof chunk === 'string' ? chunk : chunk.toString();
|
|
116
|
+
return true;
|
|
117
|
+
};
|
|
118
|
+
try {
|
|
119
|
+
await fn();
|
|
120
|
+
} finally {
|
|
121
|
+
process.stderr.write = origWrite;
|
|
122
|
+
}
|
|
123
|
+
return captured;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Pulls the registration count out of the wiring line, or null when the line is
|
|
128
|
+
* absent. Matched generically so a wrong number fails with the real number.
|
|
129
|
+
*/
|
|
130
|
+
function wiredCountFrom(stderr) {
|
|
131
|
+
const m = /hooks\/ wired into \.claude\/settings\.json \((\d+) registrations?\)/.exec(stderr);
|
|
132
|
+
return m ? Number(m[1]) : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function settingsPathIn(consumerDir) {
|
|
136
|
+
return path.join(consumerDir, '.claude', 'settings.json');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// U1 — a first-time wiring on the up-to-date path is reported
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
describe('M5 — update up-to-date path: a first-time settings.json wiring is never silent', () => {
|
|
144
|
+
let consumerDir;
|
|
145
|
+
beforeEach(() => {
|
|
146
|
+
consumerDir = makeTmpDir();
|
|
147
|
+
});
|
|
148
|
+
afterEach(() => {
|
|
149
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('U1: an up-to-date consumer whose hooks were never wired gets the wiring reported', async () => {
|
|
153
|
+
setupPinnedRepo(consumerDir);
|
|
154
|
+
const expected = seedHookScripts(consumerDir, DEFAULT_HOOK_SCRIPTS);
|
|
155
|
+
assert.ok(
|
|
156
|
+
!existsSync(settingsPathIn(consumerDir)),
|
|
157
|
+
'precondition: this consumer has never had hooks wired',
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
let result;
|
|
161
|
+
const stderr = await captureStderr(async () => {
|
|
162
|
+
result = await runUpdate(consumerDir, {
|
|
163
|
+
fetcher: noFetchFetcher(),
|
|
164
|
+
availableTags: AVAILABLE_TAGS,
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
assert.equal(
|
|
169
|
+
result.upToDate,
|
|
170
|
+
true,
|
|
171
|
+
`this test must exercise the up-to-date short-circuit; got: ${JSON.stringify(result)}`,
|
|
172
|
+
);
|
|
173
|
+
assert.ok(
|
|
174
|
+
existsSync(settingsPathIn(consumerDir)),
|
|
175
|
+
'the short-circuit performed a real first-time write to settings.json',
|
|
176
|
+
);
|
|
177
|
+
assert.equal(
|
|
178
|
+
wiredCountFrom(stderr),
|
|
179
|
+
expected,
|
|
180
|
+
`a genuine first-time write to consumer-owned state must be reported (§6), with the ` +
|
|
181
|
+
`registration count derived from the scripts on disk (${expected}); stderr was:\n${stderr}`,
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('U1b: the reported registration count follows the scripts on disk, never a literal', async () => {
|
|
186
|
+
// A consumer who only ever installed a subset (e.g. an older content tag).
|
|
187
|
+
const subset = DEFAULT_HOOK_SCRIPTS.slice(0, 2);
|
|
188
|
+
setupPinnedRepo(consumerDir);
|
|
189
|
+
const expected = seedHookScripts(consumerDir, subset);
|
|
190
|
+
|
|
191
|
+
const stderr = await captureStderr(() =>
|
|
192
|
+
runUpdate(consumerDir, { fetcher: noFetchFetcher(), availableTags: AVAILABLE_TAGS }),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
assert.equal(
|
|
196
|
+
wiredCountFrom(stderr),
|
|
197
|
+
expected,
|
|
198
|
+
`the count must follow the hook scripts present on disk, not the full table and not a ` +
|
|
199
|
+
`pinned number; stderr was:\n${stderr}`,
|
|
200
|
+
);
|
|
201
|
+
assert.notEqual(
|
|
202
|
+
expected,
|
|
203
|
+
DEFAULT_HOOK_SCRIPTS.length,
|
|
204
|
+
'precondition: the subset must differ from the full table for this test to bite',
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('U1c: the branch reports the wiring but never claims an install', async () => {
|
|
209
|
+
setupPinnedRepo(consumerDir);
|
|
210
|
+
seedHookScripts(consumerDir, DEFAULT_HOOK_SCRIPTS);
|
|
211
|
+
|
|
212
|
+
const stderr = await captureStderr(() =>
|
|
213
|
+
runUpdate(consumerDir, { fetcher: noFetchFetcher(), availableTags: AVAILABLE_TAGS }),
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
assert.ok(wiredCountFrom(stderr) !== null, `expected a wiring line; stderr was:\n${stderr}`);
|
|
217
|
+
assert.ok(
|
|
218
|
+
!/hooks\/ installed \(/.test(stderr),
|
|
219
|
+
`this branch installs nothing — claiming "hooks/ installed (N scripts)" for scripts an ` +
|
|
220
|
+
`earlier run wrote would be a count this run did not earn; stderr was:\n${stderr}`,
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('U1d: the wiring line shares the info: dev-agents-sync: stderr prefix of its siblings', async () => {
|
|
225
|
+
setupPinnedRepo(consumerDir);
|
|
226
|
+
seedHookScripts(consumerDir, DEFAULT_HOOK_SCRIPTS);
|
|
227
|
+
|
|
228
|
+
const stderr = await captureStderr(() =>
|
|
229
|
+
runUpdate(consumerDir, { fetcher: noFetchFetcher(), availableTags: AVAILABLE_TAGS }),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const wiringLine = stderr.split('\n').find((l) => l.includes('hooks/ wired into'));
|
|
233
|
+
assert.ok(wiringLine, `expected a wiring line; stderr was:\n${stderr}`);
|
|
234
|
+
assert.match(wiringLine, /^info: dev-agents-sync: /);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// U2 — a repeat no-op run stays silent
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
describe('M5 — update up-to-date path: an idempotent no-op stays silent', () => {
|
|
243
|
+
let consumerDir;
|
|
244
|
+
beforeEach(() => {
|
|
245
|
+
consumerDir = makeTmpDir();
|
|
246
|
+
});
|
|
247
|
+
afterEach(() => {
|
|
248
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('U2: a second up-to-date update writes nothing and reports no wiring', async () => {
|
|
252
|
+
setupPinnedRepo(consumerDir);
|
|
253
|
+
seedHookScripts(consumerDir, DEFAULT_HOOK_SCRIPTS);
|
|
254
|
+
|
|
255
|
+
const firstStderr = await captureStderr(() =>
|
|
256
|
+
runUpdate(consumerDir, { fetcher: noFetchFetcher(), availableTags: AVAILABLE_TAGS }),
|
|
257
|
+
);
|
|
258
|
+
assert.ok(
|
|
259
|
+
wiredCountFrom(firstStderr) !== null,
|
|
260
|
+
`precondition: the first run must report the wiring; stderr was:\n${firstStderr}`,
|
|
261
|
+
);
|
|
262
|
+
const afterFirst = readFileSync(settingsPathIn(consumerDir), 'utf8');
|
|
263
|
+
|
|
264
|
+
const secondStderr = await captureStderr(() =>
|
|
265
|
+
runUpdate(consumerDir, { fetcher: noFetchFetcher(), availableTags: AVAILABLE_TAGS }),
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
assert.equal(
|
|
269
|
+
wiredCountFrom(secondStderr),
|
|
270
|
+
null,
|
|
271
|
+
`the merge is idempotent, so a repeat run changed nothing and must say nothing — the ` +
|
|
272
|
+
`line reports what THIS run did, not standing state; stderr was:\n${secondStderr}`,
|
|
273
|
+
);
|
|
274
|
+
assert.equal(
|
|
275
|
+
readFileSync(settingsPathIn(consumerDir), 'utf8'),
|
|
276
|
+
afterFirst,
|
|
277
|
+
'a repeat run must leave settings.json byte-identical',
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('U2b: an up-to-date consumer with no hook scripts and nothing to prune stays silent', async () => {
|
|
282
|
+
setupPinnedRepo(consumerDir);
|
|
283
|
+
mkdirSync(path.join(consumerDir, '.claude', 'hooks'), { recursive: true });
|
|
284
|
+
|
|
285
|
+
const stderr = await captureStderr(() =>
|
|
286
|
+
runUpdate(consumerDir, { fetcher: noFetchFetcher(), availableTags: AVAILABLE_TAGS }),
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
assert.ok(
|
|
290
|
+
!/hooks\/ (wired into|nothing to wire|settings\.json wiring skipped)/.test(stderr),
|
|
291
|
+
`nothing was written, so nothing is reported (no spurious line on every run); ` +
|
|
292
|
+
`stderr was:\n${stderr}`,
|
|
293
|
+
);
|
|
294
|
+
assert.ok(
|
|
295
|
+
!existsSync(settingsPathIn(consumerDir)),
|
|
296
|
+
'a no-op merge must not create settings.json',
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// U3 / U4 — --dry-run and --no-hooks-wiring
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
describe('M5 — update up-to-date path: --dry-run and --no-hooks-wiring contracts', () => {
|
|
306
|
+
let consumerDir;
|
|
307
|
+
beforeEach(() => {
|
|
308
|
+
consumerDir = makeTmpDir();
|
|
309
|
+
});
|
|
310
|
+
afterEach(() => {
|
|
311
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('U3: --dry-run on a never-wired consumer emits nothing and writes nothing', async () => {
|
|
315
|
+
setupPinnedRepo(consumerDir);
|
|
316
|
+
seedHookScripts(consumerDir, DEFAULT_HOOK_SCRIPTS);
|
|
317
|
+
|
|
318
|
+
let result;
|
|
319
|
+
const stderr = await captureStderr(async () => {
|
|
320
|
+
result = await runUpdate(consumerDir, {
|
|
321
|
+
dryRun: true,
|
|
322
|
+
fetcher: noFetchFetcher(),
|
|
323
|
+
availableTags: AVAILABLE_TAGS,
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
assert.ok(
|
|
328
|
+
!/hooks\/ (wired into|nothing to wire|settings\.json wiring skipped)/.test(stderr),
|
|
329
|
+
`--dry-run wires nothing, so it must report no wiring outcome (planning is not doing); ` +
|
|
330
|
+
`stderr was:\n${stderr}`,
|
|
331
|
+
);
|
|
332
|
+
assert.ok(
|
|
333
|
+
!existsSync(settingsPathIn(consumerDir)),
|
|
334
|
+
'--dry-run must not create settings.json',
|
|
335
|
+
);
|
|
336
|
+
assert.equal(result.upToDate, true, 'the up-to-date result contract is unchanged');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('U3b: --dry-run leaves an already-present settings.json byte-identical', async () => {
|
|
340
|
+
setupPinnedRepo(consumerDir);
|
|
341
|
+
seedHookScripts(consumerDir, DEFAULT_HOOK_SCRIPTS);
|
|
342
|
+
mkdirSync(path.join(consumerDir, '.claude'), { recursive: true });
|
|
343
|
+
const before = JSON.stringify({ env: { EXAMPLE: '1' } }, null, 2) + '\n';
|
|
344
|
+
writeFileSync(settingsPathIn(consumerDir), before, 'utf8');
|
|
345
|
+
|
|
346
|
+
const stderr = await captureStderr(() =>
|
|
347
|
+
runUpdate(consumerDir, {
|
|
348
|
+
dryRun: true,
|
|
349
|
+
fetcher: noFetchFetcher(),
|
|
350
|
+
availableTags: AVAILABLE_TAGS,
|
|
351
|
+
}),
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
assert.equal(
|
|
355
|
+
readFileSync(settingsPathIn(consumerDir), 'utf8'),
|
|
356
|
+
before,
|
|
357
|
+
'--dry-run must never write to settings.json',
|
|
358
|
+
);
|
|
359
|
+
assert.equal(wiredCountFrom(stderr), null, `stderr was:\n${stderr}`);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('U4: --no-hooks-wiring on this path merges nothing and claims nothing', async () => {
|
|
363
|
+
setupPinnedRepo(consumerDir);
|
|
364
|
+
seedHookScripts(consumerDir, DEFAULT_HOOK_SCRIPTS);
|
|
365
|
+
|
|
366
|
+
const stderr = await captureStderr(() =>
|
|
367
|
+
runUpdate(consumerDir, {
|
|
368
|
+
noHooksWiring: true,
|
|
369
|
+
fetcher: noFetchFetcher(),
|
|
370
|
+
availableTags: AVAILABLE_TAGS,
|
|
371
|
+
}),
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
assert.ok(
|
|
375
|
+
!/hooks\/ (wired into|nothing to wire)/.test(stderr),
|
|
376
|
+
`an opted-out run must never be reported as wired; stderr was:\n${stderr}`,
|
|
377
|
+
);
|
|
378
|
+
assert.ok(
|
|
379
|
+
!existsSync(settingsPathIn(consumerDir)),
|
|
380
|
+
'--no-hooks-wiring must not create settings.json',
|
|
381
|
+
);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('U4b: --no-hooks-wiring still reports the skip on the install path', async () => {
|
|
385
|
+
// Regression guard for the sibling branch: the install path installs scripts
|
|
386
|
+
// that are left inert, which the consumer must be told about.
|
|
387
|
+
setupPinnedRepo(consumerDir, { resolvedVersion: '1.0.0' });
|
|
388
|
+
|
|
389
|
+
const stderr = await captureStderr(() =>
|
|
390
|
+
runUpdate(consumerDir, {
|
|
391
|
+
noHooksWiring: true,
|
|
392
|
+
fetcher: fetcherWithHooks(DEFAULT_HOOK_SCRIPTS),
|
|
393
|
+
availableTags: AVAILABLE_TAGS,
|
|
394
|
+
}),
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
assert.match(
|
|
398
|
+
stderr,
|
|
399
|
+
/hooks\/ settings\.json wiring skipped \(--no-hooks-wiring\)/,
|
|
400
|
+
`scripts landed on disk but inert — the consumer must be told; stderr was:\n${stderr}`,
|
|
401
|
+
);
|
|
402
|
+
assert.ok(!/hooks\/ wired into/.test(stderr), `stderr was:\n${stderr}`);
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
// U5 / U6 — exit codes and the no-fetch guarantee
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
describe('M5 — update up-to-date path: exit codes and no-fetch unchanged', () => {
|
|
411
|
+
let consumerDir;
|
|
412
|
+
beforeEach(() => {
|
|
413
|
+
consumerDir = makeTmpDir();
|
|
414
|
+
});
|
|
415
|
+
afterEach(() => {
|
|
416
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('U5: a wiring run still resolves with the up-to-date result (no exit-code change)', async () => {
|
|
420
|
+
setupPinnedRepo(consumerDir);
|
|
421
|
+
seedHookScripts(consumerDir, DEFAULT_HOOK_SCRIPTS);
|
|
422
|
+
|
|
423
|
+
const result = await runUpdate(consumerDir, {
|
|
424
|
+
fetcher: noFetchFetcher(),
|
|
425
|
+
availableTags: AVAILABLE_TAGS,
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
assert.equal(result.upToDate, true);
|
|
429
|
+
assert.equal(result.resolvedVersion, LATEST_IN_RANGE);
|
|
430
|
+
assert.deepEqual(result.prunedRetired, [], 'nothing retired was present to prune');
|
|
431
|
+
assert.match(result.message, /already up to date/);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('U5b: a malformed settings.json still fails loud with ESETTINGS_PARSE and no wiring line', async () => {
|
|
435
|
+
setupPinnedRepo(consumerDir);
|
|
436
|
+
seedHookScripts(consumerDir, DEFAULT_HOOK_SCRIPTS);
|
|
437
|
+
mkdirSync(path.join(consumerDir, '.claude'), { recursive: true });
|
|
438
|
+
const malformed = '{ not json';
|
|
439
|
+
writeFileSync(settingsPathIn(consumerDir), malformed, 'utf8');
|
|
440
|
+
|
|
441
|
+
let thrown = null;
|
|
442
|
+
const stderr = await captureStderr(async () => {
|
|
443
|
+
try {
|
|
444
|
+
await runUpdate(consumerDir, {
|
|
445
|
+
fetcher: noFetchFetcher(),
|
|
446
|
+
availableTags: AVAILABLE_TAGS,
|
|
447
|
+
});
|
|
448
|
+
} catch (err) {
|
|
449
|
+
thrown = err;
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
assert.ok(thrown, 'a malformed settings.json must still throw on this path');
|
|
454
|
+
assert.equal(thrown.code, 'ESETTINGS_PARSE', 'the stable error code is unchanged');
|
|
455
|
+
assert.equal(thrown.exitCode, 1, 'the documented exit code is unchanged');
|
|
456
|
+
assert.equal(
|
|
457
|
+
readFileSync(settingsPathIn(consumerDir), 'utf8'),
|
|
458
|
+
malformed,
|
|
459
|
+
'the consumer file is left byte-identical (no partial write)',
|
|
460
|
+
);
|
|
461
|
+
assert.equal(
|
|
462
|
+
wiredCountFrom(stderr),
|
|
463
|
+
null,
|
|
464
|
+
`a run that wrote nothing must not claim a wiring; stderr was:\n${stderr}`,
|
|
465
|
+
);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('U6: the short-circuit performs no fetch', async () => {
|
|
469
|
+
setupPinnedRepo(consumerDir);
|
|
470
|
+
seedHookScripts(consumerDir, DEFAULT_HOOK_SCRIPTS);
|
|
471
|
+
|
|
472
|
+
let fetched = false;
|
|
473
|
+
const result = await runUpdate(consumerDir, {
|
|
474
|
+
fetcher: async () => {
|
|
475
|
+
fetched = true;
|
|
476
|
+
return {};
|
|
477
|
+
},
|
|
478
|
+
availableTags: AVAILABLE_TAGS,
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
assert.equal(result.upToDate, true);
|
|
482
|
+
assert.equal(fetched, false, 'the up-to-date branch must not fetch to wire hooks');
|
|
483
|
+
});
|
|
484
|
+
});
|