@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,379 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Core logic for `boop update` — hash-based scaffold file updating.
|
|
4
|
+
*
|
|
5
|
+
* Compares installed scaffold files against their stored hashes to detect
|
|
6
|
+
* local modifications, then selectively updates from the capability package.
|
|
7
|
+
*/
|
|
8
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
9
|
+
if (k2 === undefined) k2 = k;
|
|
10
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
11
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
12
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
13
|
+
}
|
|
14
|
+
Object.defineProperty(o, k2, desc);
|
|
15
|
+
}) : (function(o, m, k, k2) {
|
|
16
|
+
if (k2 === undefined) k2 = k;
|
|
17
|
+
o[k2] = m[k];
|
|
18
|
+
}));
|
|
19
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
20
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
21
|
+
}) : function(o, v) {
|
|
22
|
+
o["default"] = v;
|
|
23
|
+
});
|
|
24
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
25
|
+
var ownKeys = function(o) {
|
|
26
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
27
|
+
var ar = [];
|
|
28
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
29
|
+
return ar;
|
|
30
|
+
};
|
|
31
|
+
return ownKeys(o);
|
|
32
|
+
};
|
|
33
|
+
return function (mod) {
|
|
34
|
+
if (mod && mod.__esModule) return mod;
|
|
35
|
+
var result = {};
|
|
36
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
37
|
+
__setModuleDefault(result, mod);
|
|
38
|
+
return result;
|
|
39
|
+
};
|
|
40
|
+
})();
|
|
41
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
+
exports.compareFileHash = compareFileHash;
|
|
43
|
+
exports.planCapabilityUpdate = planCapabilityUpdate;
|
|
44
|
+
exports.executeUpdatePlan = executeUpdatePlan;
|
|
45
|
+
exports.formatUpdateSummary = formatUpdateSummary;
|
|
46
|
+
const fs = __importStar(require("fs"));
|
|
47
|
+
const path = __importStar(require("path"));
|
|
48
|
+
const scaffold_1 = require("./scaffold");
|
|
49
|
+
const capability_1 = require("./capability");
|
|
50
|
+
const project_1 = require("./project");
|
|
51
|
+
const safe_io_1 = require("./safe-io");
|
|
52
|
+
/**
|
|
53
|
+
* Compare a file's current hash against a stored hash.
|
|
54
|
+
*/
|
|
55
|
+
function compareFileHash(filePath, storedHash) {
|
|
56
|
+
if (!fs.existsSync(filePath)) {
|
|
57
|
+
return 'missing';
|
|
58
|
+
}
|
|
59
|
+
const currentHash = (0, scaffold_1.hashFile)(filePath);
|
|
60
|
+
return currentHash === storedHash ? 'match' : 'modified';
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Plan an update for a single capability. Returns null if the capability
|
|
64
|
+
* is not installed or has no package.
|
|
65
|
+
*/
|
|
66
|
+
function planCapabilityUpdate(targetDir, capabilityName, projectManifest) {
|
|
67
|
+
// Capability must be installed
|
|
68
|
+
if (!projectManifest.capabilities.includes(capabilityName)) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
// Must have a package
|
|
72
|
+
const pkgDir = (0, capability_1.getPackageDir)(capabilityName);
|
|
73
|
+
if (!pkgDir)
|
|
74
|
+
return null;
|
|
75
|
+
const pkgJsonPath = path.join(pkgDir, 'package.json');
|
|
76
|
+
if (!fs.existsSync(pkgJsonPath))
|
|
77
|
+
return null;
|
|
78
|
+
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
|
|
79
|
+
const boopkit = pkgJson.boopkit;
|
|
80
|
+
if (!boopkit?.capability)
|
|
81
|
+
return null;
|
|
82
|
+
const newVersion = pkgJson.version || '0.0.0';
|
|
83
|
+
const meta = projectManifest.capabilityMeta?.[capabilityName];
|
|
84
|
+
const storedHashes = meta?.fileHashes || {};
|
|
85
|
+
const actions = [];
|
|
86
|
+
let unchangedCount = 0;
|
|
87
|
+
// Collect files from package scaffold dir
|
|
88
|
+
const packageFiles = new Map(); // relPath → absolute source path
|
|
89
|
+
if (boopkit.scaffoldDir) {
|
|
90
|
+
const scaffoldDir = path.join(pkgDir, boopkit.scaffoldDir);
|
|
91
|
+
if (fs.existsSync(scaffoldDir)) {
|
|
92
|
+
collectFiles(scaffoldDir, scaffoldDir, packageFiles);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Collect files from package skills dir
|
|
96
|
+
if (boopkit.skillsDir) {
|
|
97
|
+
const skillsDir = path.join(pkgDir, boopkit.skillsDir);
|
|
98
|
+
if (fs.existsSync(skillsDir)) {
|
|
99
|
+
const entries = fs.readdirSync(skillsDir);
|
|
100
|
+
for (const entry of entries) {
|
|
101
|
+
const srcPath = path.join(skillsDir, entry);
|
|
102
|
+
if (fs.statSync(srcPath).isFile()) {
|
|
103
|
+
packageFiles.set(path.join('skills', entry), srcPath);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// For each file in the package, determine action
|
|
109
|
+
for (const [relPath, srcPath] of packageFiles) {
|
|
110
|
+
const destPath = path.join(targetDir, relPath);
|
|
111
|
+
const storedHash = storedHashes[relPath];
|
|
112
|
+
const newHash = (0, scaffold_1.hashFile)(srcPath);
|
|
113
|
+
if (!storedHash) {
|
|
114
|
+
// New file — not in stored hashes
|
|
115
|
+
if (fs.existsSync(destPath)) {
|
|
116
|
+
// File exists but wasn't tracked — treat as modified (skip)
|
|
117
|
+
actions.push({
|
|
118
|
+
relativePath: relPath,
|
|
119
|
+
action: 'skip',
|
|
120
|
+
reason: 'File exists but has no stored hash (untracked)',
|
|
121
|
+
currentHash: (0, scaffold_1.hashFile)(destPath),
|
|
122
|
+
newHash,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
actions.push({
|
|
127
|
+
relativePath: relPath,
|
|
128
|
+
action: 'add',
|
|
129
|
+
reason: 'New file from package',
|
|
130
|
+
newHash,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const state = compareFileHash(destPath, storedHash);
|
|
136
|
+
switch (state) {
|
|
137
|
+
case 'match':
|
|
138
|
+
// Unmodified — safe to overwrite
|
|
139
|
+
if (newHash === storedHash) {
|
|
140
|
+
// File hasn't changed in package either — no action needed
|
|
141
|
+
unchangedCount++;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
actions.push({
|
|
145
|
+
relativePath: relPath,
|
|
146
|
+
action: 'overwrite',
|
|
147
|
+
reason: 'Unmodified locally, updated in package',
|
|
148
|
+
storedHash,
|
|
149
|
+
currentHash: storedHash,
|
|
150
|
+
newHash,
|
|
151
|
+
});
|
|
152
|
+
break;
|
|
153
|
+
case 'modified':
|
|
154
|
+
// Locally modified — skip with warning
|
|
155
|
+
actions.push({
|
|
156
|
+
relativePath: relPath,
|
|
157
|
+
action: 'skip',
|
|
158
|
+
reason: 'Locally modified',
|
|
159
|
+
storedHash,
|
|
160
|
+
currentHash: (0, scaffold_1.hashFile)(destPath),
|
|
161
|
+
newHash,
|
|
162
|
+
});
|
|
163
|
+
break;
|
|
164
|
+
case 'missing':
|
|
165
|
+
// File was deleted locally — respect user's deletion, skip by default
|
|
166
|
+
actions.push({
|
|
167
|
+
relativePath: relPath,
|
|
168
|
+
action: 'skip',
|
|
169
|
+
reason: 'Locally deleted',
|
|
170
|
+
storedHash,
|
|
171
|
+
newHash,
|
|
172
|
+
});
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Check for files removed from package (in stored hashes but not in package)
|
|
177
|
+
for (const relPath of Object.keys(storedHashes)) {
|
|
178
|
+
if (!packageFiles.has(relPath)) {
|
|
179
|
+
actions.push({
|
|
180
|
+
relativePath: relPath,
|
|
181
|
+
action: 'deprecate',
|
|
182
|
+
reason: 'Removed from package, leaving in place',
|
|
183
|
+
storedHash: storedHashes[relPath],
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const upToDate = actions.length === 0;
|
|
188
|
+
return {
|
|
189
|
+
capability: capabilityName,
|
|
190
|
+
targetDir,
|
|
191
|
+
packageDir: pkgDir,
|
|
192
|
+
currentVersion: meta?.version,
|
|
193
|
+
newVersion,
|
|
194
|
+
actions,
|
|
195
|
+
unchangedCount,
|
|
196
|
+
upToDate,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Execute an update plan, writing files and updating config.
|
|
201
|
+
* If force is true, overwrites even locally modified files (with backup).
|
|
202
|
+
*/
|
|
203
|
+
function executeUpdatePlan(plan, options = {}) {
|
|
204
|
+
const result = {
|
|
205
|
+
capability: plan.capability,
|
|
206
|
+
overwritten: [],
|
|
207
|
+
skipped: [],
|
|
208
|
+
added: [],
|
|
209
|
+
deprecated: [],
|
|
210
|
+
forcedOverwrite: [],
|
|
211
|
+
skippedDeleted: [],
|
|
212
|
+
pruned: [],
|
|
213
|
+
unchangedCount: plan.unchangedCount,
|
|
214
|
+
};
|
|
215
|
+
const pkgJson = JSON.parse(fs.readFileSync(path.join(plan.packageDir, 'package.json'), 'utf-8'));
|
|
216
|
+
const boopkit = pkgJson.boopkit;
|
|
217
|
+
const newHashes = {};
|
|
218
|
+
// Start with existing hashes
|
|
219
|
+
const manifest = (0, project_1.readProjectManifest)(plan.targetDir);
|
|
220
|
+
if (manifest?.capabilityMeta?.[plan.capability]) {
|
|
221
|
+
Object.assign(newHashes, manifest.capabilityMeta[plan.capability].fileHashes);
|
|
222
|
+
}
|
|
223
|
+
for (const action of plan.actions) {
|
|
224
|
+
const destPath = path.join(plan.targetDir, action.relativePath);
|
|
225
|
+
switch (action.action) {
|
|
226
|
+
case 'overwrite': {
|
|
227
|
+
const srcPath = resolveSourcePath(plan.packageDir, boopkit, action.relativePath);
|
|
228
|
+
if (srcPath) {
|
|
229
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
230
|
+
fs.copyFileSync(srcPath, destPath);
|
|
231
|
+
newHashes[action.relativePath] = (0, scaffold_1.hashFile)(destPath);
|
|
232
|
+
result.overwritten.push(action.relativePath);
|
|
233
|
+
}
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
case 'skip': {
|
|
237
|
+
const isLocallyDeleted = action.reason === 'Locally deleted';
|
|
238
|
+
if (isLocallyDeleted && options.restore) {
|
|
239
|
+
// Restore mode: re-add locally deleted files
|
|
240
|
+
const srcPath = resolveSourcePath(plan.packageDir, boopkit, action.relativePath);
|
|
241
|
+
if (srcPath) {
|
|
242
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
243
|
+
fs.copyFileSync(srcPath, destPath);
|
|
244
|
+
newHashes[action.relativePath] = (0, scaffold_1.hashFile)(destPath);
|
|
245
|
+
result.added.push(action.relativePath);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
else if (!isLocallyDeleted && options.force) {
|
|
249
|
+
// Force mode: backup and overwrite modified files
|
|
250
|
+
const srcPath = resolveSourcePath(plan.packageDir, boopkit, action.relativePath);
|
|
251
|
+
if (srcPath) {
|
|
252
|
+
if (fs.existsSync(destPath)) {
|
|
253
|
+
(0, safe_io_1.createBackup)(destPath);
|
|
254
|
+
}
|
|
255
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
256
|
+
fs.copyFileSync(srcPath, destPath);
|
|
257
|
+
newHashes[action.relativePath] = (0, scaffold_1.hashFile)(destPath);
|
|
258
|
+
result.overwritten.push(action.relativePath);
|
|
259
|
+
result.forcedOverwrite.push(action.relativePath);
|
|
260
|
+
console.warn(`\x1b[33mWARN\x1b[0m Force-overwriting modified file: ${action.relativePath} (backup saved as ${action.relativePath}.bak)`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
result.skipped.push(action.relativePath);
|
|
265
|
+
if (isLocallyDeleted) {
|
|
266
|
+
result.skippedDeleted.push(action.relativePath);
|
|
267
|
+
}
|
|
268
|
+
// Keep existing hash unchanged
|
|
269
|
+
}
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
case 'add': {
|
|
273
|
+
const srcPath = resolveSourcePath(plan.packageDir, boopkit, action.relativePath);
|
|
274
|
+
if (srcPath) {
|
|
275
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
276
|
+
fs.copyFileSync(srcPath, destPath);
|
|
277
|
+
newHashes[action.relativePath] = (0, scaffold_1.hashFile)(destPath);
|
|
278
|
+
result.added.push(action.relativePath);
|
|
279
|
+
}
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
case 'deprecate': {
|
|
283
|
+
result.deprecated.push(action.relativePath);
|
|
284
|
+
// Remove from hashes since it's no longer in the package
|
|
285
|
+
delete newHashes[action.relativePath];
|
|
286
|
+
if (options.prune) {
|
|
287
|
+
const filePath = path.join(plan.targetDir, action.relativePath);
|
|
288
|
+
if (fs.existsSync(filePath)) {
|
|
289
|
+
(0, safe_io_1.createBackup)(filePath);
|
|
290
|
+
fs.unlinkSync(filePath);
|
|
291
|
+
result.pruned.push(action.relativePath);
|
|
292
|
+
console.warn(`\x1b[33mWARN\x1b[0m Pruned deprecated file: ${action.relativePath} (backup saved as ${action.relativePath}.bak)`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// Update .boop/config.json
|
|
300
|
+
if (manifest) {
|
|
301
|
+
if (!manifest.capabilityMeta) {
|
|
302
|
+
manifest.capabilityMeta = {};
|
|
303
|
+
}
|
|
304
|
+
manifest.capabilityMeta[plan.capability] = {
|
|
305
|
+
installedAt: new Date().toISOString(),
|
|
306
|
+
version: plan.newVersion,
|
|
307
|
+
fileHashes: newHashes,
|
|
308
|
+
};
|
|
309
|
+
(0, project_1.writeProjectManifest)(plan.targetDir, manifest);
|
|
310
|
+
}
|
|
311
|
+
return result;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Resolve the source path for a relative file path from the package.
|
|
315
|
+
*/
|
|
316
|
+
function resolveSourcePath(pkgDir, boopkit, relativePath) {
|
|
317
|
+
// Skills are stored as skills/<filename> in relPath
|
|
318
|
+
if (relativePath.startsWith('skills/') && boopkit.skillsDir) {
|
|
319
|
+
const filename = relativePath.slice('skills/'.length);
|
|
320
|
+
const srcPath = path.join(pkgDir, boopkit.skillsDir, filename);
|
|
321
|
+
return fs.existsSync(srcPath) ? srcPath : null;
|
|
322
|
+
}
|
|
323
|
+
// Scaffold files
|
|
324
|
+
if (boopkit.scaffoldDir) {
|
|
325
|
+
const srcPath = path.join(pkgDir, boopkit.scaffoldDir, relativePath);
|
|
326
|
+
return fs.existsSync(srcPath) ? srcPath : null;
|
|
327
|
+
}
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Recursively collect files from a directory into a map of relPath → absPath.
|
|
332
|
+
*/
|
|
333
|
+
function collectFiles(baseDir, dir, result) {
|
|
334
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
335
|
+
for (const entry of entries) {
|
|
336
|
+
const fullPath = path.join(dir, entry.name);
|
|
337
|
+
if (entry.isDirectory()) {
|
|
338
|
+
collectFiles(baseDir, fullPath, result);
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
const relPath = path.relative(baseDir, fullPath);
|
|
342
|
+
result.set(relPath, fullPath);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Format an update result as a human-readable summary line.
|
|
348
|
+
*/
|
|
349
|
+
function formatUpdateSummary(result) {
|
|
350
|
+
const parts = [];
|
|
351
|
+
const normalOverwrites = result.overwritten.length - result.forcedOverwrite.length;
|
|
352
|
+
if (normalOverwrites > 0) {
|
|
353
|
+
parts.push(`Updated ${normalOverwrites} file${normalOverwrites === 1 ? '' : 's'}`);
|
|
354
|
+
}
|
|
355
|
+
if (result.forcedOverwrite.length > 0) {
|
|
356
|
+
parts.push(`force-overwrote ${result.forcedOverwrite.length} modified file${result.forcedOverwrite.length === 1 ? '' : 's'}`);
|
|
357
|
+
}
|
|
358
|
+
const skippedModified = result.skipped.length - (result.skippedDeleted?.length || 0);
|
|
359
|
+
if (skippedModified > 0) {
|
|
360
|
+
parts.push(`skipped ${skippedModified} (locally modified)`);
|
|
361
|
+
}
|
|
362
|
+
if (result.skippedDeleted && result.skippedDeleted.length > 0) {
|
|
363
|
+
parts.push(`skipped ${result.skippedDeleted.length} (locally deleted)`);
|
|
364
|
+
}
|
|
365
|
+
if (result.added.length > 0) {
|
|
366
|
+
parts.push(`added ${result.added.length} new file${result.added.length === 1 ? '' : 's'}`);
|
|
367
|
+
}
|
|
368
|
+
if (result.pruned && result.pruned.length > 0) {
|
|
369
|
+
parts.push(`pruned ${result.pruned.length} deprecated file${result.pruned.length === 1 ? '' : 's'}`);
|
|
370
|
+
}
|
|
371
|
+
const unpruned = result.deprecated.length - (result.pruned?.length || 0);
|
|
372
|
+
if (unpruned > 0) {
|
|
373
|
+
parts.push(`${unpruned} deprecated (left in place)`);
|
|
374
|
+
}
|
|
375
|
+
if (result.unchangedCount > 0) {
|
|
376
|
+
parts.push(`${result.unchangedCount} unchanged`);
|
|
377
|
+
}
|
|
378
|
+
return parts.length > 0 ? parts.join(', ') : 'Already up to date';
|
|
379
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@boopkit/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI tool to scaffold new projects from boop profiles and capabilities",
|
|
5
|
+
"main": "dist/cli.js",
|
|
6
|
+
"workspaces": [
|
|
7
|
+
"packages/*"
|
|
8
|
+
],
|
|
9
|
+
"bin": {
|
|
10
|
+
"boop": "dist/cli.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist/",
|
|
14
|
+
"profiles/"
|
|
15
|
+
],
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc",
|
|
21
|
+
"dev": "tsx src/cli.ts",
|
|
22
|
+
"start": "node dist/cli.js",
|
|
23
|
+
"test": "jest"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [],
|
|
26
|
+
"license": "ISC",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"bcryptjs": "^3.0.3",
|
|
29
|
+
"commander": "^14.0.3",
|
|
30
|
+
"gray-matter": "^4.0.3",
|
|
31
|
+
"handlebars": "^4.7.8"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/bcryptjs": "^2.4.6",
|
|
35
|
+
"@types/cookie-parser": "^1.4.10",
|
|
36
|
+
"@types/jest": "^30.0.0",
|
|
37
|
+
"@types/node": "^25.5.0",
|
|
38
|
+
"@types/supertest": "^7.2.0",
|
|
39
|
+
"cookie-parser": "^1.4.7",
|
|
40
|
+
"jest": "^30.3.0",
|
|
41
|
+
"supertest": "^7.2.2",
|
|
42
|
+
"ts-jest": "^29.4.6",
|
|
43
|
+
"tsx": "^4.19.4",
|
|
44
|
+
"typescript": "^5.9.3"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: web-app
|
|
3
|
+
description: Full-stack web application (Express + Handlebars + Tailwind)
|
|
4
|
+
defaultCapabilities:
|
|
5
|
+
# Application
|
|
6
|
+
- web
|
|
7
|
+
- api
|
|
8
|
+
- services
|
|
9
|
+
- database
|
|
10
|
+
# Development
|
|
11
|
+
- coding
|
|
12
|
+
- testing
|
|
13
|
+
- project-mgmt
|
|
14
|
+
- product-design
|
|
15
|
+
- skills
|
|
16
|
+
# Demo
|
|
17
|
+
- hello
|
|
18
|
+
kernel:
|
|
19
|
+
runtime: node
|
|
20
|
+
languages:
|
|
21
|
+
- typescript
|
|
22
|
+
deploy: heroku
|
|
23
|
+
port: 3000
|
|
24
|
+
nodeVersion: ">=24.0.0"
|
|
25
|
+
dependencies: []
|
|
26
|
+
devDependencies:
|
|
27
|
+
- typescript
|
|
28
|
+
- tsx
|
|
29
|
+
- nodemon
|
|
30
|
+
- "@types/express"
|
|
31
|
+
- "@types/node"
|
|
32
|
+
scripts:
|
|
33
|
+
start: "tsx src/server.ts"
|
|
34
|
+
dev: "nodemon --exec tsx src/server.ts"
|
|
35
|
+
build: "tsc"
|
|
36
|
+
test: "jest --runInBand"
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
# Web App Profile
|
|
40
|
+
|
|
41
|
+
A full-stack web application built on Express, server-rendered with Handlebars, styled with Tailwind CSS, and backed by PostgreSQL via Prisma ORM. Designed for rapid product experiments that deploy to Heroku.
|
|
42
|
+
|
|
43
|
+
## What You Get
|
|
44
|
+
|
|
45
|
+
### Multi-Process Architecture
|
|
46
|
+
The kernel scaffolds the web process entry point:
|
|
47
|
+
|
|
48
|
+
- **Web** (`src/server.ts`) — Express app serving the UI and API on port 3000
|
|
49
|
+
|
|
50
|
+
As your application grows, the architecture supports additional Heroku process types for async work:
|
|
51
|
+
|
|
52
|
+
- **Worker** — Background message queue consumer for async tasks
|
|
53
|
+
- **Scheduler** — Cron-based job publisher
|
|
54
|
+
|
|
55
|
+
These are not scaffolded by default. See `reference/message-queue-patterns.ref.md` for the documented patterns when you're ready to add async processing.
|
|
56
|
+
|
|
57
|
+
### Application Layers
|
|
58
|
+
|
|
59
|
+
| Layer | Location | Responsibility |
|
|
60
|
+
|-------|----------|----------------|
|
|
61
|
+
| **Controllers** | `src/controllers/` | Protocol-specific request handlers (HTTP routes, MQ messages, CLI). Dispatch commands, no business logic. |
|
|
62
|
+
| **Services** | `src/services/` | Protocol-agnostic business logic and domain operations. Auto-discovered from `*.service.ts` files. |
|
|
63
|
+
| **Data Access** | `src/db/` | Prisma ORM models, migrations, and generated client. |
|
|
64
|
+
| **Config** | `src/config/` | Environment-specific configuration (database, logging, runtime settings). |
|
|
65
|
+
| **Middleware** | `src/middleware/` | Express middleware (auth, error handling, request parsing). |
|
|
66
|
+
| **Utils** | `src/utils/` | Shared utility functions. |
|
|
67
|
+
|
|
68
|
+
### TypeScript Setup
|
|
69
|
+
- Path aliases (`@config/*`, `@db/*`, `@services/*`, `@controllers/*`, `@middleware/*`, `@utils/*`)
|
|
70
|
+
- Transpile-only in dev and production for fast startup
|
|
71
|
+
- Strict mode with custom type roots
|
|
72
|
+
|
|
73
|
+
### Deployment
|
|
74
|
+
- Heroku with Procfile-based process management
|
|
75
|
+
- `release` phase for database migrations (`prisma migrate deploy`)
|
|
76
|
+
- Environment-based config via Heroku config vars
|
|
77
|
+
|
|
78
|
+
## Default Capabilities
|
|
79
|
+
|
|
80
|
+
The `web-app` profile ships all of the following capabilities by default:
|
|
81
|
+
|
|
82
|
+
**Application capabilities** — bring project artifacts, dependencies, and skills:
|
|
83
|
+
- **web** — Handlebars templates, Tailwind CSS, and static asset serving
|
|
84
|
+
- **api** — Express routes, controllers, and middleware for building REST APIs
|
|
85
|
+
- **services** — Service layer scaffolding, auto-discovery, and design skills
|
|
86
|
+
- **database** — PostgreSQL with Prisma ORM, migrations, and seeding
|
|
87
|
+
|
|
88
|
+
**Development capabilities** — pure skill bundles that guide agent behavior:
|
|
89
|
+
- **coding** — Development conventions and code style skills
|
|
90
|
+
- **testing** — Jest setup, test utilities, TDD workflow skills
|
|
91
|
+
- **project-mgmt** — Beads issue tracking, planning skills
|
|
92
|
+
- **product-design** — Feature requirements, design thinking skills
|
|
93
|
+
- **skills** — Skill authoring and maintenance
|
|
94
|
+
|
|
95
|
+
**Demo:**
|
|
96
|
+
- **hello** — Minimal demo capability to validate the pipeline
|
|
97
|
+
|
|
98
|
+
Additional capabilities can be added post-init with `boop add`.
|
|
99
|
+
|
|
100
|
+
## Optional Add-ons
|
|
101
|
+
|
|
102
|
+
These capabilities are not included by default but can be added to a web-app project:
|
|
103
|
+
|
|
104
|
+
- **users-and-accounts** — User management, authentication, and account settings. Add with `boop add users-and-accounts`.
|
|
105
|
+
- **noticeboard** — A richer reference demo showcasing the full service-layer pattern. Add with `boop add noticeboard`.
|
|
106
|
+
|
|
107
|
+
## Reference & Skills
|
|
108
|
+
|
|
109
|
+
This profile ships with reference docs and skill files tuned for this stack:
|
|
110
|
+
|
|
111
|
+
- `reference/architecture.ref.md` — System architecture and layer responsibilities
|
|
112
|
+
- `reference/message-queue-patterns.ref.md` — Async processing patterns
|
|
113
|
+
- `skills/` — Development, testing, design, and planning skills for the web-app stack
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
There are multiple 'stacks' at play in the ecosystem:
|
|
3
|
+
## This Project
|
|
4
|
+
A full-stack web application deployed to Heroku.
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
## Multi-Process System
|
|
8
|
+
The application starts with a single web process (see `Procfile`):
|
|
9
|
+
|
|
10
|
+
1. **Web Server** (`src/server.ts`) - Express app on port 3000
|
|
11
|
+
- Main web interface and Core API
|
|
12
|
+
- Handles dashboards, authentication, content serving
|
|
13
|
+
|
|
14
|
+
As the application grows, the architecture supports additional Heroku process types for async work:
|
|
15
|
+
|
|
16
|
+
2. **Worker** (`src/worker.ts`) - Background message queue consumer
|
|
17
|
+
- Processes async tasks: long running content generation
|
|
18
|
+
- Listens to RabbitMQ queues (Heroku CLOUDAMQP)
|
|
19
|
+
|
|
20
|
+
3. **Scheduler** (`src/scheduler.ts`) - Cron-based task scheduler
|
|
21
|
+
- Publishes scheduled messages to queues for worker processing
|
|
22
|
+
- Not typically used in local development (can run with `npm run scheduler -- run-jobs` to trigger immediately)
|
|
23
|
+
|
|
24
|
+
> **Note:** Worker and scheduler entry points are not scaffolded by `boop init`. Add them when your project needs async processing. See [Message Queue Patterns](./message-queue-patterns.ref.md) for implementation guidance.
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
## Data Layer
|
|
28
|
+
### **PostgreSQL** (Prisma ORM) - Primary data store
|
|
29
|
+
- Schema: `src/db/prisma/schema.prisma`
|
|
30
|
+
- Models in `src/db/models/`, Generated client at `@prisma/*`
|
|
31
|
+
|
|
32
|
+
> **Context:** This project uses a hybrid relational + document model: key entities are tables with FK relationships; most attributes live in an `info` JSON column with `info_schema` and `revision` for change tracking.
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
### **ClickHouse** - Analytics/OLAP database
|
|
36
|
+
(Not enabled here yet)
|
|
37
|
+
|
|
38
|
+
### **Message Queue** (RabbitMQ via Heroku CLOUDAMQP)
|
|
39
|
+
- Async communication between web/scheduler and workers
|
|
40
|
+
- Local dev uses dev prefixes (e.g., `mike-`) to isolate queues
|
|
41
|
+
- A `worker` process must be running to consume messages
|
|
42
|
+
- A `scheduler` process may be run to publish messages triggering scheduled tasks
|
|
43
|
+
- In production this is triggered by Heroku
|
|
44
|
+
- In local dev, run `npm run scheduler -- run-jobs` to trigger immediately (results in messages published to queues)
|
|
45
|
+
- See [Message Queue Patterns](./message-queue-patterns.ref.md) for consumer modes, buffered batch ACK, and backpressure patterns
|
|
46
|
+
- **Graceful shutdown**: MQ service owns a coordinated SIGINT handler. Subsystems that need to complete work before AMQP connections close (e.g. S3 batch writer flush + ACK) register via `registerBeforeCloseHook()`. On SIGINT the shutdown sequence is: (1) run all before-close hooks, (2) close all tracked AMQP connections. This prevents race conditions where connections close while messages are still being acknowledged.
|
|
47
|
+
|
|
48
|
+
### **S3** (Cloud Storage)
|
|
49
|
+
(not yet defined; will be used for asset storage, e.g. images, generated content, etc.)
|
|
50
|
+
|
|
51
|
+
## Application Layers
|
|
52
|
+
|
|
53
|
+
### Controllers (`src/controllers/`)
|
|
54
|
+
Protocol-specific command/request handlers. A controller receives commands over a specific transport — HTTP routes, MQ messages, scheduler triggers, CLI invocations — and orchestrates the response by calling services. Controllers own command dispatch (e.g., publishing messages to queues) and protocol-specific concerns (parsing message payloads, sending HTTP responses). They should **not** contain business logic or direct data access.
|
|
55
|
+
|
|
56
|
+
### Services (`src/services/`)
|
|
57
|
+
Business logic and domain operations. Services are protocol-agnostic — they have no knowledge of HTTP, message queues, or CLI arguments. They expose functions that controllers (or other services) call. Services access the data layer (Prisma, ClickHouse, S3) through configured clients. Services are registered in the `services` bag via `loadServices()` and accessed by name (e.g., `services.batch_processor`).
|
|
58
|
+
|
|
59
|
+
Some services are low-level and more 'data' oriented or third-party API wrappers (e.g., `ClickHouseService`, `StorageService`), while others implement higher-level domain logic or 'app level' logic (e.g., `ContentGenerationService`, `UserManagementService`). Services can call other services, but should avoid circular dependencies. Low level services should not call high-level services to prevent tight coupling between domain logic and data access layers.
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
### Data Access
|
|
63
|
+
- **Prisma ORM** (`src/db/prisma/`) — PostgreSQL models, migrations, and generated client
|
|
64
|
+
- **ClickHouse** (`src/ch/`) — Analytics queries via `ClickHouseService`
|
|
65
|
+
- **S3** — Object storage via `StorageService` with per-feature clients
|