@boopkit/cli 0.8.0 → 0.8.1
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 +8 -6
- package/dist/capability-catalog.json +10 -3
- package/dist/capability-versions.json +11 -10
- package/dist/cli.js +0 -0
- package/dist/commands/add.js +35 -7
- package/dist/commands/adopt.js +1 -1
- package/dist/commands/loop-merge.d.ts +11 -0
- package/dist/commands/loop-merge.js +135 -14
- package/dist/commands/loop.js +1 -1
- package/dist/commands/migrate-skill-edits.js +2 -2
- package/dist/commands/status.d.ts +10 -0
- package/dist/commands/status.js +27 -2
- package/dist/commands/update.js +157 -21
- package/dist/lib/capability.js +4 -1
- package/dist/lib/execute.js +19 -2
- package/dist/lib/install-layout.d.ts +5 -0
- package/dist/lib/install-layout.js +8 -0
- package/dist/lib/manifest.d.ts +1 -1
- package/dist/lib/moves.d.ts +88 -0
- package/dist/lib/moves.js +381 -0
- package/dist/lib/original-text.d.ts +2 -0
- package/dist/lib/original-text.js +15 -7
- package/dist/lib/preflight.d.ts +14 -1
- package/dist/lib/preflight.js +53 -17
- package/dist/lib/project.d.ts +17 -0
- package/dist/lib/project.js +26 -0
- package/dist/lib/scaffold.d.ts +12 -1
- package/dist/lib/scaffold.js +43 -4
- package/dist/lib/update.d.ts +36 -2
- package/dist/lib/update.js +82 -4
- package/docs/init.md +22 -0
- package/docs/loop.md +11 -3
- package/docs/skills.md +17 -13
- package/package.json +1 -1
- package/profiles/base/profile.md +13 -10
- package/profiles/development/profile.md +10 -8
- package/profiles/web-app/profile.md +6 -4
- package/profiles/web-app/reference/message-queue-patterns.ref.md +8 -13
- package/src/templates/kernel/tests/app.test.ts.hbs +17 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The move pass of `boop update`: files a capability stopped shipping because
|
|
4
|
+
* another capability ships them now (specs/skill-moves.spec.md).
|
|
5
|
+
*
|
|
6
|
+
* The capability the files leave declares where they went, in `file_moves`, and
|
|
7
|
+
* names any capability split out of it in `split_into`. The pass reads those
|
|
8
|
+
* declarations from every installed capability, settles each moved path the
|
|
9
|
+
* project holds a stored hash for, and runs before any capability is planned:
|
|
10
|
+
* a move is a rename on disk plus a transfer of stored hashes, and everything
|
|
11
|
+
* after that is the receiving capability's ordinary update.
|
|
12
|
+
*/
|
|
13
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
14
|
+
if (k2 === undefined) k2 = k;
|
|
15
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
16
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
17
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
18
|
+
}
|
|
19
|
+
Object.defineProperty(o, k2, desc);
|
|
20
|
+
}) : (function(o, m, k, k2) {
|
|
21
|
+
if (k2 === undefined) k2 = k;
|
|
22
|
+
o[k2] = m[k];
|
|
23
|
+
}));
|
|
24
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
25
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
26
|
+
}) : function(o, v) {
|
|
27
|
+
o["default"] = v;
|
|
28
|
+
});
|
|
29
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
30
|
+
var ownKeys = function(o) {
|
|
31
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
32
|
+
var ar = [];
|
|
33
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
34
|
+
return ar;
|
|
35
|
+
};
|
|
36
|
+
return ownKeys(o);
|
|
37
|
+
};
|
|
38
|
+
return function (mod) {
|
|
39
|
+
if (mod && mod.__esModule) return mod;
|
|
40
|
+
var result = {};
|
|
41
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
42
|
+
__setModuleDefault(result, mod);
|
|
43
|
+
return result;
|
|
44
|
+
};
|
|
45
|
+
})();
|
|
46
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
47
|
+
exports.planMoves = planMoves;
|
|
48
|
+
exports.moveActions = moveActions;
|
|
49
|
+
exports.executeMoves = executeMoves;
|
|
50
|
+
exports.arrivingPaths = arrivingPaths;
|
|
51
|
+
const fs = __importStar(require("fs"));
|
|
52
|
+
const path = __importStar(require("path"));
|
|
53
|
+
const capability_1 = require("./capability");
|
|
54
|
+
const install_layout_1 = require("./install-layout");
|
|
55
|
+
const project_1 = require("./project");
|
|
56
|
+
const scaffold_1 = require("./scaffold");
|
|
57
|
+
const update_1 = require("./update");
|
|
58
|
+
/**
|
|
59
|
+
* Settle every declared move the project holds paths for. Writes nothing.
|
|
60
|
+
*
|
|
61
|
+
* `updating` is the capabilities this update was asked for: a move runs when
|
|
62
|
+
* either end of it is among them. `unaddable` names split capabilities the
|
|
63
|
+
* update tried and failed to add, with the reason, so their moves wait.
|
|
64
|
+
*/
|
|
65
|
+
function planMoves(targetDir, manifest, updating, options = {}) {
|
|
66
|
+
const moves = [];
|
|
67
|
+
const splitsToAdd = [];
|
|
68
|
+
const template = (0, scaffold_1.readSkillProjectTemplate)();
|
|
69
|
+
for (const fromCapability of manifest.capabilities) {
|
|
70
|
+
const declarations = readDeclarations(fromCapability, targetDir);
|
|
71
|
+
if (!declarations)
|
|
72
|
+
continue;
|
|
73
|
+
const storedHashes = manifest.capabilityMeta?.[fromCapability]?.fileHashes ?? {};
|
|
74
|
+
for (const [from, declaration] of Object.entries(declarations.fileMoves)) {
|
|
75
|
+
const covered = Object.entries(storedHashes).filter(([p]) => isUnder(p, from));
|
|
76
|
+
if (covered.length === 0)
|
|
77
|
+
continue;
|
|
78
|
+
const isFolder = from.endsWith('/');
|
|
79
|
+
const destination = resolveDestination(targetDir, declaration.capability, declaration.path ?? from);
|
|
80
|
+
const toCapability = destination.capability;
|
|
81
|
+
const to = destination.path;
|
|
82
|
+
if (![fromCapability, declaration.capability, toCapability].some(c => updating.includes(c)))
|
|
83
|
+
continue;
|
|
84
|
+
const move = {
|
|
85
|
+
fromCapability,
|
|
86
|
+
from,
|
|
87
|
+
toCapability,
|
|
88
|
+
to,
|
|
89
|
+
isSkillFolder: isFolder && isSkillFolderPath(from),
|
|
90
|
+
outcome: 'move',
|
|
91
|
+
state: 'will-move',
|
|
92
|
+
storedHashes: Object.fromEntries(covered),
|
|
93
|
+
};
|
|
94
|
+
const oldProjectFile = path.join(targetDir, from, 'PROJECT.md');
|
|
95
|
+
const newProjectFile = path.join(targetDir, to, 'PROJECT.md');
|
|
96
|
+
const isSplit = toCapability === declaration.capability && declarations.splitInto.includes(toCapability);
|
|
97
|
+
if (manifest.capabilities.includes(toCapability)) {
|
|
98
|
+
if (!destination.ships) {
|
|
99
|
+
hold(move, 'waiting-for-package', `${toCapability}'s installed package does not ship ${to} yet. Run \`boop update ${toCapability}\` to carry it across.`);
|
|
100
|
+
}
|
|
101
|
+
else if (isFolder && alreadyInstalled(targetDir, manifest, toCapability, to)) {
|
|
102
|
+
const oldEdited = covered.some(([p, hash]) => fs.existsSync(path.join(targetDir, p)) && (0, scaffold_1.hashFile)(path.join(targetDir, p)) !== hash);
|
|
103
|
+
const oldRules = holdsRules(oldProjectFile, template);
|
|
104
|
+
if (!oldEdited && !(oldRules && holdsRules(newProjectFile, template))) {
|
|
105
|
+
move.outcome = 'merge';
|
|
106
|
+
move.projectFileCarried = oldRules;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
hold(move, 'kept-for-project-content', `${toCapability} already installed this skill as ${trimSlash(to)}. Move this project's rules from ${trimSlash(from)} into ${path.join(to, 'PROJECT.md')}, following the ${update_1.TRIAGE_SKILL} skill, with boop's copy at ${path.join(to, 'SKILL.md')}, then delete ${trimSlash(from)}.`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else if (!isFolder && to !== from && fs.existsSync(path.join(targetDir, to))) {
|
|
113
|
+
// The spec has no case for a renamed file whose new path is taken; never write over it.
|
|
114
|
+
hold(move, 'kept-for-project-content', `${to} already exists, so boop did not move ${from} onto it. Keep whichever this project needs, delete the other, and run \`boop update\`.`);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
move.projectFileCarried = isFolder && holdsRules(oldProjectFile, template);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else if (isSplit) {
|
|
121
|
+
const reason = options.unaddable?.[toCapability];
|
|
122
|
+
if (reason) {
|
|
123
|
+
hold(move, 'waiting-for-capability', `${toCapability} was split from ${fromCapability}, and boop could not add it: ${reason}. Run \`boop add ${toCapability}\`, and the next update carries ${trimSlash(from)} across.`);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
move.state = 'split-will-add';
|
|
127
|
+
move.projectFileCarried = isFolder && holdsRules(oldProjectFile, template);
|
|
128
|
+
if (!splitsToAdd.some(s => s.capability === toCapability)) {
|
|
129
|
+
splitsToAdd.push({ capability: toCapability, splitFrom: fromCapability });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
else if (!move.isSkillFolder) {
|
|
134
|
+
// A code file or reference page belongs to the project once it is there (file safety)
|
|
135
|
+
move.outcome = 'deprecate';
|
|
136
|
+
}
|
|
137
|
+
else if (carriesProjectContent(targetDir, from, move.storedHashes, template)) {
|
|
138
|
+
hold(move, 'kept-for-project-content', `Kept because it holds this project's rules. Run \`boop add ${toCapability}\` and \`boop update\` to carry it across, or delete the folder if this project does not need it.`);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
move.outcome = 'remove';
|
|
142
|
+
}
|
|
143
|
+
moves.push(move);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { moves, splitsToAdd };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* The actions a planned move adds to the leaving capability's plan: one per
|
|
150
|
+
* stored path for a move, removal or deprecation, and one for the whole key
|
|
151
|
+
* for a hold, which changes nothing and carries the instruction.
|
|
152
|
+
*/
|
|
153
|
+
function moveActions(move) {
|
|
154
|
+
const shared = { toCapability: move.toCapability };
|
|
155
|
+
if (move.outcome === 'hold') {
|
|
156
|
+
return [{
|
|
157
|
+
...shared,
|
|
158
|
+
relativePath: trimSlash(move.from),
|
|
159
|
+
action: 'skip',
|
|
160
|
+
reason: `Moved to ${move.toCapability}, waiting`,
|
|
161
|
+
movedTo: move.to,
|
|
162
|
+
instruction: move.instruction,
|
|
163
|
+
}];
|
|
164
|
+
}
|
|
165
|
+
return Object.entries(move.storedHashes).map(([relativePath, storedHash]) => {
|
|
166
|
+
const movedTo = newPathFor(relativePath, move.from, move.to);
|
|
167
|
+
switch (move.outcome) {
|
|
168
|
+
case 'deprecate':
|
|
169
|
+
return {
|
|
170
|
+
...shared,
|
|
171
|
+
relativePath,
|
|
172
|
+
action: 'deprecate',
|
|
173
|
+
reason: `Moved to ${move.toCapability}, which this project does not install; leaving in place`,
|
|
174
|
+
storedHash,
|
|
175
|
+
movedTo,
|
|
176
|
+
};
|
|
177
|
+
case 'move':
|
|
178
|
+
return {
|
|
179
|
+
...shared,
|
|
180
|
+
relativePath,
|
|
181
|
+
action: 'move',
|
|
182
|
+
reason: `Moved to ${move.toCapability} as ${movedTo}`,
|
|
183
|
+
storedHash,
|
|
184
|
+
movedTo,
|
|
185
|
+
projectFileCarried: move.isSkillFolder ? move.projectFileCarried : undefined,
|
|
186
|
+
};
|
|
187
|
+
default:
|
|
188
|
+
return {
|
|
189
|
+
...shared,
|
|
190
|
+
relativePath,
|
|
191
|
+
action: 'remove',
|
|
192
|
+
reason: move.outcome === 'merge'
|
|
193
|
+
? `${move.toCapability} already installed ${movedTo}`
|
|
194
|
+
: `Now ships in ${move.toCapability}, which this project does not install`,
|
|
195
|
+
storedHash,
|
|
196
|
+
movedTo,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Carry out every planned `move`, `remove` and `merge`, and write the manifest
|
|
203
|
+
* once afterwards. A hold or deprecation is left to the leaving capability's
|
|
204
|
+
* plan. Returns what happened, by the capability the files left.
|
|
205
|
+
*/
|
|
206
|
+
function executeMoves(targetDir, moves) {
|
|
207
|
+
const results = new Map();
|
|
208
|
+
const manifest = (0, project_1.readProjectManifest)(targetDir);
|
|
209
|
+
if (!manifest)
|
|
210
|
+
return results;
|
|
211
|
+
manifest.capabilityMeta = manifest.capabilityMeta ?? {};
|
|
212
|
+
const meta = manifest.capabilityMeta;
|
|
213
|
+
const template = (0, scaffold_1.readSkillProjectTemplate)();
|
|
214
|
+
for (const move of moves) {
|
|
215
|
+
if (move.outcome !== 'move' && move.outcome !== 'remove' && move.outcome !== 'merge')
|
|
216
|
+
continue;
|
|
217
|
+
let result = results.get(move.fromCapability);
|
|
218
|
+
if (!result) {
|
|
219
|
+
result = { capability: move.fromCapability, moved: [], removed: [], instructions: [] };
|
|
220
|
+
results.set(move.fromCapability, result);
|
|
221
|
+
}
|
|
222
|
+
const fromHashes = meta[move.fromCapability]?.fileHashes ?? {};
|
|
223
|
+
const oldAbs = path.join(targetDir, move.from);
|
|
224
|
+
const newAbs = path.join(targetDir, move.to);
|
|
225
|
+
const existedOnDisk = fs.existsSync(oldAbs);
|
|
226
|
+
if (move.outcome === 'move') {
|
|
227
|
+
if (existedOnDisk && move.from !== move.to) {
|
|
228
|
+
fs.mkdirSync(path.dirname(trimSlash(newAbs)), { recursive: true });
|
|
229
|
+
fs.renameSync(trimSlash(oldAbs), trimSlash(newAbs));
|
|
230
|
+
}
|
|
231
|
+
const receiving = meta[move.toCapability] ?? { installedAt: new Date().toISOString(), fileHashes: {} };
|
|
232
|
+
meta[move.toCapability] = receiving;
|
|
233
|
+
receiving.movedFrom = receiving.movedFrom ?? {};
|
|
234
|
+
for (const [oldPath, hash] of Object.entries(move.storedHashes)) {
|
|
235
|
+
const newPath = newPathFor(oldPath, move.from, move.to);
|
|
236
|
+
delete fromHashes[oldPath];
|
|
237
|
+
// A hash the receiving capability recorded itself is a newer base than the one moving in
|
|
238
|
+
if (!(newPath in receiving.fileHashes)) {
|
|
239
|
+
receiving.fileHashes[newPath] = hash;
|
|
240
|
+
receiving.movedFrom[newPath] = { capability: move.fromCapability, path: oldPath };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (Object.keys(receiving.movedFrom).length === 0)
|
|
244
|
+
delete receiving.movedFrom;
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
if (move.outcome === 'merge' && move.projectFileCarried) {
|
|
248
|
+
fs.mkdirSync(newAbs, { recursive: true });
|
|
249
|
+
fs.renameSync(path.join(oldAbs, 'PROJECT.md'), path.join(newAbs, 'PROJECT.md'));
|
|
250
|
+
}
|
|
251
|
+
for (const oldPath of Object.keys(move.storedHashes)) {
|
|
252
|
+
fs.rmSync(path.join(targetDir, oldPath), { force: true });
|
|
253
|
+
delete fromHashes[oldPath];
|
|
254
|
+
}
|
|
255
|
+
if (move.from.endsWith('/'))
|
|
256
|
+
removeFolderIfEmpty(trimSlash(oldAbs), template);
|
|
257
|
+
}
|
|
258
|
+
if (move.outcome === 'remove') {
|
|
259
|
+
if (existedOnDisk)
|
|
260
|
+
result.removed.push(move.from);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
result.moved.push({ from: move.from, to: move.to, toCapability: move.toCapability });
|
|
264
|
+
const oldName = path.basename(trimSlash(move.from));
|
|
265
|
+
const newName = path.basename(trimSlash(move.to));
|
|
266
|
+
if (move.isSkillFolder && oldName !== newName) {
|
|
267
|
+
result.instructions.push({
|
|
268
|
+
relativePath: move.from,
|
|
269
|
+
instruction: `Search this project's own files for ${oldName} and change each mention to ${newName}.`,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
(0, project_1.writeProjectManifest)(targetDir, manifest);
|
|
274
|
+
return results;
|
|
275
|
+
}
|
|
276
|
+
/** The paths that arrive in each capability by a planned move, for the add and plan that must leave them out */
|
|
277
|
+
function arrivingPaths(moves, capability) {
|
|
278
|
+
return moves
|
|
279
|
+
.filter(m => m.toCapability === capability && m.outcome !== 'hold' && m.outcome !== 'deprecate')
|
|
280
|
+
.map(m => m.to);
|
|
281
|
+
}
|
|
282
|
+
function isUnder(relPath, key) {
|
|
283
|
+
return (0, install_layout_1.isUnderAny)(relPath, [key]);
|
|
284
|
+
}
|
|
285
|
+
function hold(move, state, instruction) {
|
|
286
|
+
move.outcome = 'hold';
|
|
287
|
+
move.state = state;
|
|
288
|
+
move.instruction = instruction;
|
|
289
|
+
}
|
|
290
|
+
function readDeclarations(capability, targetDir) {
|
|
291
|
+
const pkgDir = (0, capability_1.getPackageDir)(capability, [targetDir]);
|
|
292
|
+
if (!pkgDir)
|
|
293
|
+
return null;
|
|
294
|
+
const capJsonPath = path.join(pkgDir, 'capability.json');
|
|
295
|
+
if (!fs.existsSync(capJsonPath))
|
|
296
|
+
return null;
|
|
297
|
+
const capJson = JSON.parse(fs.readFileSync(capJsonPath, 'utf-8'));
|
|
298
|
+
if (!capJson.file_moves)
|
|
299
|
+
return null;
|
|
300
|
+
return { fileMoves: capJson.file_moves, splitInto: capJson.split_into ?? [] };
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Where a moved path ships now. When the named capability's package does not
|
|
304
|
+
* ship it but declares a move of its own for it, the path moved again before
|
|
305
|
+
* this project updated, so follow it.
|
|
306
|
+
*/
|
|
307
|
+
function resolveDestination(targetDir, capability, toPath) {
|
|
308
|
+
const seen = new Set();
|
|
309
|
+
let current = { capability, path: toPath };
|
|
310
|
+
while (!seen.has(current.capability)) {
|
|
311
|
+
seen.add(current.capability);
|
|
312
|
+
const pkgDir = (0, capability_1.getPackageDir)(current.capability, [targetDir]);
|
|
313
|
+
if (!pkgDir)
|
|
314
|
+
break;
|
|
315
|
+
const dests = (0, install_layout_1.capabilityPlacementsFromDir)(pkgDir).map(p => p.dest);
|
|
316
|
+
if (dests.some(d => isUnder(d, current.path)))
|
|
317
|
+
return { ...current, ships: true };
|
|
318
|
+
const onward = Object.entries(readDeclarations(current.capability, targetDir)?.fileMoves ?? {})
|
|
319
|
+
.find(([key]) => isUnder(current.path, key));
|
|
320
|
+
if (!onward)
|
|
321
|
+
break;
|
|
322
|
+
const [key, declaration] = onward;
|
|
323
|
+
current = { capability: declaration.capability, path: newPathFor(current.path, key, declaration.path ?? key) };
|
|
324
|
+
}
|
|
325
|
+
return { ...current, ships: false };
|
|
326
|
+
}
|
|
327
|
+
/** Whether the receiving capability has already put the new folder in this project */
|
|
328
|
+
function alreadyInstalled(targetDir, manifest, capability, folder) {
|
|
329
|
+
const hashes = manifest.capabilityMeta?.[capability]?.fileHashes ?? {};
|
|
330
|
+
return Object.keys(hashes).some(p => isUnder(p, folder)) || fs.existsSync(path.join(targetDir, folder));
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Whether an old skill folder carries anything of the project's: a tracked
|
|
334
|
+
* file that differs from its stored hash, a PROJECT.md that holds rules, or a
|
|
335
|
+
* file boop did not install.
|
|
336
|
+
*/
|
|
337
|
+
function carriesProjectContent(targetDir, folder, storedHashes, template) {
|
|
338
|
+
for (const [p, hash] of Object.entries(storedHashes)) {
|
|
339
|
+
const abs = path.join(targetDir, p);
|
|
340
|
+
if (fs.existsSync(abs) && (0, scaffold_1.hashFile)(abs) !== hash)
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
const projectFile = path.join(folder, 'PROJECT.md');
|
|
344
|
+
if (holdsRules(path.join(targetDir, projectFile), template))
|
|
345
|
+
return true;
|
|
346
|
+
return filesUnder(path.join(targetDir, folder)).some(abs => {
|
|
347
|
+
const rel = path.relative(targetDir, abs);
|
|
348
|
+
return rel !== projectFile && !(rel in storedHashes);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
function holdsRules(projectFilePath, template) {
|
|
352
|
+
return fs.existsSync(projectFilePath) && fs.readFileSync(projectFilePath, 'utf-8') !== template;
|
|
353
|
+
}
|
|
354
|
+
/** Delete a skill folder once nothing is left in it but a template PROJECT.md */
|
|
355
|
+
function removeFolderIfEmpty(folderAbs, template) {
|
|
356
|
+
if (!fs.existsSync(folderAbs))
|
|
357
|
+
return;
|
|
358
|
+
const projectFile = path.join(folderAbs, 'PROJECT.md');
|
|
359
|
+
const left = filesUnder(folderAbs);
|
|
360
|
+
if (left.every(f => f === projectFile && fs.readFileSync(f, 'utf-8') === template)) {
|
|
361
|
+
fs.rmSync(folderAbs, { recursive: true, force: true });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
function filesUnder(dir) {
|
|
365
|
+
if (!fs.existsSync(dir))
|
|
366
|
+
return [];
|
|
367
|
+
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
|
|
368
|
+
const abs = path.join(dir, entry.name);
|
|
369
|
+
return entry.isDirectory() ? filesUnder(abs) : [abs];
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
function newPathFor(relPath, from, to) {
|
|
373
|
+
return from.endsWith('/') ? to + relPath.slice(from.length) : to;
|
|
374
|
+
}
|
|
375
|
+
function isSkillFolderPath(folder) {
|
|
376
|
+
const segments = folder.split('/');
|
|
377
|
+
return segments.length === 3 && segments[0] === 'skills' && segments[2] === '';
|
|
378
|
+
}
|
|
379
|
+
function trimSlash(p) {
|
|
380
|
+
return p.endsWith('/') ? p.slice(0, -1) : p;
|
|
381
|
+
}
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* The hash names the original exactly, whichever release it came from, so the
|
|
9
9
|
* lookup searches for content with that hash — first in the project's own git
|
|
10
10
|
* history of the file, then in the capability's published versions on npm.
|
|
11
|
+
* A file that arrived by a move from another capability is also searched for
|
|
12
|
+
* under the path it had there, in history and in that capability's versions.
|
|
11
13
|
*/
|
|
12
14
|
export type OriginalTextSource = {
|
|
13
15
|
kind: 'git';
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* The hash names the original exactly, whichever release it came from, so the
|
|
10
10
|
* lookup searches for content with that hash — first in the project's own git
|
|
11
11
|
* history of the file, then in the capability's published versions on npm.
|
|
12
|
+
* A file that arrived by a move from another capability is also searched for
|
|
13
|
+
* under the path it had there, in history and in that capability's versions.
|
|
12
14
|
*/
|
|
13
15
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
14
16
|
if (k2 === undefined) k2 = k;
|
|
@@ -60,8 +62,17 @@ const scaffold_1 = require("./scaffold");
|
|
|
60
62
|
* is a miss for that source, and a miss in both is `{ found: false }`.
|
|
61
63
|
*/
|
|
62
64
|
function findOriginalText(projectDir, relPath, storedHash, options = {}) {
|
|
65
|
+
const registry = options.registry ?? exports.npmRegistry;
|
|
66
|
+
const owner = owningCapability(projectDir, relPath);
|
|
67
|
+
// A file that arrived by a move was installed under another path, by another capability
|
|
68
|
+
const movedFrom = owner?.movedFrom?.[relPath];
|
|
63
69
|
return (findInGitHistory(projectDir, relPath, storedHash) ??
|
|
64
|
-
findInPublishedVersions(projectDir, relPath, storedHash,
|
|
70
|
+
(owner ? findInPublishedVersions(projectDir, owner.capability, relPath, storedHash, registry) : null) ??
|
|
71
|
+
(movedFrom
|
|
72
|
+
? findInGitHistory(projectDir, movedFrom.path, storedHash) ??
|
|
73
|
+
findInPublishedVersions(projectDir, movedFrom.capability, movedFrom.path, storedHash, registry)
|
|
74
|
+
: null) ??
|
|
75
|
+
{ found: false });
|
|
65
76
|
}
|
|
66
77
|
function findInGitHistory(projectDir, relPath, storedHash) {
|
|
67
78
|
let commits;
|
|
@@ -89,10 +100,7 @@ function findInGitHistory(projectDir, relPath, storedHash) {
|
|
|
89
100
|
function git(cwd, ...args) {
|
|
90
101
|
return (0, child_process_1.execFileSync)('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 });
|
|
91
102
|
}
|
|
92
|
-
function findInPublishedVersions(projectDir, relPath, storedHash, registry) {
|
|
93
|
-
const capability = owningCapability(projectDir, relPath);
|
|
94
|
-
if (!capability)
|
|
95
|
-
return { found: false };
|
|
103
|
+
function findInPublishedVersions(projectDir, capability, relPath, storedHash, registry) {
|
|
96
104
|
const packageName = packageNameFor(capability, projectDir);
|
|
97
105
|
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boop-original-text-'));
|
|
98
106
|
try {
|
|
@@ -113,7 +121,7 @@ function findInPublishedVersions(projectDir, relPath, storedHash, registry) {
|
|
|
113
121
|
finally {
|
|
114
122
|
fs.rmSync(workDir, { recursive: true, force: true });
|
|
115
123
|
}
|
|
116
|
-
return
|
|
124
|
+
return null;
|
|
117
125
|
}
|
|
118
126
|
function owningCapability(projectDir, relPath) {
|
|
119
127
|
let manifest;
|
|
@@ -124,7 +132,7 @@ function owningCapability(projectDir, relPath) {
|
|
|
124
132
|
return null;
|
|
125
133
|
}
|
|
126
134
|
const entry = Object.entries(manifest?.capabilityMeta ?? {}).find(([, meta]) => relPath in (meta.fileHashes ?? {}));
|
|
127
|
-
return entry ? entry[0] : null;
|
|
135
|
+
return entry ? { ...entry[1], capability: entry[0] } : null;
|
|
128
136
|
}
|
|
129
137
|
function packageNameFor(capability, projectDir) {
|
|
130
138
|
// The installed package knows its own name; `users-and-accounts` ships as @boopkit/cap-users.
|
package/dist/lib/preflight.d.ts
CHANGED
|
@@ -21,6 +21,19 @@ export interface PlanAddResult {
|
|
|
21
21
|
filesToCopy: string[];
|
|
22
22
|
/** Warnings generated during pre-flight (non-fatal) */
|
|
23
23
|
warnings: string[];
|
|
24
|
+
/** Files the add creates because the project lacks them (relative paths) */
|
|
25
|
+
creates: string[];
|
|
26
|
+
}
|
|
27
|
+
export interface PlanAddOptions {
|
|
28
|
+
/** Where to resolve the capability package; defaults to the project alone */
|
|
29
|
+
searchPaths?: string[];
|
|
30
|
+
/** Command that installs the capability package into the project, run before anything reads it there */
|
|
31
|
+
installCommand?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Paths not to copy, because they arrive by a move (a folder ends in `/`).
|
|
34
|
+
* `boop update` passes them when it adds a capability split out of another.
|
|
35
|
+
*/
|
|
36
|
+
excludePaths?: string[];
|
|
24
37
|
}
|
|
25
38
|
/**
|
|
26
39
|
* Plan a `boop add` operation. Reads filesystem, validates everything,
|
|
@@ -28,4 +41,4 @@ export interface PlanAddResult {
|
|
|
28
41
|
*
|
|
29
42
|
* Throws PreflightError if any check fails.
|
|
30
43
|
*/
|
|
31
|
-
export declare function planAdd(targetDir: string, capabilityName: string): PlanAddResult;
|
|
44
|
+
export declare function planAdd(targetDir: string, capabilityName: string, options?: PlanAddOptions): PlanAddResult;
|
package/dist/lib/preflight.js
CHANGED
|
@@ -61,21 +61,46 @@ exports.PreflightError = PreflightError;
|
|
|
61
61
|
*
|
|
62
62
|
* Throws PreflightError if any check fails.
|
|
63
63
|
*/
|
|
64
|
-
function planAdd(targetDir, capabilityName) {
|
|
64
|
+
function planAdd(targetDir, capabilityName, options = {}) {
|
|
65
65
|
const checks = [];
|
|
66
66
|
const tasks = [];
|
|
67
67
|
const warnings = [];
|
|
68
|
+
const creates = [];
|
|
68
69
|
const boopRoot = (0, config_1.getBoopRoot)();
|
|
70
|
+
const searchPaths = options.searchPaths ?? [targetDir];
|
|
71
|
+
const pkgJsonPath = path.join(targetDir, 'package.json');
|
|
72
|
+
const hasPackageJson = fs.existsSync(pkgJsonPath);
|
|
69
73
|
// --- Pre-flight checks ---
|
|
70
|
-
// 1. Validate project
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
// 1. Validate project. A project boop did not scaffold has no config yet;
|
|
75
|
+
// the add creates one, so this is the first capability it records.
|
|
76
|
+
const existingManifest = (0, project_1.readProjectManifest)(targetDir);
|
|
77
|
+
const projectManifest = existingManifest ?? (0, project_1.manifestForUnscaffoldedProject)(targetDir);
|
|
78
|
+
if (existingManifest) {
|
|
79
|
+
checks.push({ name: 'project-exists', passed: true });
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
checks.push({ name: 'project-exists', passed: true, message: 'No .boop/config.json — it will be created' });
|
|
83
|
+
if (!hasPackageJson) {
|
|
84
|
+
creates.push('package.json');
|
|
85
|
+
tasks.push({
|
|
86
|
+
op: 'create-package-json',
|
|
87
|
+
target: pkgJsonPath,
|
|
88
|
+
detail: 'Create a minimal package.json, which records capability packages',
|
|
89
|
+
status: 'pending',
|
|
90
|
+
data: { projectName: projectManifest.name },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
creates.push('.boop/config.json');
|
|
94
|
+
tasks.push({
|
|
95
|
+
op: 'create-manifest',
|
|
96
|
+
target: path.join(targetDir, '.boop', 'config.json'),
|
|
97
|
+
detail: `Create .boop/config.json for ${projectManifest.name} (profile: ${projectManifest.profile})`,
|
|
98
|
+
status: 'pending',
|
|
99
|
+
data: { manifest: projectManifest },
|
|
100
|
+
});
|
|
75
101
|
}
|
|
76
|
-
checks.push({ name: 'project-exists', passed: true });
|
|
77
102
|
// 2. Resolve capability (search project's node_modules first, then CLI root)
|
|
78
|
-
const cap = (0, capability_1.getCapability)(capabilityName,
|
|
103
|
+
const cap = (0, capability_1.getCapability)(capabilityName, searchPaths);
|
|
79
104
|
if (!cap) {
|
|
80
105
|
checks.push({ name: 'capability-exists', passed: false, message: `Capability "${capabilityName}" not found` });
|
|
81
106
|
throw new PreflightError('Pre-flight failed', checks);
|
|
@@ -101,11 +126,13 @@ function planAdd(targetDir, capabilityName) {
|
|
|
101
126
|
checks.push({ name: 'requires-satisfied', passed: true });
|
|
102
127
|
}
|
|
103
128
|
// 5. Enumerate files that would be copied — check for conflicts.
|
|
104
|
-
const pkgDir = (0, capability_1.getPackageDir)(capabilityName,
|
|
129
|
+
const pkgDir = (0, capability_1.getPackageDir)(capabilityName, searchPaths);
|
|
105
130
|
const capDir = pkgDir ?? path.join(boopRoot, 'capabilities', capabilityName);
|
|
106
131
|
// The placement list is the same one `copyCapabilityFiles` copies from, so
|
|
107
132
|
// this cannot disagree with what `boop add` actually writes.
|
|
108
|
-
const
|
|
133
|
+
const excludePaths = options.excludePaths ?? [];
|
|
134
|
+
const filesToCopy = (0, install_layout_1.capabilityPlacements)(capabilityName, searchPaths).map(p => p.dest)
|
|
135
|
+
.filter(dest => !(0, install_layout_1.isUnderAny)(dest, excludePaths));
|
|
109
136
|
const fileConflicts = filesToCopy.filter(dest => fs.existsSync(path.join(targetDir, dest)));
|
|
110
137
|
// File conflicts are warnings (skip behavior), not failures
|
|
111
138
|
if (fileConflicts.length > 0) {
|
|
@@ -165,9 +192,8 @@ function planAdd(targetDir, capabilityName) {
|
|
|
165
192
|
}
|
|
166
193
|
// 6. Check for script conflicts
|
|
167
194
|
if (cap.scripts) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
195
|
+
if (hasPackageJson) {
|
|
196
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
|
|
171
197
|
const existingScripts = pkg.scripts || {};
|
|
172
198
|
const conflicts = [];
|
|
173
199
|
for (const scriptName of Object.keys(cap.scripts)) {
|
|
@@ -193,6 +219,17 @@ function planAdd(targetDir, capabilityName) {
|
|
|
193
219
|
throw new PreflightError(`Pre-flight failed: ${failedChecks.map(c => c.message).join('; ')}`, checks);
|
|
194
220
|
}
|
|
195
221
|
// --- Build task manifest ---
|
|
222
|
+
// Task: install the capability package. It follows the files a bare project
|
|
223
|
+
// lacks, so npm records it in boop's package.json rather than making its own.
|
|
224
|
+
if (options.installCommand) {
|
|
225
|
+
tasks.push({
|
|
226
|
+
op: 'install-package',
|
|
227
|
+
target: targetDir,
|
|
228
|
+
detail: options.installCommand,
|
|
229
|
+
status: 'pending',
|
|
230
|
+
data: { command: options.installCommand },
|
|
231
|
+
});
|
|
232
|
+
}
|
|
196
233
|
// Task: copy files
|
|
197
234
|
if (filesToCopy.length > 0) {
|
|
198
235
|
tasks.push({
|
|
@@ -201,7 +238,7 @@ function planAdd(targetDir, capabilityName) {
|
|
|
201
238
|
target: targetDir,
|
|
202
239
|
detail: `Copy ${filesToCopy.length} file(s) from ${capabilityName}`,
|
|
203
240
|
status: 'pending',
|
|
204
|
-
data: { files: filesToCopy, conflicts: fileConflicts },
|
|
241
|
+
data: { files: filesToCopy, conflicts: fileConflicts, exclude: excludePaths },
|
|
205
242
|
});
|
|
206
243
|
}
|
|
207
244
|
// Task: merge dependencies
|
|
@@ -320,8 +357,7 @@ function planAdd(targetDir, capabilityName) {
|
|
|
320
357
|
});
|
|
321
358
|
// Task: rebuild CSS if we injected styles and the project has tailwind:build
|
|
322
359
|
const hasInjectCss = tasks.some(t => t.op === 'inject-css');
|
|
323
|
-
if (hasInjectCss) {
|
|
324
|
-
const pkgJsonPath = path.join(targetDir, 'package.json');
|
|
360
|
+
if (hasInjectCss && hasPackageJson) {
|
|
325
361
|
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
|
|
326
362
|
if (pkgJson.scripts?.['tailwind:build']) {
|
|
327
363
|
tasks.push({
|
|
@@ -340,5 +376,5 @@ function planAdd(targetDir, capabilityName) {
|
|
|
340
376
|
preflight: { passed: true, checks },
|
|
341
377
|
tasks,
|
|
342
378
|
};
|
|
343
|
-
return { manifest, cap, projectManifest, filesToCopy, warnings };
|
|
379
|
+
return { manifest, cap, projectManifest, filesToCopy, warnings, creates };
|
|
344
380
|
}
|
package/dist/lib/project.d.ts
CHANGED
|
@@ -8,6 +8,15 @@ export interface CapabilityMeta {
|
|
|
8
8
|
installedAt: string;
|
|
9
9
|
version?: string;
|
|
10
10
|
fileHashes: Record<string, string>;
|
|
11
|
+
/**
|
|
12
|
+
* For a file that arrived by a move from another capability: where boop
|
|
13
|
+
* installed it before, so the original text can still be found. Dropped once
|
|
14
|
+
* this capability writes the file's hash itself.
|
|
15
|
+
*/
|
|
16
|
+
movedFrom?: Record<string, {
|
|
17
|
+
capability: string;
|
|
18
|
+
path: string;
|
|
19
|
+
}>;
|
|
11
20
|
}
|
|
12
21
|
export interface BoopManifest {
|
|
13
22
|
name: string;
|
|
@@ -30,6 +39,14 @@ export declare function readProjectManifest(dir: string): BoopManifest | null;
|
|
|
30
39
|
* Creates the .boop/ directory if it does not exist.
|
|
31
40
|
*/
|
|
32
41
|
export declare function writeProjectManifest(dir: string, manifest: BoopManifest): void;
|
|
42
|
+
/**
|
|
43
|
+
* The manifest `boop add` creates for a project boop did not scaffold, such as
|
|
44
|
+
* an app made by another tool. It is named like `boop init` names a project,
|
|
45
|
+
* unless the project's own package.json already gives it a name, and records
|
|
46
|
+
* the `development` profile, which is what a repository that brings in boop's
|
|
47
|
+
* development capabilities without its scaffold uses.
|
|
48
|
+
*/
|
|
49
|
+
export declare function manifestForUnscaffoldedProject(dir: string): BoopManifest;
|
|
33
50
|
/**
|
|
34
51
|
* Check if a directory contains a boop project (.boop/config.json exists).
|
|
35
52
|
*/
|
package/dist/lib/project.js
CHANGED
|
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.readProjectManifest = readProjectManifest;
|
|
37
37
|
exports.writeProjectManifest = writeProjectManifest;
|
|
38
|
+
exports.manifestForUnscaffoldedProject = manifestForUnscaffoldedProject;
|
|
38
39
|
exports.isBoopProject = isBoopProject;
|
|
39
40
|
const fs = __importStar(require("fs"));
|
|
40
41
|
const path = __importStar(require("path"));
|
|
@@ -63,6 +64,31 @@ function writeProjectManifest(dir, manifest) {
|
|
|
63
64
|
const manifestPath = path.join(boopDir, 'config.json');
|
|
64
65
|
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
|
65
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* The manifest `boop add` creates for a project boop did not scaffold, such as
|
|
69
|
+
* an app made by another tool. It is named like `boop init` names a project,
|
|
70
|
+
* unless the project's own package.json already gives it a name, and records
|
|
71
|
+
* the `development` profile, which is what a repository that brings in boop's
|
|
72
|
+
* development capabilities without its scaffold uses.
|
|
73
|
+
*/
|
|
74
|
+
function manifestForUnscaffoldedProject(dir) {
|
|
75
|
+
return {
|
|
76
|
+
name: packageJsonName(dir) ?? path.basename(dir),
|
|
77
|
+
profile: 'development',
|
|
78
|
+
boopVersion: '0.1.0',
|
|
79
|
+
capabilities: [],
|
|
80
|
+
createdAt: new Date().toISOString(),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function packageJsonName(dir) {
|
|
84
|
+
try {
|
|
85
|
+
const { name } = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
|
|
86
|
+
return typeof name === 'string' && name.length > 0 ? name : null;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
66
92
|
/**
|
|
67
93
|
* Check if a directory contains a boop project (.boop/config.json exists).
|
|
68
94
|
*/
|