@boopkit/cli 0.6.8 → 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 +14 -4
- package/dist/capability-catalog.json +10 -0
- package/dist/capability-versions.json +12 -11
- 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 +3 -0
- package/package.json +15 -2
- package/profiles/development/profile.md +79 -0
- package/profiles/web-app/profile.md +7 -6
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The single answer to "where does a file land when it is installed".
|
|
4
|
+
*
|
|
5
|
+
* `boop add` copies a capability's files into a project and `boop init` copies a
|
|
6
|
+
* profile's reference pages; before this module the mapping from source file to
|
|
7
|
+
* destination path was written out twice — once read-only inside `planAdd`, once
|
|
8
|
+
* while copying inside `copyFromPackage` — so the two could disagree about what a
|
|
9
|
+
* project receives and nothing would notice. Everything here is a pure read: it
|
|
10
|
+
* looks at a package or profile directory and returns `Placement[]`. It never
|
|
11
|
+
* writes, never needs a `targetDir`, and never touches `.boop/`.
|
|
12
|
+
*
|
|
13
|
+
* Three consumers depend on it: `copyFromPackage` (src/lib/scaffold.ts),
|
|
14
|
+
* `planAdd` (src/lib/preflight.ts) and `tools/generate-installed-layout.ts`,
|
|
15
|
+
* which materialises the installed layout inside the boop repository itself.
|
|
16
|
+
* Adding a fourth answer to this question is the defect this module exists to
|
|
17
|
+
* prevent.
|
|
18
|
+
*/
|
|
19
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
20
|
+
if (k2 === undefined) k2 = k;
|
|
21
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
22
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
23
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
24
|
+
}
|
|
25
|
+
Object.defineProperty(o, k2, desc);
|
|
26
|
+
}) : (function(o, m, k, k2) {
|
|
27
|
+
if (k2 === undefined) k2 = k;
|
|
28
|
+
o[k2] = m[k];
|
|
29
|
+
}));
|
|
30
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
31
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
32
|
+
}) : function(o, v) {
|
|
33
|
+
o["default"] = v;
|
|
34
|
+
});
|
|
35
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
36
|
+
var ownKeys = function(o) {
|
|
37
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
38
|
+
var ar = [];
|
|
39
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
40
|
+
return ar;
|
|
41
|
+
};
|
|
42
|
+
return ownKeys(o);
|
|
43
|
+
};
|
|
44
|
+
return function (mod) {
|
|
45
|
+
if (mod && mod.__esModule) return mod;
|
|
46
|
+
var result = {};
|
|
47
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
48
|
+
__setModuleDefault(result, mod);
|
|
49
|
+
return result;
|
|
50
|
+
};
|
|
51
|
+
})();
|
|
52
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
53
|
+
exports.PlacementCollisionError = void 0;
|
|
54
|
+
exports.describePlacementOwner = describePlacementOwner;
|
|
55
|
+
exports.capabilityPlacementsFromDir = capabilityPlacementsFromDir;
|
|
56
|
+
exports.capabilityPlacements = capabilityPlacements;
|
|
57
|
+
exports.profilePlacements = profilePlacements;
|
|
58
|
+
exports.skillDirectoryPlacements = skillDirectoryPlacements;
|
|
59
|
+
exports.findPlacementCollisions = findPlacementCollisions;
|
|
60
|
+
exports.assertNoPlacementCollisions = assertNoPlacementCollisions;
|
|
61
|
+
const fs = __importStar(require("fs"));
|
|
62
|
+
const path = __importStar(require("path"));
|
|
63
|
+
const config_1 = require("./config");
|
|
64
|
+
const capability_1 = require("./capability");
|
|
65
|
+
class PlacementCollisionError extends Error {
|
|
66
|
+
constructor(collisions) {
|
|
67
|
+
super(`Placement collision:\n${collisions.map(describeCollision).join('\n')}`);
|
|
68
|
+
this.name = 'PlacementCollisionError';
|
|
69
|
+
this.collisions = collisions;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
exports.PlacementCollisionError = PlacementCollisionError;
|
|
73
|
+
/** How a collision names each side: the owner, and for hand-authored files, that nothing ships it. */
|
|
74
|
+
function describePlacementOwner(placement) {
|
|
75
|
+
return placement.ownerKind === 'hand-authored'
|
|
76
|
+
? `${placement.owner} (hand-authored, no owner)`
|
|
77
|
+
: placement.owner;
|
|
78
|
+
}
|
|
79
|
+
function describeCollision(collision) {
|
|
80
|
+
const owners = collision.placements.map(describePlacementOwner).join(' and ');
|
|
81
|
+
return ` ${collision.dest} — claimed by ${owners}`;
|
|
82
|
+
}
|
|
83
|
+
function toPosix(relPath) {
|
|
84
|
+
return relPath.split(path.sep).join('/');
|
|
85
|
+
}
|
|
86
|
+
/** Every file under `dir`, absolute, depth-first, sorted for a stable placement order. */
|
|
87
|
+
function walkFiles(dir) {
|
|
88
|
+
const found = [];
|
|
89
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
90
|
+
const full = path.join(dir, entry.name);
|
|
91
|
+
if (entry.isDirectory() || (entry.isSymbolicLink() && fs.statSync(full).isDirectory())) {
|
|
92
|
+
found.push(...walkFiles(full));
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
found.push(full);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return found;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* `<skillsDir>/<skill-name>/**` → `skills/<skill-name>/**`.
|
|
102
|
+
* Only directories are skills; a loose file beside them ships nothing.
|
|
103
|
+
*/
|
|
104
|
+
function skillPlacements(skillsDir, owner, ownerKind) {
|
|
105
|
+
if (!fs.existsSync(skillsDir))
|
|
106
|
+
return [];
|
|
107
|
+
const placements = [];
|
|
108
|
+
for (const entry of fs.readdirSync(skillsDir).sort()) {
|
|
109
|
+
const skillDir = path.join(skillsDir, entry);
|
|
110
|
+
if (!fs.statSync(skillDir).isDirectory())
|
|
111
|
+
continue;
|
|
112
|
+
for (const source of walkFiles(skillDir)) {
|
|
113
|
+
placements.push({
|
|
114
|
+
owner,
|
|
115
|
+
ownerKind,
|
|
116
|
+
source,
|
|
117
|
+
dest: `skills/${entry}/${toPosix(path.relative(skillDir, source))}`,
|
|
118
|
+
kind: 'skill',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return placements;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* `<scaffoldDir>/**` → `<destPrefix>**`, preserving the tree.
|
|
126
|
+
* The scaffold directory mirrors the target project's own layout.
|
|
127
|
+
*/
|
|
128
|
+
function scaffoldPlacements(scaffoldDir, owner, ownerKind, destPrefix = '') {
|
|
129
|
+
if (!fs.existsSync(scaffoldDir))
|
|
130
|
+
return [];
|
|
131
|
+
return walkFiles(scaffoldDir).map(source => ({
|
|
132
|
+
owner,
|
|
133
|
+
ownerKind,
|
|
134
|
+
source,
|
|
135
|
+
dest: destPrefix + toPosix(path.relative(scaffoldDir, source)),
|
|
136
|
+
kind: 'scaffold',
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Placements for an already-resolved capability package directory.
|
|
141
|
+
*
|
|
142
|
+
* `capabilityPlacements` delegates here once it has resolved a name to a package;
|
|
143
|
+
* the generator calls it directly because it walks `packages/cap-*` on disk and
|
|
144
|
+
* has no need of npm module resolution (and must not require a build).
|
|
145
|
+
*/
|
|
146
|
+
function capabilityPlacementsFromDir(pkgDir) {
|
|
147
|
+
const pkgJsonPath = path.join(pkgDir, 'package.json');
|
|
148
|
+
if (!fs.existsSync(pkgJsonPath))
|
|
149
|
+
return [];
|
|
150
|
+
const boopkit = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).boopkit;
|
|
151
|
+
if (!boopkit)
|
|
152
|
+
return [];
|
|
153
|
+
const owner = path.basename(pkgDir);
|
|
154
|
+
const placements = [];
|
|
155
|
+
if (boopkit.skillsDir) {
|
|
156
|
+
placements.push(...skillPlacements(path.join(pkgDir, boopkit.skillsDir), owner, 'capability'));
|
|
157
|
+
}
|
|
158
|
+
if (boopkit.scaffoldDir) {
|
|
159
|
+
placements.push(...scaffoldPlacements(path.join(pkgDir, boopkit.scaffoldDir), owner, 'capability'));
|
|
160
|
+
}
|
|
161
|
+
return placements;
|
|
162
|
+
}
|
|
163
|
+
function isCapabilityPackage(pkgDir) {
|
|
164
|
+
const pkgJsonPath = path.join(pkgDir, 'package.json');
|
|
165
|
+
if (!fs.existsSync(pkgJsonPath))
|
|
166
|
+
return false;
|
|
167
|
+
return JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).boopkit?.capability === true;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Every file a capability ships and where it lands in a project.
|
|
171
|
+
*
|
|
172
|
+
* Resolves the capability the way `boop add` does — the installed package first,
|
|
173
|
+
* then the `capabilities/` source tree that only test fixtures still use.
|
|
174
|
+
*/
|
|
175
|
+
function capabilityPlacements(capabilityName, searchPaths) {
|
|
176
|
+
const pkgDir = (0, capability_1.getPackageDir)(capabilityName, searchPaths);
|
|
177
|
+
if (pkgDir && isCapabilityPackage(pkgDir)) {
|
|
178
|
+
return capabilityPlacementsFromDir(pkgDir);
|
|
179
|
+
}
|
|
180
|
+
const capDir = path.join((0, config_1.getBoopRoot)(), 'capabilities', capabilityName);
|
|
181
|
+
if (!fs.existsSync(capDir))
|
|
182
|
+
return [];
|
|
183
|
+
const owner = `cap-${capabilityName}`;
|
|
184
|
+
return [
|
|
185
|
+
...skillPlacements(path.join(capDir, 'skills'), owner, 'capability'),
|
|
186
|
+
...scaffoldPlacements(path.join(capDir, 'files'), owner, 'capability'),
|
|
187
|
+
];
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Every file a profile ships and where it lands, mirroring `boop init`
|
|
191
|
+
* (src/commands/init.ts) — today that is the profile's reference pages.
|
|
192
|
+
*/
|
|
193
|
+
function profilePlacements(profileName, repoRoot) {
|
|
194
|
+
const profileDir = path.join(repoRoot ?? (0, config_1.getBoopRoot)(), 'profiles', profileName);
|
|
195
|
+
return scaffoldPlacements(path.join(profileDir, 'reference'), profileName, 'profile', 'reference/');
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The directory-level view of a skill's placements: one entry per skill folder.
|
|
199
|
+
*
|
|
200
|
+
* The mirror materialises a skill as a single link to its folder rather than a
|
|
201
|
+
* link per file, and this derives that view from the same placement list rather
|
|
202
|
+
* than re-deriving `skills/<name>` from a package layout a second time.
|
|
203
|
+
*/
|
|
204
|
+
function skillDirectoryPlacements(placements) {
|
|
205
|
+
// Keyed by folder *and* source, so two capabilities shipping the same skill
|
|
206
|
+
// name stay two placements and the collision check can still see them.
|
|
207
|
+
const folders = new Map();
|
|
208
|
+
for (const placement of placements) {
|
|
209
|
+
if (placement.kind !== 'skill')
|
|
210
|
+
continue;
|
|
211
|
+
const [, skillName] = placement.dest.split('/');
|
|
212
|
+
const dest = `skills/${skillName}`;
|
|
213
|
+
// The file's path within its skill folder is the same suffix in source and dest,
|
|
214
|
+
// so trimming it from the source yields the folder the link must point at.
|
|
215
|
+
const withinSkill = placement.dest.slice(dest.length + 1);
|
|
216
|
+
const source = placement.source.slice(0, placement.source.length - withinSkill.length - 1);
|
|
217
|
+
folders.set(`${dest}\u0000${source}`, { ...placement, source, dest });
|
|
218
|
+
}
|
|
219
|
+
return [...folders.values()];
|
|
220
|
+
}
|
|
221
|
+
/** Every destination claimed by two or more different source files. */
|
|
222
|
+
function findPlacementCollisions(placements) {
|
|
223
|
+
const byDest = new Map();
|
|
224
|
+
for (const placement of placements) {
|
|
225
|
+
const claimants = byDest.get(placement.dest) ?? [];
|
|
226
|
+
claimants.push(placement);
|
|
227
|
+
byDest.set(placement.dest, claimants);
|
|
228
|
+
}
|
|
229
|
+
const collisions = [];
|
|
230
|
+
for (const [dest, claimants] of byDest) {
|
|
231
|
+
const sources = new Set(claimants.map(p => p.source));
|
|
232
|
+
if (sources.size > 1)
|
|
233
|
+
collisions.push({ dest, placements: claimants });
|
|
234
|
+
}
|
|
235
|
+
return collisions.sort((a, b) => a.dest.localeCompare(b.dest));
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Throw if any destination is claimed twice.
|
|
239
|
+
*
|
|
240
|
+
* The install path would not throw — `safeCopyDir` applies `onConflict: 'skip'`
|
|
241
|
+
* per file, so a second claimant silently half-merges into the first. That is
|
|
242
|
+
* fine to warn about while installing one capability into a project and fatal
|
|
243
|
+
* when writing a mirror of the whole library, which is why this is a separate
|
|
244
|
+
* assertion rather than something `planAdd` inherits.
|
|
245
|
+
*/
|
|
246
|
+
function assertNoPlacementCollisions(placements) {
|
|
247
|
+
const collisions = findPlacementCollisions(placements);
|
|
248
|
+
if (collisions.length > 0)
|
|
249
|
+
throw new PlacementCollisionError(collisions);
|
|
250
|
+
}
|
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
|