@dalzoubi/dev-agents-sync 1.0.14 → 1.0.16

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.
@@ -0,0 +1,459 @@
1
+ /**
2
+ * tests/first-sync-notice.test.mjs
3
+ *
4
+ * Tests for the first-sync notice emitted to stderr when a root-targeted file
5
+ * (e.g. CONSTITUTION.md) is written for the first time.
6
+ *
7
+ * Expected notice format (single line, newline-terminated):
8
+ * info: dev-agents-sync: created <path> (new in v<resolvedTag>; see release notes)\n
9
+ *
10
+ * where:
11
+ * <path> — consumer-relative path (e.g. "CONSTITUTION.md")
12
+ * <resolvedTag> — resolved tag string without leading `v` prefix prepended;
13
+ * resolveRange returns e.g. "1.0.0" so the notice reads "v1.0.0"
14
+ *
15
+ * Rules under test:
16
+ * 1. Root file created for first time → notice emitted to stderr
17
+ * 2. Root file already exists (update run) → no notice
18
+ * 3. --dry-run → no notice (file not written, no notice)
19
+ * 4. Non-root file (e.g. .claude/agents/define.md) → no notice
20
+ * 5. Notice format exactly matches the spec
21
+ */
22
+
23
+ import { describe, it, beforeEach, afterEach } from 'node:test';
24
+ import assert from 'node:assert/strict';
25
+ import {
26
+ mkdtempSync,
27
+ mkdirSync,
28
+ writeFileSync,
29
+ readFileSync,
30
+ existsSync,
31
+ rmSync,
32
+ readdirSync,
33
+ } from 'node:fs';
34
+ import { tmpdir } from 'node:os';
35
+ import path from 'node:path';
36
+ import { fileURLToPath } from 'node:url';
37
+
38
+ import { runInit } from '../src/commands/init.mjs';
39
+ import { runUpdate } from '../src/commands/update.mjs';
40
+ import { writeLockfile } from '../src/lockfile.mjs';
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Fixture helpers
44
+ // ---------------------------------------------------------------------------
45
+
46
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
47
+ const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'release-v1.0.0');
48
+
49
+ function collectFiles(baseDir, currentDir, map, prefix) {
50
+ const entries = readdirSync(currentDir, { withFileTypes: true });
51
+ for (const entry of entries) {
52
+ const full = path.join(currentDir, entry.name);
53
+ if (entry.isDirectory()) {
54
+ collectFiles(baseDir, full, map, prefix);
55
+ } else {
56
+ const rel = path.relative(baseDir, full).replace(/\\/g, '/');
57
+ map[`${prefix}/${rel}`] = readFileSync(full, 'utf8');
58
+ }
59
+ }
60
+ }
61
+
62
+ function buildFixtureFileMap(targets = ['claude', 'cursor', 'root']) {
63
+ const map = {};
64
+ for (const t of targets) {
65
+ const targetDir = path.join(FIXTURE_DIR, t);
66
+ if (!existsSync(targetDir)) continue;
67
+ collectFiles(targetDir, targetDir, map, t);
68
+ }
69
+ return map;
70
+ }
71
+
72
+ /** Fetcher that returns all targets at v1.0.0 */
73
+ function makeFullFetcher() {
74
+ return async () => buildFixtureFileMap(['claude', 'cursor', 'root']);
75
+ }
76
+
77
+ /**
78
+ * Fetcher that returns a file map where all marker versions are bumped to v1.1.0,
79
+ * simulating an upgrade. Used for update-path tests.
80
+ */
81
+ function makeV110Fetcher() {
82
+ return async () => {
83
+ const base = buildFixtureFileMap(['claude', 'cursor', 'root']);
84
+ const out = {};
85
+ for (const [key, content] of Object.entries(base)) {
86
+ out[key] = content.replace(/v1\.0\.0/g, 'v1.1.0');
87
+ }
88
+ return out;
89
+ };
90
+ }
91
+
92
+ function makeTmpDir() {
93
+ return mkdtempSync(path.join(tmpdir(), 'das-notice-test-'));
94
+ }
95
+
96
+ /**
97
+ * Captures all output written to process.stderr.write during the execution of
98
+ * `fn`. Returns the concatenated string.
99
+ *
100
+ * Matches the same capture pattern used in root-target.test.mjs.
101
+ */
102
+ async function captureStderr(fn) {
103
+ const origWrite = process.stderr.write.bind(process.stderr);
104
+ let captured = '';
105
+ process.stderr.write = (chunk) => {
106
+ captured += typeof chunk === 'string' ? chunk : chunk.toString();
107
+ return true;
108
+ };
109
+ try {
110
+ await fn();
111
+ } finally {
112
+ process.stderr.write = origWrite;
113
+ }
114
+ return captured;
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // 1. Root file created for the first time → notice emitted on stderr
119
+ // ---------------------------------------------------------------------------
120
+
121
+ describe('first-sync notice — init: root file created for first time', () => {
122
+ let consumerDir;
123
+
124
+ beforeEach(() => { consumerDir = makeTmpDir(); });
125
+ afterEach(() => { rmSync(consumerDir, { recursive: true, force: true }); });
126
+
127
+ it('emits a notice to stderr when CONSTITUTION.md is created for the first time via init', async () => {
128
+ // CONSTITUTION.md must NOT exist before the run
129
+ assert.ok(!existsSync(path.join(consumerDir, 'CONSTITUTION.md')), 'precondition: file must not exist');
130
+
131
+ const stderr = await captureStderr(() =>
132
+ runInit(consumerDir, {
133
+ targets: ['claude'],
134
+ fetcher: makeFullFetcher(),
135
+ availableTags: ['v1.0.0'],
136
+ }),
137
+ );
138
+
139
+ assert.ok(
140
+ stderr.includes('info: dev-agents-sync: created CONSTITUTION.md'),
141
+ `expected notice for CONSTITUTION.md in stderr, got:\n${stderr}`,
142
+ );
143
+ });
144
+
145
+ it('emits a notice to stderr when CONSTITUTION.md is created for the first time via update', async () => {
146
+ // Set up lockfile but deliberately do NOT write CONSTITUTION.md
147
+ writeLockfile(consumerDir, {
148
+ source: 'github:dalzoubi/dev-agents',
149
+ range: '^1',
150
+ resolvedVersion: '1.0.0',
151
+ targets: ['claude'],
152
+ lastUpdated: '2026-04-01T00:00:00Z',
153
+ });
154
+ // Write the claude agent file so update has a managed file to work with
155
+ const agentsDir = path.join(consumerDir, '.claude', 'agents');
156
+ mkdirSync(agentsDir, { recursive: true });
157
+ writeFileSync(
158
+ path.join(agentsDir, 'define.md'),
159
+ readFileSync(path.join(FIXTURE_DIR, 'claude', 'agents', 'define.md'), 'utf8'),
160
+ 'utf8',
161
+ );
162
+
163
+ assert.ok(!existsSync(path.join(consumerDir, 'CONSTITUTION.md')), 'precondition: file must not exist');
164
+
165
+ const stderr = await captureStderr(() =>
166
+ runUpdate(consumerDir, {
167
+ fetcher: makeV110Fetcher(),
168
+ availableTags: ['v1.0.0', 'v1.1.0'],
169
+ }),
170
+ );
171
+
172
+ assert.ok(
173
+ stderr.includes('info: dev-agents-sync: created CONSTITUTION.md'),
174
+ `expected notice for CONSTITUTION.md in stderr on update, got:\n${stderr}`,
175
+ );
176
+ });
177
+ });
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // 2. Root file already exists → no notice on subsequent runs
181
+ // ---------------------------------------------------------------------------
182
+
183
+ describe('first-sync notice — no notice when root file already exists', () => {
184
+ let consumerDir;
185
+
186
+ beforeEach(() => { consumerDir = makeTmpDir(); });
187
+ afterEach(() => { rmSync(consumerDir, { recursive: true, force: true }); });
188
+
189
+ it('does not emit a notice when CONSTITUTION.md already exists (init with managed file)', async () => {
190
+ // Pre-write CONSTITUTION.md with a managed-by marker on the first line
191
+ writeFileSync(
192
+ path.join(consumerDir, 'CONSTITUTION.md'),
193
+ readFileSync(path.join(FIXTURE_DIR, 'root', 'CONSTITUTION.md'), 'utf8'),
194
+ 'utf8',
195
+ );
196
+
197
+ const stderr = await captureStderr(() =>
198
+ runInit(consumerDir, {
199
+ targets: ['claude'],
200
+ fetcher: makeFullFetcher(),
201
+ availableTags: ['v1.0.0'],
202
+ }),
203
+ );
204
+
205
+ assert.ok(
206
+ !stderr.includes('info: dev-agents-sync: created CONSTITUTION.md'),
207
+ `expected no first-sync notice when file already existed, got:\n${stderr}`,
208
+ );
209
+ });
210
+
211
+ it('does not emit a notice when CONSTITUTION.md already exists (update run)', async () => {
212
+ // Set up a fully initialized consumer with CONSTITUTION.md present
213
+ writeLockfile(consumerDir, {
214
+ source: 'github:dalzoubi/dev-agents',
215
+ range: '^1',
216
+ resolvedVersion: '1.0.0',
217
+ targets: ['claude'],
218
+ lastUpdated: '2026-04-01T00:00:00Z',
219
+ });
220
+ writeFileSync(
221
+ path.join(consumerDir, 'CONSTITUTION.md'),
222
+ readFileSync(path.join(FIXTURE_DIR, 'root', 'CONSTITUTION.md'), 'utf8'),
223
+ 'utf8',
224
+ );
225
+ const agentsDir = path.join(consumerDir, '.claude', 'agents');
226
+ mkdirSync(agentsDir, { recursive: true });
227
+ writeFileSync(
228
+ path.join(agentsDir, 'define.md'),
229
+ readFileSync(path.join(FIXTURE_DIR, 'claude', 'agents', 'define.md'), 'utf8'),
230
+ 'utf8',
231
+ );
232
+
233
+ const stderr = await captureStderr(() =>
234
+ runUpdate(consumerDir, {
235
+ fetcher: makeV110Fetcher(),
236
+ availableTags: ['v1.0.0', 'v1.1.0'],
237
+ }),
238
+ );
239
+
240
+ assert.ok(
241
+ !stderr.includes('info: dev-agents-sync: created CONSTITUTION.md'),
242
+ `expected no first-sync notice when file already existed on update, got:\n${stderr}`,
243
+ );
244
+ });
245
+ });
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // 3. --dry-run → no notice
249
+ // ---------------------------------------------------------------------------
250
+
251
+ describe('first-sync notice — suppressed on --dry-run', () => {
252
+ let consumerDir;
253
+
254
+ beforeEach(() => { consumerDir = makeTmpDir(); });
255
+ afterEach(() => { rmSync(consumerDir, { recursive: true, force: true }); });
256
+
257
+ it('does not emit a notice during a dry-run init even when file would be new', async () => {
258
+ assert.ok(!existsSync(path.join(consumerDir, 'CONSTITUTION.md')), 'precondition: file must not exist');
259
+
260
+ const stderr = await captureStderr(() =>
261
+ runInit(consumerDir, {
262
+ targets: ['claude'],
263
+ fetcher: makeFullFetcher(),
264
+ availableTags: ['v1.0.0'],
265
+ dryRun: true,
266
+ }),
267
+ );
268
+
269
+ assert.ok(
270
+ !stderr.includes('info: dev-agents-sync: created CONSTITUTION.md'),
271
+ `expected no notice on dry-run, got:\n${stderr}`,
272
+ );
273
+ });
274
+
275
+ it('does not write CONSTITUTION.md during dry-run (verifying dry-run is genuine)', async () => {
276
+ await runInit(consumerDir, {
277
+ targets: ['claude'],
278
+ fetcher: makeFullFetcher(),
279
+ availableTags: ['v1.0.0'],
280
+ dryRun: true,
281
+ });
282
+
283
+ assert.ok(
284
+ !existsSync(path.join(consumerDir, 'CONSTITUTION.md')),
285
+ 'dry-run must not write any files',
286
+ );
287
+ });
288
+
289
+ it('does not emit a notice during a dry-run update even when file would be new', async () => {
290
+ // Lockfile exists but CONSTITUTION.md does not
291
+ writeLockfile(consumerDir, {
292
+ source: 'github:dalzoubi/dev-agents',
293
+ range: '^1',
294
+ resolvedVersion: '1.0.0',
295
+ targets: ['claude'],
296
+ lastUpdated: '2026-04-01T00:00:00Z',
297
+ });
298
+
299
+ assert.ok(!existsSync(path.join(consumerDir, 'CONSTITUTION.md')), 'precondition: file must not exist');
300
+
301
+ const stderr = await captureStderr(() =>
302
+ runUpdate(consumerDir, {
303
+ fetcher: makeV110Fetcher(),
304
+ availableTags: ['v1.0.0', 'v1.1.0'],
305
+ dryRun: true,
306
+ }),
307
+ );
308
+
309
+ assert.ok(
310
+ !stderr.includes('info: dev-agents-sync: created CONSTITUTION.md'),
311
+ `expected no notice on dry-run update, got:\n${stderr}`,
312
+ );
313
+ });
314
+ });
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // 4. Non-root file → no notice
318
+ // ---------------------------------------------------------------------------
319
+
320
+ describe('first-sync notice — not emitted for non-root files', () => {
321
+ let consumerDir;
322
+
323
+ beforeEach(() => { consumerDir = makeTmpDir(); });
324
+ afterEach(() => { rmSync(consumerDir, { recursive: true, force: true }); });
325
+
326
+ it('does not emit a notice when only .claude/agents/define.md is newly created', async () => {
327
+ // Use a fetcher that only returns claude/ files (no root/)
328
+ const claudeOnlyFetcher = async () => buildFixtureFileMap(['claude']);
329
+
330
+ const stderr = await captureStderr(() =>
331
+ runInit(consumerDir, {
332
+ targets: ['claude'],
333
+ fetcher: claudeOnlyFetcher,
334
+ availableTags: ['v1.0.0'],
335
+ }),
336
+ );
337
+
338
+ assert.ok(
339
+ !stderr.includes('info: dev-agents-sync: created'),
340
+ `expected no first-sync notice for non-root files, got:\n${stderr}`,
341
+ );
342
+ });
343
+
344
+ it('does not emit a notice for .cursor/ files newly created during init', async () => {
345
+ const cursorOnlyFetcher = async () => buildFixtureFileMap(['cursor']);
346
+
347
+ const stderr = await captureStderr(() =>
348
+ runInit(consumerDir, {
349
+ targets: ['cursor'],
350
+ fetcher: cursorOnlyFetcher,
351
+ availableTags: ['v1.0.0'],
352
+ }),
353
+ );
354
+
355
+ assert.ok(
356
+ !stderr.includes('info: dev-agents-sync: created'),
357
+ `expected no first-sync notice for cursor/ files, got:\n${stderr}`,
358
+ );
359
+ });
360
+ });
361
+
362
+ // ---------------------------------------------------------------------------
363
+ // 5. Exact notice format
364
+ // ---------------------------------------------------------------------------
365
+
366
+ describe('first-sync notice — exact format', () => {
367
+ let consumerDir;
368
+
369
+ beforeEach(() => { consumerDir = makeTmpDir(); });
370
+ afterEach(() => { rmSync(consumerDir, { recursive: true, force: true }); });
371
+
372
+ it('notice matches exact format: info: dev-agents-sync: created <path> (new in v<resolvedTag>; see release notes)', async () => {
373
+ // resolveRange(['v1.0.0'], '^1') returns '1.0.0' (no leading v)
374
+ // so the notice must contain "v1.0.0" (the `v` is prepended in the message)
375
+ assert.ok(!existsSync(path.join(consumerDir, 'CONSTITUTION.md')), 'precondition: file must not exist');
376
+
377
+ const stderr = await captureStderr(() =>
378
+ runInit(consumerDir, {
379
+ targets: ['claude'],
380
+ fetcher: makeFullFetcher(),
381
+ availableTags: ['v1.0.0'],
382
+ }),
383
+ );
384
+
385
+ const expectedNotice =
386
+ 'info: dev-agents-sync: created CONSTITUTION.md (new in v1.0.0; see release notes)\n';
387
+
388
+ assert.ok(
389
+ stderr.includes(expectedNotice),
390
+ `notice format mismatch.\nExpected to find:\n ${JSON.stringify(expectedNotice)}\nIn stderr:\n ${JSON.stringify(stderr)}`,
391
+ );
392
+ });
393
+
394
+ it('notice uses the consumer-relative path (not the relKey root/ prefix, not the abs path)', async () => {
395
+ assert.ok(!existsSync(path.join(consumerDir, 'CONSTITUTION.md')), 'precondition: file must not exist');
396
+
397
+ const stderr = await captureStderr(() =>
398
+ runInit(consumerDir, {
399
+ targets: ['claude'],
400
+ fetcher: makeFullFetcher(),
401
+ availableTags: ['v1.0.0'],
402
+ }),
403
+ );
404
+
405
+ // Must NOT contain the internal relKey prefix
406
+ assert.ok(
407
+ !stderr.includes('root/CONSTITUTION.md'),
408
+ `notice must not expose internal relKey format "root/CONSTITUTION.md", got:\n${stderr}`,
409
+ );
410
+
411
+ // Must NOT contain an absolute path
412
+ assert.ok(
413
+ !stderr.includes(consumerDir),
414
+ `notice must not expose absolute path, got:\n${stderr}`,
415
+ );
416
+
417
+ // Must contain the bare consumer-relative path
418
+ assert.ok(
419
+ stderr.includes('CONSTITUTION.md'),
420
+ `notice must include consumer-relative path "CONSTITUTION.md", got:\n${stderr}`,
421
+ );
422
+ });
423
+
424
+ it('notice includes the resolved tag version with v prefix for update path', async () => {
425
+ // Update from 1.0.0 → 1.1.0; CONSTITUTION.md does not exist yet
426
+ writeLockfile(consumerDir, {
427
+ source: 'github:dalzoubi/dev-agents',
428
+ range: '^1',
429
+ resolvedVersion: '1.0.0',
430
+ targets: ['claude'],
431
+ lastUpdated: '2026-04-01T00:00:00Z',
432
+ });
433
+ const agentsDir = path.join(consumerDir, '.claude', 'agents');
434
+ mkdirSync(agentsDir, { recursive: true });
435
+ writeFileSync(
436
+ path.join(agentsDir, 'define.md'),
437
+ readFileSync(path.join(FIXTURE_DIR, 'claude', 'agents', 'define.md'), 'utf8'),
438
+ 'utf8',
439
+ );
440
+
441
+ assert.ok(!existsSync(path.join(consumerDir, 'CONSTITUTION.md')), 'precondition: file must not exist');
442
+
443
+ const stderr = await captureStderr(() =>
444
+ runUpdate(consumerDir, {
445
+ fetcher: makeV110Fetcher(),
446
+ availableTags: ['v1.0.0', 'v1.1.0'],
447
+ }),
448
+ );
449
+
450
+ // resolveRange(['v1.0.0', 'v1.1.0'], '^1') → '1.1.0'
451
+ const expectedNotice =
452
+ 'info: dev-agents-sync: created CONSTITUTION.md (new in v1.1.0; see release notes)\n';
453
+
454
+ assert.ok(
455
+ stderr.includes(expectedNotice),
456
+ `notice must show resolved tag v1.1.0.\nExpected:\n ${JSON.stringify(expectedNotice)}\nGot:\n ${JSON.stringify(stderr)}`,
457
+ );
458
+ });
459
+ });
@@ -0,0 +1,117 @@
1
+ <!-- managed-by: dev-agents-sync v1.0.0 -->
2
+ # Constitution
3
+ Constitution version: v1
4
+
5
+ These 14 principles are binding across all agents and all consumer repos.
6
+
7
+ ---
8
+
9
+ ## §1 — Spec before code
10
+
11
+ **Rule:** No non-trivial change ships without a written spec.
12
+
13
+ **Rationale:** Reviewing intent before code is cheaper than reviewing code without intent.
14
+
15
+ ---
16
+
17
+ ## §2 — Smallest reviewable change
18
+
19
+ **Rule:** Prefer the smallest diff that proves the behavior; split anything larger into ordered slices.
20
+
21
+ **Rationale:** Small diffs review faster and revert cleanly.
22
+
23
+ ---
24
+
25
+ ## §3 — Reuse before invention
26
+
27
+ **Rule:** Before adding a new component, pattern, or utility, search for an existing one and prefer extending it.
28
+
29
+ **Rationale:** Duplication is the most expensive consistency bug.
30
+
31
+ ---
32
+
33
+ ## §4 — Explicit over implicit
34
+
35
+ **Rule:** Name what's happening instead of relying on convention or inference.
36
+
37
+ **Rationale:** Clever inference is fragile across humans, agents, and refactors.
38
+
39
+ ---
40
+
41
+ ## §5 — Fail loud, recover gracefully
42
+
43
+ **Rule:** Detect failure at the boundary, log with structured context, surface a generic actionable message to the user, and never swallow the error silently.
44
+
45
+ **Rationale:** Silent failure is the longest-running production bug.
46
+
47
+ ---
48
+
49
+ ## §6 — Observability is a feature
50
+
51
+ **Rule:** Every meaningful path emits a structured log with a correlation ID.
52
+
53
+ **Rationale:** Logs without structure or correlation are an archive, not a tool.
54
+
55
+ ---
56
+
57
+ ## §7 — Tests prove behavior, not coverage
58
+
59
+ **Rule:** Write tests that fail when the behavior breaks; coverage percentage is a proxy, not the goal.
60
+
61
+ **Rationale:** Coverage-chasing produces brittle tests that survive bugs.
62
+
63
+ ---
64
+
65
+ ## §8 — Backward compatibility is load-bearing
66
+
67
+ **Rule:** Changes to lockfile schemas, marker formats, public APIs, and DB migrations are versioned, additive when possible, and reversible.
68
+
69
+ **Rationale:** Breaking it without a migration story breaks every consumer at once.
70
+
71
+ ---
72
+
73
+ ## §9 — Determinism over cleverness
74
+
75
+ **Rule:** Same inputs, same outputs — no timestamps, random ordering, or environment-dependent state in generated artifacts.
76
+
77
+ **Rationale:** Determinism makes diffs reviewable and drift detectable.
78
+
79
+ ---
80
+
81
+ ## §10 — Boring tools, sharp edges where it matters
82
+
83
+ **Rule:** Default to the well-understood library or pattern; reserve novelty for the place where it actually pays for its complexity.
84
+
85
+ **Rationale:** Novelty everywhere is a velocity tax.
86
+
87
+ ---
88
+
89
+ ## §11 — User data is sacred
90
+
91
+ **Rule:** Collect the minimum, store it briefly, log it never, and have a written deletion plan from day one.
92
+
93
+ **Rationale:** Privacy is a one-way ratchet.
94
+
95
+ ---
96
+
97
+ ## §12 — Errors are UX
98
+
99
+ **Rule:** Every user-facing error has a generic translated message, a stable error code, and a clear next action; raw stacks never reach the user.
100
+
101
+ **Rationale:** A bad error message is a product bug.
102
+
103
+ ---
104
+
105
+ ## §13 — Comments explain why
106
+
107
+ **Rule:** Code says what; comments say why, especially for non-obvious tradeoffs and load-bearing constraints.
108
+
109
+ **Rationale:** "What" decays under refactor; "why" is what the next maintainer actually needs.
110
+
111
+ ---
112
+
113
+ ## §14 — Disagreement is part of the job
114
+
115
+ **Rule:** When a requirement is weak, ambiguous, or wrong, say so plainly and propose the alternative; do not rubber-stamp.
116
+
117
+ **Rationale:** Agreement-by-default produces specs that fail in implementation.