@boopkit/cli 0.6.9 → 0.6.10

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.
@@ -45,6 +45,7 @@ const fs = __importStar(require("fs"));
45
45
  const path = __importStar(require("path"));
46
46
  const config_1 = require("./config");
47
47
  const capability_1 = require("./capability");
48
+ const install_layout_1 = require("./install-layout");
48
49
  const project_1 = require("./project");
49
50
  class PreflightError extends Error {
50
51
  constructor(message, checks) {
@@ -99,42 +100,13 @@ function planAdd(targetDir, capabilityName) {
99
100
  }
100
101
  checks.push({ name: 'requires-satisfied', passed: true });
101
102
  }
102
- // 5. Enumerate files that would be copied — check for conflicts
103
- // Resolve from package (scaffold/ and skills/) or source tree (files/ and skills/)
103
+ // 5. Enumerate files that would be copied — check for conflicts.
104
104
  const pkgDir = (0, capability_1.getPackageDir)(capabilityName, [targetDir]);
105
- const pkgJsonPath = pkgDir ? path.join(pkgDir, 'package.json') : null;
106
- const pkgJson = pkgJsonPath && fs.existsSync(pkgJsonPath)
107
- ? JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'))
108
- : null;
109
- const boopkit = pkgJson?.boopkit;
110
- const capDir = boopkit
111
- ? pkgDir
112
- : path.join(boopRoot, 'capabilities', capabilityName);
113
- const filesToCopy = [];
114
- const fileConflicts = [];
115
- // Skills files
116
- const skillsDir = boopkit?.skillsDir
117
- ? path.join(capDir, boopkit.skillsDir)
118
- : path.join(capDir, 'skills');
119
- if (fs.existsSync(skillsDir)) {
120
- for (const entry of fs.readdirSync(skillsDir)) {
121
- const destPath = path.join(targetDir, 'skills', entry);
122
- const relPath = path.join('skills', entry);
123
- filesToCopy.push(relPath);
124
- if (fs.existsSync(destPath)) {
125
- fileConflicts.push(relPath);
126
- }
127
- }
128
- }
129
- // Capability files from scaffold/ (package) or files/ (source tree)
130
- if (cap.files) {
131
- const filesDir = boopkit?.scaffoldDir
132
- ? path.join(capDir, boopkit.scaffoldDir)
133
- : path.join(capDir, 'files');
134
- if (fs.existsSync(filesDir)) {
135
- enumerateDir(filesDir, targetDir, filesToCopy, fileConflicts);
136
- }
137
- }
105
+ const capDir = pkgDir ?? path.join(boopRoot, 'capabilities', capabilityName);
106
+ // The placement list is the same one `copyCapabilityFiles` copies from, so
107
+ // this cannot disagree with what `boop add` actually writes.
108
+ const filesToCopy = (0, install_layout_1.capabilityPlacements)(capabilityName, [targetDir]).map(p => p.dest);
109
+ const fileConflicts = filesToCopy.filter(dest => fs.existsSync(path.join(targetDir, dest)));
138
110
  // File conflicts are warnings (skip behavior), not failures
139
111
  if (fileConflicts.length > 0) {
140
112
  checks.push({
@@ -370,25 +342,3 @@ function planAdd(targetDir, capabilityName) {
370
342
  };
371
343
  return { manifest, cap, projectManifest, filesToCopy, warnings };
372
344
  }
373
- /**
374
- * Recursively enumerate files in srcDir and check for conflicts in destDir.
375
- * Pure read — does not write.
376
- */
377
- function enumerateDir(srcDir, destBase, filesToCopy, fileConflicts, baseDir) {
378
- const root = baseDir ?? srcDir;
379
- const entries = fs.readdirSync(srcDir, { withFileTypes: true });
380
- for (const entry of entries) {
381
- const srcPath = path.join(srcDir, entry.name);
382
- if (entry.isDirectory()) {
383
- enumerateDir(srcPath, destBase, filesToCopy, fileConflicts, root);
384
- }
385
- else {
386
- const rel = path.relative(root, srcPath);
387
- const destPath = path.join(destBase, rel);
388
- filesToCopy.push(rel);
389
- if (fs.existsSync(destPath)) {
390
- fileConflicts.push(rel);
391
- }
392
- }
393
- }
394
- }
@@ -45,9 +45,14 @@ export declare function copyCapabilityFiles(targetDir: string, capabilityName: s
45
45
  * `.claude/skills/<other>` entries (Claude Code plugins, user-authored skills)
46
46
  * are preserved.
47
47
  *
48
+ * Pass `only` to link a subset of what stands in `skills/`. A project's own
49
+ * `skills/` holds exactly what its capabilities installed, so `boop add` and
50
+ * `boop init` link all of it; the boop repository mirrors the whole library
51
+ * there and needs to offer only the part its manifest selects.
52
+ *
48
53
  * Idempotent. Returns the relative paths of symlinks created or already present.
49
54
  */
50
- export declare function linkSkillsToClaude(targetDir: string): string[];
55
+ export declare function linkSkillsToClaude(targetDir: string, only?: Iterable<string>): string[];
51
56
  /**
52
57
  * Merge dependencies into an existing package.json.
53
58
  * Does not overwrite existing dependency versions.
@@ -64,6 +69,12 @@ export declare function linkSkillsToClaude(targetDir: string): string[];
64
69
  * 4. '*' (last resort)
65
70
  */
66
71
  export declare function getBoopkitVersion(packageName: string): string;
72
+ /**
73
+ * The warning shown when a dependency lands in a project's package.json with no
74
+ * version range. Written to be actionable by an agent reading it in a
75
+ * transcript with no other context.
76
+ */
77
+ export declare function formatUnpinnedDependencyWarning(names: string[]): string;
67
78
  export declare function mergePackageJson(targetDir: string, dependencies?: string[], devDependencies?: string[], packages?: Record<string, string>, options?: {
68
79
  skipBackup?: boolean;
69
80
  }): void;
@@ -42,6 +42,7 @@ exports.hashFile = hashFile;
42
42
  exports.copyCapabilityFiles = copyCapabilityFiles;
43
43
  exports.linkSkillsToClaude = linkSkillsToClaude;
44
44
  exports.getBoopkitVersion = getBoopkitVersion;
45
+ exports.formatUnpinnedDependencyWarning = formatUnpinnedDependencyWarning;
45
46
  exports.mergePackageJson = mergePackageJson;
46
47
  exports.injectCssImports = injectCssImports;
47
48
  exports.collectHooks = collectHooks;
@@ -52,6 +53,7 @@ const handlebars_1 = __importDefault(require("handlebars"));
52
53
  const config_1 = require("./config");
53
54
  const capability_1 = require("./capability");
54
55
  const safe_io_1 = require("./safe-io");
56
+ const install_layout_1 = require("./install-layout");
55
57
  /**
56
58
  * Render a single Handlebars template string with variables.
57
59
  *
@@ -133,65 +135,40 @@ function copyCapabilityFiles(targetDir, capabilityName, searchPaths) {
133
135
  const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
134
136
  const boopkit = pkgJson.boopkit;
135
137
  if (boopkit?.capability) {
136
- return copyFromPackage(targetDir, capabilityName, pkgDir, boopkit);
138
+ return copyFromPackage(targetDir, capabilityName, pkgDir);
137
139
  }
138
140
  }
139
141
  // Fallback to source tree
140
142
  return copyFromSourceTree(targetDir, capabilityName);
141
143
  }
142
- function walkDir(dir) {
143
- const results = [];
144
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
145
- const fullPath = path.join(dir, entry.name);
146
- if (entry.isDirectory()) {
147
- results.push(...walkDir(fullPath));
148
- }
149
- else {
150
- results.push(fullPath);
151
- }
152
- }
153
- return results;
154
- }
155
144
  /**
156
- * Copy capability files from a package directory.
145
+ * Copy a capability's placements into the project, skipping anything already there.
146
+ *
147
+ * Returns the relative paths this capability owns in the project, and their
148
+ * hashes. A skill file is reported even when it was skipped — the skill folder
149
+ * is the capability's regardless of who wrote the bytes — while a skipped
150
+ * scaffold file is not, because it belongs to whatever put it there first.
157
151
  */
158
- function copyFromPackage(targetDir, capabilityName, pkgDir, boopkit) {
152
+ function copyPlacements(targetDir, placements) {
159
153
  const copied = [];
160
154
  const fileHashes = {};
161
- // Copy skills from package
162
- if (boopkit.skillsDir) {
163
- const skillsDir = path.join(pkgDir, boopkit.skillsDir);
164
- if (fs.existsSync(skillsDir)) {
165
- const destSkillsDir = path.join(targetDir, 'skills');
166
- (0, safe_io_1.safeCopyDir)(skillsDir, destSkillsDir, { onConflict: 'skip' });
167
- const entries = fs.readdirSync(skillsDir);
168
- for (const entry of entries) {
169
- const srcEntryPath = path.join(skillsDir, entry);
170
- if (fs.statSync(srcEntryPath).isDirectory()) {
171
- const files = walkDir(path.join(destSkillsDir, entry));
172
- for (const filePath of files) {
173
- const rel = path.join('skills', entry, path.relative(path.join(destSkillsDir, entry), filePath));
174
- copied.push(rel);
175
- fileHashes[rel] = hashFile(filePath);
176
- }
177
- }
178
- }
179
- }
180
- }
181
- // Copy scaffold files from package
182
- if (boopkit.scaffoldDir) {
183
- const scaffoldDir = path.join(pkgDir, boopkit.scaffoldDir);
184
- if (fs.existsSync(scaffoldDir)) {
185
- const scaffolded = scaffoldCapabilityFiles(targetDir, scaffoldDir);
186
- copied.push(...scaffolded);
187
- for (const rel of scaffolded) {
188
- const destPath = path.join(targetDir, rel);
189
- if (fs.existsSync(destPath)) {
190
- fileHashes[rel] = hashFile(destPath);
191
- }
192
- }
155
+ for (const placement of placements) {
156
+ const destPath = path.join(targetDir, placement.dest);
157
+ const result = (0, safe_io_1.safeCopyFile)(placement.source, destPath, { onConflict: 'skip' });
158
+ if (!result.copied && placement.kind !== 'skill')
159
+ continue;
160
+ copied.push(placement.dest);
161
+ if (fs.existsSync(destPath)) {
162
+ fileHashes[placement.dest] = hashFile(destPath);
193
163
  }
194
164
  }
165
+ return { copied, fileHashes };
166
+ }
167
+ /**
168
+ * Copy capability files from a package directory.
169
+ */
170
+ function copyFromPackage(targetDir, capabilityName, pkgDir) {
171
+ const { copied, fileHashes } = copyPlacements(targetDir, (0, install_layout_1.capabilityPlacementsFromDir)(pkgDir));
195
172
  // Store file hashes and version in .boop/config.json
196
173
  if (Object.keys(fileHashes).length > 0) {
197
174
  const pkgJson = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf-8'));
@@ -208,17 +185,27 @@ function copyFromPackage(targetDir, capabilityName, pkgDir, boopkit) {
208
185
  * `.claude/skills/<other>` entries (Claude Code plugins, user-authored skills)
209
186
  * are preserved.
210
187
  *
188
+ * Pass `only` to link a subset of what stands in `skills/`. A project's own
189
+ * `skills/` holds exactly what its capabilities installed, so `boop add` and
190
+ * `boop init` link all of it; the boop repository mirrors the whole library
191
+ * there and needs to offer only the part its manifest selects.
192
+ *
211
193
  * Idempotent. Returns the relative paths of symlinks created or already present.
212
194
  */
213
- function linkSkillsToClaude(targetDir) {
195
+ function linkSkillsToClaude(targetDir, only) {
214
196
  const skillsDir = path.join(targetDir, 'skills');
215
197
  if (!fs.existsSync(skillsDir))
216
198
  return [];
199
+ const selected = only === undefined ? null : new Set(only);
217
200
  const claudeSkillsDir = path.join(targetDir, '.claude', 'skills');
218
201
  fs.mkdirSync(claudeSkillsDir, { recursive: true });
219
202
  const linked = [];
220
203
  for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
221
- if (!entry.isDirectory())
204
+ // isDirectory() uses lstat semantics, so a symlinked skill folder reads as
205
+ // neither — and the installed-layout mirror makes every mirrored skill one.
206
+ if (!entry.isDirectory() && !entry.isSymbolicLink())
207
+ continue;
208
+ if (selected && !selected.has(entry.name))
222
209
  continue;
223
210
  const linkPath = path.join(claudeSkillsDir, entry.name);
224
211
  const target = path.join('..', '..', 'skills', entry.name);
@@ -239,37 +226,7 @@ function linkSkillsToClaude(targetDir) {
239
226
  * Copy capability files from the capabilities/ source tree (test fixtures only).
240
227
  */
241
228
  function copyFromSourceTree(targetDir, capabilityName) {
242
- const capDir = path.join((0, config_1.getBoopRoot)(), 'capabilities', capabilityName);
243
- if (!fs.existsSync(capDir)) {
244
- return [];
245
- }
246
- const copied = [];
247
- // Copy skills directory if present
248
- const skillsDir = path.join(capDir, 'skills');
249
- if (fs.existsSync(skillsDir)) {
250
- const destSkillsDir = path.join(targetDir, 'skills');
251
- (0, safe_io_1.safeCopyDir)(skillsDir, destSkillsDir, { onConflict: 'skip' });
252
- const entries = fs.readdirSync(skillsDir);
253
- for (const entry of entries) {
254
- const srcEntryPath = path.join(skillsDir, entry);
255
- if (fs.statSync(srcEntryPath).isDirectory()) {
256
- const files = walkDir(path.join(destSkillsDir, entry));
257
- for (const filePath of files) {
258
- copied.push(path.join('skills', entry, path.relative(path.join(destSkillsDir, entry), filePath)));
259
- }
260
- }
261
- }
262
- }
263
- // Scaffold files declared in the capability manifest
264
- const cap = (0, capability_1.getCapability)(capabilityName);
265
- if (cap?.files) {
266
- const filesDir = path.join(capDir, 'files');
267
- if (fs.existsSync(filesDir)) {
268
- const scaffolded = scaffoldCapabilityFiles(targetDir, filesDir);
269
- copied.push(...scaffolded);
270
- }
271
- }
272
- return copied;
229
+ return copyPlacements(targetDir, (0, install_layout_1.capabilityPlacements)(capabilityName)).copied;
273
230
  }
274
231
  /**
275
232
  * Update .boop/config.json with file hashes for a capability.
@@ -298,34 +255,6 @@ function updateCapabilityHashes(targetDir, capabilityName, fileHashes, version)
298
255
  config.capabilityMeta[capabilityName] = meta;
299
256
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
300
257
  }
301
- /**
302
- * Copy all files from a capability's files/ directory into the target project.
303
- * Preserves directory structure. Does not overwrite existing files.
304
- */
305
- function scaffoldCapabilityFiles(targetDir, filesDir) {
306
- const copied = [];
307
- function processDir(srcDir, destDir) {
308
- if (!fs.existsSync(destDir)) {
309
- fs.mkdirSync(destDir, { recursive: true });
310
- }
311
- const entries = fs.readdirSync(srcDir, { withFileTypes: true });
312
- for (const entry of entries) {
313
- const srcPath = path.join(srcDir, entry.name);
314
- const destPath = path.join(destDir, entry.name);
315
- if (entry.isDirectory()) {
316
- processDir(srcPath, destPath);
317
- }
318
- else {
319
- if (!fs.existsSync(destPath)) {
320
- fs.copyFileSync(srcPath, destPath);
321
- copied.push(path.relative(targetDir, destPath));
322
- }
323
- }
324
- }
325
- }
326
- processDir(filesDir, targetDir);
327
- return copied;
328
- }
329
258
  /**
330
259
  * Merge dependencies into an existing package.json.
331
260
  * Does not overwrite existing dependency versions.
@@ -377,6 +306,23 @@ function getBoopkitVersion(packageName) {
377
306
  }
378
307
  return '*';
379
308
  }
309
+ /**
310
+ * Parse a dependency string like "express@^5" or "@prisma/client@^6" into
311
+ * name + version.
312
+ *
313
+ * A bare name still yields `'*'` rather than throwing. That fallback is for
314
+ * capabilities boop did not write — a third-party or locally authored manifest
315
+ * should scaffold rather than abort an install halfway through — but for a
316
+ * capability that ships with boop a bare name is a defect, not a shorthand.
317
+ * `'*'` hands a new project whatever major shipped most recently, which is how
318
+ * a scaffold ended up on TypeScript 6 while boop ran its own suite on 5.9.3.
319
+ *
320
+ * So the fallback stays and gets loud instead: `mergePackageJson` warns about
321
+ * every unpinned range it writes, and
322
+ * `tests/lib/capability-contracts.test.ts` fails when one of boop's own
323
+ * manifests or profiles declares a bare name, which is where the regression
324
+ * actually gets caught.
325
+ */
380
326
  function parseDep(dep) {
381
327
  // Handle scoped packages: @scope/name@version
382
328
  const match = dep.match(/^(@[^@/]+\/[^@]+)@(.+)$/) || dep.match(/^([^@]+)@(.+)$/);
@@ -385,12 +331,42 @@ function parseDep(dep) {
385
331
  }
386
332
  return { name: dep, version: '*' };
387
333
  }
334
+ /**
335
+ * The warning shown when a dependency lands in a project's package.json with no
336
+ * version range. Written to be actionable by an agent reading it in a
337
+ * transcript with no other context.
338
+ */
339
+ function formatUnpinnedDependencyWarning(names) {
340
+ if (names.length === 0)
341
+ return '';
342
+ const lines = [];
343
+ lines.push(`\x1b[33mWARN\x1b[0m ${names.length === 1 ? 'A dependency was' : `${names.length} dependencies were`} added with no version range:`);
344
+ for (const name of names) {
345
+ lines.push(` ${name}: "*"`);
346
+ }
347
+ lines.push('');
348
+ lines.push(' "*" resolves to whatever major shipped most recently, so two projects');
349
+ lines.push(' scaffolded a month apart get different toolchains and neither matches the');
350
+ lines.push(' one boop tests against. Pin the declaration to a caret major — "express@^5"');
351
+ lines.push(' rather than "express" — in the capability.json or profile that declares it,');
352
+ lines.push(' then set the range by hand in this project\'s package.json.');
353
+ return lines.join('\n');
354
+ }
388
355
  function mergePackageJson(targetDir, dependencies, devDependencies, packages, options) {
389
356
  const pkgPath = path.join(targetDir, 'package.json');
390
357
  if (!fs.existsSync(pkgPath)) {
391
358
  return;
392
359
  }
393
360
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
361
+ // Names written with no range. Collected on the way through rather than
362
+ // derived from the declarations, so it also catches getBoopkitVersion falling
363
+ // all the way through to its own '*'.
364
+ const unpinned = [];
365
+ const record = (name, version) => {
366
+ if (version === '*')
367
+ unpinned.push(name);
368
+ return version;
369
+ };
394
370
  if (dependencies) {
395
371
  if (!pkg.dependencies)
396
372
  pkg.dependencies = {};
@@ -398,10 +374,10 @@ function mergePackageJson(targetDir, dependencies, devDependencies, packages, op
398
374
  const { name, version } = parseDep(dep);
399
375
  if (!(name in pkg.dependencies)) {
400
376
  if (name.startsWith('@boopkit/')) {
401
- pkg.dependencies[name] = getBoopkitVersion(name);
377
+ pkg.dependencies[name] = record(name, getBoopkitVersion(name));
402
378
  }
403
379
  else {
404
- pkg.dependencies[name] = version;
380
+ pkg.dependencies[name] = record(name, version);
405
381
  }
406
382
  }
407
383
  }
@@ -412,7 +388,7 @@ function mergePackageJson(targetDir, dependencies, devDependencies, packages, op
412
388
  for (const dep of devDependencies) {
413
389
  const { name, version } = parseDep(dep);
414
390
  if (!(name in pkg.devDependencies)) {
415
- pkg.devDependencies[name] = version;
391
+ pkg.devDependencies[name] = record(name, version);
416
392
  }
417
393
  }
418
394
  }
@@ -421,13 +397,16 @@ function mergePackageJson(targetDir, dependencies, devDependencies, packages, op
421
397
  pkg.dependencies = {};
422
398
  for (const [name, version] of Object.entries(packages)) {
423
399
  if (!(name in pkg.dependencies)) {
424
- pkg.dependencies[name] = version;
400
+ pkg.dependencies[name] = record(name, version);
425
401
  }
426
402
  }
427
403
  }
428
404
  if (!options?.skipBackup)
429
405
  (0, safe_io_1.createBackup)(pkgPath);
430
406
  fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
407
+ if (unpinned.length > 0) {
408
+ console.warn(formatUnpinnedDependencyWarning(unpinned));
409
+ }
431
410
  }
432
411
  /**
433
412
  * Scan the styles/ directory for CSS partials and inject @import lines
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Detect package.json scripts that contradict the runner settings a capability
3
+ * now ships, and offer the edit that reconciles them.
4
+ *
5
+ * The case this exists for: `cap-testing` used to ship `"test": "jest --runInBand"`.
6
+ * It now ships a `jest.config.js` carrying `maxWorkers` and `workerIdleMemoryLimit`
7
+ * instead, because in-band execution runs every test file through one process that
8
+ * Jest never recycles, and a long suite dies as that process's heap grows.
9
+ *
10
+ * `boop update` cannot simply fix those projects. Scripts are merged only when
11
+ * absent (see `merge-scripts` in lib/execute.ts), which is the right default —
12
+ * clobbering a script somebody customised would be worse than leaving it stale.
13
+ * So the old command survives the update, and because a CLI `--runInBand` is the
14
+ * first and unconditional branch of Jest's `shouldRunInBand()`, it overrides the
15
+ * new config entirely. That is the state worth warning about: the project now has
16
+ * a config file that looks like the fix, reads like the fix in review, and does
17
+ * nothing at all.
18
+ *
19
+ * Warning rather than prompting is deliberate. `boop update` is usually run by an
20
+ * agent, often inside `boop loop`, where there is no terminal to answer a question
21
+ * and a blocking prompt would hang the run. The warning therefore has to carry
22
+ * everything needed to act: which script, what is wrong with it, and the exact
23
+ * command that fixes it.
24
+ */
25
+ export interface StaleScript {
26
+ /** The script's key in package.json, e.g. `test`. */
27
+ name: string;
28
+ /** The command as it stands today. */
29
+ command: string;
30
+ /** What the command becomes once the offending flags are removed. */
31
+ rewritten: string;
32
+ /** The flags that triggered the finding, in the order they appeared. */
33
+ flags: string[];
34
+ }
35
+ /**
36
+ * Pure: given a package.json `scripts` object, return the scripts whose command
37
+ * forces in-band Jest execution and therefore defeats the shipped config.
38
+ */
39
+ export declare function findStaleRunnerScripts(scripts: Record<string, string> | undefined): StaleScript[];
40
+ /**
41
+ * Apply the findings to the project's package.json.
42
+ *
43
+ * A finding is only applied when the script still holds exactly the command the
44
+ * finding was made against. Anything else means the file changed underneath us,
45
+ * and rewriting it then would discard somebody's edit. Returns the findings that
46
+ * were actually written.
47
+ */
48
+ export declare function rewriteStaleRunnerScripts(targetDir: string, stale: StaleScript[]): StaleScript[];
49
+ /**
50
+ * The warning shown after an update. Written to be actionable by an agent reading
51
+ * it in a transcript with no other context.
52
+ */
53
+ export declare function formatStaleScriptWarning(stale: StaleScript[]): string;
@@ -0,0 +1,160 @@
1
+ "use strict";
2
+ /**
3
+ * Detect package.json scripts that contradict the runner settings a capability
4
+ * now ships, and offer the edit that reconciles them.
5
+ *
6
+ * The case this exists for: `cap-testing` used to ship `"test": "jest --runInBand"`.
7
+ * It now ships a `jest.config.js` carrying `maxWorkers` and `workerIdleMemoryLimit`
8
+ * instead, because in-band execution runs every test file through one process that
9
+ * Jest never recycles, and a long suite dies as that process's heap grows.
10
+ *
11
+ * `boop update` cannot simply fix those projects. Scripts are merged only when
12
+ * absent (see `merge-scripts` in lib/execute.ts), which is the right default —
13
+ * clobbering a script somebody customised would be worse than leaving it stale.
14
+ * So the old command survives the update, and because a CLI `--runInBand` is the
15
+ * first and unconditional branch of Jest's `shouldRunInBand()`, it overrides the
16
+ * new config entirely. That is the state worth warning about: the project now has
17
+ * a config file that looks like the fix, reads like the fix in review, and does
18
+ * nothing at all.
19
+ *
20
+ * Warning rather than prompting is deliberate. `boop update` is usually run by an
21
+ * agent, often inside `boop loop`, where there is no terminal to answer a question
22
+ * and a blocking prompt would hang the run. The warning therefore has to carry
23
+ * everything needed to act: which script, what is wrong with it, and the exact
24
+ * command that fixes it.
25
+ */
26
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
27
+ if (k2 === undefined) k2 = k;
28
+ var desc = Object.getOwnPropertyDescriptor(m, k);
29
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
30
+ desc = { enumerable: true, get: function() { return m[k]; } };
31
+ }
32
+ Object.defineProperty(o, k2, desc);
33
+ }) : (function(o, m, k, k2) {
34
+ if (k2 === undefined) k2 = k;
35
+ o[k2] = m[k];
36
+ }));
37
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
38
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
39
+ }) : function(o, v) {
40
+ o["default"] = v;
41
+ });
42
+ var __importStar = (this && this.__importStar) || (function () {
43
+ var ownKeys = function(o) {
44
+ ownKeys = Object.getOwnPropertyNames || function (o) {
45
+ var ar = [];
46
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
47
+ return ar;
48
+ };
49
+ return ownKeys(o);
50
+ };
51
+ return function (mod) {
52
+ if (mod && mod.__esModule) return mod;
53
+ var result = {};
54
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
55
+ __setModuleDefault(result, mod);
56
+ return result;
57
+ };
58
+ })();
59
+ Object.defineProperty(exports, "__esModule", { value: true });
60
+ exports.findStaleRunnerScripts = findStaleRunnerScripts;
61
+ exports.rewriteStaleRunnerScripts = rewriteStaleRunnerScripts;
62
+ exports.formatStaleScriptWarning = formatStaleScriptWarning;
63
+ const fs = __importStar(require("fs"));
64
+ const path = __importStar(require("path"));
65
+ /** Jest's in-band flag and its single-letter alias. */
66
+ const IN_BAND_FLAGS = new Set(['--runInBand', '--runInBand=true', '-i']);
67
+ /**
68
+ * Does this command invoke Jest? Matched on the token's basename so that `jest`,
69
+ * `npx jest` and `node_modules/.bin/jest` all count, while a command that merely
70
+ * contains the same characters somewhere does not.
71
+ */
72
+ function invokesJest(tokens) {
73
+ return tokens.some((token) => {
74
+ if (token.startsWith('-'))
75
+ return false;
76
+ const base = path.basename(token);
77
+ return base === 'jest' || base === 'jest.js';
78
+ });
79
+ }
80
+ /**
81
+ * Pure: given a package.json `scripts` object, return the scripts whose command
82
+ * forces in-band Jest execution and therefore defeats the shipped config.
83
+ */
84
+ function findStaleRunnerScripts(scripts) {
85
+ if (!scripts)
86
+ return [];
87
+ const findings = [];
88
+ for (const [name, command] of Object.entries(scripts)) {
89
+ if (typeof command !== 'string')
90
+ continue;
91
+ const tokens = command.split(/\s+/).filter(Boolean);
92
+ if (!invokesJest(tokens))
93
+ continue;
94
+ const flags = tokens.filter((t) => IN_BAND_FLAGS.has(t));
95
+ if (flags.length === 0)
96
+ continue;
97
+ findings.push({
98
+ name,
99
+ command,
100
+ rewritten: tokens.filter((t) => !IN_BAND_FLAGS.has(t)).join(' '),
101
+ flags,
102
+ });
103
+ }
104
+ return findings;
105
+ }
106
+ /**
107
+ * Apply the findings to the project's package.json.
108
+ *
109
+ * A finding is only applied when the script still holds exactly the command the
110
+ * finding was made against. Anything else means the file changed underneath us,
111
+ * and rewriting it then would discard somebody's edit. Returns the findings that
112
+ * were actually written.
113
+ */
114
+ function rewriteStaleRunnerScripts(targetDir, stale) {
115
+ if (stale.length === 0)
116
+ return [];
117
+ const pkgPath = path.join(targetDir, 'package.json');
118
+ if (!fs.existsSync(pkgPath))
119
+ return [];
120
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
121
+ if (!pkg.scripts)
122
+ return [];
123
+ const applied = [];
124
+ for (const finding of stale) {
125
+ if (pkg.scripts[finding.name] !== finding.command)
126
+ continue;
127
+ pkg.scripts[finding.name] = finding.rewritten;
128
+ applied.push(finding);
129
+ }
130
+ if (applied.length > 0) {
131
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
132
+ }
133
+ return applied;
134
+ }
135
+ /**
136
+ * The warning shown after an update. Written to be actionable by an agent reading
137
+ * it in a transcript with no other context.
138
+ */
139
+ function formatStaleScriptWarning(stale) {
140
+ if (stale.length === 0)
141
+ return '';
142
+ const lines = [];
143
+ lines.push(`\x1b[33mWARN\x1b[0m ${stale.length === 1 ? 'A package.json script forces' : `${stale.length} package.json scripts force`} Jest to run in band, which overrides the settings this capability ships.`);
144
+ for (const finding of stale) {
145
+ lines.push(` ${finding.name}: ${finding.command}`);
146
+ lines.push(` ${' '.repeat(finding.name.length)} \x1b[32m→ ${finding.rewritten}\x1b[0m`);
147
+ }
148
+ lines.push('');
149
+ lines.push(' Why it matters: in band, every test file runs through one process that Jest');
150
+ lines.push(' never recycles, so the heap grows for the whole run until a long suite dies.');
151
+ lines.push(' The scaffolded jest.config.js sets maxWorkers and workerIdleMemoryLimit to');
152
+ lines.push(' bound that, but a command-line --runInBand wins over both, so the config is');
153
+ lines.push(' present and inert until this script changes.');
154
+ lines.push('');
155
+ lines.push(' boop did not change it for you because it does not overwrite scripts you may');
156
+ lines.push(' have customised. To apply the edit above:');
157
+ lines.push('');
158
+ lines.push(' \x1b[36mboop update --fix-scripts\x1b[0m');
159
+ return lines.join('\n');
160
+ }