@boopkit/cli 0.7.1 → 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.
Files changed (39) hide show
  1. package/README.md +8 -6
  2. package/dist/capability-catalog.json +10 -3
  3. package/dist/capability-versions.json +12 -11
  4. package/dist/commands/add.js +35 -7
  5. package/dist/commands/adopt.js +1 -1
  6. package/dist/commands/loop-merge.d.ts +11 -0
  7. package/dist/commands/loop-merge.js +135 -14
  8. package/dist/commands/loop.d.ts +19 -1
  9. package/dist/commands/loop.js +71 -3
  10. package/dist/commands/migrate-skill-edits.js +2 -2
  11. package/dist/commands/status.d.ts +10 -0
  12. package/dist/commands/status.js +27 -2
  13. package/dist/commands/update.js +157 -21
  14. package/dist/lib/capability.js +4 -1
  15. package/dist/lib/execute.js +19 -2
  16. package/dist/lib/install-layout.d.ts +5 -0
  17. package/dist/lib/install-layout.js +8 -0
  18. package/dist/lib/manifest.d.ts +1 -1
  19. package/dist/lib/moves.d.ts +88 -0
  20. package/dist/lib/moves.js +381 -0
  21. package/dist/lib/original-text.d.ts +2 -0
  22. package/dist/lib/original-text.js +15 -7
  23. package/dist/lib/preflight.d.ts +14 -1
  24. package/dist/lib/preflight.js +53 -17
  25. package/dist/lib/project.d.ts +17 -0
  26. package/dist/lib/project.js +26 -0
  27. package/dist/lib/scaffold.d.ts +12 -1
  28. package/dist/lib/scaffold.js +43 -4
  29. package/dist/lib/update.d.ts +36 -2
  30. package/dist/lib/update.js +82 -4
  31. package/docs/init.md +22 -0
  32. package/docs/loop.md +17 -3
  33. package/docs/skills.md +17 -13
  34. package/package.json +1 -1
  35. package/profiles/base/profile.md +13 -10
  36. package/profiles/development/profile.md +10 -8
  37. package/profiles/web-app/profile.md +6 -4
  38. package/profiles/web-app/reference/message-queue-patterns.ref.md +8 -13
  39. 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, options.registry ?? exports.npmRegistry));
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 { found: false };
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.
@@ -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;
@@ -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
- const projectManifest = (0, project_1.readProjectManifest)(targetDir);
72
- if (!projectManifest) {
73
- checks.push({ name: 'project-exists', passed: false, message: 'Not a boop project (no .boop/config.json)' });
74
- throw new PreflightError('Pre-flight failed', checks);
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, [targetDir]);
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, [targetDir]);
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 filesToCopy = (0, install_layout_1.capabilityPlacements)(capabilityName, [targetDir]).map(p => p.dest);
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
- const pkgPath = path.join(targetDir, 'package.json');
169
- if (fs.existsSync(pkgPath)) {
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
  }
@@ -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
  */
@@ -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
  */