@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.
- package/README.md +13 -3
- package/dist/capability-versions.json +10 -10
- package/dist/cli.js +1 -0
- package/dist/commands/init.js +19 -6
- package/dist/commands/list.js +5 -1
- package/dist/commands/update.d.ts +1 -0
- package/dist/commands/update.js +42 -2
- package/dist/lib/capability.d.ts +11 -0
- package/dist/lib/capability.js +26 -0
- package/dist/lib/config.d.ts +6 -1
- package/dist/lib/install-layout.d.ts +86 -0
- package/dist/lib/install-layout.js +250 -0
- package/dist/lib/preflight.js +7 -57
- package/dist/lib/scaffold.d.ts +12 -1
- package/dist/lib/scaffold.js +92 -113
- package/dist/lib/stale-scripts.d.ts +53 -0
- package/dist/lib/stale-scripts.js +160 -0
- package/dist/lib/update.js +14 -57
- package/docs/init.md +6 -1
- package/docs/skills.md +2 -0
- package/package.json +6 -2
- package/profiles/development/profile.md +79 -0
- package/profiles/web-app/profile.md +6 -6
package/dist/lib/preflight.js
CHANGED
|
@@ -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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
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
|
-
}
|
package/dist/lib/scaffold.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/scaffold.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
152
|
+
function copyPlacements(targetDir, placements) {
|
|
159
153
|
const copied = [];
|
|
160
154
|
const fileHashes = {};
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|