@boopkit/cli 0.8.0 → 0.8.2
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 +13 -12
- package/dist/cli.js +0 -0
- package/dist/commands/add.js +35 -7
- package/dist/commands/adopt.js +1 -1
- package/dist/commands/init.d.ts +5 -0
- package/dist/commands/init.js +12 -5
- package/dist/commands/loop-merge.d.ts +11 -0
- package/dist/commands/loop-merge.js +135 -14
- package/dist/commands/loop.d.ts +5 -1
- package/dist/commands/loop.js +38 -19
- 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 +30 -3
- 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/stale-scripts.js +54 -14
- 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 +13 -3
- package/docs/skills.md +23 -15
- 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,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The move pass of `boop update`: files a capability stopped shipping because
|
|
3
|
+
* another capability ships them now (specs/skill-moves.spec.md).
|
|
4
|
+
*
|
|
5
|
+
* The capability the files leave declares where they went, in `file_moves`, and
|
|
6
|
+
* names any capability split out of it in `split_into`. The pass reads those
|
|
7
|
+
* declarations from every installed capability, settles each moved path the
|
|
8
|
+
* project holds a stored hash for, and runs before any capability is planned:
|
|
9
|
+
* a move is a rename on disk plus a transfer of stored hashes, and everything
|
|
10
|
+
* after that is the receiving capability's ordinary update.
|
|
11
|
+
*/
|
|
12
|
+
import { BoopManifest } from './project';
|
|
13
|
+
import { FileInstruction, FileUpdateAction } from './update';
|
|
14
|
+
export interface FileMoveDeclaration {
|
|
15
|
+
capability: string;
|
|
16
|
+
/** Where the files land; the key when absent */
|
|
17
|
+
path?: string;
|
|
18
|
+
}
|
|
19
|
+
export type MoveState = 'will-move' | 'waiting-for-capability' | 'waiting-for-package' | 'kept-for-project-content' | 'split-will-add';
|
|
20
|
+
/**
|
|
21
|
+
* What the pass decided for one declared key the project holds paths under.
|
|
22
|
+
*
|
|
23
|
+
* - `move`: rename on disk and transfer the hashes (cases 1 to 4 and 10).
|
|
24
|
+
* - `remove`: delete the old skill folder, which holds nothing of the project's (cases 5 and 7).
|
|
25
|
+
* - `merge`: the receiving capability already installed the new folder; delete the old one,
|
|
26
|
+
* carrying its `PROJECT.md` across when that holds rules (case 9).
|
|
27
|
+
* - `hold`: change nothing, and say what would let the move happen (cases 6, 8, 9, and a split that cannot be added).
|
|
28
|
+
* - `deprecate`: a file outside a skill folder, whose receiving capability the project does not install.
|
|
29
|
+
*/
|
|
30
|
+
export interface PlannedMove {
|
|
31
|
+
fromCapability: string;
|
|
32
|
+
/** The declared key; a folder ends in `/` */
|
|
33
|
+
from: string;
|
|
34
|
+
toCapability: string;
|
|
35
|
+
to: string;
|
|
36
|
+
isSkillFolder: boolean;
|
|
37
|
+
outcome: 'move' | 'remove' | 'merge' | 'hold' | 'deprecate';
|
|
38
|
+
state: MoveState;
|
|
39
|
+
/** True when the old folder's PROJECT.md holds more than the template */
|
|
40
|
+
projectFileCarried?: boolean;
|
|
41
|
+
/** For a hold: what would let the move happen */
|
|
42
|
+
instruction?: string;
|
|
43
|
+
/** The stored paths under the key, and their hashes */
|
|
44
|
+
storedHashes: Record<string, string>;
|
|
45
|
+
}
|
|
46
|
+
export interface MovePassPlan {
|
|
47
|
+
moves: PlannedMove[];
|
|
48
|
+
/** Capabilities split out of an installed one, which the update adds before moving files into them */
|
|
49
|
+
splitsToAdd: {
|
|
50
|
+
capability: string;
|
|
51
|
+
splitFrom: string;
|
|
52
|
+
}[];
|
|
53
|
+
}
|
|
54
|
+
export interface MoveResult {
|
|
55
|
+
/** The capability the files left */
|
|
56
|
+
capability: string;
|
|
57
|
+
moved: {
|
|
58
|
+
from: string;
|
|
59
|
+
to: string;
|
|
60
|
+
toCapability: string;
|
|
61
|
+
}[];
|
|
62
|
+
removed: string[];
|
|
63
|
+
instructions: FileInstruction[];
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Settle every declared move the project holds paths for. Writes nothing.
|
|
67
|
+
*
|
|
68
|
+
* `updating` is the capabilities this update was asked for: a move runs when
|
|
69
|
+
* either end of it is among them. `unaddable` names split capabilities the
|
|
70
|
+
* update tried and failed to add, with the reason, so their moves wait.
|
|
71
|
+
*/
|
|
72
|
+
export declare function planMoves(targetDir: string, manifest: BoopManifest, updating: string[], options?: {
|
|
73
|
+
unaddable?: Record<string, string>;
|
|
74
|
+
}): MovePassPlan;
|
|
75
|
+
/**
|
|
76
|
+
* The actions a planned move adds to the leaving capability's plan: one per
|
|
77
|
+
* stored path for a move, removal or deprecation, and one for the whole key
|
|
78
|
+
* for a hold, which changes nothing and carries the instruction.
|
|
79
|
+
*/
|
|
80
|
+
export declare function moveActions(move: PlannedMove): FileUpdateAction[];
|
|
81
|
+
/**
|
|
82
|
+
* Carry out every planned `move`, `remove` and `merge`, and write the manifest
|
|
83
|
+
* once afterwards. A hold or deprecation is left to the leaving capability's
|
|
84
|
+
* plan. Returns what happened, by the capability the files left.
|
|
85
|
+
*/
|
|
86
|
+
export declare function executeMoves(targetDir: string, moves: PlannedMove[]): Map<string, MoveResult>;
|
|
87
|
+
/** The paths that arrive in each capability by a planned move, for the add and plan that must leave them out */
|
|
88
|
+
export declare function arrivingPaths(moves: PlannedMove[], capability: string): string[];
|
|
@@ -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;
|