@boopkit/cli 0.1.0
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 +65 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +85 -0
- package/dist/commands/add.d.ts +6 -0
- package/dist/commands/add.js +173 -0
- package/dist/commands/adopt.d.ts +5 -0
- package/dist/commands/adopt.js +286 -0
- package/dist/commands/init.d.ts +5 -0
- package/dist/commands/init.js +295 -0
- package/dist/commands/list.d.ts +6 -0
- package/dist/commands/list.js +105 -0
- package/dist/commands/loop.d.ts +63 -0
- package/dist/commands/loop.js +765 -0
- package/dist/commands/status.d.ts +40 -0
- package/dist/commands/status.js +303 -0
- package/dist/commands/status.test.d.ts +1 -0
- package/dist/commands/status.test.js +118 -0
- package/dist/commands/update.d.ts +8 -0
- package/dist/commands/update.js +204 -0
- package/dist/lib/capability.d.ts +17 -0
- package/dist/lib/capability.js +172 -0
- package/dist/lib/config.d.ts +70 -0
- package/dist/lib/config.js +59 -0
- package/dist/lib/db-status.d.ts +64 -0
- package/dist/lib/db-status.js +257 -0
- package/dist/lib/env.d.ts +25 -0
- package/dist/lib/env.js +116 -0
- package/dist/lib/execute.d.ts +21 -0
- package/dist/lib/execute.js +228 -0
- package/dist/lib/interpolate.d.ts +4 -0
- package/dist/lib/interpolate.js +13 -0
- package/dist/lib/manifest.d.ts +54 -0
- package/dist/lib/manifest.js +11 -0
- package/dist/lib/preflight.d.ts +31 -0
- package/dist/lib/preflight.js +402 -0
- package/dist/lib/profile.d.ts +11 -0
- package/dist/lib/profile.js +73 -0
- package/dist/lib/project.d.ts +36 -0
- package/dist/lib/project.js +71 -0
- package/dist/lib/project.test.d.ts +1 -0
- package/dist/lib/project.test.js +118 -0
- package/dist/lib/rewire.d.ts +19 -0
- package/dist/lib/rewire.js +222 -0
- package/dist/lib/safe-io.d.ts +66 -0
- package/dist/lib/safe-io.js +167 -0
- package/dist/lib/scaffold.d.ts +87 -0
- package/dist/lib/scaffold.js +548 -0
- package/dist/lib/update.d.ts +73 -0
- package/dist/lib/update.js +379 -0
- package/package.json +46 -0
- package/profiles/web-app/profile.md +113 -0
- package/profiles/web-app/reference/architecture.ref.md +65 -0
- package/profiles/web-app/reference/message-queue-patterns.ref.md +247 -0
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.renderTemplate = renderTemplate;
|
|
40
|
+
exports.scaffoldKernel = scaffoldKernel;
|
|
41
|
+
exports.hashFile = hashFile;
|
|
42
|
+
exports.copyCapabilityFiles = copyCapabilityFiles;
|
|
43
|
+
exports.getBoopkitVersion = getBoopkitVersion;
|
|
44
|
+
exports.mergePackageJson = mergePackageJson;
|
|
45
|
+
exports.injectCssImports = injectCssImports;
|
|
46
|
+
exports.collectHooks = collectHooks;
|
|
47
|
+
exports.generateSkillsIndex = generateSkillsIndex;
|
|
48
|
+
const fs = __importStar(require("fs"));
|
|
49
|
+
const path = __importStar(require("path"));
|
|
50
|
+
const crypto = __importStar(require("crypto"));
|
|
51
|
+
const handlebars_1 = __importDefault(require("handlebars"));
|
|
52
|
+
const config_1 = require("./config");
|
|
53
|
+
const capability_1 = require("./capability");
|
|
54
|
+
const safe_io_1 = require("./safe-io");
|
|
55
|
+
/**
|
|
56
|
+
* Render a single Handlebars template string with variables.
|
|
57
|
+
*
|
|
58
|
+
* TRUST BOUNDARY: noEscape is intentional — template variables (hook code,
|
|
59
|
+
* import statements, etc.) contain raw code that must not be HTML-escaped.
|
|
60
|
+
* This is safe as long as all capability manifests are first-party/trusted.
|
|
61
|
+
* If third-party capabilities are ever supported, hook code from manifests
|
|
62
|
+
* must be sanitized or sandboxed before reaching this function. See also
|
|
63
|
+
* collectHooks(), which passes manifest hook code through as-is.
|
|
64
|
+
*/
|
|
65
|
+
function renderTemplate(templateContent, variables) {
|
|
66
|
+
const compiled = handlebars_1.default.compile(templateContent, { noEscape: true });
|
|
67
|
+
return compiled(variables);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Get the path to the kernel templates directory.
|
|
71
|
+
*/
|
|
72
|
+
function getTemplatesDir() {
|
|
73
|
+
return path.join((0, config_1.getBoopRoot)(), 'src', 'templates', 'kernel');
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Copy and render all kernel templates into a target directory.
|
|
77
|
+
* Creates directories as needed. Does not overwrite existing files
|
|
78
|
+
* unless overwrite is true.
|
|
79
|
+
*/
|
|
80
|
+
function scaffoldKernel(targetDir, variables, overwrite = false) {
|
|
81
|
+
const templatesDir = getTemplatesDir();
|
|
82
|
+
const written = [];
|
|
83
|
+
function processDir(srcDir, destDir) {
|
|
84
|
+
if (!fs.existsSync(destDir)) {
|
|
85
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
86
|
+
}
|
|
87
|
+
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
const srcPath = path.join(srcDir, entry.name);
|
|
90
|
+
if (entry.isDirectory()) {
|
|
91
|
+
processDir(srcPath, path.join(destDir, entry.name));
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
// Strip .hbs extension for output filename
|
|
95
|
+
const destName = entry.name.endsWith('.hbs')
|
|
96
|
+
? entry.name.slice(0, -4)
|
|
97
|
+
: entry.name;
|
|
98
|
+
const destPath = path.join(destDir, destName);
|
|
99
|
+
if (!overwrite && fs.existsSync(destPath)) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const content = fs.readFileSync(srcPath, 'utf-8');
|
|
103
|
+
// Only render .hbs files through Handlebars
|
|
104
|
+
const rendered = entry.name.endsWith('.hbs')
|
|
105
|
+
? renderTemplate(content, variables)
|
|
106
|
+
: content;
|
|
107
|
+
fs.writeFileSync(destPath, rendered, 'utf-8');
|
|
108
|
+
written.push(path.relative(targetDir, destPath));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
processDir(templatesDir, targetDir);
|
|
113
|
+
return written;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Compute SHA-256 hash of a file's content.
|
|
117
|
+
*/
|
|
118
|
+
function hashFile(filePath) {
|
|
119
|
+
const content = fs.readFileSync(filePath);
|
|
120
|
+
const hash = crypto.createHash('sha256').update(content).digest('hex');
|
|
121
|
+
return `sha256:${hash}`;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Copy capability-specific files into a target project directory.
|
|
125
|
+
* Tries package resolution first (scaffold/ and skills/ dirs), falls back to source tree.
|
|
126
|
+
* Returns list of copied file paths relative to targetDir, and file hashes.
|
|
127
|
+
*/
|
|
128
|
+
function copyCapabilityFiles(targetDir, capabilityName) {
|
|
129
|
+
const pkgDir = (0, capability_1.getPackageDir)(capabilityName);
|
|
130
|
+
if (pkgDir) {
|
|
131
|
+
// Read package.json for boopkit config
|
|
132
|
+
const pkgJsonPath = path.join(pkgDir, 'package.json');
|
|
133
|
+
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
|
|
134
|
+
const boopkit = pkgJson.boopkit;
|
|
135
|
+
if (boopkit?.capability) {
|
|
136
|
+
return copyFromPackage(targetDir, capabilityName, pkgDir, boopkit);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Fallback to source tree
|
|
140
|
+
return copyFromSourceTree(targetDir, capabilityName);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Copy capability files from a package directory.
|
|
144
|
+
*/
|
|
145
|
+
function copyFromPackage(targetDir, capabilityName, pkgDir, boopkit) {
|
|
146
|
+
const copied = [];
|
|
147
|
+
const fileHashes = {};
|
|
148
|
+
// Copy skills from package
|
|
149
|
+
if (boopkit.skillsDir) {
|
|
150
|
+
const skillsDir = path.join(pkgDir, boopkit.skillsDir);
|
|
151
|
+
if (fs.existsSync(skillsDir)) {
|
|
152
|
+
const destSkillsDir = path.join(targetDir, 'skills');
|
|
153
|
+
(0, safe_io_1.safeCopyDir)(skillsDir, destSkillsDir, { onConflict: 'skip' });
|
|
154
|
+
const entries = fs.readdirSync(skillsDir);
|
|
155
|
+
for (const entry of entries) {
|
|
156
|
+
const destPath = path.join(destSkillsDir, entry);
|
|
157
|
+
copied.push(path.join('skills', entry));
|
|
158
|
+
if (fs.existsSync(destPath)) {
|
|
159
|
+
fileHashes[path.join('skills', entry)] = hashFile(destPath);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// Copy scaffold files from package
|
|
165
|
+
if (boopkit.scaffoldDir) {
|
|
166
|
+
const scaffoldDir = path.join(pkgDir, boopkit.scaffoldDir);
|
|
167
|
+
if (fs.existsSync(scaffoldDir)) {
|
|
168
|
+
const scaffolded = scaffoldCapabilityFiles(targetDir, scaffoldDir);
|
|
169
|
+
copied.push(...scaffolded);
|
|
170
|
+
for (const rel of scaffolded) {
|
|
171
|
+
const destPath = path.join(targetDir, rel);
|
|
172
|
+
if (fs.existsSync(destPath)) {
|
|
173
|
+
fileHashes[rel] = hashFile(destPath);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Store file hashes and version in .boop/config.json
|
|
179
|
+
if (Object.keys(fileHashes).length > 0) {
|
|
180
|
+
const pkgJson = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf-8'));
|
|
181
|
+
updateCapabilityHashes(targetDir, capabilityName, fileHashes, pkgJson.version);
|
|
182
|
+
}
|
|
183
|
+
return copied;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Copy capability files from the capabilities/ source tree (test fixtures only).
|
|
187
|
+
*/
|
|
188
|
+
function copyFromSourceTree(targetDir, capabilityName) {
|
|
189
|
+
const capDir = path.join((0, config_1.getBoopRoot)(), 'capabilities', capabilityName);
|
|
190
|
+
if (!fs.existsSync(capDir)) {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
const copied = [];
|
|
194
|
+
// Copy skills directory if present
|
|
195
|
+
const skillsDir = path.join(capDir, 'skills');
|
|
196
|
+
if (fs.existsSync(skillsDir)) {
|
|
197
|
+
const destSkillsDir = path.join(targetDir, 'skills');
|
|
198
|
+
(0, safe_io_1.safeCopyDir)(skillsDir, destSkillsDir, { onConflict: 'skip' });
|
|
199
|
+
const entries = fs.readdirSync(skillsDir);
|
|
200
|
+
for (const entry of entries) {
|
|
201
|
+
copied.push(path.join('skills', entry));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Scaffold files declared in the capability manifest
|
|
205
|
+
const cap = (0, capability_1.getCapability)(capabilityName);
|
|
206
|
+
if (cap?.files) {
|
|
207
|
+
const filesDir = path.join(capDir, 'files');
|
|
208
|
+
if (fs.existsSync(filesDir)) {
|
|
209
|
+
const scaffolded = scaffoldCapabilityFiles(targetDir, filesDir);
|
|
210
|
+
copied.push(...scaffolded);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return copied;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Update .boop/config.json with file hashes for a capability.
|
|
217
|
+
* Upgrades old format (capabilities as string[]) to new format (Record with hashes).
|
|
218
|
+
*/
|
|
219
|
+
function updateCapabilityHashes(targetDir, capabilityName, fileHashes, version) {
|
|
220
|
+
const configDir = path.join(targetDir, '.boop');
|
|
221
|
+
const configPath = path.join(configDir, 'config.json');
|
|
222
|
+
if (!fs.existsSync(configPath)) {
|
|
223
|
+
console.warn(`⚠ Skipping hash storage for ${capabilityName}: .boop/config.json not found`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
227
|
+
// Migrate old format if needed: capabilities was string[], needs to support both
|
|
228
|
+
// We store hashes in a separate capabilityMeta field to avoid breaking the existing capabilities array
|
|
229
|
+
if (!config.capabilityMeta) {
|
|
230
|
+
config.capabilityMeta = {};
|
|
231
|
+
}
|
|
232
|
+
const meta = {
|
|
233
|
+
installedAt: new Date().toISOString(),
|
|
234
|
+
fileHashes,
|
|
235
|
+
};
|
|
236
|
+
if (version) {
|
|
237
|
+
meta.version = version;
|
|
238
|
+
}
|
|
239
|
+
config.capabilityMeta[capabilityName] = meta;
|
|
240
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Copy all files from a capability's files/ directory into the target project.
|
|
244
|
+
* Preserves directory structure. Does not overwrite existing files.
|
|
245
|
+
*/
|
|
246
|
+
function scaffoldCapabilityFiles(targetDir, filesDir) {
|
|
247
|
+
const copied = [];
|
|
248
|
+
function processDir(srcDir, destDir) {
|
|
249
|
+
if (!fs.existsSync(destDir)) {
|
|
250
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
251
|
+
}
|
|
252
|
+
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
|
|
253
|
+
for (const entry of entries) {
|
|
254
|
+
const srcPath = path.join(srcDir, entry.name);
|
|
255
|
+
const destPath = path.join(destDir, entry.name);
|
|
256
|
+
if (entry.isDirectory()) {
|
|
257
|
+
processDir(srcPath, destPath);
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
if (!fs.existsSync(destPath)) {
|
|
261
|
+
fs.copyFileSync(srcPath, destPath);
|
|
262
|
+
copied.push(path.relative(targetDir, destPath));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
processDir(filesDir, targetDir);
|
|
268
|
+
return copied;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Merge dependencies into an existing package.json.
|
|
272
|
+
* Does not overwrite existing dependency versions.
|
|
273
|
+
*/
|
|
274
|
+
/**
|
|
275
|
+
* Parse a dependency string like "express" or "@prisma/client@^6" into name + version.
|
|
276
|
+
*/
|
|
277
|
+
/**
|
|
278
|
+
* Read the version from a @boopkit/ package's package.json and return ^version.
|
|
279
|
+
* Falls back to '*' if the package.json cannot be read.
|
|
280
|
+
*/
|
|
281
|
+
function getBoopkitVersion(packageName) {
|
|
282
|
+
const pkgName = packageName.replace(/^@boopkit\//, '');
|
|
283
|
+
const pkgJsonPath = path.join((0, config_1.getBoopRoot)(), 'packages', pkgName, 'package.json');
|
|
284
|
+
try {
|
|
285
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
|
|
286
|
+
return `^${pkg.version}`;
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return '*';
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function parseDep(dep) {
|
|
293
|
+
// Handle scoped packages: @scope/name@version
|
|
294
|
+
const match = dep.match(/^(@[^@/]+\/[^@]+)@(.+)$/) || dep.match(/^([^@]+)@(.+)$/);
|
|
295
|
+
if (match) {
|
|
296
|
+
return { name: match[1], version: match[2] };
|
|
297
|
+
}
|
|
298
|
+
return { name: dep, version: '*' };
|
|
299
|
+
}
|
|
300
|
+
function mergePackageJson(targetDir, dependencies, devDependencies, packages, options) {
|
|
301
|
+
const pkgPath = path.join(targetDir, 'package.json');
|
|
302
|
+
if (!fs.existsSync(pkgPath)) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
306
|
+
if (dependencies) {
|
|
307
|
+
if (!pkg.dependencies)
|
|
308
|
+
pkg.dependencies = {};
|
|
309
|
+
for (const dep of dependencies) {
|
|
310
|
+
const { name, version } = parseDep(dep);
|
|
311
|
+
if (!(name in pkg.dependencies)) {
|
|
312
|
+
if (name.startsWith('@boopkit/')) {
|
|
313
|
+
pkg.dependencies[name] = getBoopkitVersion(name);
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
pkg.dependencies[name] = version;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (devDependencies) {
|
|
322
|
+
if (!pkg.devDependencies)
|
|
323
|
+
pkg.devDependencies = {};
|
|
324
|
+
for (const dep of devDependencies) {
|
|
325
|
+
const { name, version } = parseDep(dep);
|
|
326
|
+
if (!(name in pkg.devDependencies)) {
|
|
327
|
+
pkg.devDependencies[name] = version;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (packages) {
|
|
332
|
+
if (!pkg.dependencies)
|
|
333
|
+
pkg.dependencies = {};
|
|
334
|
+
for (const [name, version] of Object.entries(packages)) {
|
|
335
|
+
if (!(name in pkg.dependencies)) {
|
|
336
|
+
pkg.dependencies[name] = version;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (!options?.skipBackup)
|
|
341
|
+
(0, safe_io_1.createBackup)(pkgPath);
|
|
342
|
+
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Scan the styles/ directory for CSS partials and inject @import lines
|
|
346
|
+
* into styles/styles.css after the last existing @import line.
|
|
347
|
+
*/
|
|
348
|
+
function injectCssImports(targetDir, cssCapMap, options) {
|
|
349
|
+
const stylesDir = path.join(targetDir, 'styles');
|
|
350
|
+
const stylesFile = path.join(stylesDir, 'styles.css');
|
|
351
|
+
if (!fs.existsSync(stylesFile))
|
|
352
|
+
return;
|
|
353
|
+
const partials = fs.readdirSync(stylesDir)
|
|
354
|
+
.filter(f => f.endsWith('.css') && f !== 'styles.css')
|
|
355
|
+
.sort();
|
|
356
|
+
if (partials.length === 0)
|
|
357
|
+
return;
|
|
358
|
+
const content = fs.readFileSync(stylesFile, 'utf-8');
|
|
359
|
+
const importLines = partials.map(f => {
|
|
360
|
+
const capName = cssCapMap?.[f];
|
|
361
|
+
const annotation = capName ? `/* @boopkit/cap-${capName} */\n` : '';
|
|
362
|
+
return `${annotation}@import "./${f}";`;
|
|
363
|
+
}).join('\n');
|
|
364
|
+
// Find the last @import line and insert after it
|
|
365
|
+
const lines = content.split('\n');
|
|
366
|
+
let lastImportIdx = -1;
|
|
367
|
+
for (let i = 0; i < lines.length; i++) {
|
|
368
|
+
if (lines[i].trimStart().startsWith('@import ')) {
|
|
369
|
+
lastImportIdx = i;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (lastImportIdx === -1)
|
|
373
|
+
return;
|
|
374
|
+
lines.splice(lastImportIdx + 1, 0, importLines);
|
|
375
|
+
if (!options?.skipBackup)
|
|
376
|
+
(0, safe_io_1.createBackup)(stylesFile);
|
|
377
|
+
fs.writeFileSync(stylesFile, lines.join('\n'), 'utf-8');
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Collect hooks from installed capabilities, group by phase, sort by order,
|
|
381
|
+
* and render into strings suitable for injection into server.ts.hbs.
|
|
382
|
+
*
|
|
383
|
+
* TRUST BOUNDARY: Hook code from capability manifests is injected verbatim
|
|
384
|
+
* into the generated server file — no escaping, validation, or sandboxing.
|
|
385
|
+
* This is safe while all capabilities are first-party. Before supporting
|
|
386
|
+
* third-party capabilities, hook code must be validated or sandboxed to
|
|
387
|
+
* prevent arbitrary code injection into scaffolded projects.
|
|
388
|
+
*/
|
|
389
|
+
function collectHooks(capabilities) {
|
|
390
|
+
const phases = {
|
|
391
|
+
imports: [],
|
|
392
|
+
init: [],
|
|
393
|
+
preRoutes: [],
|
|
394
|
+
postRoutes: [],
|
|
395
|
+
};
|
|
396
|
+
for (let i = 0; i < capabilities.length; i++) {
|
|
397
|
+
const cap = capabilities[i];
|
|
398
|
+
for (const hook of cap.hooks) {
|
|
399
|
+
phases[hook.phase].push({
|
|
400
|
+
capName: cap.name,
|
|
401
|
+
order: hook.order ?? 50,
|
|
402
|
+
code: hook.code,
|
|
403
|
+
description: hook.description,
|
|
404
|
+
installIdx: i,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// Sort non-import phases by order, then by install index for tie-breaking
|
|
409
|
+
for (const phase of ['init', 'preRoutes', 'postRoutes']) {
|
|
410
|
+
phases[phase].sort((a, b) => a.order - b.order || a.installIdx - b.installIdx);
|
|
411
|
+
}
|
|
412
|
+
// Sort imports alphabetically by code
|
|
413
|
+
phases.imports.sort((a, b) => a.code.localeCompare(b.code));
|
|
414
|
+
function renderImports() {
|
|
415
|
+
const marker = '// @boopkit:imports';
|
|
416
|
+
const entries = phases.imports;
|
|
417
|
+
if (entries.length === 0)
|
|
418
|
+
return marker;
|
|
419
|
+
// Group imports by capability name, preserving first-seen order
|
|
420
|
+
const groups = new Map();
|
|
421
|
+
for (const entry of entries) {
|
|
422
|
+
const lines = entry.code.split('\n').filter(l => l.length > 0);
|
|
423
|
+
if (!groups.has(entry.capName)) {
|
|
424
|
+
groups.set(entry.capName, { lines: [], description: entry.description });
|
|
425
|
+
}
|
|
426
|
+
groups.get(entry.capName).lines.push(...lines);
|
|
427
|
+
}
|
|
428
|
+
const blocks = [];
|
|
429
|
+
for (const [capName, group] of groups) {
|
|
430
|
+
const desc = group.description ? ` ${group.description}` : '';
|
|
431
|
+
blocks.push(`/** @boopkit/cap-${capName}${desc} */\n${group.lines.join('\n')}`);
|
|
432
|
+
}
|
|
433
|
+
return blocks.join('\n\n') + '\n' + marker;
|
|
434
|
+
}
|
|
435
|
+
function renderPhase(phase) {
|
|
436
|
+
const marker = `// @boopkit:${phase}`;
|
|
437
|
+
const entries = phases[phase];
|
|
438
|
+
if (entries.length === 0)
|
|
439
|
+
return marker;
|
|
440
|
+
const rendered = entries.map(entry => {
|
|
441
|
+
const lines = entry.code.split('\n').filter(l => l.length > 0);
|
|
442
|
+
const desc = entry.description ? ` ${entry.description}` : '';
|
|
443
|
+
const comment = `/** @boopkit/cap-${entry.capName} [${entry.order}]${desc} */`;
|
|
444
|
+
return `${comment}\n${lines.join('\n')}`;
|
|
445
|
+
}).join('\n\n');
|
|
446
|
+
return rendered + '\n' + marker;
|
|
447
|
+
}
|
|
448
|
+
return {
|
|
449
|
+
hookImports: renderImports(),
|
|
450
|
+
hookInit: renderPhase('init'),
|
|
451
|
+
hookPreRoutes: renderPhase('preRoutes'),
|
|
452
|
+
hookPostRoutes: renderPhase('postRoutes'),
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Preamble for generated skills/index.md.
|
|
457
|
+
* Edit here to change the header text in every generated project.
|
|
458
|
+
*/
|
|
459
|
+
const SKILLS_INDEX_PREAMBLE = [
|
|
460
|
+
'# About Skills',
|
|
461
|
+
'## Writing new skills',
|
|
462
|
+
'When writing a new skill, create a markdown file in this directory using the pattern `<topic>.skill.md`.',
|
|
463
|
+
'Each skill is a concise set of concrete steps that an AI agent can take to achieve a specific goal or perform a specific task.',
|
|
464
|
+
'Preface the skill with "When you ... " to help the agent understand when to use it.',
|
|
465
|
+
'',
|
|
466
|
+
'## Current skills',
|
|
467
|
+
];
|
|
468
|
+
/**
|
|
469
|
+
* Category display names for skill file prefixes.
|
|
470
|
+
* Unknown prefixes get title-cased automatically.
|
|
471
|
+
*/
|
|
472
|
+
const SKILL_CATEGORIES = {
|
|
473
|
+
code: 'Code',
|
|
474
|
+
design: 'Design',
|
|
475
|
+
product: 'Product',
|
|
476
|
+
proj: 'Project',
|
|
477
|
+
reqs: 'Requirements and Docs',
|
|
478
|
+
skills: 'Skills',
|
|
479
|
+
test: 'Testing',
|
|
480
|
+
web: 'Web',
|
|
481
|
+
world: 'World',
|
|
482
|
+
};
|
|
483
|
+
/**
|
|
484
|
+
* Extract the H1 title and first content line from a skill markdown file.
|
|
485
|
+
*/
|
|
486
|
+
function parseSkillFile(content) {
|
|
487
|
+
const lines = content.split('\n');
|
|
488
|
+
let title = '';
|
|
489
|
+
let description = '';
|
|
490
|
+
for (const line of lines) {
|
|
491
|
+
const trimmed = line.trim();
|
|
492
|
+
if (!title && trimmed.startsWith('# ')) {
|
|
493
|
+
title = trimmed.slice(2).trim();
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (title && !description && trimmed.length > 0 && !trimmed.startsWith('#')) {
|
|
497
|
+
description = trimmed;
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return { title: title || 'Untitled', description };
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Generate skills/index.md from the .skill.md files present in the project.
|
|
505
|
+
* Overwrites any existing index.md.
|
|
506
|
+
*/
|
|
507
|
+
function generateSkillsIndex(targetDir) {
|
|
508
|
+
const skillsDir = path.join(targetDir, 'skills');
|
|
509
|
+
if (!fs.existsSync(skillsDir))
|
|
510
|
+
return;
|
|
511
|
+
const files = fs.readdirSync(skillsDir)
|
|
512
|
+
.filter(f => f.endsWith('.skill.md'))
|
|
513
|
+
.sort();
|
|
514
|
+
if (files.length === 0)
|
|
515
|
+
return;
|
|
516
|
+
const entries = [];
|
|
517
|
+
for (const filename of files) {
|
|
518
|
+
const content = fs.readFileSync(path.join(skillsDir, filename), 'utf-8');
|
|
519
|
+
const { title, description } = parseSkillFile(content);
|
|
520
|
+
const category = filename.split('.')[0];
|
|
521
|
+
entries.push({ filename, title, description, category });
|
|
522
|
+
}
|
|
523
|
+
// Group by category
|
|
524
|
+
const grouped = new Map();
|
|
525
|
+
for (const entry of entries) {
|
|
526
|
+
const list = grouped.get(entry.category) || [];
|
|
527
|
+
list.push(entry);
|
|
528
|
+
grouped.set(entry.category, list);
|
|
529
|
+
}
|
|
530
|
+
// Sort categories by display name
|
|
531
|
+
const sortedCategories = [...grouped.keys()].sort((a, b) => {
|
|
532
|
+
const nameA = SKILL_CATEGORIES[a] || a;
|
|
533
|
+
const nameB = SKILL_CATEGORIES[b] || b;
|
|
534
|
+
return nameA.localeCompare(nameB);
|
|
535
|
+
});
|
|
536
|
+
const lines = [...SKILLS_INDEX_PREAMBLE];
|
|
537
|
+
for (const category of sortedCategories) {
|
|
538
|
+
const displayName = SKILL_CATEGORIES[category]
|
|
539
|
+
|| category.charAt(0).toUpperCase() + category.slice(1);
|
|
540
|
+
lines.push(`### ${displayName}`);
|
|
541
|
+
for (const skill of grouped.get(category)) {
|
|
542
|
+
const desc = skill.description ? ` - ${skill.description}` : '';
|
|
543
|
+
lines.push(`- [**${skill.title}**](${skill.filename})${desc}`);
|
|
544
|
+
}
|
|
545
|
+
lines.push('');
|
|
546
|
+
}
|
|
547
|
+
fs.writeFileSync(path.join(skillsDir, 'index.md'), lines.join('\n'), 'utf-8');
|
|
548
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core logic for `boop update` — hash-based scaffold file updating.
|
|
3
|
+
*
|
|
4
|
+
* Compares installed scaffold files against their stored hashes to detect
|
|
5
|
+
* local modifications, then selectively updates from the capability package.
|
|
6
|
+
*/
|
|
7
|
+
import { BoopManifest } from './project';
|
|
8
|
+
export type FileState = 'match' | 'modified' | 'missing';
|
|
9
|
+
export interface FileUpdateAction {
|
|
10
|
+
/** Relative path from project root */
|
|
11
|
+
relativePath: string;
|
|
12
|
+
/** What will happen to this file */
|
|
13
|
+
action: 'overwrite' | 'skip' | 'add' | 'deprecate';
|
|
14
|
+
/** Why this action was chosen */
|
|
15
|
+
reason: string;
|
|
16
|
+
/** Stored hash from config */
|
|
17
|
+
storedHash?: string;
|
|
18
|
+
/** Current on-disk hash */
|
|
19
|
+
currentHash?: string;
|
|
20
|
+
/** New hash from package */
|
|
21
|
+
newHash?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface UpdatePlan {
|
|
24
|
+
capability: string;
|
|
25
|
+
targetDir: string;
|
|
26
|
+
packageDir: string;
|
|
27
|
+
/** Current version from config (if any) */
|
|
28
|
+
currentVersion?: string;
|
|
29
|
+
/** New version from package */
|
|
30
|
+
newVersion: string;
|
|
31
|
+
/** Per-file actions */
|
|
32
|
+
actions: FileUpdateAction[];
|
|
33
|
+
/** Number of files unchanged in both package and locally */
|
|
34
|
+
unchangedCount: number;
|
|
35
|
+
/** True if already up to date */
|
|
36
|
+
upToDate: boolean;
|
|
37
|
+
}
|
|
38
|
+
export interface UpdateResult {
|
|
39
|
+
capability: string;
|
|
40
|
+
overwritten: string[];
|
|
41
|
+
skipped: string[];
|
|
42
|
+
added: string[];
|
|
43
|
+
deprecated: string[];
|
|
44
|
+
forcedOverwrite: string[];
|
|
45
|
+
/** Files skipped because they were locally deleted */
|
|
46
|
+
skippedDeleted: string[];
|
|
47
|
+
/** Files removed from disk via --prune */
|
|
48
|
+
pruned: string[];
|
|
49
|
+
/** Count of files unchanged in both package and locally */
|
|
50
|
+
unchangedCount: number;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Compare a file's current hash against a stored hash.
|
|
54
|
+
*/
|
|
55
|
+
export declare function compareFileHash(filePath: string, storedHash: string): FileState;
|
|
56
|
+
/**
|
|
57
|
+
* Plan an update for a single capability. Returns null if the capability
|
|
58
|
+
* is not installed or has no package.
|
|
59
|
+
*/
|
|
60
|
+
export declare function planCapabilityUpdate(targetDir: string, capabilityName: string, projectManifest: BoopManifest): UpdatePlan | null;
|
|
61
|
+
/**
|
|
62
|
+
* Execute an update plan, writing files and updating config.
|
|
63
|
+
* If force is true, overwrites even locally modified files (with backup).
|
|
64
|
+
*/
|
|
65
|
+
export declare function executeUpdatePlan(plan: UpdatePlan, options?: {
|
|
66
|
+
force?: boolean;
|
|
67
|
+
restore?: boolean;
|
|
68
|
+
prune?: boolean;
|
|
69
|
+
}): UpdateResult;
|
|
70
|
+
/**
|
|
71
|
+
* Format an update result as a human-readable summary line.
|
|
72
|
+
*/
|
|
73
|
+
export declare function formatUpdateSummary(result: UpdateResult): string;
|