mandrel 1.60.0 → 1.62.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 +18 -12
- package/.agents/docs/configuration.md +61 -4
- package/.agents/docs/quality-gates.md +796 -0
- package/.agents/docs/workflows.md +3 -4
- 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/deliver.md +87 -26
- package/.agents/workflows/helpers/agents-sync-config.md +3 -2
- package/.agents/workflows/helpers/deliver-epic.md +12 -5
- package/.agents/workflows/helpers/deliver-stories.md +13 -7
- package/.agents/workflows/plan.md +48 -4
- package/README.md +18 -30
- package/bin/mandrel.js +235 -16
- package/docs/CHANGELOG.md +36 -0
- package/lib/cli/doctor.js +45 -3
- package/lib/cli/init.js +66 -7
- package/lib/cli/registry.js +42 -146
- package/lib/cli/sync.js +122 -23
- package/lib/cli/uninstall.js +42 -7
- package/lib/cli/update.js +257 -198
- 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,696 +0,0 @@
|
|
|
1
|
-
// lib/cli/__tests__/update.test.js
|
|
2
|
-
/**
|
|
3
|
-
* Unit tests for lib/cli/update.js — the `mandrel update` orchestrator.
|
|
4
|
-
*
|
|
5
|
-
* Every test drives runUpdate through injectable seams (currentVersion,
|
|
6
|
-
* resolveTargetVersion, npmUpdate, runSync, runMigrations, runDoctor,
|
|
7
|
-
* surfaceChangelog, write, writeErr, exit). No real npm process, no real
|
|
8
|
-
* filesystem I/O, and no real network call occur (testing-standards § Unit:
|
|
9
|
-
* all external network / filesystem I/O MUST be mocked).
|
|
10
|
-
*
|
|
11
|
-
* Coverage contract (Story #3503 AC — non-major paths):
|
|
12
|
-
* - Module shape: runUpdate named export + default function export.
|
|
13
|
-
* - Happy path: a minor-ahead target drives the ordered steps
|
|
14
|
-
* npm-update → runSync → runMigrations → doctor and reports success only
|
|
15
|
-
* when the injected doctor result is all-pass.
|
|
16
|
-
* - --dry-run prints the planned target + step plan and invokes no
|
|
17
|
-
* effectful seam.
|
|
18
|
-
* - A failing doctor result downgrades the run to a non-zero exit even
|
|
19
|
-
* after the bump applied.
|
|
20
|
-
* - up-to-date short-circuit performs no steps.
|
|
21
|
-
*
|
|
22
|
-
* The major-gate AC has its own file: update-major.test.js.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import assert from 'node:assert/strict';
|
|
26
|
-
import path from 'node:path';
|
|
27
|
-
import { describe, it } from 'node:test';
|
|
28
|
-
|
|
29
|
-
import { runInstallCommand } from '../../../.agents/scripts/lib/install-cmd-parser.js';
|
|
30
|
-
import update, {
|
|
31
|
-
defaultNpmUpdate,
|
|
32
|
-
defaultVersionRunner,
|
|
33
|
-
detectPackageManager,
|
|
34
|
-
resolveInstallCmd,
|
|
35
|
-
runUpdate,
|
|
36
|
-
} from '../update.js';
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Build an in-memory `node:fs` fake whose `existsSync` reports the given
|
|
40
|
-
* basenames as present — enough surface for `detectPackageManager` to probe
|
|
41
|
-
* lockfiles without touching the real filesystem (testing-standards § Unit:
|
|
42
|
-
* mock all filesystem I/O).
|
|
43
|
-
*
|
|
44
|
-
* @param {string[]} present - lockfile basenames to report as present.
|
|
45
|
-
*/
|
|
46
|
-
function makeLockFs(present = []) {
|
|
47
|
-
const set = new Set(present);
|
|
48
|
-
return { existsSync: (p) => set.has(path.basename(String(p))) };
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// ---------------------------------------------------------------------------
|
|
52
|
-
// Capture + seam helpers
|
|
53
|
-
// ---------------------------------------------------------------------------
|
|
54
|
-
|
|
55
|
-
/** Capture stdout/stderr writes and the exit code. */
|
|
56
|
-
function makeCapture() {
|
|
57
|
-
const out = [];
|
|
58
|
-
const err = [];
|
|
59
|
-
let exitCode = null;
|
|
60
|
-
return {
|
|
61
|
-
out,
|
|
62
|
-
err,
|
|
63
|
-
get exitCode() {
|
|
64
|
-
return exitCode;
|
|
65
|
-
},
|
|
66
|
-
write: (s) => out.push(s),
|
|
67
|
-
writeErr: (s) => err.push(s),
|
|
68
|
-
exit: (code) => {
|
|
69
|
-
exitCode = code;
|
|
70
|
-
},
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Build the recording seam set for a happy-path run. `doctorOk` toggles the
|
|
76
|
-
* injected doctor verdict so the all-pass / failure branches are both
|
|
77
|
-
* exercised through the same harness.
|
|
78
|
-
*/
|
|
79
|
-
function makeSeams({ target = '1.44.0', doctorOk = true } = {}) {
|
|
80
|
-
const calls = [];
|
|
81
|
-
return {
|
|
82
|
-
calls,
|
|
83
|
-
currentVersion: '1.43.0',
|
|
84
|
-
resolveTargetVersion: async () => {
|
|
85
|
-
calls.push('resolveTargetVersion');
|
|
86
|
-
return target;
|
|
87
|
-
},
|
|
88
|
-
npmUpdate: async (version) => {
|
|
89
|
-
calls.push(`npmUpdate:${version}`);
|
|
90
|
-
},
|
|
91
|
-
runSync: (_opts) => {
|
|
92
|
-
calls.push('runSync');
|
|
93
|
-
return { copied: 0, planned: 0, dryRun: false };
|
|
94
|
-
},
|
|
95
|
-
runMigrations: ({ fromVersion, toVersion }) => {
|
|
96
|
-
calls.push(`runMigrations:${fromVersion}->${toVersion}`);
|
|
97
|
-
return { applied: [], skipped: [] };
|
|
98
|
-
},
|
|
99
|
-
runDoctor: async () => {
|
|
100
|
-
calls.push('runDoctor');
|
|
101
|
-
return {
|
|
102
|
-
ok: doctorOk,
|
|
103
|
-
results: [
|
|
104
|
-
{ name: 'node-version', ok: true },
|
|
105
|
-
{ name: 'agents-materialized', ok: doctorOk },
|
|
106
|
-
],
|
|
107
|
-
};
|
|
108
|
-
},
|
|
109
|
-
surfaceChangelog: async (version) => {
|
|
110
|
-
calls.push(`surfaceChangelog:${version}`);
|
|
111
|
-
},
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// ---------------------------------------------------------------------------
|
|
116
|
-
// Module shape
|
|
117
|
-
// ---------------------------------------------------------------------------
|
|
118
|
-
|
|
119
|
-
describe('update module exports', () => {
|
|
120
|
-
it('exports runUpdate as a named export', () => {
|
|
121
|
-
assert.equal(typeof runUpdate, 'function');
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it('exports a default function for bin/mandrel.js dispatch', () => {
|
|
125
|
-
assert.equal(typeof update, 'function');
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
// ---------------------------------------------------------------------------
|
|
130
|
-
// AC — ordered cycle drives npm-update → runSync → runMigrations → doctor
|
|
131
|
-
// ---------------------------------------------------------------------------
|
|
132
|
-
|
|
133
|
-
describe('runUpdate — non-major happy path', () => {
|
|
134
|
-
it('drives the steps in order and reports success on an all-pass doctor', async () => {
|
|
135
|
-
const seams = makeSeams({ target: '1.44.0', doctorOk: true });
|
|
136
|
-
const cap = makeCapture();
|
|
137
|
-
|
|
138
|
-
const result = await runUpdate({
|
|
139
|
-
argv: [],
|
|
140
|
-
...seams,
|
|
141
|
-
write: cap.write,
|
|
142
|
-
writeErr: cap.writeErr,
|
|
143
|
-
exit: cap.exit,
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
// Ordered seam invocation: resolve → npm-update → sync → migrate → doctor.
|
|
147
|
-
assert.deepEqual(seams.calls, [
|
|
148
|
-
'resolveTargetVersion',
|
|
149
|
-
'npmUpdate:1.44.0',
|
|
150
|
-
'runSync',
|
|
151
|
-
'runMigrations:1.43.0->1.44.0',
|
|
152
|
-
'runDoctor',
|
|
153
|
-
'surfaceChangelog:1.44.0',
|
|
154
|
-
]);
|
|
155
|
-
assert.equal(result.ok, true);
|
|
156
|
-
assert.equal(result.action, 'updated');
|
|
157
|
-
assert.deepEqual(result.stepsRun, [
|
|
158
|
-
'npm-update',
|
|
159
|
-
'runSync',
|
|
160
|
-
'runMigrations',
|
|
161
|
-
'doctor',
|
|
162
|
-
]);
|
|
163
|
-
assert.equal(cap.exitCode, null);
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it('threads the resolved target into the npm-update and migration seams', async () => {
|
|
167
|
-
const seams = makeSeams({ target: '1.50.2' });
|
|
168
|
-
const cap = makeCapture();
|
|
169
|
-
|
|
170
|
-
await runUpdate({
|
|
171
|
-
argv: [],
|
|
172
|
-
...seams,
|
|
173
|
-
write: cap.write,
|
|
174
|
-
writeErr: cap.writeErr,
|
|
175
|
-
exit: cap.exit,
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
assert.ok(seams.calls.includes('npmUpdate:1.50.2'));
|
|
179
|
-
assert.ok(seams.calls.includes('runMigrations:1.43.0->1.50.2'));
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it('does not report success when doctor reports a failure', async () => {
|
|
183
|
-
const seams = makeSeams({ target: '1.44.0', doctorOk: false });
|
|
184
|
-
const cap = makeCapture();
|
|
185
|
-
|
|
186
|
-
const result = await runUpdate({
|
|
187
|
-
argv: [],
|
|
188
|
-
...seams,
|
|
189
|
-
write: cap.write,
|
|
190
|
-
writeErr: cap.writeErr,
|
|
191
|
-
exit: cap.exit,
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
assert.equal(result.ok, false);
|
|
195
|
-
assert.equal(result.action, 'doctor-failed');
|
|
196
|
-
// The bump still applied — all four steps ran before doctor failed.
|
|
197
|
-
assert.deepEqual(result.stepsRun, [
|
|
198
|
-
'npm-update',
|
|
199
|
-
'runSync',
|
|
200
|
-
'runMigrations',
|
|
201
|
-
'doctor',
|
|
202
|
-
]);
|
|
203
|
-
assert.equal(cap.exitCode, 1);
|
|
204
|
-
assert.match(cap.err.join(''), /doctor reported failures/);
|
|
205
|
-
assert.match(cap.err.join(''), /agents-materialized/);
|
|
206
|
-
});
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
// ---------------------------------------------------------------------------
|
|
210
|
-
// AC — --dry-run prints the plan and writes nothing / invokes no seam
|
|
211
|
-
// ---------------------------------------------------------------------------
|
|
212
|
-
|
|
213
|
-
describe('runUpdate — --dry-run', () => {
|
|
214
|
-
it('prints the planned target version and step plan', async () => {
|
|
215
|
-
const seams = makeSeams({ target: '1.44.0' });
|
|
216
|
-
const cap = makeCapture();
|
|
217
|
-
|
|
218
|
-
const result = await runUpdate({
|
|
219
|
-
argv: ['--dry-run'],
|
|
220
|
-
...seams,
|
|
221
|
-
write: cap.write,
|
|
222
|
-
writeErr: cap.writeErr,
|
|
223
|
-
exit: cap.exit,
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
const joined = cap.out.join('');
|
|
227
|
-
assert.match(joined, /1\.43\.0 → v1\.44\.0/);
|
|
228
|
-
assert.match(joined, /npm-update/);
|
|
229
|
-
assert.match(joined, /runSync/);
|
|
230
|
-
assert.match(joined, /runMigrations/);
|
|
231
|
-
assert.match(joined, /doctor/);
|
|
232
|
-
assert.match(joined, /Dry run: no files written/);
|
|
233
|
-
assert.equal(result.action, 'dry-run');
|
|
234
|
-
assert.equal(result.dryRun, true);
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
it('invokes no effectful seam and never calls exit', async () => {
|
|
238
|
-
const seams = makeSeams({ target: '1.44.0' });
|
|
239
|
-
const cap = makeCapture();
|
|
240
|
-
|
|
241
|
-
await runUpdate({
|
|
242
|
-
argv: ['--dry-run'],
|
|
243
|
-
...seams,
|
|
244
|
-
write: cap.write,
|
|
245
|
-
writeErr: cap.writeErr,
|
|
246
|
-
exit: cap.exit,
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
// resolveTargetVersion runs (the plan needs the target); nothing else.
|
|
250
|
-
assert.deepEqual(seams.calls, ['resolveTargetVersion']);
|
|
251
|
-
assert.equal(cap.exitCode, null);
|
|
252
|
-
});
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
// ---------------------------------------------------------------------------
|
|
256
|
-
// up-to-date short-circuit
|
|
257
|
-
// ---------------------------------------------------------------------------
|
|
258
|
-
|
|
259
|
-
describe('runUpdate — already on the newest version', () => {
|
|
260
|
-
it('performs no steps and reports up-to-date', async () => {
|
|
261
|
-
const seams = makeSeams({ target: '1.43.0' });
|
|
262
|
-
const cap = makeCapture();
|
|
263
|
-
|
|
264
|
-
const result = await runUpdate({
|
|
265
|
-
argv: [],
|
|
266
|
-
...seams,
|
|
267
|
-
write: cap.write,
|
|
268
|
-
writeErr: cap.writeErr,
|
|
269
|
-
exit: cap.exit,
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
assert.equal(result.ok, true);
|
|
273
|
-
assert.equal(result.action, 'up-to-date');
|
|
274
|
-
assert.deepEqual(result.stepsRun, []);
|
|
275
|
-
assert.deepEqual(seams.calls, ['resolveTargetVersion']);
|
|
276
|
-
assert.match(cap.out.join(''), /Already up to date/);
|
|
277
|
-
});
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
// ---------------------------------------------------------------------------
|
|
281
|
-
// Seam-required guards
|
|
282
|
-
// ---------------------------------------------------------------------------
|
|
283
|
-
|
|
284
|
-
describe('runUpdate — missing required seams', () => {
|
|
285
|
-
it('throws when resolveTargetVersion is absent', async () => {
|
|
286
|
-
await assert.rejects(
|
|
287
|
-
() => runUpdate({ argv: [], currentVersion: '1.43.0' }),
|
|
288
|
-
/resolveTargetVersion seam is required/,
|
|
289
|
-
);
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
it('throws when npmUpdate is absent on a live (non-dry-run) bump', async () => {
|
|
293
|
-
await assert.rejects(
|
|
294
|
-
() =>
|
|
295
|
-
runUpdate({
|
|
296
|
-
argv: [],
|
|
297
|
-
currentVersion: '1.43.0',
|
|
298
|
-
resolveTargetVersion: async () => '1.44.0',
|
|
299
|
-
write: () => {},
|
|
300
|
-
writeErr: () => {},
|
|
301
|
-
exit: () => {},
|
|
302
|
-
}),
|
|
303
|
-
/npmUpdate seam is required/,
|
|
304
|
-
);
|
|
305
|
-
});
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
// ---------------------------------------------------------------------------
|
|
309
|
-
// AC — win32 shell:true spawn shape (CVE-2024-27980) for the version probe
|
|
310
|
-
// ---------------------------------------------------------------------------
|
|
311
|
-
|
|
312
|
-
describe('defaultVersionRunner — win32 spawn shape', () => {
|
|
313
|
-
it('passes shell matching the platform to spawnSync (CVE-2024-27980)', () => {
|
|
314
|
-
const calls = [];
|
|
315
|
-
const fakeSpawn = (cmd, args, opts) => {
|
|
316
|
-
calls.push({ cmd, args, opts });
|
|
317
|
-
return { status: 0, stdout: '1.46.0\n', stderr: '' };
|
|
318
|
-
};
|
|
319
|
-
|
|
320
|
-
const version = defaultVersionRunner({ spawnSync: fakeSpawn });
|
|
321
|
-
|
|
322
|
-
assert.equal(version, '1.46.0');
|
|
323
|
-
assert.equal(calls.length, 1);
|
|
324
|
-
// Fixed argv vector — the package name is a constant, never operator text.
|
|
325
|
-
assert.equal(calls[0].cmd, 'npm');
|
|
326
|
-
assert.deepEqual(calls[0].args, ['view', 'mandrel', 'version']);
|
|
327
|
-
// The shell flag is win32-gated: true on Windows, false elsewhere.
|
|
328
|
-
assert.equal(calls[0].opts.shell, process.platform === 'win32');
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
it('throws a descriptive error when the probe spawn errors (ENOENT)', () => {
|
|
332
|
-
const fakeSpawn = () => ({
|
|
333
|
-
status: null,
|
|
334
|
-
stdout: '',
|
|
335
|
-
stderr: '',
|
|
336
|
-
error: new Error('spawnSync npm ENOENT'),
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
assert.throws(
|
|
340
|
-
() => defaultVersionRunner({ spawnSync: fakeSpawn }),
|
|
341
|
-
/failed to probe newest mandrel version: spawnSync npm ENOENT/,
|
|
342
|
-
);
|
|
343
|
-
});
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
// ---------------------------------------------------------------------------
|
|
347
|
-
// AC — win32 shell:true spawn shape for the install (via runInstallCommand)
|
|
348
|
-
// ---------------------------------------------------------------------------
|
|
349
|
-
|
|
350
|
-
describe('defaultNpmUpdate — win32 install spawn shape', () => {
|
|
351
|
-
it('routes the install through runInstallCommand with the win32 shell flag', () => {
|
|
352
|
-
const calls = [];
|
|
353
|
-
const fakeSpawn = (bin, args, opts) => {
|
|
354
|
-
calls.push({ bin, args, opts });
|
|
355
|
-
return { status: 0, stderr: '' };
|
|
356
|
-
};
|
|
357
|
-
// Wire the real shared helper so the win32 shell handling under test is the
|
|
358
|
-
// production tokenizer/spawner, not a re-implementation.
|
|
359
|
-
const runInstall = (cmd, cwd) =>
|
|
360
|
-
runInstallCommand(cmd, cwd, { spawnSync: fakeSpawn });
|
|
361
|
-
|
|
362
|
-
defaultNpmUpdate('1.46.0', { runInstall, cwd: '/repo' });
|
|
363
|
-
|
|
364
|
-
assert.equal(calls.length, 1);
|
|
365
|
-
assert.equal(calls[0].bin, 'npm');
|
|
366
|
-
assert.deepEqual(calls[0].args, ['install', 'mandrel@1.46.0']);
|
|
367
|
-
assert.equal(calls[0].opts.cwd, '/repo');
|
|
368
|
-
assert.equal(calls[0].opts.shell, process.platform === 'win32');
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
it('throws when the install command exits non-zero', () => {
|
|
372
|
-
const runInstall = () => ({ status: 1, stderr: 'boom' });
|
|
373
|
-
assert.throws(
|
|
374
|
-
() => defaultNpmUpdate('1.46.0', { runInstall }),
|
|
375
|
-
/install command `npm install mandrel@1\.46\.0` exited 1: boom/,
|
|
376
|
-
);
|
|
377
|
-
});
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
// ---------------------------------------------------------------------------
|
|
381
|
-
// AC — --install-cmd override (default vs overridden argv)
|
|
382
|
-
// ---------------------------------------------------------------------------
|
|
383
|
-
|
|
384
|
-
describe('resolveInstallCmd — default vs override', () => {
|
|
385
|
-
it('defaults to npm install mandrel@<target> when no override', () => {
|
|
386
|
-
assert.equal(resolveInstallCmd('1.46.0'), 'npm install mandrel@1.46.0');
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
it('uses the operator override verbatim when supplied', () => {
|
|
390
|
-
assert.equal(
|
|
391
|
-
resolveInstallCmd('1.46.0', 'pnpm add mandrel@latest'),
|
|
392
|
-
'pnpm add mandrel@latest',
|
|
393
|
-
);
|
|
394
|
-
});
|
|
395
|
-
|
|
396
|
-
it('falls back to the default when the override is blank', () => {
|
|
397
|
-
assert.equal(
|
|
398
|
-
resolveInstallCmd('1.46.0', ' '),
|
|
399
|
-
'npm install mandrel@1.46.0',
|
|
400
|
-
);
|
|
401
|
-
});
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
// ---------------------------------------------------------------------------
|
|
405
|
-
// AC-1/AC-2 — package-manager detection from the project lockfile
|
|
406
|
-
// ---------------------------------------------------------------------------
|
|
407
|
-
|
|
408
|
-
describe('detectPackageManager — lockfile probe', () => {
|
|
409
|
-
it('detects pnpm and the workspace root from pnpm-lock.yaml + pnpm-workspace.yaml', () => {
|
|
410
|
-
const fs = makeLockFs(['pnpm-lock.yaml', 'pnpm-workspace.yaml']);
|
|
411
|
-
assert.deepEqual(detectPackageManager('/ws', fs), {
|
|
412
|
-
packageManager: 'pnpm',
|
|
413
|
-
workspaceRoot: true,
|
|
414
|
-
});
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
it('detects pnpm without -w when no pnpm-workspace.yaml is present', () => {
|
|
418
|
-
const fs = makeLockFs(['pnpm-lock.yaml']);
|
|
419
|
-
assert.deepEqual(detectPackageManager('/proj', fs), {
|
|
420
|
-
packageManager: 'pnpm',
|
|
421
|
-
workspaceRoot: false,
|
|
422
|
-
});
|
|
423
|
-
});
|
|
424
|
-
|
|
425
|
-
it('detects yarn from yarn.lock', () => {
|
|
426
|
-
const fs = makeLockFs(['yarn.lock']);
|
|
427
|
-
assert.deepEqual(detectPackageManager('/proj', fs), {
|
|
428
|
-
packageManager: 'yarn',
|
|
429
|
-
workspaceRoot: false,
|
|
430
|
-
});
|
|
431
|
-
});
|
|
432
|
-
|
|
433
|
-
it('falls back to npm when only package-lock.json (or nothing) is present', () => {
|
|
434
|
-
assert.deepEqual(
|
|
435
|
-
detectPackageManager('/proj', makeLockFs(['package-lock.json'])),
|
|
436
|
-
{
|
|
437
|
-
packageManager: 'npm',
|
|
438
|
-
workspaceRoot: false,
|
|
439
|
-
},
|
|
440
|
-
);
|
|
441
|
-
assert.deepEqual(detectPackageManager('/proj', makeLockFs([])), {
|
|
442
|
-
packageManager: 'npm',
|
|
443
|
-
workspaceRoot: false,
|
|
444
|
-
});
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
it('prefers pnpm over yarn when both lockfiles exist', () => {
|
|
448
|
-
const fs = makeLockFs(['pnpm-lock.yaml', 'yarn.lock']);
|
|
449
|
-
assert.equal(detectPackageManager('/proj', fs).packageManager, 'pnpm');
|
|
450
|
-
});
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
// ---------------------------------------------------------------------------
|
|
454
|
-
// AC-1/AC-2/AC-3 — resolveInstallCmd builds the PM-aware command
|
|
455
|
-
// ---------------------------------------------------------------------------
|
|
456
|
-
|
|
457
|
-
describe('resolveInstallCmd — package-manager aware', () => {
|
|
458
|
-
it('builds `pnpm add -D … -w` at a workspace root (AC-1)', () => {
|
|
459
|
-
assert.equal(
|
|
460
|
-
resolveInstallCmd('1.48.0', undefined, {
|
|
461
|
-
packageManager: 'pnpm',
|
|
462
|
-
workspaceRoot: true,
|
|
463
|
-
}),
|
|
464
|
-
'pnpm add -D mandrel@1.48.0 -w',
|
|
465
|
-
);
|
|
466
|
-
});
|
|
467
|
-
|
|
468
|
-
it('omits -w for a non-root pnpm project', () => {
|
|
469
|
-
assert.equal(
|
|
470
|
-
resolveInstallCmd('1.48.0', undefined, { packageManager: 'pnpm' }),
|
|
471
|
-
'pnpm add -D mandrel@1.48.0',
|
|
472
|
-
);
|
|
473
|
-
});
|
|
474
|
-
|
|
475
|
-
it('builds `yarn add -D …` for a yarn project (AC-2)', () => {
|
|
476
|
-
assert.equal(
|
|
477
|
-
resolveInstallCmd('1.48.0', undefined, { packageManager: 'yarn' }),
|
|
478
|
-
'yarn add -D mandrel@1.48.0',
|
|
479
|
-
);
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
it('substitutes a {target} placeholder in an override (AC-3)', () => {
|
|
483
|
-
assert.equal(
|
|
484
|
-
resolveInstallCmd('1.48.0', 'pnpm add -D mandrel@{target} -w'),
|
|
485
|
-
'pnpm add -D mandrel@1.48.0 -w',
|
|
486
|
-
);
|
|
487
|
-
});
|
|
488
|
-
|
|
489
|
-
it('uses a placeholder-free override verbatim', () => {
|
|
490
|
-
assert.equal(
|
|
491
|
-
resolveInstallCmd('1.48.0', 'pnpm add mandrel@latest'),
|
|
492
|
-
'pnpm add mandrel@latest',
|
|
493
|
-
);
|
|
494
|
-
});
|
|
495
|
-
});
|
|
496
|
-
|
|
497
|
-
// ---------------------------------------------------------------------------
|
|
498
|
-
// AC-1/AC-2 — defaultNpmUpdate resolves the command from the detected PM
|
|
499
|
-
// ---------------------------------------------------------------------------
|
|
500
|
-
|
|
501
|
-
describe('defaultNpmUpdate — package-manager detection', () => {
|
|
502
|
-
it('runs `pnpm add -D … -w` in a pnpm workspace root (AC-1)', () => {
|
|
503
|
-
const fs = makeLockFs(['pnpm-lock.yaml', 'pnpm-workspace.yaml']);
|
|
504
|
-
const calls = [];
|
|
505
|
-
const runInstall = (cmd, cwd) => {
|
|
506
|
-
calls.push({ cmd, cwd });
|
|
507
|
-
return { status: 0, stderr: '' };
|
|
508
|
-
};
|
|
509
|
-
|
|
510
|
-
defaultNpmUpdate('1.48.0', { runInstall, cwd: '/ws', fs });
|
|
511
|
-
|
|
512
|
-
assert.equal(calls.length, 1);
|
|
513
|
-
assert.equal(calls[0].cmd, 'pnpm add -D mandrel@1.48.0 -w');
|
|
514
|
-
assert.equal(calls[0].cwd, '/ws');
|
|
515
|
-
});
|
|
516
|
-
|
|
517
|
-
it('runs `yarn add -D …` when yarn.lock is present (AC-2)', () => {
|
|
518
|
-
const fs = makeLockFs(['yarn.lock']);
|
|
519
|
-
const calls = [];
|
|
520
|
-
const runInstall = (cmd) => {
|
|
521
|
-
calls.push(cmd);
|
|
522
|
-
return { status: 0, stderr: '' };
|
|
523
|
-
};
|
|
524
|
-
|
|
525
|
-
defaultNpmUpdate('1.48.0', { runInstall, cwd: '/proj', fs });
|
|
526
|
-
|
|
527
|
-
assert.deepEqual(calls, ['yarn add -D mandrel@1.48.0']);
|
|
528
|
-
});
|
|
529
|
-
|
|
530
|
-
it('keeps `npm install …` unchanged for an npm repo (AC-2)', () => {
|
|
531
|
-
const fs = makeLockFs(['package-lock.json']);
|
|
532
|
-
const calls = [];
|
|
533
|
-
const runInstall = (cmd) => {
|
|
534
|
-
calls.push(cmd);
|
|
535
|
-
return { status: 0, stderr: '' };
|
|
536
|
-
};
|
|
537
|
-
|
|
538
|
-
defaultNpmUpdate('1.48.0', { runInstall, cwd: '/proj', fs });
|
|
539
|
-
|
|
540
|
-
assert.deepEqual(calls, ['npm install mandrel@1.48.0']);
|
|
541
|
-
});
|
|
542
|
-
});
|
|
543
|
-
|
|
544
|
-
// ---------------------------------------------------------------------------
|
|
545
|
-
// AC-4 — install failure surfaces a PM-specific node_modules repair hint
|
|
546
|
-
// ---------------------------------------------------------------------------
|
|
547
|
-
|
|
548
|
-
describe('defaultNpmUpdate — repair hint on failure (AC-4)', () => {
|
|
549
|
-
it('names the detected package manager when the install exits non-zero', () => {
|
|
550
|
-
const fs = makeLockFs(['pnpm-lock.yaml']);
|
|
551
|
-
const runInstall = () => ({ status: 1, stderr: 'boom' });
|
|
552
|
-
|
|
553
|
-
assert.throws(
|
|
554
|
-
() => defaultNpmUpdate('1.48.0', { runInstall, cwd: '/ws', fs }),
|
|
555
|
-
/exited 1: boom[\s\S]*run `pnpm install`/,
|
|
556
|
-
);
|
|
557
|
-
});
|
|
558
|
-
|
|
559
|
-
it('includes the repair hint when the install spawn throws', () => {
|
|
560
|
-
const fs = makeLockFs(['yarn.lock']);
|
|
561
|
-
const runInstall = () => {
|
|
562
|
-
throw new Error('ENOENT');
|
|
563
|
-
};
|
|
564
|
-
|
|
565
|
-
assert.throws(
|
|
566
|
-
() => defaultNpmUpdate('1.48.0', { runInstall, cwd: '/ws', fs }),
|
|
567
|
-
/failed to spawn: ENOENT[\s\S]*run `yarn install`/,
|
|
568
|
-
);
|
|
569
|
-
});
|
|
570
|
-
});
|
|
571
|
-
|
|
572
|
-
describe('runUpdate — --install-cmd flag threading', () => {
|
|
573
|
-
it('threads no installCmd into npmUpdate by default', async () => {
|
|
574
|
-
const seen = [];
|
|
575
|
-
const cap = makeCapture();
|
|
576
|
-
await runUpdate({
|
|
577
|
-
argv: [],
|
|
578
|
-
currentVersion: '1.43.0',
|
|
579
|
-
resolveTargetVersion: async () => '1.44.0',
|
|
580
|
-
npmUpdate: async (_target, opts) => {
|
|
581
|
-
seen.push(opts);
|
|
582
|
-
},
|
|
583
|
-
runSync: () => ({}),
|
|
584
|
-
runMigrations: () => ({}),
|
|
585
|
-
runDoctor: async () => ({ ok: true, results: [] }),
|
|
586
|
-
write: cap.write,
|
|
587
|
-
writeErr: cap.writeErr,
|
|
588
|
-
exit: cap.exit,
|
|
589
|
-
});
|
|
590
|
-
assert.equal(seen.length, 1);
|
|
591
|
-
assert.equal(seen[0].installCmd, undefined);
|
|
592
|
-
});
|
|
593
|
-
|
|
594
|
-
it('threads the --install-cmd value into npmUpdate', async () => {
|
|
595
|
-
const seen = [];
|
|
596
|
-
const cap = makeCapture();
|
|
597
|
-
await runUpdate({
|
|
598
|
-
argv: ['--install-cmd', 'pnpm add mandrel@1.44.0'],
|
|
599
|
-
currentVersion: '1.43.0',
|
|
600
|
-
resolveTargetVersion: async () => '1.44.0',
|
|
601
|
-
npmUpdate: async (_target, opts) => {
|
|
602
|
-
seen.push(opts);
|
|
603
|
-
},
|
|
604
|
-
runSync: () => ({}),
|
|
605
|
-
runMigrations: () => ({}),
|
|
606
|
-
runDoctor: async () => ({ ok: true, results: [] }),
|
|
607
|
-
write: cap.write,
|
|
608
|
-
writeErr: cap.writeErr,
|
|
609
|
-
exit: cap.exit,
|
|
610
|
-
});
|
|
611
|
-
assert.equal(seen.length, 1);
|
|
612
|
-
assert.equal(seen[0].installCmd, 'pnpm add mandrel@1.44.0');
|
|
613
|
-
});
|
|
614
|
-
|
|
615
|
-
it('accepts the --install-cmd=<value> form', async () => {
|
|
616
|
-
const seen = [];
|
|
617
|
-
const cap = makeCapture();
|
|
618
|
-
await runUpdate({
|
|
619
|
-
argv: ['--install-cmd=yarn up mandrel@1.44.0'],
|
|
620
|
-
currentVersion: '1.43.0',
|
|
621
|
-
resolveTargetVersion: async () => '1.44.0',
|
|
622
|
-
npmUpdate: async (_target, opts) => {
|
|
623
|
-
seen.push(opts);
|
|
624
|
-
},
|
|
625
|
-
runSync: () => ({}),
|
|
626
|
-
runMigrations: () => ({}),
|
|
627
|
-
runDoctor: async () => ({ ok: true, results: [] }),
|
|
628
|
-
write: cap.write,
|
|
629
|
-
writeErr: cap.writeErr,
|
|
630
|
-
exit: cap.exit,
|
|
631
|
-
});
|
|
632
|
-
assert.equal(seen[0].installCmd, 'yarn up mandrel@1.44.0');
|
|
633
|
-
});
|
|
634
|
-
});
|
|
635
|
-
|
|
636
|
-
// ---------------------------------------------------------------------------
|
|
637
|
-
// AC — default export wiring threads installCmd + runInstall (no real I/O)
|
|
638
|
-
// ---------------------------------------------------------------------------
|
|
639
|
-
|
|
640
|
-
describe('update default export — install routing', () => {
|
|
641
|
-
// An in-memory fs that reports "no cache present" so isStale always invokes
|
|
642
|
-
// the injected versionRunner and the cache refresh write goes nowhere real.
|
|
643
|
-
function makeMemoryFs() {
|
|
644
|
-
return {
|
|
645
|
-
readFileSync: () => {
|
|
646
|
-
const err = new Error('ENOENT');
|
|
647
|
-
err.code = 'ENOENT';
|
|
648
|
-
throw err;
|
|
649
|
-
},
|
|
650
|
-
writeFileSync: () => {},
|
|
651
|
-
mkdirSync: () => {},
|
|
652
|
-
existsSync: () => false,
|
|
653
|
-
};
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
it('runs the overridden install command through the injected runInstall seam', async () => {
|
|
657
|
-
const installs = [];
|
|
658
|
-
await update(['--install-cmd', 'pnpm add mandrel@1.44.0'], {
|
|
659
|
-
currentVersion: '1.43.0',
|
|
660
|
-
fs: makeMemoryFs(),
|
|
661
|
-
versionRunner: () => '1.44.0',
|
|
662
|
-
runInstall: (cmd, cwd) => {
|
|
663
|
-
installs.push({ cmd, cwd });
|
|
664
|
-
return { status: 0, stderr: '' };
|
|
665
|
-
},
|
|
666
|
-
runSync: () => ({}),
|
|
667
|
-
runMigrations: () => ({}),
|
|
668
|
-
runDoctor: async () => ({ ok: true, results: [] }),
|
|
669
|
-
write: () => {},
|
|
670
|
-
writeErr: () => {},
|
|
671
|
-
exit: () => {},
|
|
672
|
-
});
|
|
673
|
-
assert.equal(installs.length, 1);
|
|
674
|
-
assert.equal(installs[0].cmd, 'pnpm add mandrel@1.44.0');
|
|
675
|
-
});
|
|
676
|
-
|
|
677
|
-
it('runs the default npm install command when no override is given', async () => {
|
|
678
|
-
const installs = [];
|
|
679
|
-
await update([], {
|
|
680
|
-
currentVersion: '1.43.0',
|
|
681
|
-
fs: makeMemoryFs(),
|
|
682
|
-
versionRunner: () => '1.44.0',
|
|
683
|
-
runInstall: (cmd) => {
|
|
684
|
-
installs.push(cmd);
|
|
685
|
-
return { status: 0, stderr: '' };
|
|
686
|
-
},
|
|
687
|
-
runSync: () => ({}),
|
|
688
|
-
runMigrations: () => ({}),
|
|
689
|
-
runDoctor: async () => ({ ok: true, results: [] }),
|
|
690
|
-
write: () => {},
|
|
691
|
-
writeErr: () => {},
|
|
692
|
-
exit: () => {},
|
|
693
|
-
});
|
|
694
|
-
assert.deepEqual(installs, ['npm install mandrel@1.44.0']);
|
|
695
|
-
});
|
|
696
|
-
});
|