@boopkit/cli 0.6.9 → 0.6.11

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 CHANGED
@@ -67,7 +67,7 @@ Capabilities can declare `requires` relationships (e.g. `users-and-accounts` req
67
67
 
68
68
  ## Profiles
69
69
 
70
- A profile is where a project starts. Today there's one; more will follow as the common shapes of AI-first products become clearer.
70
+ A profile names a set of capabilities. Two ship today.
71
71
 
72
72
  ### `web-app`
73
73
 
@@ -75,6 +75,16 @@ Full-stack web application — Express + Handlebars + Tailwind CSS + PostgreSQL
75
75
 
76
76
  Ships with application capabilities (`web`, `api`, `services`, `database`), development skills (`coding`, `testing`, `project-mgmt`, `product-design`, `skills`, `workflows`), and the `hello` demo capability.
77
77
 
78
+ This is the profile `boop init` scaffolds from — it carries a kernel, so there is an application to write.
79
+
80
+ ### `development`
81
+
82
+ The development capabilities on their own (`coding`, `testing`, `project-mgmt`, `product-design`, `skills`, `workflows`) — no web layer, no API layer, no database.
83
+
84
+ For a project with no application domain boop has anything to say about: a CLI, a library, a data pipeline. Those share no application layers at all, but they are developed the same way.
85
+
86
+ It carries no kernel — no runtime, no port, no deploy target — so there is nothing for `boop init` to scaffold and `boop init --profile development` is refused. Use it by naming it as the `profile` in an existing project's `.boop/config.json`, or reach for `boop add` on the capabilities you want. Boop's own repository is the first project to declare it.
87
+
78
88
  List profiles with `boop list profiles`.
79
89
 
80
90
  ## Available Capabilities
@@ -119,10 +129,10 @@ Adding a capability installs its skills into your project's `skills/` directory.
119
129
  - **`coding`** — boop-coding-beads-flow, boop-coding-development, boop-coding-commit-changes, boop-coding-review-changes, boop-coding-debugging
120
130
  - **`product-design`** — boop-product-design-engineering-design, boop-product-design-feature-documentation, boop-product-design-feature-requirements, boop-product-design-reference-integration, boop-product-design-thought-partnership, boop-writing-technical-prose, boop-writing-documents, boop-writing-semantic-line-breaks
121
131
  - **`project-mgmt`** — boop-project-mgmt-beads-planning, boop-project-mgmt-engineering-planning
122
- - **`skills`** — boop-skill-authoring, boop-skills-discovery, boop-skills-agent-surface-design, boop-get-smart, boop-so-what
132
+ - **`skills`** — boop-skill-authoring, boop-skills-discovery, boop-skills-agent-surface-design, boop-get-smart, boop-so-what, boop-skills-hyperlinking
123
133
  - **`testing`** — boop-testing-tdd-light, boop-testing-testing-express-web, boop-testing-testing
124
134
  - **`ux`** — boop-ux-purpose-judgement, boop-ux-visual-judgement, boop-ux-eval-and-refine-workflow, boop-ux-app-ui-dev-workflow
125
- - **`workflows`** — boop-feature-dev-workflow, boop-light-dev-workflow, boop-refactor-dev-workflow, boop-test-audit-workflow, boop-architecture-audit-workflow, boop-issue-sweep-workflow, boop-website-dev-workflow, boop-design-handoff-workflow, boop-knowledge-backfill
135
+ - **`workflows`** — boop-feature-dev-workflow, boop-light-dev-workflow, boop-refactor-dev-workflow, boop-test-audit-workflow, boop-architecture-audit-workflow, boop-issue-sweep-workflow, boop-website-dev-workflow, boop-design-handoff-workflow, boop-knowledge-backfill, boop-stray-disposition
126
136
 
127
137
  **Application capabilities**
128
138
 
@@ -1,21 +1,21 @@
1
1
  {
2
- "@boopkit/cap-api": "0.4.2",
2
+ "@boopkit/cap-api": "0.4.4",
3
3
  "@boopkit/cap-background-jobs": "0.4.2",
4
4
  "@boopkit/cap-cloud-storage": "0.4.0",
5
5
  "@boopkit/cap-coding": "0.4.4",
6
6
  "@boopkit/cap-database": "0.4.3",
7
7
  "@boopkit/cap-email": "0.4.2",
8
- "@boopkit/cap-hello": "0.4.0",
9
- "@boopkit/cap-noticeboard": "0.4.0",
10
- "@boopkit/cap-oauth-client": "0.1.0",
8
+ "@boopkit/cap-hello": "0.4.1",
9
+ "@boopkit/cap-noticeboard": "0.4.1",
10
+ "@boopkit/cap-oauth-client": "0.1.1",
11
11
  "@boopkit/cap-product-design": "0.6.7",
12
- "@boopkit/cap-project-mgmt": "0.4.5",
12
+ "@boopkit/cap-project-mgmt": "0.4.9",
13
13
  "@boopkit/cap-services": "0.4.2",
14
- "@boopkit/cap-skills": "0.5.3",
15
- "@boopkit/cap-testing": "0.5.3",
16
- "@boopkit/cap-users": "0.4.4",
14
+ "@boopkit/cap-skills": "0.5.4",
15
+ "@boopkit/cap-testing": "0.5.6",
16
+ "@boopkit/cap-users": "0.7.0",
17
17
  "@boopkit/cap-ux": "0.2.2",
18
- "@boopkit/cap-web": "0.5.3",
19
- "@boopkit/cap-workflows": "0.1.0",
18
+ "@boopkit/cap-web": "0.5.4",
19
+ "@boopkit/cap-workflows": "0.2.0",
20
20
  "@boopkit/logger": "0.4.0"
21
21
  }
package/dist/cli.js CHANGED
@@ -91,6 +91,7 @@ program
91
91
  .option('--prune', 'Remove deprecated scaffold files from disk (creates .bak backup)')
92
92
  .option('--skip-npm', 'Skip npm update (only re-scaffold from installed versions)')
93
93
  .option('--tag <tag>', 'Use a specific npm dist-tag instead of major-version pinning (e.g. beta)')
94
+ .option('--fix-scripts', 'Rewrite package.json scripts that contradict shipped runner settings')
94
95
  .option('--json', 'Output structured JSON')
95
96
  .action(update_1.runUpdate);
96
97
  program
@@ -71,8 +71,21 @@ function runInit(name, options) {
71
71
  process.exitCode = 1;
72
72
  return;
73
73
  }
74
+ // --- Guard: a profile with no kernel has no application to scaffold ---
75
+ // `boop init` exists to write a running project from kernel templates. A
76
+ // profile that names only development capabilities (profiles/development/)
77
+ // has no runtime, port or deploy target to render them with, so refuse here
78
+ // rather than emit a project that cannot start.
79
+ if (!profile.kernel) {
80
+ console.error(`Error: Profile "${profileName}" carries no kernel, so there is nothing to scaffold.`);
81
+ console.error('It declares capabilities for a project that already exists: name it as the "profile" in that');
82
+ console.error(`project's .boop/config.json, or run \`boop add\` for the capabilities you want.`);
83
+ process.exitCode = 1;
84
+ return;
85
+ }
86
+ const kernel = profile.kernel;
74
87
  // --- Node version check ---
75
- const requiredNodeVersion = profile.kernel.nodeVersion;
88
+ const requiredNodeVersion = kernel.nodeVersion;
76
89
  if (requiredNodeVersion && !process.env.BOOP_SKIP_VERSION_CHECK) {
77
90
  const minVersion = requiredNodeVersion.replace(/^[>=^~]+/, '');
78
91
  const currentMajor = parseInt(process.versions.node.split('.')[0], 10);
@@ -126,14 +139,14 @@ function runInit(name, options) {
126
139
  // --- Scaffold kernel ---
127
140
  const variables = {
128
141
  projectName: name,
129
- port: profile.kernel.port,
130
- scripts: profile.kernel.scripts,
131
- nodeVersion: profile.kernel.nodeVersion ?? '>=18.0.0',
142
+ port: kernel.port,
143
+ scripts: kernel.scripts,
144
+ nodeVersion: kernel.nodeVersion ?? '>=18.0.0',
132
145
  ...hookVars,
133
146
  };
134
147
  const kernelFiles = (0, scaffold_1.scaffoldKernel)(targetDir, variables);
135
148
  // --- Merge kernel dependencies ---
136
- (0, scaffold_1.mergePackageJson)(targetDir, profile.kernel.dependencies, profile.kernel.devDependencies, undefined, { skipBackup: true });
149
+ (0, scaffold_1.mergePackageJson)(targetDir, kernel.dependencies, kernel.devDependencies, undefined, { skipBackup: true });
137
150
  // --- Create minimal manifest so capability hash storage can find config.json ---
138
151
  (0, project_1.writeProjectManifest)(targetDir, {
139
152
  name,
@@ -183,7 +196,7 @@ function runInit(name, options) {
183
196
  (0, scaffold_1.scaffoldKernel)(targetDir, variables, true);
184
197
  // Re-scaffolding overwrites package.json (template has empty deps),
185
198
  // so re-merge kernel deps + cap package deps that were added earlier.
186
- (0, scaffold_1.mergePackageJson)(targetDir, profile.kernel.dependencies, profile.kernel.devDependencies, undefined, { skipBackup: true });
199
+ (0, scaffold_1.mergePackageJson)(targetDir, kernel.dependencies, kernel.devDependencies, undefined, { skipBackup: true });
187
200
  (0, scaffold_1.mergePackageJson)(targetDir, undefined, undefined, capDeps, { skipBackup: true });
188
201
  }
189
202
  // Post-install conflict check (when preflight was deferred)
@@ -35,7 +35,11 @@ function listProfilesCommand() {
35
35
  for (const p of profiles) {
36
36
  console.log(` ${p.name}`);
37
37
  console.log(` ${p.description}`);
38
- console.log(` runtime: ${p.kernel.runtime} | deploy: ${p.kernel.deploy} | port: ${p.kernel.port}`);
38
+ // A profile with no application domain carries no kernel, so there is no
39
+ // runtime line to print for it.
40
+ if (p.kernel) {
41
+ console.log(` runtime: ${p.kernel.runtime} | deploy: ${p.kernel.deploy} | port: ${p.kernel.port}`);
42
+ }
39
43
  const grouped = new Map();
40
44
  for (const capName of p.defaultCapabilities) {
41
45
  const cap = (0, capability_1.getCapability)(capName);
@@ -180,6 +180,8 @@ async function enrichWithSetupChecks(data, envValues, projectDir, projectName) {
180
180
  type: c.type,
181
181
  label: (0, interpolate_1.interpolate)(c.label, vars),
182
182
  hint: c.hint ? (0, interpolate_1.interpolate)(c.hint, vars) : undefined,
183
+ // Probe queries are literal SQL — pass them through untouched.
184
+ query: c.query,
183
185
  }));
184
186
  // Extract this capability's .prisma files for scoped prisma-migrate checks
185
187
  const prismaFiles = capDef?.files?.filter(f => f.endsWith('.prisma'));
@@ -6,5 +6,6 @@ export interface UpdateOptions {
6
6
  json?: boolean;
7
7
  skipNpm?: boolean;
8
8
  tag?: string;
9
+ fixScripts?: boolean;
9
10
  }
10
11
  export declare function runUpdate(capabilityName: string | undefined, options: UpdateOptions): void;
@@ -41,6 +41,7 @@ const project_1 = require("../lib/project");
41
41
  const capability_1 = require("../lib/capability");
42
42
  const update_1 = require("../lib/update");
43
43
  const scaffold_1 = require("../lib/scaffold");
44
+ const stale_scripts_1 = require("../lib/stale-scripts");
44
45
  function runUpdate(capabilityName, options) {
45
46
  const targetDir = process.cwd();
46
47
  // Must be in a boop project
@@ -95,8 +96,9 @@ function runUpdate(capabilityName, options) {
95
96
  }
96
97
  // Dry run: show plans and exit
97
98
  if (options.dryRun) {
99
+ const stale = detectStaleScripts(targetDir);
98
100
  if (options.json) {
99
- console.log(JSON.stringify({ capabilities: plans }, null, 2));
101
+ console.log(JSON.stringify({ capabilities: plans, staleScripts: stale }, null, 2));
100
102
  return;
101
103
  }
102
104
  for (const plan of plans) {
@@ -114,6 +116,9 @@ function runUpdate(capabilityName, options) {
114
116
  console.log(` ${icon} ${action.relativePath} — ${action.reason}`);
115
117
  }
116
118
  }
119
+ if (stale.length > 0) {
120
+ console.log(`\n${(0, stale_scripts_1.formatStaleScriptWarning)(stale)}`);
121
+ }
117
122
  return;
118
123
  }
119
124
  // Execute updates
@@ -141,9 +146,21 @@ function runUpdate(capabilityName, options) {
141
146
  if (!options.dryRun) {
142
147
  (0, scaffold_1.linkSkillsToClaude)(targetDir);
143
148
  }
149
+ // A script that contradicts the settings a capability now ships survives the
150
+ // update, because scripts are only merged when absent. Say so, or fix it on
151
+ // request. See lib/stale-scripts.ts for why this is a warning and not a prompt.
152
+ const stale = detectStaleScripts(targetDir);
153
+ const fixedScripts = options.fixScripts ? (0, stale_scripts_1.rewriteStaleRunnerScripts)(targetDir, stale) : [];
154
+ const remainingStale = options.fixScripts
155
+ ? stale.filter((f) => !fixedScripts.some((a) => a.name === f.name))
156
+ : stale;
144
157
  // Output
145
158
  if (options.json) {
146
- console.log(JSON.stringify({ capabilities: results }, null, 2));
159
+ console.log(JSON.stringify({
160
+ capabilities: results,
161
+ staleScripts: remainingStale,
162
+ fixedScripts,
163
+ }, null, 2));
147
164
  return;
148
165
  }
149
166
  console.log(`\n${'─'.repeat(40)}`);
@@ -182,6 +199,29 @@ function runUpdate(capabilityName, options) {
182
199
  }
183
200
  }
184
201
  }
202
+ for (const fixed of fixedScripts) {
203
+ console.log(`\n\x1b[32m✓\x1b[0m package.json script "${fixed.name}" rewritten: ${fixed.command} → ${fixed.rewritten}`);
204
+ }
205
+ if (remainingStale.length > 0) {
206
+ console.log(`\n${(0, stale_scripts_1.formatStaleScriptWarning)(remainingStale)}`);
207
+ }
208
+ }
209
+ /**
210
+ * Read the project's package.json scripts and return those that contradict the
211
+ * runner settings the capabilities now ship. Missing or unreadable package.json
212
+ * is not an error here — this check is advisory and must never fail an update.
213
+ */
214
+ function detectStaleScripts(targetDir) {
215
+ const pkgPath = path.join(targetDir, 'package.json');
216
+ if (!fs.existsSync(pkgPath))
217
+ return [];
218
+ try {
219
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
220
+ return (0, stale_scripts_1.findStaleRunnerScripts)(pkg.scripts);
221
+ }
222
+ catch {
223
+ return [];
224
+ }
185
225
  }
186
226
  /**
187
227
  * Diagnose why no update plans were generated and return a specific message.
@@ -5,6 +5,17 @@ import { CapabilityMetadata } from './config';
5
5
  * Returns null if not found.
6
6
  */
7
7
  export declare function getPackageDir(name: string, searchPaths?: string[]): string | null;
8
+ /**
9
+ * The capability name a package directory ships — the name a project's
10
+ * `.boop/config.json` records for it.
11
+ *
12
+ * Usually that is the directory name minus `cap-`, but the two are allowed to
13
+ * differ and `capability.json` is the authority: `packages/cap-users` ships
14
+ * `users-and-accounts`. Reading the directory alone is a pure filesystem
15
+ * lookup, so it works against a source tree that has never been built or
16
+ * installed — which is what the installed-layout generator needs.
17
+ */
18
+ export declare function capabilityNameForPackageDir(pkgDir: string): string;
8
19
  /**
9
20
  * Look up a capability in the baked-in catalog by name.
10
21
  * Returns the catalog entry (with package field) or null if not found.
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.getPackageDir = getPackageDir;
37
+ exports.capabilityNameForPackageDir = capabilityNameForPackageDir;
37
38
  exports.getCatalogEntry = getCatalogEntry;
38
39
  exports.listCapabilities = listCapabilities;
39
40
  exports.getCapability = getCapability;
@@ -59,6 +60,31 @@ function getPackageDir(name, searchPaths) {
59
60
  return findPackageDirByCapabilityName(name, paths);
60
61
  }
61
62
  }
63
+ /**
64
+ * The capability name a package directory ships — the name a project's
65
+ * `.boop/config.json` records for it.
66
+ *
67
+ * Usually that is the directory name minus `cap-`, but the two are allowed to
68
+ * differ and `capability.json` is the authority: `packages/cap-users` ships
69
+ * `users-and-accounts`. Reading the directory alone is a pure filesystem
70
+ * lookup, so it works against a source tree that has never been built or
71
+ * installed — which is what the installed-layout generator needs.
72
+ */
73
+ function capabilityNameForPackageDir(pkgDir) {
74
+ const capJsonPath = path.join(pkgDir, 'capability.json');
75
+ if (fs.existsSync(capJsonPath)) {
76
+ try {
77
+ const { name } = JSON.parse(fs.readFileSync(capJsonPath, 'utf-8'));
78
+ if (typeof name === 'string' && name.length > 0)
79
+ return name;
80
+ }
81
+ catch {
82
+ // An unreadable capability.json is not worth failing over here; the
83
+ // directory name is the same answer in every case but the mismatches.
84
+ }
85
+ }
86
+ return path.basename(pkgDir).replace(/^cap-/, '');
87
+ }
62
88
  /**
63
89
  * Read capability metadata from a package's capability.json.
64
90
  * Returns null if the package is not found or has no capability.json.
@@ -14,7 +14,12 @@ export interface ProfileMetadata {
14
14
  name: string;
15
15
  description: string;
16
16
  defaultCapabilities: string[];
17
- kernel: KernelConfig;
17
+ /**
18
+ * Optional: every field of KernelConfig parameterises the scaffolding of a
19
+ * running web process, so a profile with no application domain (see
20
+ * `profiles/development/`) has no honest values to put here and omits it.
21
+ */
22
+ kernel?: KernelConfig;
18
23
  }
19
24
  export interface CapabilityScript {
20
25
  cmd: string;
@@ -34,9 +39,16 @@ export interface CapabilityEnvVar {
34
39
  required?: 'boot' | 'runtime';
35
40
  }
36
41
  export interface CapabilitySetupCheck {
37
- type: 'env-set' | 'service' | 'database' | 'prisma-migrate' | 'prisma-generate';
42
+ type: 'env-set' | 'service' | 'database' | 'prisma-migrate' | 'prisma-generate' | 'probe';
38
43
  label: string;
39
44
  hint?: string;
45
+ /**
46
+ * For `type: 'probe'` only — a read-only SQL query the capability asserts
47
+ * about its own data. The check passes when the query returns no rows. The
48
+ * query runs in a read-only transaction, so it cannot write. Ignored by
49
+ * every other check type.
50
+ */
51
+ query?: string;
40
52
  }
41
53
  export interface CapabilityPostInstall {
42
54
  prerequisites?: string[];
@@ -51,6 +51,22 @@ export declare function isPrismaClientGenerated(projectDir: string): boolean;
51
51
  * Returns true if in sync, false if not, null if prisma not available.
52
52
  */
53
53
  export declare function checkPrismaMigrateStatus(projectDir: string): Promise<boolean | null>;
54
+ /**
55
+ * Run a capability-supplied read-only data probe.
56
+ *
57
+ * The query is executed in a read-only transaction (via PGOPTIONS), so a probe
58
+ * that accidentally contains a write raises "cannot execute … in a read-only
59
+ * transaction" rather than mutating anything. The probe is a positive detector:
60
+ * it reports a violation ONLY when it observes one.
61
+ *
62
+ * Returns:
63
+ * true — clean: the query returned no rows.
64
+ * false — violation: the query returned at least one row.
65
+ * null — could not run (psql missing, DB unreachable, table absent, or the
66
+ * query errored). The caller treats this as "no violation observed"
67
+ * so an un-runnable probe never raises a false alarm.
68
+ */
69
+ export declare function checkDataProbe(dbUrl: string, query: string): Promise<boolean | null>;
54
70
  export interface SetupStep {
55
71
  label: string;
56
72
  done: boolean;
@@ -73,4 +89,5 @@ export declare function runSetupChecks(checks: {
73
89
  type: string;
74
90
  label: string;
75
91
  hint?: string;
92
+ query?: string;
76
93
  }[], envValues: Map<string, string>, projectDir: string, projectName: string, prismaFiles?: string[]): Promise<SetupStep[]>;
@@ -43,6 +43,7 @@ exports.checkTablesExist = checkTablesExist;
43
43
  exports.hasPrismaModels = hasPrismaModels;
44
44
  exports.isPrismaClientGenerated = isPrismaClientGenerated;
45
45
  exports.checkPrismaMigrateStatus = checkPrismaMigrateStatus;
46
+ exports.checkDataProbe = checkDataProbe;
46
47
  exports.runSetupChecks = runSetupChecks;
47
48
  const child_process_1 = require("child_process");
48
49
  const fs = __importStar(require("fs"));
@@ -258,11 +259,46 @@ function checkPrismaMigrateStatus(projectDir) {
258
259
  });
259
260
  }
260
261
  /**
261
- * Run a single setup check by type. Returns true/false for pass/fail.
262
+ * Run a capability-supplied read-only data probe.
263
+ *
264
+ * The query is executed in a read-only transaction (via PGOPTIONS), so a probe
265
+ * that accidentally contains a write raises "cannot execute … in a read-only
266
+ * transaction" rather than mutating anything. The probe is a positive detector:
267
+ * it reports a violation ONLY when it observes one.
268
+ *
269
+ * Returns:
270
+ * true — clean: the query returned no rows.
271
+ * false — violation: the query returned at least one row.
272
+ * null — could not run (psql missing, DB unreachable, table absent, or the
273
+ * query errored). The caller treats this as "no violation observed"
274
+ * so an un-runnable probe never raises a false alarm.
275
+ */
276
+ function checkDataProbe(dbUrl, query) {
277
+ return new Promise((resolve) => {
278
+ (0, child_process_1.execFile)('psql', [dbUrl, '-tAc', query], {
279
+ timeout: 3000,
280
+ // Force a read-only session: any write in the query fails at the server.
281
+ env: { ...process.env, PGOPTIONS: '-c default_transaction_read_only=on' },
282
+ }, (error, stdout) => {
283
+ if (error) {
284
+ // psql not installed, DB unreachable, missing table, or a query error.
285
+ // None of these is an observed violation, so don't raise a false alarm.
286
+ resolve(null);
287
+ }
288
+ else {
289
+ // With -tA a zero-row result is empty output; any output means the
290
+ // query found a violating row.
291
+ resolve(stdout.trim() === '' ? true : false);
292
+ }
293
+ });
294
+ });
295
+ }
296
+ /**
297
+ * Run a single setup check. Returns true/false for pass/fail.
262
298
  * Checks can depend on earlier results via ctx.results.
263
299
  */
264
- async function runCheck(type, ctx) {
265
- switch (type) {
300
+ async function runCheck(check, ctx) {
301
+ switch (check.type) {
266
302
  case 'env-set': {
267
303
  const dbUrl = ctx.envValues.get('DATABASE_URL');
268
304
  return !!dbUrl;
@@ -319,6 +355,20 @@ async function runCheck(type, ctx) {
319
355
  case 'prisma-generate': {
320
356
  return isPrismaClientGenerated(ctx.projectDir);
321
357
  }
358
+ case 'probe': {
359
+ // A read-only data probe: passes when the capability's query finds no
360
+ // violating rows. Needs a query and a database URL; without either there
361
+ // is nothing to observe, so it passes rather than raising a false alarm.
362
+ if (!check.query)
363
+ return true;
364
+ const dbUrl = ctx.envValues.get('DATABASE_URL');
365
+ if (!dbUrl)
366
+ return true;
367
+ const result = await checkDataProbe(dbUrl, check.query);
368
+ // Only an observed violation (result === false) fails the check; a probe
369
+ // that could not run (null) is treated as clean.
370
+ return result !== false;
371
+ }
322
372
  default:
323
373
  return false;
324
374
  }
@@ -331,7 +381,7 @@ async function runSetupChecks(checks, envValues, projectDir, projectName, prisma
331
381
  const ctx = { envValues, projectDir, projectName, results: new Map(), prismaFiles };
332
382
  const steps = [];
333
383
  for (const check of checks) {
334
- const done = await runCheck(check.type, ctx);
384
+ const done = await runCheck(check, ctx);
335
385
  ctx.results.set(check.type, done);
336
386
  let hint = check.hint;
337
387
  // When prisma-migrate fails and no migrations exist yet, the first
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The single answer to "where does a file land when it is installed".
3
+ *
4
+ * `boop add` copies a capability's files into a project and `boop init` copies a
5
+ * profile's reference pages; before this module the mapping from source file to
6
+ * destination path was written out twice — once read-only inside `planAdd`, once
7
+ * while copying inside `copyFromPackage` — so the two could disagree about what a
8
+ * project receives and nothing would notice. Everything here is a pure read: it
9
+ * looks at a package or profile directory and returns `Placement[]`. It never
10
+ * writes, never needs a `targetDir`, and never touches `.boop/`.
11
+ *
12
+ * Three consumers depend on it: `copyFromPackage` (src/lib/scaffold.ts),
13
+ * `planAdd` (src/lib/preflight.ts) and `tools/generate-installed-layout.ts`,
14
+ * which materialises the installed layout inside the boop repository itself.
15
+ * Adding a fourth answer to this question is the defect this module exists to
16
+ * prevent.
17
+ */
18
+ /** `skill` files land under `skills/`; `scaffold` files land wherever the package's scaffold tree says. */
19
+ export type PlacementKind = 'skill' | 'scaffold';
20
+ /**
21
+ * What ships a file. `hand-authored` is not an installable thing — it labels a
22
+ * file that already exists in a tree the mirror writes into, so a collision with
23
+ * one can be reported with the same machinery.
24
+ */
25
+ export type PlacementOwnerKind = 'capability' | 'profile' | 'hand-authored';
26
+ export interface Placement {
27
+ /** 'cap-database', 'web-app', or 'skills/boop-dolt-recovery' — the thing that ships this file. */
28
+ owner: string;
29
+ ownerKind: PlacementOwnerKind;
30
+ /** Absolute path of the file inside the package or profile directory. */
31
+ source: string;
32
+ /** Where it lands, POSIX-relative to the project root, e.g. 'skills/boop-database-prisma/SKILL.md'. */
33
+ dest: string;
34
+ kind: PlacementKind;
35
+ }
36
+ /** Two or more different sources claiming one destination. */
37
+ export interface PlacementCollision {
38
+ dest: string;
39
+ placements: Placement[];
40
+ }
41
+ export declare class PlacementCollisionError extends Error {
42
+ readonly collisions: PlacementCollision[];
43
+ constructor(collisions: PlacementCollision[]);
44
+ }
45
+ /** How a collision names each side: the owner, and for hand-authored files, that nothing ships it. */
46
+ export declare function describePlacementOwner(placement: Placement): string;
47
+ /**
48
+ * Placements for an already-resolved capability package directory.
49
+ *
50
+ * `capabilityPlacements` delegates here once it has resolved a name to a package;
51
+ * the generator calls it directly because it walks `packages/cap-*` on disk and
52
+ * has no need of npm module resolution (and must not require a build).
53
+ */
54
+ export declare function capabilityPlacementsFromDir(pkgDir: string): Placement[];
55
+ /**
56
+ * Every file a capability ships and where it lands in a project.
57
+ *
58
+ * Resolves the capability the way `boop add` does — the installed package first,
59
+ * then the `capabilities/` source tree that only test fixtures still use.
60
+ */
61
+ export declare function capabilityPlacements(capabilityName: string, searchPaths?: string[]): Placement[];
62
+ /**
63
+ * Every file a profile ships and where it lands, mirroring `boop init`
64
+ * (src/commands/init.ts) — today that is the profile's reference pages.
65
+ */
66
+ export declare function profilePlacements(profileName: string, repoRoot?: string): Placement[];
67
+ /**
68
+ * The directory-level view of a skill's placements: one entry per skill folder.
69
+ *
70
+ * The mirror materialises a skill as a single link to its folder rather than a
71
+ * link per file, and this derives that view from the same placement list rather
72
+ * than re-deriving `skills/<name>` from a package layout a second time.
73
+ */
74
+ export declare function skillDirectoryPlacements(placements: Placement[]): Placement[];
75
+ /** Every destination claimed by two or more different source files. */
76
+ export declare function findPlacementCollisions(placements: Placement[]): PlacementCollision[];
77
+ /**
78
+ * Throw if any destination is claimed twice.
79
+ *
80
+ * The install path would not throw — `safeCopyDir` applies `onConflict: 'skip'`
81
+ * per file, so a second claimant silently half-merges into the first. That is
82
+ * fine to warn about while installing one capability into a project and fatal
83
+ * when writing a mirror of the whole library, which is why this is a separate
84
+ * assertion rather than something `planAdd` inherits.
85
+ */
86
+ export declare function assertNoPlacementCollisions(placements: Placement[]): void;