@boopkit/cli 0.6.14 → 0.6.15

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.
@@ -2,21 +2,21 @@
2
2
  "@boopkit/cap-api": "0.4.5",
3
3
  "@boopkit/cap-background-jobs": "0.4.3",
4
4
  "@boopkit/cap-cloud-storage": "0.4.1",
5
- "@boopkit/cap-coding": "0.4.5",
5
+ "@boopkit/cap-coding": "0.4.6",
6
6
  "@boopkit/cap-database": "0.4.4",
7
7
  "@boopkit/cap-email": "0.4.3",
8
8
  "@boopkit/cap-hello": "0.4.1",
9
9
  "@boopkit/cap-noticeboard": "0.4.1",
10
10
  "@boopkit/cap-oauth-client": "0.1.1",
11
11
  "@boopkit/cap-oauth-server": "0.1.4",
12
- "@boopkit/cap-product-design": "0.6.8",
13
- "@boopkit/cap-project-mgmt": "0.4.10",
12
+ "@boopkit/cap-product-design": "0.6.9",
13
+ "@boopkit/cap-project-mgmt": "0.4.11",
14
14
  "@boopkit/cap-services": "0.4.3",
15
- "@boopkit/cap-skills": "0.5.5",
15
+ "@boopkit/cap-skills": "0.5.6",
16
16
  "@boopkit/cap-testing": "0.5.7",
17
17
  "@boopkit/cap-users": "0.8.1",
18
18
  "@boopkit/cap-ux": "0.2.3",
19
19
  "@boopkit/cap-web": "0.5.5",
20
- "@boopkit/cap-workflows": "0.2.1",
20
+ "@boopkit/cap-workflows": "0.2.2",
21
21
  "@boopkit/logger": "0.4.0"
22
22
  }
package/dist/cli.js CHANGED
File without changes
@@ -369,7 +369,7 @@ function runInit(name, options, deps = {}) {
369
369
  (0, scaffold_1.injectCssImports)(targetDir, cssCapMap, { skipBackup: true });
370
370
  // --- Symlink skills/* into .claude/skills/ for Claude Code auto-discovery, and give each a PROJECT.md ---
371
371
  (0, scaffold_1.linkSkillsToClaude)(targetDir);
372
- (0, scaffold_1.createSkillProjectFiles)(targetDir);
372
+ (0, scaffold_1.createSkillProjectFiles)(targetDir, capSearchPaths);
373
373
  // --- Merge capability scripts into package.json ---
374
374
  if (Object.keys(allScripts).length > 0) {
375
375
  const pkgPath = path.join(targetDir, 'package.json');
@@ -105,7 +105,7 @@ function runUpdate(capabilityName, options) {
105
105
  console.log(`\n${plan.capability}:`);
106
106
  // Recording a hash for a file that already holds what boop ships changes nothing the project can see
107
107
  const visible = plan.actions.filter(a => a.action !== 'record-hash');
108
- if (visible.length === 0) {
108
+ if (visible.length === 0 && plan.scriptActions.length === 0) {
109
109
  console.log(' Already up to date');
110
110
  continue;
111
111
  }
@@ -122,6 +122,9 @@ function runUpdate(capabilityName, options) {
122
122
  if (action.instruction)
123
123
  console.log(instructionLine(action.instruction));
124
124
  }
125
+ for (const line of scriptLines(plan.scriptActions)) {
126
+ console.log(line);
127
+ }
125
128
  }
126
129
  if (stale.length > 0) {
127
130
  console.log(`\n${(0, stale_scripts_1.formatStaleScriptWarning)(stale)}`);
@@ -142,6 +145,8 @@ function runUpdate(capabilityName, options) {
142
145
  skippedDeleted: [],
143
146
  pruned: [],
144
147
  migrated: [],
148
+ scriptsAdded: [],
149
+ scriptsKept: plan.scriptActions.filter(a => a.action === 'keep'),
145
150
  unchangedCount: plan.unchangedCount,
146
151
  instructions: [],
147
152
  });
@@ -219,6 +224,9 @@ function runUpdate(capabilityName, options) {
219
224
  console.log(` \x1b[33m⚠\x1b[0m ${f} (deprecated — removed from package, use --prune to delete)`);
220
225
  }
221
226
  }
227
+ for (const line of scriptLines([...(result.scriptsAdded || []), ...(result.scriptsKept || [])])) {
228
+ console.log(line);
229
+ }
222
230
  });
223
231
  for (const fixed of fixedScripts) {
224
232
  console.log(`\n\x1b[32m✓\x1b[0m package.json script "${fixed.name}" rewritten: ${fixed.command} → ${fixed.rewritten}`);
@@ -227,6 +235,27 @@ function runUpdate(capabilityName, options) {
227
235
  console.log(`\n${(0, stale_scripts_1.formatStaleScriptWarning)(remainingStale)}`);
228
236
  }
229
237
  }
238
+ /**
239
+ * How the report shows the npm scripts a capability declares: an added one reads
240
+ * like a new file, and a kept one shows the command the capability ships, so a
241
+ * project whose own command has fallen behind can see that it has. The label
242
+ * claims only that the commands differ: with no record of the command boop wrote,
243
+ * a command an older capability version shipped looks just like a customisation.
244
+ */
245
+ function scriptLines(actions) {
246
+ const lines = [];
247
+ for (const action of actions) {
248
+ if (action.action === 'add') {
249
+ lines.push(` \x1b[32m+\x1b[0m npm run ${action.name} (new script)`);
250
+ lines.push(instructionLine(action.command));
251
+ }
252
+ else {
253
+ lines.push(` \x1b[33m◐\x1b[0m npm run ${action.name} (your command differs; kept)`);
254
+ lines.push(instructionLine(`this capability ships: ${action.command}`));
255
+ }
256
+ }
257
+ return lines;
258
+ }
230
259
  /**
231
260
  * A reference page or code file the project edited, and boop has not, is doing
232
261
  * what those files are for, so the report does not list it.
@@ -50,6 +50,7 @@ const project_1 = require("./project");
50
50
  const env_1 = require("./env");
51
51
  const rewire_1 = require("./rewire");
52
52
  const safe_io_1 = require("./safe-io");
53
+ const script_merge_1 = require("./script-merge");
53
54
  /**
54
55
  * Execute all tasks in a manifest. Returns the result with per-task status.
55
56
  */
@@ -110,19 +111,9 @@ function executeTask(task, manifest) {
110
111
  break;
111
112
  }
112
113
  case 'merge-scripts': {
114
+ // One answer to "does this script go in", shared with `boop update`
113
115
  const data = task.data;
114
- const pkgPath = path.join(targetDir, 'package.json');
115
- if (fs.existsSync(pkgPath)) {
116
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
117
- if (!pkg.scripts)
118
- pkg.scripts = {};
119
- for (const [scriptName, scriptDef] of Object.entries(data.scripts)) {
120
- if (!(scriptName in pkg.scripts)) {
121
- pkg.scripts[scriptName] = scriptDef.cmd;
122
- }
123
- }
124
- fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
125
- }
116
+ (0, script_merge_1.applyScriptUpdates)(targetDir, (0, script_merge_1.planScriptUpdates)(data.scripts, (0, script_merge_1.readProjectScripts)(targetDir)));
126
117
  break;
127
118
  }
128
119
  case 'link-package': {
@@ -70,17 +70,47 @@ export declare function copyCapabilityFiles(targetDir: string, capabilityName: s
70
70
  */
71
71
  export declare function linkSkillsToClaude(targetDir: string, only?: Iterable<string>): string[];
72
72
  /**
73
- * Give every skill folder under `<targetDir>/skills/` that lacks one a
74
- * `PROJECT.md`, where the project records how it applies the skill. Each boop
75
- * `SKILL.md` opens by pointing at it; the file being present is the backstop
76
- * for an agent that skipped the pointer.
73
+ * The names of the skill folders under `<targetDir>/skills/` that boop installed.
74
+ *
75
+ * A project's `skills/` is not all boop's: a project writes skills of its own
76
+ * beside the ones its capabilities ship, and those folders are already entirely
77
+ * the project's file. Two records say which are boop's, and the union is taken
78
+ * because neither is complete on its own:
79
+ *
80
+ * - the `fileHashes` a capability stored in `.boop/config.json`, which is the
81
+ * record of what was actually copied into this project;
82
+ * - the skill placements of an installed capability that has no stored hashes —
83
+ * a manifest written before `capabilityMeta` existed, or one whose hash
84
+ * storage was skipped.
85
+ *
86
+ * Returns null when the directory holds no `.boop/config.json`, i.e. nothing
87
+ * here is a boop project and no folder can be attributed either way.
88
+ */
89
+ export declare function installedSkillNames(targetDir: string, searchPaths?: string[]): Set<string> | null;
90
+ /**
91
+ * Give every boop-installed skill folder under `<targetDir>/skills/` that lacks
92
+ * one a `PROJECT.md`, where the project records how it applies the skill. Each
93
+ * boop `SKILL.md` opens by pointing at it; the file being present is the
94
+ * backstop for an agent that skipped the pointer.
95
+ *
96
+ * Only a folder boop installed gets one. A skill the project wrote itself is
97
+ * already the project's file outright, so a `PROJECT.md` beside it separates
98
+ * nothing — and because this runs on every init, add and update, a project
99
+ * could never delete the stub it left there.
100
+ *
101
+ * For the same reason it takes back a stub an earlier boop wrote beside a
102
+ * project-owned skill, but only while the file's bytes are still exactly the
103
+ * template's: anything the project typed into a `PROJECT.md` keeps it.
77
104
  *
78
105
  * The file is the project's: boop never hashes it, so `boop update` can never
79
106
  * report it, and an existing one is never opened for writing.
80
107
  *
81
- * Returns the relative paths of the files created.
108
+ * Returns the relative paths of the files created and of the stubs removed.
82
109
  */
83
- export declare function createSkillProjectFiles(targetDir: string): string[];
110
+ export declare function createSkillProjectFiles(targetDir: string, searchPaths?: string[]): {
111
+ created: string[];
112
+ removed: string[];
113
+ };
84
114
  /**
85
115
  * Whether a skill's PROJECT.md has rules under "Offered to boop" — anything in
86
116
  * that section beyond the template's own explanation of it.
@@ -44,6 +44,7 @@ exports.hashFile = hashFile;
44
44
  exports.hashContent = hashContent;
45
45
  exports.copyCapabilityFiles = copyCapabilityFiles;
46
46
  exports.linkSkillsToClaude = linkSkillsToClaude;
47
+ exports.installedSkillNames = installedSkillNames;
47
48
  exports.createSkillProjectFiles = createSkillProjectFiles;
48
49
  exports.hasOfferedRules = hasOfferedRules;
49
50
  exports.readSkillProjectTemplate = readSkillProjectTemplate;
@@ -60,6 +61,7 @@ const config_1 = require("./config");
60
61
  const capability_1 = require("./capability");
61
62
  const safe_io_1 = require("./safe-io");
62
63
  const install_layout_1 = require("./install-layout");
64
+ const project_1 = require("./project");
63
65
  /**
64
66
  * Render a single Handlebars template string with variables.
65
67
  *
@@ -254,34 +256,101 @@ function linkSkillsToClaude(targetDir, only) {
254
256
  return linked;
255
257
  }
256
258
  /**
257
- * Give every skill folder under `<targetDir>/skills/` that lacks one a
258
- * `PROJECT.md`, where the project records how it applies the skill. Each boop
259
- * `SKILL.md` opens by pointing at it; the file being present is the backstop
260
- * for an agent that skipped the pointer.
259
+ * The names of the skill folders under `<targetDir>/skills/` that boop installed.
260
+ *
261
+ * A project's `skills/` is not all boop's: a project writes skills of its own
262
+ * beside the ones its capabilities ship, and those folders are already entirely
263
+ * the project's file. Two records say which are boop's, and the union is taken
264
+ * because neither is complete on its own:
265
+ *
266
+ * - the `fileHashes` a capability stored in `.boop/config.json`, which is the
267
+ * record of what was actually copied into this project;
268
+ * - the skill placements of an installed capability that has no stored hashes —
269
+ * a manifest written before `capabilityMeta` existed, or one whose hash
270
+ * storage was skipped.
271
+ *
272
+ * Returns null when the directory holds no `.boop/config.json`, i.e. nothing
273
+ * here is a boop project and no folder can be attributed either way.
274
+ */
275
+ function installedSkillNames(targetDir, searchPaths) {
276
+ const manifest = (0, project_1.readProjectManifest)(targetDir);
277
+ if (!manifest)
278
+ return null;
279
+ const names = new Set();
280
+ const addDest = (dest) => {
281
+ // Stored destinations are POSIX, but tolerate a manifest written on Windows.
282
+ const [top, skillName] = dest.split(/[\\/]/);
283
+ if (top === 'skills' && skillName)
284
+ names.add(skillName);
285
+ };
286
+ const meta = manifest.capabilityMeta ?? {};
287
+ for (const entry of Object.values(meta)) {
288
+ for (const dest of Object.keys(entry?.fileHashes ?? {}))
289
+ addDest(dest);
290
+ }
291
+ // Walking a package's files is the expensive half, so it runs only for a
292
+ // capability whose hashes are missing — where it is the only record left.
293
+ for (const capability of manifest.capabilities ?? []) {
294
+ if (Object.keys(meta[capability]?.fileHashes ?? {}).length > 0)
295
+ continue;
296
+ for (const placement of (0, install_layout_1.capabilityPlacements)(capability, searchPaths ?? [targetDir])) {
297
+ if (placement.kind === 'skill')
298
+ addDest(placement.dest);
299
+ }
300
+ }
301
+ return names;
302
+ }
303
+ /**
304
+ * Give every boop-installed skill folder under `<targetDir>/skills/` that lacks
305
+ * one a `PROJECT.md`, where the project records how it applies the skill. Each
306
+ * boop `SKILL.md` opens by pointing at it; the file being present is the
307
+ * backstop for an agent that skipped the pointer.
308
+ *
309
+ * Only a folder boop installed gets one. A skill the project wrote itself is
310
+ * already the project's file outright, so a `PROJECT.md` beside it separates
311
+ * nothing — and because this runs on every init, add and update, a project
312
+ * could never delete the stub it left there.
313
+ *
314
+ * For the same reason it takes back a stub an earlier boop wrote beside a
315
+ * project-owned skill, but only while the file's bytes are still exactly the
316
+ * template's: anything the project typed into a `PROJECT.md` keeps it.
261
317
  *
262
318
  * The file is the project's: boop never hashes it, so `boop update` can never
263
319
  * report it, and an existing one is never opened for writing.
264
320
  *
265
- * Returns the relative paths of the files created.
321
+ * Returns the relative paths of the files created and of the stubs removed.
266
322
  */
267
- function createSkillProjectFiles(targetDir) {
323
+ function createSkillProjectFiles(targetDir, searchPaths) {
268
324
  const skillsDir = path.join(targetDir, 'skills');
269
325
  if (!fs.existsSync(skillsDir))
270
- return [];
326
+ return { created: [], removed: [] };
327
+ const installed = installedSkillNames(targetDir, searchPaths);
328
+ if (installed === null)
329
+ return { created: [], removed: [] };
271
330
  const template = readSkillProjectTemplate();
272
331
  const created = [];
332
+ const removed = [];
273
333
  for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
274
334
  // A symlinked skill folder is someone else's. In the boop repository each one
275
335
  // points into a capability package, from which a PROJECT.md would ship to every project.
276
336
  if (!entry.isDirectory())
277
337
  continue;
278
338
  const filePath = path.join(skillsDir, entry.name, 'PROJECT.md');
279
- if (fs.lstatSync(filePath, { throwIfNoEntry: false }))
339
+ const relPath = path.join('skills', entry.name, 'PROJECT.md');
340
+ const existing = fs.lstatSync(filePath, { throwIfNoEntry: false });
341
+ if (!installed.has(entry.name)) {
342
+ if (existing?.isFile() && fs.readFileSync(filePath, 'utf-8') === template) {
343
+ fs.unlinkSync(filePath);
344
+ removed.push(relPath);
345
+ }
346
+ continue;
347
+ }
348
+ if (existing)
280
349
  continue;
281
350
  fs.writeFileSync(filePath, template, { flag: 'wx' });
282
- created.push(path.join('skills', entry.name, 'PROJECT.md'));
351
+ created.push(relPath);
283
352
  }
284
- return created;
353
+ return { created, removed };
285
354
  }
286
355
  /**
287
356
  * Whether a skill's PROJECT.md has rules under "Offered to boop" — anything in
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Merge the npm scripts a capability declares into a project's package.json
3
+ * during `boop update`.
4
+ *
5
+ * The case this exists for (boop-ldou): a capability ships a new command as two
6
+ * things — a file under `scaffold/`, and a `scripts` entry in `capability.json`.
7
+ * `boop add` merges those scripts at install time (see `merge-scripts` in
8
+ * lib/execute.ts), but `boop update` only ever copied files, so an upgraded
9
+ * project received `src/scripts/migrate-credentials.ts` and no way to run it by
10
+ * name. Every document, post-install note and status hint that said
11
+ * `npm run credentials:migrate` was right for a freshly scaffolded project and
12
+ * wrong for every upgraded one — precisely the projects a migration tool is for.
13
+ *
14
+ * The merge is additive, like `boop add`'s: a script is written only when the
15
+ * project has no script of that name. A name the project already uses is left
16
+ * exactly as it stands, because a command somebody customised is theirs, and
17
+ * clobbering it would be worse than leaving it alone — the same restraint
18
+ * `rewriteStaleRunnerScripts` shows when a command has changed underneath it.
19
+ * What boop does do is say so, so a kept command that no longer matches what the
20
+ * capability ships is visible rather than silent.
21
+ */
22
+ import { CapabilityScript } from './config';
23
+ export interface ScriptUpdateAction {
24
+ /** The script's key in package.json, e.g. `credentials:migrate`. */
25
+ name: string;
26
+ /** The command the capability declares. */
27
+ command: string;
28
+ /** `add` writes it; `keep` leaves the project's own command in place. */
29
+ action: 'add' | 'keep';
30
+ /** Why this action was chosen. */
31
+ reason: string;
32
+ /** The command the project has today, when there is one. */
33
+ currentCommand?: string;
34
+ }
35
+ /**
36
+ * Pure: given what a capability declares and what the project's package.json
37
+ * holds, decide what happens to each script.
38
+ *
39
+ * A script whose command already matches what the capability ships is not
40
+ * returned at all — there is nothing to do and nothing worth reporting.
41
+ */
42
+ export declare function planScriptUpdates(declared: Record<string, CapabilityScript> | undefined, projectScripts: Record<string, string> | undefined): ScriptUpdateAction[];
43
+ /**
44
+ * Write the `add` actions into the project's package.json, and return the ones
45
+ * actually written.
46
+ *
47
+ * Re-reads the file rather than trusting the plan: an action is applied only
48
+ * while the name is still free, so a script written between planning and here
49
+ * survives. A missing or unreadable package.json is not an error — scaffold
50
+ * files have already been delivered by the time this runs, and failing the whole
51
+ * update over the script merge would be worse than reporting nothing.
52
+ */
53
+ export declare function applyScriptUpdates(targetDir: string, actions: ScriptUpdateAction[]): ScriptUpdateAction[];
54
+ /** The project's package.json scripts, or undefined when there is no readable one. */
55
+ export declare function readProjectScripts(targetDir: string): Record<string, string> | undefined;
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+ /**
3
+ * Merge the npm scripts a capability declares into a project's package.json
4
+ * during `boop update`.
5
+ *
6
+ * The case this exists for (boop-ldou): a capability ships a new command as two
7
+ * things — a file under `scaffold/`, and a `scripts` entry in `capability.json`.
8
+ * `boop add` merges those scripts at install time (see `merge-scripts` in
9
+ * lib/execute.ts), but `boop update` only ever copied files, so an upgraded
10
+ * project received `src/scripts/migrate-credentials.ts` and no way to run it by
11
+ * name. Every document, post-install note and status hint that said
12
+ * `npm run credentials:migrate` was right for a freshly scaffolded project and
13
+ * wrong for every upgraded one — precisely the projects a migration tool is for.
14
+ *
15
+ * The merge is additive, like `boop add`'s: a script is written only when the
16
+ * project has no script of that name. A name the project already uses is left
17
+ * exactly as it stands, because a command somebody customised is theirs, and
18
+ * clobbering it would be worse than leaving it alone — the same restraint
19
+ * `rewriteStaleRunnerScripts` shows when a command has changed underneath it.
20
+ * What boop does do is say so, so a kept command that no longer matches what the
21
+ * capability ships is visible rather than silent.
22
+ */
23
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
24
+ if (k2 === undefined) k2 = k;
25
+ var desc = Object.getOwnPropertyDescriptor(m, k);
26
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
27
+ desc = { enumerable: true, get: function() { return m[k]; } };
28
+ }
29
+ Object.defineProperty(o, k2, desc);
30
+ }) : (function(o, m, k, k2) {
31
+ if (k2 === undefined) k2 = k;
32
+ o[k2] = m[k];
33
+ }));
34
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
35
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
36
+ }) : function(o, v) {
37
+ o["default"] = v;
38
+ });
39
+ var __importStar = (this && this.__importStar) || (function () {
40
+ var ownKeys = function(o) {
41
+ ownKeys = Object.getOwnPropertyNames || function (o) {
42
+ var ar = [];
43
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
44
+ return ar;
45
+ };
46
+ return ownKeys(o);
47
+ };
48
+ return function (mod) {
49
+ if (mod && mod.__esModule) return mod;
50
+ var result = {};
51
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
52
+ __setModuleDefault(result, mod);
53
+ return result;
54
+ };
55
+ })();
56
+ Object.defineProperty(exports, "__esModule", { value: true });
57
+ exports.planScriptUpdates = planScriptUpdates;
58
+ exports.applyScriptUpdates = applyScriptUpdates;
59
+ exports.readProjectScripts = readProjectScripts;
60
+ const fs = __importStar(require("fs"));
61
+ const path = __importStar(require("path"));
62
+ /**
63
+ * Pure: given what a capability declares and what the project's package.json
64
+ * holds, decide what happens to each script.
65
+ *
66
+ * A script whose command already matches what the capability ships is not
67
+ * returned at all — there is nothing to do and nothing worth reporting.
68
+ */
69
+ function planScriptUpdates(declared, projectScripts) {
70
+ if (!declared)
71
+ return [];
72
+ const actions = [];
73
+ for (const [name, script] of Object.entries(declared)) {
74
+ const command = typeof script === 'string' ? script : script?.cmd;
75
+ if (!command)
76
+ continue;
77
+ const currentCommand = projectScripts?.[name];
78
+ if (currentCommand === undefined) {
79
+ actions.push({ name, command, action: 'add', reason: 'New script from capability' });
80
+ continue;
81
+ }
82
+ if (currentCommand === command)
83
+ continue;
84
+ actions.push({
85
+ name,
86
+ command,
87
+ action: 'keep',
88
+ reason: 'Project has its own command for this script',
89
+ currentCommand,
90
+ });
91
+ }
92
+ return actions;
93
+ }
94
+ /**
95
+ * Write the `add` actions into the project's package.json, and return the ones
96
+ * actually written.
97
+ *
98
+ * Re-reads the file rather than trusting the plan: an action is applied only
99
+ * while the name is still free, so a script written between planning and here
100
+ * survives. A missing or unreadable package.json is not an error — scaffold
101
+ * files have already been delivered by the time this runs, and failing the whole
102
+ * update over the script merge would be worse than reporting nothing.
103
+ */
104
+ function applyScriptUpdates(targetDir, actions) {
105
+ const additions = actions.filter(a => a.action === 'add');
106
+ if (additions.length === 0)
107
+ return [];
108
+ const pkgPath = path.join(targetDir, 'package.json');
109
+ if (!fs.existsSync(pkgPath))
110
+ return [];
111
+ let pkg;
112
+ try {
113
+ pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
114
+ }
115
+ catch {
116
+ return [];
117
+ }
118
+ if (!pkg.scripts)
119
+ pkg.scripts = {};
120
+ const applied = [];
121
+ for (const action of additions) {
122
+ if (action.name in pkg.scripts)
123
+ continue;
124
+ pkg.scripts[action.name] = action.command;
125
+ applied.push(action);
126
+ }
127
+ if (applied.length > 0) {
128
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
129
+ }
130
+ return applied;
131
+ }
132
+ /** The project's package.json scripts, or undefined when there is no readable one. */
133
+ function readProjectScripts(targetDir) {
134
+ const pkgPath = path.join(targetDir, 'package.json');
135
+ if (!fs.existsSync(pkgPath))
136
+ return undefined;
137
+ try {
138
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
139
+ return pkg.scripts;
140
+ }
141
+ catch {
142
+ return undefined;
143
+ }
144
+ }
@@ -8,8 +8,9 @@
8
8
  * Jest never recycles, and a long suite dies as that process's heap grows.
9
9
  *
10
10
  * `boop update` cannot simply fix those projects. Scripts are merged only when
11
- * absent (see `merge-scripts` in lib/execute.ts), which is the right default —
12
- * clobbering a script somebody customised would be worse than leaving it stale.
11
+ * absent (see lib/script-merge.ts, which both `boop add` and `boop update` use),
12
+ * which is the right default — clobbering a script somebody customised would be
13
+ * worse than leaving it stale.
13
14
  * So the old command survives the update, and because a CLI `--runInBand` is the
14
15
  * first and unconditional branch of Jest's `shouldRunInBand()`, it overrides the
15
16
  * new config entirely. That is the state worth warning about: the project now has
@@ -9,8 +9,9 @@
9
9
  * Jest never recycles, and a long suite dies as that process's heap grows.
10
10
  *
11
11
  * `boop update` cannot simply fix those projects. Scripts are merged only when
12
- * absent (see `merge-scripts` in lib/execute.ts), which is the right default —
13
- * clobbering a script somebody customised would be worse than leaving it stale.
12
+ * absent (see lib/script-merge.ts, which both `boop add` and `boop update` use),
13
+ * which is the right default — clobbering a script somebody customised would be
14
+ * worse than leaving it stale.
14
15
  * So the old command survives the update, and because a CLI `--runInBand` is the
15
16
  * first and unconditional branch of Jest's `shouldRunInBand()`, it overrides the
16
17
  * new config entirely. That is the state worth warning about: the project now has
@@ -5,6 +5,7 @@
5
5
  * local modifications, then selectively updates from the capability package.
6
6
  */
7
7
  import { BoopManifest } from './project';
8
+ import { ScriptUpdateAction } from './script-merge';
8
9
  export type FileState = 'match' | 'modified' | 'missing';
9
10
  /**
10
11
  * How a locally modified file stands against the base (what boop shipped last)
@@ -53,6 +54,8 @@ export interface UpdatePlan {
53
54
  newVersion: string;
54
55
  /** Per-file actions */
55
56
  actions: FileUpdateAction[];
57
+ /** What happens to each npm script the capability declares */
58
+ scriptActions: ScriptUpdateAction[];
56
59
  /** Number of files unchanged in both package and locally */
57
60
  unchangedCount: number;
58
61
  /** True if already up to date */
@@ -71,6 +74,10 @@ export interface UpdateResult {
71
74
  pruned: string[];
72
75
  /** Files migrated from old flat format to new folder format */
73
76
  migrated: string[];
77
+ /** npm scripts the capability declares that this update wrote into package.json */
78
+ scriptsAdded: ScriptUpdateAction[];
79
+ /** npm scripts left as the project has them, because it has its own command */
80
+ scriptsKept: ScriptUpdateAction[];
74
81
  /** Count of files unchanged in both package and locally */
75
82
  unchangedCount: number;
76
83
  /** What to do about the files this update skipped or replaced, where the report says anything */