@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.
Files changed (53) hide show
  1. package/README.md +65 -0
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +85 -0
  4. package/dist/commands/add.d.ts +6 -0
  5. package/dist/commands/add.js +173 -0
  6. package/dist/commands/adopt.d.ts +5 -0
  7. package/dist/commands/adopt.js +286 -0
  8. package/dist/commands/init.d.ts +5 -0
  9. package/dist/commands/init.js +295 -0
  10. package/dist/commands/list.d.ts +6 -0
  11. package/dist/commands/list.js +105 -0
  12. package/dist/commands/loop.d.ts +63 -0
  13. package/dist/commands/loop.js +765 -0
  14. package/dist/commands/status.d.ts +40 -0
  15. package/dist/commands/status.js +303 -0
  16. package/dist/commands/status.test.d.ts +1 -0
  17. package/dist/commands/status.test.js +118 -0
  18. package/dist/commands/update.d.ts +8 -0
  19. package/dist/commands/update.js +204 -0
  20. package/dist/lib/capability.d.ts +17 -0
  21. package/dist/lib/capability.js +172 -0
  22. package/dist/lib/config.d.ts +70 -0
  23. package/dist/lib/config.js +59 -0
  24. package/dist/lib/db-status.d.ts +64 -0
  25. package/dist/lib/db-status.js +257 -0
  26. package/dist/lib/env.d.ts +25 -0
  27. package/dist/lib/env.js +116 -0
  28. package/dist/lib/execute.d.ts +21 -0
  29. package/dist/lib/execute.js +228 -0
  30. package/dist/lib/interpolate.d.ts +4 -0
  31. package/dist/lib/interpolate.js +13 -0
  32. package/dist/lib/manifest.d.ts +54 -0
  33. package/dist/lib/manifest.js +11 -0
  34. package/dist/lib/preflight.d.ts +31 -0
  35. package/dist/lib/preflight.js +402 -0
  36. package/dist/lib/profile.d.ts +11 -0
  37. package/dist/lib/profile.js +73 -0
  38. package/dist/lib/project.d.ts +36 -0
  39. package/dist/lib/project.js +71 -0
  40. package/dist/lib/project.test.d.ts +1 -0
  41. package/dist/lib/project.test.js +118 -0
  42. package/dist/lib/rewire.d.ts +19 -0
  43. package/dist/lib/rewire.js +222 -0
  44. package/dist/lib/safe-io.d.ts +66 -0
  45. package/dist/lib/safe-io.js +167 -0
  46. package/dist/lib/scaffold.d.ts +87 -0
  47. package/dist/lib/scaffold.js +548 -0
  48. package/dist/lib/update.d.ts +73 -0
  49. package/dist/lib/update.js +379 -0
  50. package/package.json +46 -0
  51. package/profiles/web-app/profile.md +113 -0
  52. package/profiles/web-app/reference/architecture.ref.md +65 -0
  53. 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