@boopkit/cli 0.6.9 → 0.6.10
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 +13 -3
- package/dist/capability-versions.json +10 -10
- package/dist/cli.js +1 -0
- package/dist/commands/init.js +19 -6
- package/dist/commands/list.js +5 -1
- package/dist/commands/update.d.ts +1 -0
- package/dist/commands/update.js +42 -2
- package/dist/lib/capability.d.ts +11 -0
- package/dist/lib/capability.js +26 -0
- package/dist/lib/config.d.ts +6 -1
- package/dist/lib/install-layout.d.ts +86 -0
- package/dist/lib/install-layout.js +250 -0
- package/dist/lib/preflight.js +7 -57
- package/dist/lib/scaffold.d.ts +12 -1
- package/dist/lib/scaffold.js +92 -113
- package/dist/lib/stale-scripts.d.ts +53 -0
- package/dist/lib/stale-scripts.js +160 -0
- package/dist/lib/update.js +14 -57
- package/docs/init.md +6 -1
- package/docs/skills.md +2 -0
- package/package.json +6 -2
- package/profiles/development/profile.md +79 -0
- package/profiles/web-app/profile.md +6 -6
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
|
|
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
|
+
"@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.
|
|
9
|
-
"@boopkit/cap-noticeboard": "0.4.
|
|
10
|
-
"@boopkit/cap-oauth-client": "0.1.
|
|
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.
|
|
12
|
+
"@boopkit/cap-project-mgmt": "0.4.9",
|
|
13
13
|
"@boopkit/cap-services": "0.4.2",
|
|
14
|
-
"@boopkit/cap-skills": "0.5.
|
|
15
|
-
"@boopkit/cap-testing": "0.5.
|
|
16
|
-
"@boopkit/cap-users": "0.
|
|
14
|
+
"@boopkit/cap-skills": "0.5.4",
|
|
15
|
+
"@boopkit/cap-testing": "0.5.6",
|
|
16
|
+
"@boopkit/cap-users": "0.6.0",
|
|
17
17
|
"@boopkit/cap-ux": "0.2.2",
|
|
18
|
-
"@boopkit/cap-web": "0.5.
|
|
19
|
-
"@boopkit/cap-workflows": "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
|
package/dist/commands/init.js
CHANGED
|
@@ -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 =
|
|
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:
|
|
130
|
-
scripts:
|
|
131
|
-
nodeVersion:
|
|
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,
|
|
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,
|
|
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)
|
package/dist/commands/list.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/commands/update.js
CHANGED
|
@@ -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({
|
|
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.
|
package/dist/lib/capability.d.ts
CHANGED
|
@@ -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.
|
package/dist/lib/capability.js
CHANGED
|
@@ -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.
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -14,7 +14,12 @@ export interface ProfileMetadata {
|
|
|
14
14
|
name: string;
|
|
15
15
|
description: string;
|
|
16
16
|
defaultCapabilities: string[];
|
|
17
|
-
|
|
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;
|
|
@@ -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;
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The single answer to "where does a file land when it is installed".
|
|
4
|
+
*
|
|
5
|
+
* `boop add` copies a capability's files into a project and `boop init` copies a
|
|
6
|
+
* profile's reference pages; before this module the mapping from source file to
|
|
7
|
+
* destination path was written out twice — once read-only inside `planAdd`, once
|
|
8
|
+
* while copying inside `copyFromPackage` — so the two could disagree about what a
|
|
9
|
+
* project receives and nothing would notice. Everything here is a pure read: it
|
|
10
|
+
* looks at a package or profile directory and returns `Placement[]`. It never
|
|
11
|
+
* writes, never needs a `targetDir`, and never touches `.boop/`.
|
|
12
|
+
*
|
|
13
|
+
* Three consumers depend on it: `copyFromPackage` (src/lib/scaffold.ts),
|
|
14
|
+
* `planAdd` (src/lib/preflight.ts) and `tools/generate-installed-layout.ts`,
|
|
15
|
+
* which materialises the installed layout inside the boop repository itself.
|
|
16
|
+
* Adding a fourth answer to this question is the defect this module exists to
|
|
17
|
+
* prevent.
|
|
18
|
+
*/
|
|
19
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
20
|
+
if (k2 === undefined) k2 = k;
|
|
21
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
22
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
23
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
24
|
+
}
|
|
25
|
+
Object.defineProperty(o, k2, desc);
|
|
26
|
+
}) : (function(o, m, k, k2) {
|
|
27
|
+
if (k2 === undefined) k2 = k;
|
|
28
|
+
o[k2] = m[k];
|
|
29
|
+
}));
|
|
30
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
31
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
32
|
+
}) : function(o, v) {
|
|
33
|
+
o["default"] = v;
|
|
34
|
+
});
|
|
35
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
36
|
+
var ownKeys = function(o) {
|
|
37
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
38
|
+
var ar = [];
|
|
39
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
40
|
+
return ar;
|
|
41
|
+
};
|
|
42
|
+
return ownKeys(o);
|
|
43
|
+
};
|
|
44
|
+
return function (mod) {
|
|
45
|
+
if (mod && mod.__esModule) return mod;
|
|
46
|
+
var result = {};
|
|
47
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
48
|
+
__setModuleDefault(result, mod);
|
|
49
|
+
return result;
|
|
50
|
+
};
|
|
51
|
+
})();
|
|
52
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
53
|
+
exports.PlacementCollisionError = void 0;
|
|
54
|
+
exports.describePlacementOwner = describePlacementOwner;
|
|
55
|
+
exports.capabilityPlacementsFromDir = capabilityPlacementsFromDir;
|
|
56
|
+
exports.capabilityPlacements = capabilityPlacements;
|
|
57
|
+
exports.profilePlacements = profilePlacements;
|
|
58
|
+
exports.skillDirectoryPlacements = skillDirectoryPlacements;
|
|
59
|
+
exports.findPlacementCollisions = findPlacementCollisions;
|
|
60
|
+
exports.assertNoPlacementCollisions = assertNoPlacementCollisions;
|
|
61
|
+
const fs = __importStar(require("fs"));
|
|
62
|
+
const path = __importStar(require("path"));
|
|
63
|
+
const config_1 = require("./config");
|
|
64
|
+
const capability_1 = require("./capability");
|
|
65
|
+
class PlacementCollisionError extends Error {
|
|
66
|
+
constructor(collisions) {
|
|
67
|
+
super(`Placement collision:\n${collisions.map(describeCollision).join('\n')}`);
|
|
68
|
+
this.name = 'PlacementCollisionError';
|
|
69
|
+
this.collisions = collisions;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
exports.PlacementCollisionError = PlacementCollisionError;
|
|
73
|
+
/** How a collision names each side: the owner, and for hand-authored files, that nothing ships it. */
|
|
74
|
+
function describePlacementOwner(placement) {
|
|
75
|
+
return placement.ownerKind === 'hand-authored'
|
|
76
|
+
? `${placement.owner} (hand-authored, no owner)`
|
|
77
|
+
: placement.owner;
|
|
78
|
+
}
|
|
79
|
+
function describeCollision(collision) {
|
|
80
|
+
const owners = collision.placements.map(describePlacementOwner).join(' and ');
|
|
81
|
+
return ` ${collision.dest} — claimed by ${owners}`;
|
|
82
|
+
}
|
|
83
|
+
function toPosix(relPath) {
|
|
84
|
+
return relPath.split(path.sep).join('/');
|
|
85
|
+
}
|
|
86
|
+
/** Every file under `dir`, absolute, depth-first, sorted for a stable placement order. */
|
|
87
|
+
function walkFiles(dir) {
|
|
88
|
+
const found = [];
|
|
89
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
90
|
+
const full = path.join(dir, entry.name);
|
|
91
|
+
if (entry.isDirectory() || (entry.isSymbolicLink() && fs.statSync(full).isDirectory())) {
|
|
92
|
+
found.push(...walkFiles(full));
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
found.push(full);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return found;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* `<skillsDir>/<skill-name>/**` → `skills/<skill-name>/**`.
|
|
102
|
+
* Only directories are skills; a loose file beside them ships nothing.
|
|
103
|
+
*/
|
|
104
|
+
function skillPlacements(skillsDir, owner, ownerKind) {
|
|
105
|
+
if (!fs.existsSync(skillsDir))
|
|
106
|
+
return [];
|
|
107
|
+
const placements = [];
|
|
108
|
+
for (const entry of fs.readdirSync(skillsDir).sort()) {
|
|
109
|
+
const skillDir = path.join(skillsDir, entry);
|
|
110
|
+
if (!fs.statSync(skillDir).isDirectory())
|
|
111
|
+
continue;
|
|
112
|
+
for (const source of walkFiles(skillDir)) {
|
|
113
|
+
placements.push({
|
|
114
|
+
owner,
|
|
115
|
+
ownerKind,
|
|
116
|
+
source,
|
|
117
|
+
dest: `skills/${entry}/${toPosix(path.relative(skillDir, source))}`,
|
|
118
|
+
kind: 'skill',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return placements;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* `<scaffoldDir>/**` → `<destPrefix>**`, preserving the tree.
|
|
126
|
+
* The scaffold directory mirrors the target project's own layout.
|
|
127
|
+
*/
|
|
128
|
+
function scaffoldPlacements(scaffoldDir, owner, ownerKind, destPrefix = '') {
|
|
129
|
+
if (!fs.existsSync(scaffoldDir))
|
|
130
|
+
return [];
|
|
131
|
+
return walkFiles(scaffoldDir).map(source => ({
|
|
132
|
+
owner,
|
|
133
|
+
ownerKind,
|
|
134
|
+
source,
|
|
135
|
+
dest: destPrefix + toPosix(path.relative(scaffoldDir, source)),
|
|
136
|
+
kind: 'scaffold',
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Placements for an already-resolved capability package directory.
|
|
141
|
+
*
|
|
142
|
+
* `capabilityPlacements` delegates here once it has resolved a name to a package;
|
|
143
|
+
* the generator calls it directly because it walks `packages/cap-*` on disk and
|
|
144
|
+
* has no need of npm module resolution (and must not require a build).
|
|
145
|
+
*/
|
|
146
|
+
function capabilityPlacementsFromDir(pkgDir) {
|
|
147
|
+
const pkgJsonPath = path.join(pkgDir, 'package.json');
|
|
148
|
+
if (!fs.existsSync(pkgJsonPath))
|
|
149
|
+
return [];
|
|
150
|
+
const boopkit = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).boopkit;
|
|
151
|
+
if (!boopkit)
|
|
152
|
+
return [];
|
|
153
|
+
const owner = path.basename(pkgDir);
|
|
154
|
+
const placements = [];
|
|
155
|
+
if (boopkit.skillsDir) {
|
|
156
|
+
placements.push(...skillPlacements(path.join(pkgDir, boopkit.skillsDir), owner, 'capability'));
|
|
157
|
+
}
|
|
158
|
+
if (boopkit.scaffoldDir) {
|
|
159
|
+
placements.push(...scaffoldPlacements(path.join(pkgDir, boopkit.scaffoldDir), owner, 'capability'));
|
|
160
|
+
}
|
|
161
|
+
return placements;
|
|
162
|
+
}
|
|
163
|
+
function isCapabilityPackage(pkgDir) {
|
|
164
|
+
const pkgJsonPath = path.join(pkgDir, 'package.json');
|
|
165
|
+
if (!fs.existsSync(pkgJsonPath))
|
|
166
|
+
return false;
|
|
167
|
+
return JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).boopkit?.capability === true;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Every file a capability ships and where it lands in a project.
|
|
171
|
+
*
|
|
172
|
+
* Resolves the capability the way `boop add` does — the installed package first,
|
|
173
|
+
* then the `capabilities/` source tree that only test fixtures still use.
|
|
174
|
+
*/
|
|
175
|
+
function capabilityPlacements(capabilityName, searchPaths) {
|
|
176
|
+
const pkgDir = (0, capability_1.getPackageDir)(capabilityName, searchPaths);
|
|
177
|
+
if (pkgDir && isCapabilityPackage(pkgDir)) {
|
|
178
|
+
return capabilityPlacementsFromDir(pkgDir);
|
|
179
|
+
}
|
|
180
|
+
const capDir = path.join((0, config_1.getBoopRoot)(), 'capabilities', capabilityName);
|
|
181
|
+
if (!fs.existsSync(capDir))
|
|
182
|
+
return [];
|
|
183
|
+
const owner = `cap-${capabilityName}`;
|
|
184
|
+
return [
|
|
185
|
+
...skillPlacements(path.join(capDir, 'skills'), owner, 'capability'),
|
|
186
|
+
...scaffoldPlacements(path.join(capDir, 'files'), owner, 'capability'),
|
|
187
|
+
];
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Every file a profile ships and where it lands, mirroring `boop init`
|
|
191
|
+
* (src/commands/init.ts) — today that is the profile's reference pages.
|
|
192
|
+
*/
|
|
193
|
+
function profilePlacements(profileName, repoRoot) {
|
|
194
|
+
const profileDir = path.join(repoRoot ?? (0, config_1.getBoopRoot)(), 'profiles', profileName);
|
|
195
|
+
return scaffoldPlacements(path.join(profileDir, 'reference'), profileName, 'profile', 'reference/');
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The directory-level view of a skill's placements: one entry per skill folder.
|
|
199
|
+
*
|
|
200
|
+
* The mirror materialises a skill as a single link to its folder rather than a
|
|
201
|
+
* link per file, and this derives that view from the same placement list rather
|
|
202
|
+
* than re-deriving `skills/<name>` from a package layout a second time.
|
|
203
|
+
*/
|
|
204
|
+
function skillDirectoryPlacements(placements) {
|
|
205
|
+
// Keyed by folder *and* source, so two capabilities shipping the same skill
|
|
206
|
+
// name stay two placements and the collision check can still see them.
|
|
207
|
+
const folders = new Map();
|
|
208
|
+
for (const placement of placements) {
|
|
209
|
+
if (placement.kind !== 'skill')
|
|
210
|
+
continue;
|
|
211
|
+
const [, skillName] = placement.dest.split('/');
|
|
212
|
+
const dest = `skills/${skillName}`;
|
|
213
|
+
// The file's path within its skill folder is the same suffix in source and dest,
|
|
214
|
+
// so trimming it from the source yields the folder the link must point at.
|
|
215
|
+
const withinSkill = placement.dest.slice(dest.length + 1);
|
|
216
|
+
const source = placement.source.slice(0, placement.source.length - withinSkill.length - 1);
|
|
217
|
+
folders.set(`${dest}\u0000${source}`, { ...placement, source, dest });
|
|
218
|
+
}
|
|
219
|
+
return [...folders.values()];
|
|
220
|
+
}
|
|
221
|
+
/** Every destination claimed by two or more different source files. */
|
|
222
|
+
function findPlacementCollisions(placements) {
|
|
223
|
+
const byDest = new Map();
|
|
224
|
+
for (const placement of placements) {
|
|
225
|
+
const claimants = byDest.get(placement.dest) ?? [];
|
|
226
|
+
claimants.push(placement);
|
|
227
|
+
byDest.set(placement.dest, claimants);
|
|
228
|
+
}
|
|
229
|
+
const collisions = [];
|
|
230
|
+
for (const [dest, claimants] of byDest) {
|
|
231
|
+
const sources = new Set(claimants.map(p => p.source));
|
|
232
|
+
if (sources.size > 1)
|
|
233
|
+
collisions.push({ dest, placements: claimants });
|
|
234
|
+
}
|
|
235
|
+
return collisions.sort((a, b) => a.dest.localeCompare(b.dest));
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Throw if any destination is claimed twice.
|
|
239
|
+
*
|
|
240
|
+
* The install path would not throw — `safeCopyDir` applies `onConflict: 'skip'`
|
|
241
|
+
* per file, so a second claimant silently half-merges into the first. That is
|
|
242
|
+
* fine to warn about while installing one capability into a project and fatal
|
|
243
|
+
* when writing a mirror of the whole library, which is why this is a separate
|
|
244
|
+
* assertion rather than something `planAdd` inherits.
|
|
245
|
+
*/
|
|
246
|
+
function assertNoPlacementCollisions(placements) {
|
|
247
|
+
const collisions = findPlacementCollisions(placements);
|
|
248
|
+
if (collisions.length > 0)
|
|
249
|
+
throw new PlacementCollisionError(collisions);
|
|
250
|
+
}
|