mandrel 1.60.0 → 1.61.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/.agents/README.md +74 -32
- package/.agents/docs/SDLC.md +8 -9
- package/.agents/docs/configuration.md +61 -4
- package/.agents/docs/quality-gates.md +796 -0
- package/.agents/docs/workflows.md +2 -3
- package/.agents/runtime-deps.json +2 -2
- package/.agents/scripts/README.md +1 -1
- package/.agents/scripts/agents-bootstrap-github.js +23 -119
- package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +46 -0
- package/.agents/scripts/lib/bootstrap/gh-preflight.js +7 -9
- package/.agents/scripts/lib/bootstrap/manifest.js +21 -1
- package/.agents/scripts/lib/bootstrap/merge-methods.js +31 -16
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +32 -11
- package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
- package/.agents/scripts/lib/detect-package-manager.js +72 -0
- package/.agents/scripts/lib/errors/index.js +4 -4
- package/.agents/scripts/lib/label-taxonomy.js +2 -2
- package/.agents/scripts/lib/onboard/detect-stack.js +10 -10
- package/.agents/scripts/lib/onboard/init-tail.js +218 -0
- package/.agents/scripts/lib/onboard/scaffold-docs.js +18 -3
- package/.agents/scripts/lib/runtime-deps/preflight.js +6 -6
- package/.agents/scripts/lib/worktree/node-modules-strategy.js +5 -2
- package/.agents/workflows/agents-update.md +14 -29
- package/.agents/workflows/helpers/agents-sync-config.md +3 -2
- package/.agents/workflows/plan.md +45 -3
- package/README.md +18 -30
- package/bin/mandrel.js +235 -16
- package/docs/CHANGELOG.md +24 -0
- package/lib/cli/doctor.js +45 -3
- package/lib/cli/init.js +66 -7
- package/lib/cli/registry.js +41 -145
- package/lib/cli/sync.js +122 -23
- package/lib/cli/uninstall.js +42 -7
- package/lib/cli/update.js +145 -192
- package/lib/cli/version-helpers.js +59 -0
- package/package.json +6 -6
- package/.agents/workflows/onboard.md +0 -208
- package/lib/cli/__tests__/migrate.test.js +0 -268
- package/lib/cli/__tests__/sync-local-zone.test.js +0 -247
- package/lib/cli/__tests__/sync.test.js +0 -372
- package/lib/cli/__tests__/update-changelog-surface.test.js +0 -357
- package/lib/cli/__tests__/update-major.test.js +0 -217
- package/lib/cli/__tests__/update-reexec.test.js +0 -513
- package/lib/cli/__tests__/update.test.js +0 -696
- package/lib/cli/__tests__/version-check.test.js +0 -398
- package/lib/migrations/__tests__/index.test.js +0 -216
|
@@ -1,247 +0,0 @@
|
|
|
1
|
-
// lib/cli/__tests__/sync-local-zone.test.js
|
|
2
|
-
/**
|
|
3
|
-
* Unit tests for the `.agents/local/` sync-exempt local-additions zone
|
|
4
|
-
* (Story #3498, f-drift-local-zone).
|
|
5
|
-
*
|
|
6
|
-
* Contract under test (Story #3498 AC):
|
|
7
|
-
* 1. runSync skips any path under `.agents/local/` when copying the
|
|
8
|
-
* package payload — proven by pre-populating a consumer-authored
|
|
9
|
-
* `.agents/local/custom.md` in the destination via an injected fs
|
|
10
|
-
* seam and asserting it is left byte-identical after a sync.
|
|
11
|
-
* 2. `mandrel sync` writes no file into `.agents/local/` from the package
|
|
12
|
-
* payload — proven by seeding a (hypothetical) payload file under the
|
|
13
|
-
* source `.agents/local/` and asserting it is never enumerated, never
|
|
14
|
-
* copied, and never appears in the dry-run plan.
|
|
15
|
-
*
|
|
16
|
-
* Every test drives runSync through the same injectable seams used by
|
|
17
|
-
* sync.test.js (resolvePackageRoot, fs, cwd, write, writeErr, exit) backed
|
|
18
|
-
* by an in-memory filesystem fake, so no real package resolution and no
|
|
19
|
-
* real disk I/O occur (testing-standards § Unit: all filesystem I/O MUST
|
|
20
|
-
* be mocked; sync.js injectable-seam style — no real child processes).
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import assert from 'node:assert/strict';
|
|
24
|
-
import path from 'node:path';
|
|
25
|
-
import { describe, it } from 'node:test';
|
|
26
|
-
|
|
27
|
-
import { runSync } from '../sync.js';
|
|
28
|
-
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
// In-memory filesystem fake (mirrors sync.test.js)
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Build an in-memory fs whose `seed` maps absolute file paths → contents.
|
|
35
|
-
* Directories are inferred from the seeded file paths.
|
|
36
|
-
*
|
|
37
|
-
* Tracks writes (copyFileSync) and guards against symlink creation.
|
|
38
|
-
*/
|
|
39
|
-
function makeFs(seed = {}) {
|
|
40
|
-
const files = new Map(Object.entries(seed));
|
|
41
|
-
const symlinkCalls = [];
|
|
42
|
-
|
|
43
|
-
function dirEntries(dir) {
|
|
44
|
-
const norm = dir.endsWith(path.sep) ? dir.slice(0, -1) : dir;
|
|
45
|
-
const children = new Map(); // name → isDir
|
|
46
|
-
for (const abs of files.keys()) {
|
|
47
|
-
if (!abs.startsWith(norm + path.sep)) continue;
|
|
48
|
-
const rest = abs.slice(norm.length + 1);
|
|
49
|
-
const segments = rest.split(path.sep);
|
|
50
|
-
const name = segments[0];
|
|
51
|
-
children.set(name, segments.length > 1);
|
|
52
|
-
}
|
|
53
|
-
return [...children.entries()].map(([name, isDir]) => ({
|
|
54
|
-
name,
|
|
55
|
-
isDirectory: () => isDir,
|
|
56
|
-
}));
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return {
|
|
60
|
-
files,
|
|
61
|
-
symlinkCalls,
|
|
62
|
-
readdirSync(dir, _opts) {
|
|
63
|
-
return dirEntries(dir);
|
|
64
|
-
},
|
|
65
|
-
existsSync(p) {
|
|
66
|
-
const norm = p.endsWith(path.sep) ? p.slice(0, -1) : p;
|
|
67
|
-
if (files.has(norm)) return true;
|
|
68
|
-
for (const abs of files.keys()) {
|
|
69
|
-
if (abs.startsWith(norm + path.sep)) return true;
|
|
70
|
-
}
|
|
71
|
-
return false;
|
|
72
|
-
},
|
|
73
|
-
mkdirSync(_dir, _opts) {
|
|
74
|
-
// No-op: directories are implied by file paths in this fake.
|
|
75
|
-
},
|
|
76
|
-
copyFileSync(src, dest) {
|
|
77
|
-
if (!files.has(src)) {
|
|
78
|
-
throw new Error(`copyFileSync: source missing ${src}`);
|
|
79
|
-
}
|
|
80
|
-
files.set(dest, files.get(src));
|
|
81
|
-
},
|
|
82
|
-
symlinkSync(target, p) {
|
|
83
|
-
symlinkCalls.push({ target, path: p });
|
|
84
|
-
},
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Capture stdout/stderr writes and the exit code. */
|
|
89
|
-
function makeCapture() {
|
|
90
|
-
const out = [];
|
|
91
|
-
const err = [];
|
|
92
|
-
let exitCode = null;
|
|
93
|
-
return {
|
|
94
|
-
out,
|
|
95
|
-
err,
|
|
96
|
-
get exitCode() {
|
|
97
|
-
return exitCode;
|
|
98
|
-
},
|
|
99
|
-
write: (s) => out.push(s),
|
|
100
|
-
writeErr: (s) => err.push(s),
|
|
101
|
-
exit: (code) => {
|
|
102
|
-
exitCode = code;
|
|
103
|
-
},
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const PROJECT = path.join(path.sep, 'proj');
|
|
108
|
-
const PKG_ROOT = path.join(PROJECT, 'node_modules', 'mandrel');
|
|
109
|
-
const SRC_AGENTS = path.join(PKG_ROOT, '.agents');
|
|
110
|
-
const resolveToPkg = () => PKG_ROOT;
|
|
111
|
-
|
|
112
|
-
/** Seed a normal package payload of two non-local files under <pkg>/.agents/. */
|
|
113
|
-
function seedPackagePayload() {
|
|
114
|
-
return {
|
|
115
|
-
[path.join(SRC_AGENTS, 'instructions.md')]: '# instructions\n',
|
|
116
|
-
[path.join(SRC_AGENTS, 'rules', 'security-baseline.md')]: '# security\n',
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const baseOpts = (fs, cap) => ({
|
|
121
|
-
argv: [],
|
|
122
|
-
resolvePackageRoot: resolveToPkg,
|
|
123
|
-
fs,
|
|
124
|
-
cwd: () => PROJECT,
|
|
125
|
-
write: cap.write,
|
|
126
|
-
writeErr: cap.writeErr,
|
|
127
|
-
exit: cap.exit,
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
// ---------------------------------------------------------------------------
|
|
131
|
-
// AC1 — pre-existing consumer .agents/local/ additions survive sync
|
|
132
|
-
// ---------------------------------------------------------------------------
|
|
133
|
-
|
|
134
|
-
describe('runSync — .agents/local/ consumer additions survive', () => {
|
|
135
|
-
it('leaves a pre-existing .agents/local/custom.md untouched', () => {
|
|
136
|
-
// Arrange: a normal payload plus a consumer-authored file already living
|
|
137
|
-
// in the destination's local zone.
|
|
138
|
-
const localAddition = path.join(PROJECT, '.agents', 'local', 'custom.md');
|
|
139
|
-
const fs = makeFs({
|
|
140
|
-
...seedPackagePayload(),
|
|
141
|
-
[localAddition]: '# my custom local note\n',
|
|
142
|
-
});
|
|
143
|
-
const cap = makeCapture();
|
|
144
|
-
|
|
145
|
-
// Act
|
|
146
|
-
const result = runSync(baseOpts(fs, cap));
|
|
147
|
-
|
|
148
|
-
// Assert: the local addition is byte-identical and the regular payload
|
|
149
|
-
// still materialized.
|
|
150
|
-
assert.equal(fs.files.get(localAddition), '# my custom local note\n');
|
|
151
|
-
assert.equal(
|
|
152
|
-
fs.files.get(path.join(PROJECT, '.agents', 'instructions.md')),
|
|
153
|
-
'# instructions\n',
|
|
154
|
-
);
|
|
155
|
-
assert.equal(result.copied, 2);
|
|
156
|
-
assert.equal(cap.exitCode, null);
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
it('never writes into the destination .agents/local/ subtree', () => {
|
|
160
|
-
const localAddition = path.join(PROJECT, '.agents', 'local', 'custom.md');
|
|
161
|
-
const fs = makeFs({
|
|
162
|
-
...seedPackagePayload(),
|
|
163
|
-
[localAddition]: '# my custom local note\n',
|
|
164
|
-
});
|
|
165
|
-
const cap = makeCapture();
|
|
166
|
-
|
|
167
|
-
runSync(baseOpts(fs, cap));
|
|
168
|
-
|
|
169
|
-
// The only destination file under .agents/local/ is the one the consumer
|
|
170
|
-
// authored; sync added nothing there.
|
|
171
|
-
const localPrefix = path.join(PROJECT, '.agents', 'local') + path.sep;
|
|
172
|
-
const localDestFiles = [...fs.files.keys()].filter((k) =>
|
|
173
|
-
k.startsWith(localPrefix),
|
|
174
|
-
);
|
|
175
|
-
assert.deepEqual(localDestFiles, [localAddition]);
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
// ---------------------------------------------------------------------------
|
|
180
|
-
// AC2 — a payload file under .agents/local/ is never materialized
|
|
181
|
-
// ---------------------------------------------------------------------------
|
|
182
|
-
|
|
183
|
-
describe('runSync — payload .agents/local/ is skipped on copy', () => {
|
|
184
|
-
it('does not copy a source .agents/local/ file into the destination', () => {
|
|
185
|
-
// Arrange: a payload that (hypothetically) ships a file under local/.
|
|
186
|
-
// The published payload ships none, but the skip must hold defensively.
|
|
187
|
-
const srcLocal = path.join(SRC_AGENTS, 'local', 'should-not-copy.md');
|
|
188
|
-
const fs = makeFs({
|
|
189
|
-
...seedPackagePayload(),
|
|
190
|
-
[srcLocal]: '# payload local file\n',
|
|
191
|
-
});
|
|
192
|
-
const cap = makeCapture();
|
|
193
|
-
|
|
194
|
-
// Act
|
|
195
|
-
const result = runSync(baseOpts(fs, cap));
|
|
196
|
-
|
|
197
|
-
// Assert: the local payload file was never copied to the destination.
|
|
198
|
-
const destLocal = path.join(
|
|
199
|
-
PROJECT,
|
|
200
|
-
'.agents',
|
|
201
|
-
'local',
|
|
202
|
-
'should-not-copy.md',
|
|
203
|
-
);
|
|
204
|
-
assert.equal(fs.files.has(destLocal), false);
|
|
205
|
-
// Only the two non-local payload files were materialized.
|
|
206
|
-
assert.equal(result.copied, 2);
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
it('omits .agents/local/ paths from the --dry-run plan', () => {
|
|
210
|
-
const srcLocal = path.join(SRC_AGENTS, 'local', 'should-not-copy.md');
|
|
211
|
-
const fs = makeFs({
|
|
212
|
-
...seedPackagePayload(),
|
|
213
|
-
[srcLocal]: '# payload local file\n',
|
|
214
|
-
});
|
|
215
|
-
const cap = makeCapture();
|
|
216
|
-
|
|
217
|
-
const result = runSync({ ...baseOpts(fs, cap), argv: ['--dry-run'] });
|
|
218
|
-
|
|
219
|
-
const joined = cap.out.join('');
|
|
220
|
-
assert.doesNotMatch(joined, /local/);
|
|
221
|
-
assert.match(joined, /Dry run: 2 file\(s\)/);
|
|
222
|
-
assert.equal(result.planned, 2);
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
it('still materializes a deeper non-top-level directory named local', () => {
|
|
226
|
-
// The skip is scoped to the top-level .agents/local/ only — a nested
|
|
227
|
-
// rules/local/ must still copy.
|
|
228
|
-
const nestedLocal = path.join(SRC_AGENTS, 'rules', 'local', 'note.md');
|
|
229
|
-
const fs = makeFs({
|
|
230
|
-
...seedPackagePayload(),
|
|
231
|
-
[nestedLocal]: '# nested local note\n',
|
|
232
|
-
});
|
|
233
|
-
const cap = makeCapture();
|
|
234
|
-
|
|
235
|
-
const result = runSync(baseOpts(fs, cap));
|
|
236
|
-
|
|
237
|
-
const destNested = path.join(
|
|
238
|
-
PROJECT,
|
|
239
|
-
'.agents',
|
|
240
|
-
'rules',
|
|
241
|
-
'local',
|
|
242
|
-
'note.md',
|
|
243
|
-
);
|
|
244
|
-
assert.equal(fs.files.get(destNested), '# nested local note\n');
|
|
245
|
-
assert.equal(result.copied, 3);
|
|
246
|
-
});
|
|
247
|
-
});
|
|
@@ -1,372 +0,0 @@
|
|
|
1
|
-
// lib/cli/__tests__/sync.test.js
|
|
2
|
-
/**
|
|
3
|
-
* Unit tests for lib/cli/sync.js — the `mandrel sync` materializer.
|
|
4
|
-
*
|
|
5
|
-
* Every test drives runSync through injectable seams (resolvePackageRoot,
|
|
6
|
-
* fs, cwd, write, writeErr, exit) backed by an in-memory filesystem fake,
|
|
7
|
-
* so no real package resolution and no real disk I/O occur (testing-standards
|
|
8
|
-
* § Unit: all filesystem I/O MUST be mocked).
|
|
9
|
-
*
|
|
10
|
-
* Coverage contract (per Story #3467 AC):
|
|
11
|
-
* 1. Copies the package .agents/ tree into ./.agents/ as plain files
|
|
12
|
-
* (no symlinks created — symlinkSync is never called).
|
|
13
|
-
* 2. Re-running is idempotent — a second run leaves ./.agents/
|
|
14
|
-
* byte-identical.
|
|
15
|
-
* 3. Exits non-zero with an actionable message when mandrel is
|
|
16
|
-
* not resolvable in node_modules.
|
|
17
|
-
* 4. --dry-run reports planned copies and writes nothing.
|
|
18
|
-
* 5. Module shape: runSync named export + default function export.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import assert from 'node:assert/strict';
|
|
22
|
-
import path from 'node:path';
|
|
23
|
-
import { describe, it } from 'node:test';
|
|
24
|
-
|
|
25
|
-
import sync, { runSync } from '../sync.js';
|
|
26
|
-
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
// In-memory filesystem fake
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Build an in-memory fs whose `seed` maps absolute file paths → contents.
|
|
33
|
-
* Directories are inferred from the seeded file paths.
|
|
34
|
-
*
|
|
35
|
-
* Tracks writes (copyFileSync) and guards against symlink creation so tests
|
|
36
|
-
* can prove the materializer never produces symlinks.
|
|
37
|
-
*/
|
|
38
|
-
function makeFs(seed = {}) {
|
|
39
|
-
// Live store: absolute path → contents. Seeded entries plus anything the
|
|
40
|
-
// code under test writes via copyFileSync.
|
|
41
|
-
const files = new Map(Object.entries(seed));
|
|
42
|
-
const symlinkCalls = [];
|
|
43
|
-
|
|
44
|
-
function dirEntries(dir) {
|
|
45
|
-
const norm = dir.endsWith(path.sep) ? dir.slice(0, -1) : dir;
|
|
46
|
-
const children = new Map(); // name → isDir
|
|
47
|
-
for (const abs of files.keys()) {
|
|
48
|
-
if (!abs.startsWith(norm + path.sep)) continue;
|
|
49
|
-
const rest = abs.slice(norm.length + 1);
|
|
50
|
-
const segments = rest.split(path.sep);
|
|
51
|
-
const name = segments[0];
|
|
52
|
-
children.set(name, segments.length > 1);
|
|
53
|
-
}
|
|
54
|
-
return [...children.entries()].map(([name, isDir]) => ({
|
|
55
|
-
name,
|
|
56
|
-
isDirectory: () => isDir,
|
|
57
|
-
}));
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return {
|
|
61
|
-
files,
|
|
62
|
-
symlinkCalls,
|
|
63
|
-
readdirSync(dir, _opts) {
|
|
64
|
-
return dirEntries(dir);
|
|
65
|
-
},
|
|
66
|
-
existsSync(p) {
|
|
67
|
-
const norm = p.endsWith(path.sep) ? p.slice(0, -1) : p;
|
|
68
|
-
if (files.has(norm)) return true;
|
|
69
|
-
// A directory "exists" if any seeded file lives under it.
|
|
70
|
-
for (const abs of files.keys()) {
|
|
71
|
-
if (abs.startsWith(norm + path.sep)) return true;
|
|
72
|
-
}
|
|
73
|
-
return false;
|
|
74
|
-
},
|
|
75
|
-
mkdirSync(_dir, _opts) {
|
|
76
|
-
// No-op: directories are implied by file paths in this fake.
|
|
77
|
-
},
|
|
78
|
-
copyFileSync(src, dest) {
|
|
79
|
-
if (!files.has(src)) {
|
|
80
|
-
throw new Error(`copyFileSync: source missing ${src}`);
|
|
81
|
-
}
|
|
82
|
-
files.set(dest, files.get(src));
|
|
83
|
-
},
|
|
84
|
-
symlinkSync(target, p) {
|
|
85
|
-
symlinkCalls.push({ target, path: p });
|
|
86
|
-
},
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** Capture stdout/stderr writes and the exit code. */
|
|
91
|
-
function makeCapture() {
|
|
92
|
-
const out = [];
|
|
93
|
-
const err = [];
|
|
94
|
-
let exitCode = null;
|
|
95
|
-
return {
|
|
96
|
-
out,
|
|
97
|
-
err,
|
|
98
|
-
get exitCode() {
|
|
99
|
-
return exitCode;
|
|
100
|
-
},
|
|
101
|
-
write: (s) => out.push(s),
|
|
102
|
-
writeErr: (s) => err.push(s),
|
|
103
|
-
exit: (code) => {
|
|
104
|
-
exitCode = code;
|
|
105
|
-
},
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const PROJECT = path.join(path.sep, 'proj');
|
|
110
|
-
const PKG_ROOT = path.join(PROJECT, 'node_modules', 'mandrel');
|
|
111
|
-
|
|
112
|
-
/** Seed a package payload of two files under <pkg>/.agents/. */
|
|
113
|
-
function seedPackagePayload() {
|
|
114
|
-
const agentsDir = path.join(PKG_ROOT, '.agents');
|
|
115
|
-
return {
|
|
116
|
-
[path.join(agentsDir, 'instructions.md')]: '# instructions\n',
|
|
117
|
-
[path.join(agentsDir, 'rules', 'security-baseline.md')]: '# security\n',
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const resolveToPkg = () => PKG_ROOT;
|
|
122
|
-
|
|
123
|
-
// ---------------------------------------------------------------------------
|
|
124
|
-
// Module shape
|
|
125
|
-
// ---------------------------------------------------------------------------
|
|
126
|
-
|
|
127
|
-
describe('sync module exports', () => {
|
|
128
|
-
it('exports runSync as a named export', () => {
|
|
129
|
-
assert.equal(typeof runSync, 'function');
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it('exports a default function for bin/mandrel.js dispatch', () => {
|
|
133
|
-
assert.equal(typeof sync, 'function');
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
// ---------------------------------------------------------------------------
|
|
138
|
-
// AC1 — copies the tree as plain files, no symlinks
|
|
139
|
-
// ---------------------------------------------------------------------------
|
|
140
|
-
|
|
141
|
-
describe('runSync — copies .agents/ payload as plain files', () => {
|
|
142
|
-
it('copies every package file into ./.agents/', () => {
|
|
143
|
-
const fs = makeFs(seedPackagePayload());
|
|
144
|
-
const cap = makeCapture();
|
|
145
|
-
const result = runSync({
|
|
146
|
-
argv: [],
|
|
147
|
-
resolvePackageRoot: resolveToPkg,
|
|
148
|
-
fs,
|
|
149
|
-
cwd: () => PROJECT,
|
|
150
|
-
write: cap.write,
|
|
151
|
-
writeErr: cap.writeErr,
|
|
152
|
-
exit: cap.exit,
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
const destInstr = path.join(PROJECT, '.agents', 'instructions.md');
|
|
156
|
-
const destRule = path.join(
|
|
157
|
-
PROJECT,
|
|
158
|
-
'.agents',
|
|
159
|
-
'rules',
|
|
160
|
-
'security-baseline.md',
|
|
161
|
-
);
|
|
162
|
-
assert.equal(fs.files.get(destInstr), '# instructions\n');
|
|
163
|
-
assert.equal(fs.files.get(destRule), '# security\n');
|
|
164
|
-
assert.equal(result.copied, 2);
|
|
165
|
-
assert.equal(cap.exitCode, null);
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it('never creates a symlink', () => {
|
|
169
|
-
const fs = makeFs(seedPackagePayload());
|
|
170
|
-
const cap = makeCapture();
|
|
171
|
-
runSync({
|
|
172
|
-
argv: [],
|
|
173
|
-
resolvePackageRoot: resolveToPkg,
|
|
174
|
-
fs,
|
|
175
|
-
cwd: () => PROJECT,
|
|
176
|
-
write: cap.write,
|
|
177
|
-
writeErr: cap.writeErr,
|
|
178
|
-
exit: cap.exit,
|
|
179
|
-
});
|
|
180
|
-
assert.equal(fs.symlinkCalls.length, 0);
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it('reports the materialized file count on stdout', () => {
|
|
184
|
-
const fs = makeFs(seedPackagePayload());
|
|
185
|
-
const cap = makeCapture();
|
|
186
|
-
runSync({
|
|
187
|
-
argv: [],
|
|
188
|
-
resolvePackageRoot: resolveToPkg,
|
|
189
|
-
fs,
|
|
190
|
-
cwd: () => PROJECT,
|
|
191
|
-
write: cap.write,
|
|
192
|
-
writeErr: cap.writeErr,
|
|
193
|
-
exit: cap.exit,
|
|
194
|
-
});
|
|
195
|
-
assert.match(cap.out.join(''), /Materialized 2 file\(s\)/);
|
|
196
|
-
});
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
// ---------------------------------------------------------------------------
|
|
200
|
-
// AC2 — idempotency
|
|
201
|
-
// ---------------------------------------------------------------------------
|
|
202
|
-
|
|
203
|
-
describe('runSync — idempotent re-run', () => {
|
|
204
|
-
it('leaves ./.agents/ byte-identical after a second run', () => {
|
|
205
|
-
const fs = makeFs(seedPackagePayload());
|
|
206
|
-
const opts = {
|
|
207
|
-
argv: [],
|
|
208
|
-
resolvePackageRoot: resolveToPkg,
|
|
209
|
-
fs,
|
|
210
|
-
cwd: () => PROJECT,
|
|
211
|
-
write: () => {},
|
|
212
|
-
writeErr: () => {},
|
|
213
|
-
exit: () => {},
|
|
214
|
-
};
|
|
215
|
-
|
|
216
|
-
runSync(opts);
|
|
217
|
-
// Snapshot the destination tree after the first run.
|
|
218
|
-
const destPrefix = path.join(PROJECT, '.agents') + path.sep;
|
|
219
|
-
const snapshot = JSON.stringify(
|
|
220
|
-
[...fs.files.entries()].filter(([k]) => k.startsWith(destPrefix)).sort(),
|
|
221
|
-
);
|
|
222
|
-
|
|
223
|
-
runSync(opts);
|
|
224
|
-
const after = JSON.stringify(
|
|
225
|
-
[...fs.files.entries()].filter(([k]) => k.startsWith(destPrefix)).sort(),
|
|
226
|
-
);
|
|
227
|
-
|
|
228
|
-
assert.equal(after, snapshot);
|
|
229
|
-
});
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
// ---------------------------------------------------------------------------
|
|
233
|
-
// AC3 — missing package → non-zero exit with actionable message
|
|
234
|
-
// ---------------------------------------------------------------------------
|
|
235
|
-
|
|
236
|
-
describe('runSync — mandrel not resolvable', () => {
|
|
237
|
-
function resolveThrows() {
|
|
238
|
-
const err = new Error("Cannot find module 'mandrel/package.json'");
|
|
239
|
-
err.code = 'MODULE_NOT_FOUND';
|
|
240
|
-
throw err;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
it('exits non-zero', () => {
|
|
244
|
-
const fs = makeFs();
|
|
245
|
-
const cap = makeCapture();
|
|
246
|
-
runSync({
|
|
247
|
-
argv: [],
|
|
248
|
-
resolvePackageRoot: resolveThrows,
|
|
249
|
-
fs,
|
|
250
|
-
cwd: () => PROJECT,
|
|
251
|
-
write: cap.write,
|
|
252
|
-
writeErr: cap.writeErr,
|
|
253
|
-
exit: cap.exit,
|
|
254
|
-
});
|
|
255
|
-
assert.equal(cap.exitCode, 1);
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
it('emits an actionable message naming the package and install command', () => {
|
|
259
|
-
const fs = makeFs();
|
|
260
|
-
const cap = makeCapture();
|
|
261
|
-
runSync({
|
|
262
|
-
argv: [],
|
|
263
|
-
resolvePackageRoot: resolveThrows,
|
|
264
|
-
fs,
|
|
265
|
-
cwd: () => PROJECT,
|
|
266
|
-
write: cap.write,
|
|
267
|
-
writeErr: cap.writeErr,
|
|
268
|
-
exit: cap.exit,
|
|
269
|
-
});
|
|
270
|
-
const joined = cap.err.join('');
|
|
271
|
-
assert.match(joined, /mandrel/);
|
|
272
|
-
assert.match(joined, /npm install mandrel/);
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
it('writes nothing to the destination', () => {
|
|
276
|
-
const fs = makeFs();
|
|
277
|
-
const cap = makeCapture();
|
|
278
|
-
runSync({
|
|
279
|
-
argv: [],
|
|
280
|
-
resolvePackageRoot: resolveThrows,
|
|
281
|
-
fs,
|
|
282
|
-
cwd: () => PROJECT,
|
|
283
|
-
write: cap.write,
|
|
284
|
-
writeErr: cap.writeErr,
|
|
285
|
-
exit: cap.exit,
|
|
286
|
-
});
|
|
287
|
-
assert.equal(fs.files.size, 0);
|
|
288
|
-
});
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
// ---------------------------------------------------------------------------
|
|
292
|
-
// AC3b — package resolvable but ships no .agents/ payload
|
|
293
|
-
// ---------------------------------------------------------------------------
|
|
294
|
-
|
|
295
|
-
describe('runSync — package present but no .agents/ payload', () => {
|
|
296
|
-
it('exits non-zero with an actionable message', () => {
|
|
297
|
-
// Seed a package whose only file is its package.json (no .agents/ tree).
|
|
298
|
-
const fs = makeFs({
|
|
299
|
-
[path.join(PKG_ROOT, 'package.json')]: '{}',
|
|
300
|
-
});
|
|
301
|
-
const cap = makeCapture();
|
|
302
|
-
runSync({
|
|
303
|
-
argv: [],
|
|
304
|
-
resolvePackageRoot: resolveToPkg,
|
|
305
|
-
fs,
|
|
306
|
-
cwd: () => PROJECT,
|
|
307
|
-
write: cap.write,
|
|
308
|
-
writeErr: cap.writeErr,
|
|
309
|
-
exit: cap.exit,
|
|
310
|
-
});
|
|
311
|
-
assert.equal(cap.exitCode, 1);
|
|
312
|
-
assert.match(cap.err.join(''), /no .agents\/ payload/);
|
|
313
|
-
});
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
// ---------------------------------------------------------------------------
|
|
317
|
-
// AC4 — --dry-run reports planned copies, writes nothing
|
|
318
|
-
// ---------------------------------------------------------------------------
|
|
319
|
-
|
|
320
|
-
describe('runSync — --dry-run', () => {
|
|
321
|
-
it('writes nothing to the destination', () => {
|
|
322
|
-
const fs = makeFs(seedPackagePayload());
|
|
323
|
-
const cap = makeCapture();
|
|
324
|
-
const before = fs.files.size;
|
|
325
|
-
runSync({
|
|
326
|
-
argv: ['--dry-run'],
|
|
327
|
-
resolvePackageRoot: resolveToPkg,
|
|
328
|
-
fs,
|
|
329
|
-
cwd: () => PROJECT,
|
|
330
|
-
write: cap.write,
|
|
331
|
-
writeErr: cap.writeErr,
|
|
332
|
-
exit: cap.exit,
|
|
333
|
-
});
|
|
334
|
-
// No new files written: store size is unchanged from the seed.
|
|
335
|
-
assert.equal(fs.files.size, before);
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
it('reports the planned copies and a dry-run summary', () => {
|
|
339
|
-
const fs = makeFs(seedPackagePayload());
|
|
340
|
-
const cap = makeCapture();
|
|
341
|
-
const result = runSync({
|
|
342
|
-
argv: ['--dry-run'],
|
|
343
|
-
resolvePackageRoot: resolveToPkg,
|
|
344
|
-
fs,
|
|
345
|
-
cwd: () => PROJECT,
|
|
346
|
-
write: cap.write,
|
|
347
|
-
writeErr: cap.writeErr,
|
|
348
|
-
exit: cap.exit,
|
|
349
|
-
});
|
|
350
|
-
const joined = cap.out.join('');
|
|
351
|
-
assert.match(joined, /would copy/);
|
|
352
|
-
assert.match(joined, /instructions\.md/);
|
|
353
|
-
assert.match(joined, /Dry run: 2 file\(s\)/);
|
|
354
|
-
assert.equal(result.planned, 2);
|
|
355
|
-
assert.equal(result.copied, 0);
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
it('does not call exit on the dry-run happy path', () => {
|
|
359
|
-
const fs = makeFs(seedPackagePayload());
|
|
360
|
-
const cap = makeCapture();
|
|
361
|
-
runSync({
|
|
362
|
-
argv: ['--dry-run'],
|
|
363
|
-
resolvePackageRoot: resolveToPkg,
|
|
364
|
-
fs,
|
|
365
|
-
cwd: () => PROJECT,
|
|
366
|
-
write: cap.write,
|
|
367
|
-
writeErr: cap.writeErr,
|
|
368
|
-
exit: cap.exit,
|
|
369
|
-
});
|
|
370
|
-
assert.equal(cap.exitCode, null);
|
|
371
|
-
});
|
|
372
|
-
});
|