bmad-studio 0.2.0 → 1.0.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 (84) hide show
  1. package/README.md +6 -0
  2. package/package.json +12 -3
  3. package/packages/client/dist/assets/index-CWL4J-eZ.css +1 -0
  4. package/packages/client/dist/assets/index-DBqsFqD5.js +535 -0
  5. package/packages/client/dist/index.html +2 -2
  6. package/packages/server/dist/app.d.ts +1 -0
  7. package/packages/server/dist/app.d.ts.map +1 -1
  8. package/packages/server/dist/app.js +9 -0
  9. package/packages/server/dist/app.js.map +1 -1
  10. package/packages/server/dist/core/ide-skill-generator.d.ts +58 -0
  11. package/packages/server/dist/core/ide-skill-generator.d.ts.map +1 -0
  12. package/packages/server/dist/core/ide-skill-generator.js +270 -0
  13. package/packages/server/dist/core/ide-skill-generator.js.map +1 -0
  14. package/packages/server/dist/core/ide-skill-generator.test.d.ts +2 -0
  15. package/packages/server/dist/core/ide-skill-generator.test.d.ts.map +1 -0
  16. package/packages/server/dist/core/ide-skill-generator.test.js +257 -0
  17. package/packages/server/dist/core/ide-skill-generator.test.js.map +1 -0
  18. package/packages/server/dist/core/module-installer.d.ts +165 -0
  19. package/packages/server/dist/core/module-installer.d.ts.map +1 -0
  20. package/packages/server/dist/core/module-installer.js +445 -0
  21. package/packages/server/dist/core/module-installer.js.map +1 -0
  22. package/packages/server/dist/core/module-installer.test.d.ts +2 -0
  23. package/packages/server/dist/core/module-installer.test.d.ts.map +1 -0
  24. package/packages/server/dist/core/module-installer.test.js +509 -0
  25. package/packages/server/dist/core/module-installer.test.js.map +1 -0
  26. package/packages/server/dist/core/module-registry.d.ts +5 -0
  27. package/packages/server/dist/core/module-registry.d.ts.map +1 -0
  28. package/packages/server/dist/core/module-registry.js +109 -0
  29. package/packages/server/dist/core/module-registry.js.map +1 -0
  30. package/packages/server/dist/core/module-registry.test.d.ts +2 -0
  31. package/packages/server/dist/core/module-registry.test.d.ts.map +1 -0
  32. package/packages/server/dist/core/module-registry.test.js +280 -0
  33. package/packages/server/dist/core/module-registry.test.js.map +1 -0
  34. package/packages/server/dist/core/write-service.d.ts +20 -0
  35. package/packages/server/dist/core/write-service.d.ts.map +1 -1
  36. package/packages/server/dist/core/write-service.js +113 -1
  37. package/packages/server/dist/core/write-service.js.map +1 -1
  38. package/packages/server/dist/core/write-service.test.js +93 -6
  39. package/packages/server/dist/core/write-service.test.js.map +1 -1
  40. package/packages/server/dist/index.js +46 -1
  41. package/packages/server/dist/index.js.map +1 -1
  42. package/packages/server/dist/parsers/module-yaml-parser.d.ts +16 -0
  43. package/packages/server/dist/parsers/module-yaml-parser.d.ts.map +1 -0
  44. package/packages/server/dist/parsers/module-yaml-parser.js +62 -0
  45. package/packages/server/dist/parsers/module-yaml-parser.js.map +1 -0
  46. package/packages/server/dist/parsers/module-yaml-parser.test.d.ts +2 -0
  47. package/packages/server/dist/parsers/module-yaml-parser.test.d.ts.map +1 -0
  48. package/packages/server/dist/parsers/module-yaml-parser.test.js +156 -0
  49. package/packages/server/dist/parsers/module-yaml-parser.test.js.map +1 -0
  50. package/packages/server/dist/parsers/skill-parser.d.ts.map +1 -1
  51. package/packages/server/dist/parsers/skill-parser.js +41 -4
  52. package/packages/server/dist/parsers/skill-parser.js.map +1 -1
  53. package/packages/server/dist/parsers/skill-parser.test.js +4 -3
  54. package/packages/server/dist/parsers/skill-parser.test.js.map +1 -1
  55. package/packages/server/dist/plugins/agents-plugin.d.ts.map +1 -1
  56. package/packages/server/dist/plugins/agents-plugin.js +9 -1
  57. package/packages/server/dist/plugins/agents-plugin.js.map +1 -1
  58. package/packages/server/dist/plugins/modules-plugin.d.ts.map +1 -1
  59. package/packages/server/dist/plugins/modules-plugin.js +882 -100
  60. package/packages/server/dist/plugins/modules-plugin.js.map +1 -1
  61. package/packages/server/dist/plugins/modules-plugin.test.js +1894 -3
  62. package/packages/server/dist/plugins/modules-plugin.test.js.map +1 -1
  63. package/packages/server/dist/plugins/overview-plugin.js +1 -1
  64. package/packages/server/dist/plugins/overview-plugin.js.map +1 -1
  65. package/packages/server/dist/plugins/settings-plugin.d.ts.map +1 -1
  66. package/packages/server/dist/plugins/settings-plugin.js +25 -1
  67. package/packages/server/dist/plugins/settings-plugin.js.map +1 -1
  68. package/packages/server/dist/plugins/settings-plugin.test.js +72 -0
  69. package/packages/server/dist/plugins/settings-plugin.test.js.map +1 -1
  70. package/packages/server/dist/plugins/teams-plugin.d.ts.map +1 -1
  71. package/packages/server/dist/plugins/teams-plugin.js +6 -6
  72. package/packages/server/dist/plugins/teams-plugin.js.map +1 -1
  73. package/packages/server/dist/plugins/teams-plugin.test.js +43 -0
  74. package/packages/server/dist/plugins/teams-plugin.test.js.map +1 -1
  75. package/packages/server/dist/plugins/workflows-plugin.d.ts.map +1 -1
  76. package/packages/server/dist/plugins/workflows-plugin.js +14 -6
  77. package/packages/server/dist/plugins/workflows-plugin.js.map +1 -1
  78. package/packages/shared/src/config.ts +26 -0
  79. package/packages/shared/src/index.ts +12 -0
  80. package/packages/shared/src/modules.ts +42 -0
  81. package/packages/shared/src/registry.ts +26 -0
  82. package/packages/shared/src/types.test.ts +37 -1
  83. package/packages/client/dist/assets/index-5nXyrx_3.css +0 -1
  84. package/packages/client/dist/assets/index-DxN3uabX.js +0 -521
@@ -1,46 +1,402 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- import { execSync } from 'node:child_process';
4
+ import { execSync, spawnSync } from 'node:child_process';
5
5
  import yaml from 'js-yaml';
6
6
  import { ValidationError, ConflictError, NotFoundError } from '../core/errors.js';
7
- function readManifest(manifestPath) {
8
- const content = fs.readFileSync(manifestPath, 'utf-8');
9
- return yaml.load(content);
10
- }
11
- function writeManifest(manifestPath, manifest) {
12
- fs.writeFileSync(manifestPath, yaml.dump(manifest, { lineWidth: -1 }), 'utf-8');
13
- }
7
+ import { fetchAndCacheRegistryIndex, isRegistryCacheStale, readCachedRegistryIndex, } from '../core/module-registry.js';
8
+ import { generateIdeSkillsForModule, removeIdeSkillsForModule, scanEntities, scanEntityDirs, } from '../core/ide-skill-generator.js';
9
+ import { copyDirThroughWriteService, downloadGithubTarball, extractZipUpload, findCrossReferences, isPlausibleModuleDir, parseGithubSource, readManifestSafe, readOutputFolder, runVariableSubstitution, validateVariables, writeManifestThroughWriteService, } from '../core/module-installer.js';
10
+ import { deleteDirectory, writeFile } from '../core/write-service.js';
11
+ import { parseModuleYaml } from '../parsers/module-yaml-parser.js';
14
12
  const MODULE_NAME_RE = /^[a-z][a-z0-9-]*$/;
15
- function copyDirRecursive(src, dest) {
16
- if (!fs.existsSync(src))
17
- return;
18
- fs.mkdirSync(dest, { recursive: true });
19
- for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
20
- const srcPath = path.join(src, entry.name);
21
- const destPath = path.join(dest, entry.name);
22
- if (entry.isDirectory()) {
23
- copyDirRecursive(srcPath, destPath);
13
+ export async function modulesPlugin(app) {
14
+ const PREVIEW_CACHE_TTL_MS = 5 * 60 * 1000;
15
+ const previewCache = new Map();
16
+ function previewCacheKey(ghSource) {
17
+ return `${ghSource.owner}/${ghSource.repo}@${ghSource.branch ?? 'main'}${ghSource.subpath ? `/${ghSource.subpath}` : ''}`;
18
+ }
19
+ function getPreviewCacheEntry(key) {
20
+ const entry = previewCache.get(key);
21
+ if (!entry)
22
+ return null;
23
+ if (Date.now() - entry.fetchedAt > PREVIEW_CACHE_TTL_MS) {
24
+ try {
25
+ fs.rmSync(entry.tmpDir, { recursive: true, force: true });
26
+ }
27
+ catch {
28
+ /* ignore cleanup errors */
29
+ }
30
+ previewCache.delete(key);
31
+ return null;
24
32
  }
25
- else {
26
- fs.copyFileSync(srcPath, destPath);
33
+ return entry;
34
+ }
35
+ // Story 17.3 — Clean-slate remove helper. Called by each install branch when
36
+ // the destination directory already exists so the install can proceed as a
37
+ // silent upgrade rather than throwing 409.
38
+ // Throws ValidationError if the module is built-in (cannot be replaced) or
39
+ // if any I/O step fails. Safe to call when the module dir doesn't exist yet
40
+ // (all steps guard with existsSync / nullish checks).
41
+ function cleanRemoveModule(moduleCode, bmadDir, manifestPath) {
42
+ const moduleDir = path.join(bmadDir, moduleCode);
43
+ const manifest = readManifestSafe(manifestPath);
44
+ const entry = manifest?.modules.find((m) => m.name === moduleCode);
45
+ if (entry?.source === 'built-in') {
46
+ throw new ValidationError(`Cannot reinstall built-in module "${moduleCode}"`);
47
+ }
48
+ // 1. Remove IDE skill launchers
49
+ if (manifest) {
50
+ const r = removeIdeSkillsForModule(app.fileStore.projectRoot, moduleCode, manifest, app.fileStore.studioDir);
51
+ if (!r.ok)
52
+ throw new ValidationError(r.error);
53
+ }
54
+ // 2. Delete the module directory
55
+ if (fs.existsSync(moduleDir)) {
56
+ const r = deleteDirectory(moduleDir, app.fileStore.studioDir);
57
+ if (!r.ok)
58
+ throw new ValidationError(r.error);
59
+ }
60
+ // 3. Remove the manifest entry so the incoming install writes a fresh one
61
+ if (manifest) {
62
+ manifest.modules = manifest.modules.filter((m) => m.name !== moduleCode);
63
+ const wrote = writeManifestThroughWriteService(manifestPath, manifest, app.fileStore.studioDir, app.fileStore);
64
+ if (!wrote.ok)
65
+ throw new ValidationError(wrote.error);
27
66
  }
28
67
  }
29
- }
30
- export async function modulesPlugin(app) {
31
- // Install module from npm package
68
+ // Story 15.9 — Read-only preview endpoint. Fetches a source (local or github)
69
+ // and returns the parsed module.yaml + entity counts WITHOUT installing anything.
70
+ // For github sources, the downloaded tarball is cached for 5 minutes so the
71
+ // subsequent install call can reuse it without re-downloading.
72
+ app.post('/api/modules/preview-source', async (request) => {
73
+ if (!('fileStore' in app)) {
74
+ throw new ValidationError('No project detected');
75
+ }
76
+ const body = request.body;
77
+ const source = body?.source;
78
+ if (!source || !source.type || !source.value) {
79
+ throw new ValidationError('Request body must include { source: { type, value } }');
80
+ }
81
+ let stagedRoot;
82
+ if (source.type === 'local') {
83
+ stagedRoot = path.isAbsolute(source.value)
84
+ ? source.value
85
+ : path.join(app.fileStore.projectRoot, source.value);
86
+ }
87
+ else if (source.type === 'github') {
88
+ let ghSource;
89
+ try {
90
+ ghSource = parseGithubSource(source.value);
91
+ }
92
+ catch (err) {
93
+ throw new ValidationError(err instanceof Error ? err.message : String(err));
94
+ }
95
+ const cacheKey = previewCacheKey(ghSource);
96
+ let entry = getPreviewCacheEntry(cacheKey);
97
+ if (!entry) {
98
+ let downloaded;
99
+ try {
100
+ downloaded = await downloadGithubTarball(ghSource);
101
+ }
102
+ catch (err) {
103
+ throw new ValidationError(err instanceof Error ? err.message : String(err));
104
+ }
105
+ entry = {
106
+ extractedRoot: downloaded.extractedRoot,
107
+ tmpDir: downloaded.tmpDir,
108
+ resolvedBranch: downloaded.resolvedBranch,
109
+ fetchedAt: Date.now(),
110
+ };
111
+ previewCache.set(cacheKey, entry);
112
+ }
113
+ stagedRoot = ghSource.subpath
114
+ ? path.join(entry.extractedRoot, ghSource.subpath)
115
+ : entry.extractedRoot;
116
+ // Note: no try/finally cleanup here — the tmpDir lives in previewCache and is
117
+ // cleaned up on TTL expiry or when the install endpoint consumes it.
118
+ }
119
+ else {
120
+ throw new ValidationError(`preview-source does not support source type "${source.type}". Use "local" or "github".`);
121
+ }
122
+ if (!isPlausibleModuleDir(stagedRoot)) {
123
+ throw new ValidationError('Source does not look like a BMAD module — expected module.yaml or one of agents/skills/workflows/tasks');
124
+ }
125
+ const parsed = parseModuleYaml(stagedRoot);
126
+ if (!parsed.ok)
127
+ throw new ValidationError(parsed.error);
128
+ const counts = {
129
+ agents: scanEntities(path.join(stagedRoot, 'agents'), '.md').length,
130
+ workflows: scanEntityDirs(path.join(stagedRoot, 'workflows')).length,
131
+ tasks: scanEntityDirs(path.join(stagedRoot, 'tasks')).length,
132
+ };
133
+ const bmadDir = path.join(app.fileStore.projectRoot, '_bmad');
134
+ const existingDir = path.join(bmadDir, parsed.data.code);
135
+ const willReplace = fs.existsSync(existingDir);
136
+ return { ok: true, moduleYaml: parsed.data, counts, willReplace };
137
+ });
138
+ // Polymorphic install endpoint — accepts npm | local | github source types via
139
+ // { source: { type, value }, variables? }, plus the legacy { packageName } shape.
140
+ // Zip uploads go to a separate multipart route POST /api/modules/install/upload (Story 15.4).
32
141
  app.post('/api/modules/install', async (request, reply) => {
33
142
  if (!('fileStore' in app)) {
34
143
  throw new ValidationError('No project detected');
35
144
  }
36
145
  const body = request.body;
37
- const packageName = body.packageName?.trim();
38
- if (!packageName) {
39
- throw new ValidationError('Package name is required');
146
+ // Defensive guard: if the request body is missing (e.g. a multipart upload sent
147
+ // to this JSON-only route — multipart goes to /upload), fail loudly with a 422
148
+ // instead of an unhandled TypeError. AC-15.4.11.
149
+ if (!body || typeof body !== 'object') {
150
+ throw new ValidationError('Request body must be JSON. For zip uploads, POST to /api/modules/install/upload instead.');
151
+ }
152
+ // TD-22 — discriminate source vs legacy. Prefer `source` if both are present.
153
+ let source;
154
+ if (body.source) {
155
+ source = body.source;
156
+ if (body.packageName) {
157
+ request.log.warn({ packageName: body.packageName }, 'Both `source` and `packageName` provided to /api/modules/install — using `source`');
158
+ }
159
+ }
160
+ else if (body.packageName) {
161
+ const pkg = body.packageName.trim();
162
+ if (!pkg) {
163
+ throw new ValidationError('Package name is required');
164
+ }
165
+ source = { type: 'npm', value: pkg };
40
166
  }
167
+ else {
168
+ throw new ValidationError('Either `source` or `packageName` is required');
169
+ }
170
+ // Story 15.5 — variables are now consumed (not just threaded through).
171
+ const variables = body.variables ?? {};
41
172
  const bmadDir = path.join(app.fileStore.projectRoot, '_bmad');
42
173
  const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
43
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bmad-install-'));
174
+ // Hard requirement (AC-15.2.9 / finding #8): every install branch needs an existing manifest.
175
+ // Without it, the installed module would be invisible to GET /api/modules.
176
+ if (!fs.existsSync(manifestPath)) {
177
+ throw new ValidationError('Cannot install module: missing _bmad/_config/manifest.yaml. Run `npx bmad-method install` to initialise the project first.');
178
+ }
179
+ // Story 15.5 — validate variables BEFORE any source-type branching so bad input
180
+ // fails fast without wasting CPU on a download/extraction.
181
+ const varValidation = validateVariables(variables);
182
+ if (!varValidation.ok)
183
+ throw new ValidationError(varValidation.error);
184
+ // Helper to build the substitution context for a given module code. Used by every branch.
185
+ const makeSubContext = (moduleCode) => ({
186
+ moduleCode,
187
+ projectRoot: app.fileStore.projectRoot,
188
+ outputFolder: readOutputFolder(app.fileStore.projectRoot),
189
+ variables,
190
+ });
191
+ // Helper to run the IDE skill generator for a freshly-installed module and
192
+ // return per-IDE counts for the response shape (Story 15.6 / AC-15.6.8).
193
+ // Re-reads the manifest to get the latest `ides[]` after the manifest update.
194
+ const runGenerator = (moduleCode) => {
195
+ const updatedManifest = readManifestSafe(manifestPath);
196
+ if (!updatedManifest)
197
+ return {};
198
+ const genResult = generateIdeSkillsForModule(app.fileStore.projectRoot, moduleCode, updatedManifest, app.fileStore.studioDir, app.fileStore);
199
+ if (!genResult.ok)
200
+ throw new ValidationError(genResult.error);
201
+ const counts = {};
202
+ for (const [ide, skills] of Object.entries(genResult.skillsByIde)) {
203
+ counts[ide] = skills.length;
204
+ }
205
+ return counts;
206
+ };
207
+ // ─────────────────────────────────────────────────────────────────────────
208
+ // local source
209
+ // ─────────────────────────────────────────────────────────────────────────
210
+ if (source.type === 'local') {
211
+ const sourcePath = path.isAbsolute(source.value)
212
+ ? source.value
213
+ : path.join(app.fileStore.projectRoot, source.value);
214
+ if (!isPlausibleModuleDir(sourcePath)) {
215
+ throw new ValidationError(`Path "${sourcePath}" does not look like a BMAD module — expected module.yaml or one of agents/skills/workflows/tasks`);
216
+ }
217
+ const parsed = parseModuleYaml(sourcePath);
218
+ if (!parsed.ok)
219
+ throw new ValidationError(parsed.error);
220
+ const moduleCode = parsed.data.code;
221
+ const destDir = path.join(bmadDir, moduleCode);
222
+ // Story 17.3 — clean-slate re-install: silently remove any existing copy before writing.
223
+ if (fs.existsSync(destDir)) {
224
+ cleanRemoveModule(moduleCode, bmadDir, manifestPath);
225
+ }
226
+ const copyResult = copyDirThroughWriteService(sourcePath, destDir, app.fileStore.studioDir, app.fileStore);
227
+ if (!copyResult.ok)
228
+ throw new ValidationError(copyResult.error);
229
+ // Story 15.5 — substitute placeholders in installed text files BEFORE updating the manifest.
230
+ const subResult = runVariableSubstitution(destDir, makeSubContext(moduleCode), app.fileStore.studioDir, app.fileStore);
231
+ if (!subResult.ok)
232
+ throw new ValidationError(subResult.error);
233
+ // Update manifest (guaranteed to exist by the check above)
234
+ const manifest = readManifestSafe(manifestPath);
235
+ const now = new Date().toISOString();
236
+ const existing = manifest.modules.find((m) => m.name === moduleCode);
237
+ if (existing) {
238
+ existing.lastUpdated = now;
239
+ existing.source = 'local';
240
+ existing.npmPackage = null;
241
+ existing.repoUrl = sourcePath;
242
+ }
243
+ else {
244
+ manifest.modules.push({
245
+ name: moduleCode,
246
+ version: parsed.data.version ?? '1.0.0',
247
+ installDate: now,
248
+ lastUpdated: now,
249
+ source: 'local',
250
+ npmPackage: null,
251
+ repoUrl: sourcePath,
252
+ });
253
+ }
254
+ const wrote = writeManifestThroughWriteService(manifestPath, manifest, app.fileStore.studioDir, app.fileStore);
255
+ if (!wrote.ok)
256
+ throw new ValidationError(wrote.error);
257
+ // Story 15.6 — generate IDE launchers AFTER the manifest is committed.
258
+ // A failure here leaves the user with an installed module + no launchers;
259
+ // recovery is the "Regenerate IDE skills" button (Story 15.8).
260
+ const skillsGenerated = runGenerator(moduleCode);
261
+ app.fileStore.rebuild();
262
+ reply.status(200);
263
+ return {
264
+ ok: true,
265
+ modules: [moduleCode],
266
+ filesCopied: { text: copyResult.textCount, binary: copyResult.binaryCount },
267
+ skillsGenerated,
268
+ };
269
+ }
270
+ // ─────────────────────────────────────────────────────────────────────────
271
+ // github source
272
+ // ─────────────────────────────────────────────────────────────────────────
273
+ if (source.type === 'github') {
274
+ // parseGithubSource throws plain Error on invalid input — convert to ValidationError.
275
+ let ghSource;
276
+ try {
277
+ ghSource = parseGithubSource(source.value);
278
+ }
279
+ catch (err) {
280
+ throw new ValidationError(err instanceof Error ? err.message : String(err));
281
+ }
282
+ // Story 15.9 — check previewCache before downloading. If the user clicked
283
+ // "Fetch" then "Install" in quick succession the tarball is already on disk;
284
+ // reuse it and skip the second download. Consume the cache entry (delete it)
285
+ // so a future preview call re-downloads fresh content.
286
+ const cacheKey = previewCacheKey(ghSource);
287
+ const cachedEntry = getPreviewCacheEntry(cacheKey);
288
+ let extractedRoot;
289
+ let tmpDir;
290
+ let resolvedBranch;
291
+ if (cachedEntry) {
292
+ previewCache.delete(cacheKey); // consumed — install now owns cleanup
293
+ extractedRoot = cachedEntry.extractedRoot;
294
+ tmpDir = cachedEntry.tmpDir;
295
+ resolvedBranch = cachedEntry.resolvedBranch;
296
+ }
297
+ else {
298
+ // No cache hit — download fresh.
299
+ let downloaded;
300
+ try {
301
+ downloaded = await downloadGithubTarball(ghSource);
302
+ }
303
+ catch (err) {
304
+ throw new ValidationError(err instanceof Error ? err.message : String(err));
305
+ }
306
+ extractedRoot = downloaded.extractedRoot;
307
+ tmpDir = downloaded.tmpDir;
308
+ resolvedBranch = downloaded.resolvedBranch;
309
+ }
310
+ try {
311
+ // Navigate to the optional subpath
312
+ const moduleSrc = ghSource.subpath
313
+ ? path.join(extractedRoot, ghSource.subpath)
314
+ : extractedRoot;
315
+ if (!isPlausibleModuleDir(moduleSrc)) {
316
+ throw new ValidationError(`${ghSource.owner}/${ghSource.repo}${ghSource.subpath ? `/${ghSource.subpath}` : ''} does not look like a BMAD module`);
317
+ }
318
+ const parsed = parseModuleYaml(moduleSrc);
319
+ if (!parsed.ok)
320
+ throw new ValidationError(parsed.error);
321
+ const moduleCode = parsed.data.code;
322
+ const destDir = path.join(bmadDir, moduleCode);
323
+ // Story 17.3 — clean-slate re-install.
324
+ if (fs.existsSync(destDir)) {
325
+ cleanRemoveModule(moduleCode, bmadDir, manifestPath);
326
+ }
327
+ const copyResult = copyDirThroughWriteService(moduleSrc, destDir, app.fileStore.studioDir, app.fileStore);
328
+ if (!copyResult.ok)
329
+ throw new ValidationError(copyResult.error);
330
+ // Story 15.5 — substitute placeholders before manifest update
331
+ const subResult = runVariableSubstitution(destDir, makeSubContext(moduleCode), app.fileStore.studioDir, app.fileStore);
332
+ if (!subResult.ok)
333
+ throw new ValidationError(subResult.error);
334
+ // Update manifest (guaranteed to exist by the check at the top of the handler)
335
+ const manifest = readManifestSafe(manifestPath);
336
+ const now = new Date().toISOString();
337
+ const repoUrl = `https://github.com/${ghSource.owner}/${ghSource.repo}`;
338
+ const existing = manifest.modules.find((m) => m.name === moduleCode);
339
+ if (existing) {
340
+ existing.lastUpdated = now;
341
+ existing.source = 'github';
342
+ existing.repoUrl = repoUrl;
343
+ existing.npmPackage = null;
344
+ }
345
+ else {
346
+ manifest.modules.push({
347
+ name: moduleCode,
348
+ version: parsed.data.version ?? '1.0.0',
349
+ installDate: now,
350
+ lastUpdated: now,
351
+ source: 'github',
352
+ npmPackage: null,
353
+ repoUrl,
354
+ });
355
+ }
356
+ const wrote = writeManifestThroughWriteService(manifestPath, manifest, app.fileStore.studioDir, app.fileStore);
357
+ if (!wrote.ok)
358
+ throw new ValidationError(wrote.error);
359
+ // Story 15.6 — generate IDE launchers after manifest commit
360
+ const skillsGenerated = runGenerator(moduleCode);
361
+ app.fileStore.rebuild();
362
+ reply.status(200);
363
+ return {
364
+ ok: true,
365
+ modules: [moduleCode],
366
+ filesCopied: { text: copyResult.textCount, binary: copyResult.binaryCount },
367
+ source: { type: 'github', value: source.value, branch: resolvedBranch },
368
+ skillsGenerated,
369
+ };
370
+ }
371
+ finally {
372
+ try {
373
+ fs.rmSync(tmpDir, { recursive: true, force: true });
374
+ }
375
+ catch {
376
+ /* ignore cleanup errors */
377
+ }
378
+ }
379
+ }
380
+ // ─────────────────────────────────────────────────────────────────────────
381
+ // Guard: reject unknown source types before falling through to npm.
382
+ // The InstallSource union only allows 'npm'|'local'|'github', but the body
383
+ // is untyped at the HTTP boundary — 'zip' or any other string would silently
384
+ // run `npm pack <value>` without this check.
385
+ // Cast to string to sidestep TS control-flow narrowing (source is already
386
+ // narrowed to the npm variant by this point, so source.type === 'npm' always,
387
+ // but this guard provides a runtime safety net for unexpected payloads).
388
+ // ─────────────────────────────────────────────────────────────────────────
389
+ const sourceTypeStr = source.type;
390
+ if (sourceTypeStr !== 'npm') {
391
+ throw new ValidationError(`Unknown source type "${sourceTypeStr}". Valid types: local, github, npm. For zip uploads use POST /api/modules/install/upload.`);
392
+ }
393
+ // ─────────────────────────────────────────────────────────────────────────
394
+ // npm source — refactored to route writes through WriteService
395
+ // ─────────────────────────────────────────────────────────────────────────
396
+ const packageName = source.value;
397
+ // TD-20 — realpathSync resolves the macOS /var → /private/var symlink so
398
+ // path comparisons inside the install loop are stable.
399
+ const tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'bmad-install-')));
44
400
  try {
45
401
  // 1. Download the package tarball via npm pack
46
402
  execSync(`npm pack ${packageName} --pack-destination ${tmpDir}`, {
@@ -51,7 +407,7 @@ export async function modulesPlugin(app) {
51
407
  // 2. Find the tarball file
52
408
  const tarballs = fs.readdirSync(tmpDir).filter((f) => f.endsWith('.tgz'));
53
409
  if (tarballs.length === 0) {
54
- return reply.status(400).send({ ok: false, error: 'No tarball downloaded' });
410
+ throw new ValidationError('No tarball downloaded');
55
411
  }
56
412
  const tarballPath = path.join(tmpDir, tarballs[0]);
57
413
  // 3. Extract the tarball
@@ -67,60 +423,95 @@ export async function modulesPlugin(app) {
67
423
  const packageDir = path.join(extractDir, 'package');
68
424
  const bmadSrcDir = path.join(packageDir, '_bmad');
69
425
  if (!fs.existsSync(bmadSrcDir)) {
70
- return reply.status(400).send({
71
- ok: false,
72
- error: `Package "${packageName}" does not contain a _bmad/ directory`,
73
- });
426
+ throw new ValidationError(`Package "${packageName}" does not contain a _bmad/ directory`);
74
427
  }
75
428
  // 5. Copy module directories from the package's _bmad/ into the project's _bmad/
429
+ // Routes through WriteService for text files, byte-copies binaries (TD-16).
76
430
  const installedModules = [];
431
+ let totalText = 0;
432
+ let totalBinary = 0;
77
433
  for (const entry of fs.readdirSync(bmadSrcDir, { withFileTypes: true })) {
78
434
  if (entry.isDirectory() && entry.name !== '_config') {
79
435
  const srcModuleDir = path.join(bmadSrcDir, entry.name);
80
- const destModuleDir = path.join(bmadDir, entry.name);
81
- copyDirRecursive(srcModuleDir, destModuleDir);
82
- installedModules.push(entry.name);
436
+ // Read module.yaml for the authoritative module code (AC-15.2.8).
437
+ // Falls back to the directory name if module.yaml is absent or doesn't
438
+ // declare a code field — consistent with local/github/zip branches.
439
+ const parsedMod = parseModuleYaml(srcModuleDir);
440
+ if (!parsedMod.ok)
441
+ throw new ValidationError(parsedMod.error);
442
+ const moduleCode = parsedMod.data.code;
443
+ const destModuleDir = path.join(bmadDir, moduleCode);
444
+ // Story 17.3 — clean-slate re-install.
445
+ if (fs.existsSync(destModuleDir)) {
446
+ cleanRemoveModule(moduleCode, bmadDir, manifestPath);
447
+ }
448
+ const copyResult = copyDirThroughWriteService(srcModuleDir, destModuleDir, app.fileStore.studioDir, app.fileStore);
449
+ if (!copyResult.ok)
450
+ throw new ValidationError(copyResult.error);
451
+ totalText += copyResult.textCount;
452
+ totalBinary += copyResult.binaryCount;
453
+ // Story 15.5 — substitute placeholders in each installed module dir.
454
+ const subResult = runVariableSubstitution(destModuleDir, makeSubContext(moduleCode), app.fileStore.studioDir, app.fileStore);
455
+ if (!subResult.ok)
456
+ throw new ValidationError(subResult.error);
457
+ installedModules.push(moduleCode);
83
458
  }
84
459
  }
85
460
  if (installedModules.length === 0) {
86
- return reply.status(400).send({
87
- ok: false,
88
- error: `Package "${packageName}" has no module directories in _bmad/`,
89
- });
461
+ throw new ValidationError(`Package "${packageName}" has no module directories in _bmad/`);
90
462
  }
91
- // 6. Update manifest.yaml with new module entries
92
- if (fs.existsSync(manifestPath)) {
93
- const manifest = readManifest(manifestPath);
94
- const now = new Date().toISOString();
95
- for (const moduleName of installedModules) {
96
- const existing = manifest.modules.find((m) => m.name === moduleName);
97
- if (existing) {
98
- existing.lastUpdated = now;
99
- existing.npmPackage = packageName;
100
- existing.source = 'npm';
101
- }
102
- else {
103
- manifest.modules.push({
104
- name: moduleName,
105
- version: '1.0.0',
106
- installDate: now,
107
- lastUpdated: now,
108
- source: 'npm',
109
- npmPackage: packageName,
110
- repoUrl: null,
111
- });
112
- }
463
+ // 6. Update manifest.yaml with new module entries (snapshot via WriteService)
464
+ const manifest = readManifestSafe(manifestPath);
465
+ const now = new Date().toISOString();
466
+ for (const moduleName of installedModules) {
467
+ const existing = manifest.modules.find((m) => m.name === moduleName);
468
+ if (existing) {
469
+ existing.lastUpdated = now;
470
+ existing.npmPackage = packageName;
471
+ existing.source = 'npm';
472
+ }
473
+ else {
474
+ manifest.modules.push({
475
+ name: moduleName,
476
+ version: '1.0.0',
477
+ installDate: now,
478
+ lastUpdated: now,
479
+ source: 'npm',
480
+ npmPackage: packageName,
481
+ repoUrl: null,
482
+ });
483
+ }
484
+ }
485
+ const wrote = writeManifestThroughWriteService(manifestPath, manifest, app.fileStore.studioDir, app.fileStore);
486
+ if (!wrote.ok)
487
+ throw new ValidationError(wrote.error);
488
+ // Story 15.6 — generate IDE launchers for each installed module.
489
+ // The npm branch installs MULTIPLE modules; sum the per-IDE counts.
490
+ const skillsGenerated = {};
491
+ for (const moduleName of installedModules) {
492
+ const counts = runGenerator(moduleName);
493
+ for (const [ide, count] of Object.entries(counts)) {
494
+ skillsGenerated[ide] = (skillsGenerated[ide] ?? 0) + count;
113
495
  }
114
- writeManifest(manifestPath, manifest);
115
496
  }
116
497
  // 7. Rebuild file store
117
498
  app.fileStore.rebuild();
118
499
  reply.status(200);
119
- return { ok: true, modules: installedModules };
500
+ return {
501
+ ok: true,
502
+ modules: installedModules,
503
+ filesCopied: { text: totalText, binary: totalBinary },
504
+ skillsGenerated,
505
+ };
120
506
  }
121
507
  catch (err) {
122
- const message = err instanceof Error ? err.message : 'Installation failed';
123
- return reply.status(400).send({ ok: false, error: message });
508
+ // Rethrow known error types so the global handler produces the correct
509
+ // status (422 for ValidationError, 409 for ConflictError). Wrap any
510
+ // unexpected error (e.g. execSync failure) as a ValidationError.
511
+ if (err instanceof ValidationError || err instanceof ConflictError || err instanceof NotFoundError) {
512
+ throw err;
513
+ }
514
+ throw new ValidationError(err instanceof Error ? err.message : 'Installation failed');
124
515
  }
125
516
  finally {
126
517
  // Cleanup temp directory
@@ -132,6 +523,141 @@ export async function modulesPlugin(app) {
132
523
  }
133
524
  }
134
525
  });
526
+ // Multipart zip upload route — Story 15.4. Separate from POST /api/modules/install
527
+ // because multipart bodies and JSON bodies have different shapes inside Fastify;
528
+ // splitting the routes is cleaner than content-type discrimination inside one handler.
529
+ app.post('/api/modules/install/upload', async (request, reply) => {
530
+ if (!('fileStore' in app)) {
531
+ throw new ValidationError('No project detected');
532
+ }
533
+ // Iterate all multipart parts in stream order so the `variables` field is
534
+ // captured regardless of whether it arrives before or after the file part.
535
+ // Using request.parts() instead of request.file() avoids the ordering bug
536
+ // where data.fields is only populated for fields that precede the file.
537
+ let zipBuffer = null;
538
+ let uploadVariables = {};
539
+ for await (const part of request.parts()) {
540
+ if (part.type === 'file') {
541
+ zipBuffer = await part.toBuffer();
542
+ }
543
+ else if (part.type === 'field' && part.fieldname === 'variables') {
544
+ const raw = part.value;
545
+ if (typeof raw === 'string') {
546
+ try {
547
+ const parsed = JSON.parse(raw);
548
+ if (parsed && typeof parsed === 'object') {
549
+ uploadVariables = parsed;
550
+ }
551
+ }
552
+ catch {
553
+ throw new ValidationError('Invalid `variables` field — expected JSON object');
554
+ }
555
+ }
556
+ }
557
+ }
558
+ if (!zipBuffer) {
559
+ throw new ValidationError('No zip file uploaded');
560
+ }
561
+ const buffer = zipBuffer;
562
+ const varValidation = validateVariables(uploadVariables);
563
+ if (!varValidation.ok)
564
+ throw new ValidationError(varValidation.error);
565
+ const bmadDir = path.join(app.fileStore.projectRoot, '_bmad');
566
+ const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
567
+ // Manifest existence guard (AC-15.4.9). Runs BEFORE extractZipUpload so a
568
+ // misconfigured project doesn't waste CPU on adm-zip extraction.
569
+ if (!fs.existsSync(manifestPath)) {
570
+ throw new ValidationError('Cannot install module: missing _bmad/_config/manifest.yaml. Run `npx bmad-method install` to initialise the project first.');
571
+ }
572
+ // extractZipUpload throws plain Error on failure (malformed zip, zip-slip, etc.)
573
+ // Convert to ValidationError so the user sees a 422 with a clean message.
574
+ let extracted;
575
+ try {
576
+ extracted = await extractZipUpload(buffer);
577
+ }
578
+ catch (err) {
579
+ throw new ValidationError(err instanceof Error ? err.message : String(err));
580
+ }
581
+ const { extractedRoot, tmpDir } = extracted;
582
+ try {
583
+ if (!isPlausibleModuleDir(extractedRoot)) {
584
+ throw new ValidationError('Uploaded zip does not look like a BMAD module');
585
+ }
586
+ const parsed = parseModuleYaml(extractedRoot);
587
+ if (!parsed.ok)
588
+ throw new ValidationError(parsed.error);
589
+ const moduleCode = parsed.data.code;
590
+ const destDir = path.join(bmadDir, moduleCode);
591
+ // Story 17.3 — clean-slate re-install.
592
+ if (fs.existsSync(destDir)) {
593
+ cleanRemoveModule(moduleCode, bmadDir, manifestPath);
594
+ }
595
+ const copyResult = copyDirThroughWriteService(extractedRoot, destDir, app.fileStore.studioDir, app.fileStore);
596
+ if (!copyResult.ok)
597
+ throw new ValidationError(copyResult.error);
598
+ // Story 15.5 — substitute placeholders before manifest update
599
+ const subResult = runVariableSubstitution(destDir, {
600
+ moduleCode,
601
+ projectRoot: app.fileStore.projectRoot,
602
+ outputFolder: readOutputFolder(app.fileStore.projectRoot),
603
+ variables: uploadVariables,
604
+ }, app.fileStore.studioDir, app.fileStore);
605
+ if (!subResult.ok)
606
+ throw new ValidationError(subResult.error);
607
+ // Update manifest (guaranteed to exist by the check at the top of the handler)
608
+ const manifest = readManifestSafe(manifestPath);
609
+ const now = new Date().toISOString();
610
+ const existing = manifest.modules.find((m) => m.name === moduleCode);
611
+ if (existing) {
612
+ existing.lastUpdated = now;
613
+ existing.source = 'zip';
614
+ existing.npmPackage = null;
615
+ existing.repoUrl = null;
616
+ }
617
+ else {
618
+ manifest.modules.push({
619
+ name: moduleCode,
620
+ version: parsed.data.version ?? '1.0.0',
621
+ installDate: now,
622
+ lastUpdated: now,
623
+ source: 'zip',
624
+ npmPackage: null,
625
+ repoUrl: null,
626
+ });
627
+ }
628
+ const wrote = writeManifestThroughWriteService(manifestPath, manifest, app.fileStore.studioDir, app.fileStore);
629
+ if (!wrote.ok)
630
+ throw new ValidationError(wrote.error);
631
+ // Story 15.6 — generate IDE launchers after manifest commit
632
+ const updatedManifest = readManifestSafe(manifestPath);
633
+ const skillsGenerated = {};
634
+ if (updatedManifest) {
635
+ const genResult = generateIdeSkillsForModule(app.fileStore.projectRoot, moduleCode, updatedManifest, app.fileStore.studioDir, app.fileStore);
636
+ if (!genResult.ok)
637
+ throw new ValidationError(genResult.error);
638
+ for (const [ide, skills] of Object.entries(genResult.skillsByIde)) {
639
+ skillsGenerated[ide] = skills.length;
640
+ }
641
+ }
642
+ app.fileStore.rebuild();
643
+ reply.status(200);
644
+ return {
645
+ ok: true,
646
+ modules: [moduleCode],
647
+ filesCopied: { text: copyResult.textCount, binary: copyResult.binaryCount },
648
+ source: { type: 'zip' },
649
+ skillsGenerated,
650
+ };
651
+ }
652
+ finally {
653
+ try {
654
+ fs.rmSync(tmpDir, { recursive: true, force: true });
655
+ }
656
+ catch {
657
+ /* ignore cleanup errors */
658
+ }
659
+ }
660
+ });
135
661
  app.post('/api/modules', async (request, reply) => {
136
662
  if (!('fileStore' in app)) {
137
663
  throw new ValidationError('No project detected');
@@ -164,10 +690,12 @@ export async function modulesPlugin(app) {
164
690
  '',
165
691
  `project_name: ${name}`,
166
692
  ].join('\n');
167
- fs.writeFileSync(path.join(moduleDir, 'config.yaml'), configContent, 'utf-8');
693
+ const wResult = writeFile(path.join(moduleDir, 'config.yaml'), configContent, app.fileStore.studioDir);
694
+ if (!wResult.ok)
695
+ throw new ValidationError(wResult.error);
168
696
  // Update manifest
169
- if (fs.existsSync(manifestPath)) {
170
- const manifest = readManifest(manifestPath);
697
+ const manifest = readManifestSafe(manifestPath);
698
+ if (manifest) {
171
699
  const now = new Date().toISOString();
172
700
  manifest.modules.push({
173
701
  name,
@@ -178,14 +706,20 @@ export async function modulesPlugin(app) {
178
706
  npmPackage: null,
179
707
  repoUrl: null,
180
708
  });
181
- writeManifest(manifestPath, manifest);
709
+ const wrote = writeManifestThroughWriteService(manifestPath, manifest, app.fileStore.studioDir, app.fileStore);
710
+ if (!wrote.ok)
711
+ throw new ValidationError(wrote.error);
182
712
  }
183
713
  // Rebuild index
184
714
  app.fileStore.rebuild();
185
715
  reply.status(201);
186
- return { ok: true, name, path: moduleDir };
716
+ return { ok: true, name };
187
717
  });
188
- app.delete('/api/modules/:name', async (request) => {
718
+ // Story 15.8 Regenerate IDE skills for an installed module. Removes all
719
+ // prefix-matched launcher dirs and re-runs the generator against the current
720
+ // module contents. Used by the "Regenerate IDE skills" button on PackagesPage
721
+ // and as the Q9 smoke test for dept-aem.
722
+ app.post('/api/modules/:name/regenerate-skills', async (request) => {
189
723
  if (!('fileStore' in app)) {
190
724
  throw new ValidationError('No project detected');
191
725
  }
@@ -193,27 +727,199 @@ export async function modulesPlugin(app) {
193
727
  const bmadDir = path.join(app.fileStore.projectRoot, '_bmad');
194
728
  const moduleDir = path.join(bmadDir, name);
195
729
  const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
196
- // Check manifest for source
197
- if (fs.existsSync(manifestPath)) {
198
- const manifest = readManifest(manifestPath);
199
- const entry = manifest.modules.find((m) => m.name === name);
200
- if (!entry) {
201
- throw new NotFoundError(`Module "${name}" not found in manifest`);
730
+ if (!fs.existsSync(moduleDir)) {
731
+ throw new NotFoundError(`Module "${name}" is not installed`);
732
+ }
733
+ const manifest = readManifestSafe(manifestPath);
734
+ if (!manifest)
735
+ throw new ValidationError('No manifest.yaml found');
736
+ // Remove existing launcher files first (snapshots text content via WriteService)
737
+ const removeResult = removeIdeSkillsForModule(app.fileStore.projectRoot, name, manifest, app.fileStore.studioDir);
738
+ if (!removeResult.ok)
739
+ throw new ValidationError(removeResult.error);
740
+ // Regenerate from the current module contents — picks up any manual edits.
741
+ const genResult = generateIdeSkillsForModule(app.fileStore.projectRoot, name, manifest, app.fileStore.studioDir, app.fileStore);
742
+ if (!genResult.ok)
743
+ throw new ValidationError(genResult.error);
744
+ const regenerated = {};
745
+ for (const [ide, skills] of Object.entries(genResult.skillsByIde)) {
746
+ regenerated[ide] = skills.length;
747
+ }
748
+ app.fileStore.rebuild();
749
+ return { ok: true, regenerated };
750
+ });
751
+ // Story 15.7 — Remove preview. Returns a structured pre-flight summary so the
752
+ // remove dialog (Story 15.9) can render an informed confirmation screen.
753
+ app.get('/api/modules/:name/remove-preview', async (request) => {
754
+ if (!('fileStore' in app)) {
755
+ throw new ValidationError('No project detected');
756
+ }
757
+ const { name } = request.params;
758
+ const bmadDir = path.join(app.fileStore.projectRoot, '_bmad');
759
+ const moduleDir = path.join(bmadDir, name);
760
+ const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
761
+ const manifest = readManifestSafe(manifestPath);
762
+ const entry = manifest?.modules.find((m) => m.name === name);
763
+ if (!entry)
764
+ throw new NotFoundError(`Module "${name}" not found in manifest`);
765
+ // Built-in modules: blocked
766
+ const removalBlocked = entry.source === 'built-in'
767
+ ? `Module "${name}" is built-in and cannot be removed via Studio.`
768
+ : null;
769
+ // Module file count + size
770
+ let fileCount = 0;
771
+ let totalBytes = 0;
772
+ if (fs.existsSync(moduleDir)) {
773
+ const walk = (dir) => {
774
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
775
+ const full = path.join(dir, e.name);
776
+ if (e.isDirectory())
777
+ walk(full);
778
+ else {
779
+ fileCount++;
780
+ totalBytes += fs.statSync(full).size;
781
+ }
782
+ }
783
+ };
784
+ walk(moduleDir);
785
+ }
786
+ // IDE skills (prefix-matched dirs under each configured IDE output dir)
787
+ const ideSkills = {};
788
+ if (manifest?.ides) {
789
+ for (const ide of manifest.ides) {
790
+ if (ide !== 'claude-code' && ide !== 'antigravity')
791
+ continue;
792
+ const dir = path.join(app.fileStore.projectRoot, ide === 'claude-code' ? '.claude/skills' : '.antigravity/skills');
793
+ if (!fs.existsSync(dir)) {
794
+ ideSkills[ide] = [];
795
+ continue;
796
+ }
797
+ const agentPrefix = `bmad-agent-${name}-`;
798
+ const otherPrefix = `bmad-${name}-`;
799
+ ideSkills[ide] = fs
800
+ .readdirSync(dir, { withFileTypes: true })
801
+ .filter((e) => e.isDirectory() &&
802
+ (e.name.startsWith(agentPrefix) || e.name.startsWith(otherPrefix)))
803
+ .map((e) => e.name);
202
804
  }
203
- if (entry.source === 'built-in') {
204
- throw new ValidationError(`Cannot remove built-in module "${name}"`);
805
+ }
806
+ // Preserved directories from module.yaml (declared output dirs)
807
+ const parsed = parseModuleYaml(moduleDir);
808
+ const preservedDirectories = [];
809
+ let moduleYamlPresent = false;
810
+ if (parsed.ok && fs.existsSync(path.join(moduleDir, 'module.yaml'))) {
811
+ moduleYamlPresent = true;
812
+ for (const declared of parsed.data.directories ?? []) {
813
+ const resolved = path.isAbsolute(declared)
814
+ ? declared
815
+ : path.join(app.fileStore.projectRoot, declared);
816
+ if (fs.existsSync(resolved)) {
817
+ preservedDirectories.push({ path: resolved, declared: true });
818
+ }
205
819
  }
206
- // Remove from manifest
207
- manifest.modules = manifest.modules.filter((m) => m.name !== name);
208
- writeManifest(manifestPath, manifest);
209
820
  }
210
- // Delete module directory
821
+ // Cross-references (TD-19 scope)
822
+ const crossReferences = findCrossReferences(app.fileStore.getIndex(), name);
823
+ // External-installer warning
824
+ const externalInstallerWarning = entry.source === 'external'
825
+ ? `Module "${name}" was installed by the BMAD installer. Removing it via Studio will not update the upstream installation — re-running the installer will reinstall it.`
826
+ : null;
827
+ return {
828
+ module: { name: entry.name, version: entry.version, source: entry.source },
829
+ moduleFiles: { count: fileCount, totalBytes },
830
+ ideSkills,
831
+ manifestEntries: { 'manifest.yaml': true },
832
+ preservedDirectories,
833
+ moduleYamlPresent,
834
+ crossReferences,
835
+ crossReferenceScopeNotice: 'Cross-reference scanning covers teams and workflow steps. References from agent menus or skill lists are not detected — review the affected modules manually after removal.',
836
+ recoverableFrom: '.bmad-studio/history/',
837
+ removalBlocked,
838
+ externalInstallerWarning,
839
+ };
840
+ });
841
+ // Story 15.7 — Rich remove handler. Replaces the thin DELETE. Removes IDE
842
+ // launcher files, then deletes the module dir through WriteService (text
843
+ // files snapshot to history/ before unlink), then removes the manifest entry.
844
+ // Preserves directories declared in module.yaml.directories[].
845
+ app.delete('/api/modules/:name', async (request) => {
846
+ if (!('fileStore' in app)) {
847
+ throw new ValidationError('No project detected');
848
+ }
849
+ const { name } = request.params;
850
+ const bmadDir = path.join(app.fileStore.projectRoot, '_bmad');
851
+ const moduleDir = path.join(bmadDir, name);
852
+ const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
853
+ const manifest = readManifestSafe(manifestPath);
854
+ const entry = manifest?.modules.find((m) => m.name === name);
855
+ if (!entry)
856
+ throw new NotFoundError(`Module "${name}" not found in manifest`);
857
+ if (entry.source === 'built-in') {
858
+ throw new ValidationError(`Cannot remove built-in module "${name}"`);
859
+ }
860
+ // Read module.yaml BEFORE deletion so we can capture directories: to preserve.
861
+ const parsed = parseModuleYaml(moduleDir);
862
+ const preserved = parsed.ok
863
+ ? (parsed.data.directories ?? []).map((d) => path.isAbsolute(d) ? d : path.join(app.fileStore.projectRoot, d))
864
+ : [];
865
+ // 1. Remove prefix-matched IDE skill directories (each deletion snapshots through WriteService)
866
+ let removedSkills = {};
867
+ if (manifest) {
868
+ const r = removeIdeSkillsForModule(app.fileStore.projectRoot, name, manifest, app.fileStore.studioDir);
869
+ if (!r.ok)
870
+ throw new ValidationError(r.error);
871
+ removedSkills = r.removedByIde;
872
+ }
873
+ // 2. Delete the module directory through WriteService (text files snapshot first).
874
+ // Count files before delete so the response summary is accurate.
875
+ let filesRemoved = 0;
211
876
  if (fs.existsSync(moduleDir)) {
212
- fs.rmSync(moduleDir, { recursive: true, force: true });
877
+ const walk = (dir) => {
878
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
879
+ const full = path.join(dir, e.name);
880
+ if (e.isDirectory())
881
+ walk(full);
882
+ else
883
+ filesRemoved++;
884
+ }
885
+ };
886
+ walk(moduleDir);
887
+ const r = deleteDirectory(moduleDir, app.fileStore.studioDir);
888
+ if (!r.ok)
889
+ throw new ValidationError(r.error);
213
890
  }
214
- // Rebuild index
891
+ // 3. Update the manifest (snapshot via WriteService)
892
+ if (manifest) {
893
+ manifest.modules = manifest.modules.filter((m) => m.name !== name);
894
+ const wrote = writeManifestThroughWriteService(manifestPath, manifest, app.fileStore.studioDir, app.fileStore);
895
+ if (!wrote.ok)
896
+ throw new ValidationError(wrote.error);
897
+ }
898
+ // 4. Rebuild index and broadcast the change
215
899
  app.fileStore.rebuild();
216
- return { ok: true, name };
900
+ if ('ws' in app) {
901
+ try {
902
+ ;
903
+ app.ws.broadcast({
904
+ type: 'file:deleted',
905
+ path: moduleDir,
906
+ category: 'config',
907
+ });
908
+ }
909
+ catch {
910
+ /* ignore — broadcast is best-effort */
911
+ }
912
+ }
913
+ return {
914
+ ok: true,
915
+ name,
916
+ removed: {
917
+ filesRemoved,
918
+ skills: removedSkills,
919
+ },
920
+ preservedDirectories: preserved,
921
+ recoverableFrom: '.bmad-studio/history/',
922
+ };
217
923
  });
218
924
  // Story 12.1 + 12.2: Add/upload entities to a module
219
925
  app.post('/api/modules/:name/entities', async (request, reply) => {
@@ -221,6 +927,9 @@ export async function modulesPlugin(app) {
221
927
  throw new ValidationError('No project detected');
222
928
  }
223
929
  const { name } = request.params;
930
+ if (!MODULE_NAME_RE.test(name)) {
931
+ throw new ValidationError('Module name must be lowercase alphanumeric with hyphens');
932
+ }
224
933
  const body = request.body;
225
934
  const entityType = body.type;
226
935
  const entityName = body.name?.trim();
@@ -258,7 +967,9 @@ export async function modulesPlugin(app) {
258
967
  '<!-- Add skill instructions here -->',
259
968
  '',
260
969
  ].join('\n');
261
- fs.writeFileSync(filePath, content, 'utf-8');
970
+ const wResult = writeFile(filePath, content, app.fileStore.studioDir);
971
+ if (!wResult.ok)
972
+ throw new ValidationError(wResult.error);
262
973
  }
263
974
  else if (entityType === 'workflow') {
264
975
  // Workflows get their own directory with a workflow.md file
@@ -282,7 +993,9 @@ export async function modulesPlugin(app) {
282
993
  '<!-- Add workflow steps here -->',
283
994
  '',
284
995
  ].join('\n');
285
- fs.writeFileSync(filePath, content, 'utf-8');
996
+ const wResult = writeFile(filePath, content, app.fileStore.studioDir);
997
+ if (!wResult.ok)
998
+ throw new ValidationError(wResult.error);
286
999
  }
287
1000
  else {
288
1001
  // Agent: .md file in agents/ directory
@@ -305,12 +1018,14 @@ export async function modulesPlugin(app) {
305
1018
  '<!-- Add agent definition here -->',
306
1019
  '',
307
1020
  ].join('\n');
308
- fs.writeFileSync(filePath, content, 'utf-8');
1021
+ const wResult = writeFile(filePath, content, app.fileStore.studioDir);
1022
+ if (!wResult.ok)
1023
+ throw new ValidationError(wResult.error);
309
1024
  }
310
1025
  // Rebuild index
311
1026
  app.fileStore.rebuild();
312
1027
  reply.status(201);
313
- return { ok: true, type: entityType, name: entityName, path: filePath };
1028
+ return { ok: true, type: entityType, name: entityName };
314
1029
  });
315
1030
  // Story 12.3: Export module manifest
316
1031
  app.post('/api/modules/:name/export', async (request) => {
@@ -320,10 +1035,10 @@ export async function modulesPlugin(app) {
320
1035
  const { name } = request.params;
321
1036
  const bmadDir = path.join(app.fileStore.projectRoot, '_bmad');
322
1037
  const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
323
- if (!fs.existsSync(manifestPath)) {
1038
+ const manifest = readManifestSafe(manifestPath);
1039
+ if (!manifest) {
324
1040
  throw new NotFoundError('No manifest found');
325
1041
  }
326
- const manifest = readManifest(manifestPath);
327
1042
  const entry = manifest.modules.find((m) => m.name === name);
328
1043
  if (!entry) {
329
1044
  throw new NotFoundError(`Module "${name}" not found in manifest`);
@@ -361,6 +1076,9 @@ export async function modulesPlugin(app) {
361
1076
  throw new ValidationError('No project detected');
362
1077
  }
363
1078
  const { name } = request.params;
1079
+ if (!MODULE_NAME_RE.test(name)) {
1080
+ throw new ValidationError('Module name must be lowercase alphanumeric with hyphens');
1081
+ }
364
1082
  const body = request.body;
365
1083
  const bmadDir = path.join(app.fileStore.projectRoot, '_bmad');
366
1084
  const moduleDir = path.join(bmadDir, name);
@@ -369,23 +1087,27 @@ export async function modulesPlugin(app) {
369
1087
  throw new NotFoundError(`Module "${name}" config not found`);
370
1088
  }
371
1089
  const configContent = fs.readFileSync(configPath, 'utf-8');
372
- const config = yaml.load(configContent);
1090
+ const config = yaml.load(configContent) ?? {};
373
1091
  if (body.version !== undefined)
374
1092
  config.version = body.version;
375
1093
  if (body.description !== undefined)
376
1094
  config.description = body.description;
377
1095
  const updated = yaml.dump(config, { lineWidth: -1 });
378
- fs.writeFileSync(configPath, updated, 'utf-8');
1096
+ const wResult = writeFile(configPath, updated, app.fileStore.studioDir);
1097
+ if (!wResult.ok)
1098
+ throw new ValidationError(wResult.error);
379
1099
  // Also update manifest.yaml
380
1100
  const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
381
- if (fs.existsSync(manifestPath)) {
382
- const manifest = readManifest(manifestPath);
1101
+ const manifest = readManifestSafe(manifestPath);
1102
+ if (manifest) {
383
1103
  const mod = manifest.modules.find((m) => m.name === name);
384
1104
  if (mod) {
385
1105
  if (body.version !== undefined)
386
1106
  mod.version = body.version;
387
- mod.lastUpdated = new Date().toISOString().split('T')[0];
388
- writeManifest(manifestPath, manifest);
1107
+ mod.lastUpdated = new Date().toISOString();
1108
+ const wrote = writeManifestThroughWriteService(manifestPath, manifest, app.fileStore.studioDir, app.fileStore);
1109
+ if (!wrote.ok)
1110
+ throw new ValidationError(wrote.error);
389
1111
  }
390
1112
  }
391
1113
  app.fileStore.rebuild();
@@ -397,10 +1119,10 @@ export async function modulesPlugin(app) {
397
1119
  }
398
1120
  const bmadDir = path.join(app.fileStore.projectRoot, '_bmad');
399
1121
  const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
400
- if (!fs.existsSync(manifestPath)) {
1122
+ const manifest = readManifestSafe(manifestPath);
1123
+ if (!manifest) {
401
1124
  return [];
402
1125
  }
403
- const manifest = readManifest(manifestPath);
404
1126
  const index = app.fileStore.getIndex();
405
1127
  return manifest.modules.map((m) => {
406
1128
  const moduleAgents = index.agents.filter((a) => a.module === m.name);
@@ -430,6 +1152,9 @@ export async function modulesPlugin(app) {
430
1152
  if (!name) {
431
1153
  throw new ValidationError('Package name is required');
432
1154
  }
1155
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(name)) {
1156
+ throw new ValidationError('Package name must start with a letter or digit and contain only alphanumeric characters, hyphens, or underscores');
1157
+ }
433
1158
  const description = body.description?.trim() || '';
434
1159
  const version = body.version?.trim() || '1.0.0';
435
1160
  const agentIds = body.agents ?? [];
@@ -492,7 +1217,10 @@ export async function modulesPlugin(app) {
492
1217
  fs.writeFileSync(path.join(packageDir, 'package.yaml'), yaml.dump(packageManifest, { lineWidth: -1 }), 'utf-8');
493
1218
  // Create tar.gz archive
494
1219
  const archivePath = path.join(tmpBase, `${name}.tar.gz`);
495
- execSync(`tar -czf "${archivePath}" -C "${tmpBase}" "${name}"`);
1220
+ const tarResult = spawnSync('tar', ['-czf', archivePath, '-C', tmpBase, name], { stdio: 'pipe' });
1221
+ if (tarResult.status !== 0) {
1222
+ throw new ValidationError('Failed to create package archive');
1223
+ }
496
1224
  // Send as downloadable file
497
1225
  reply.header('Content-Disposition', `attachment; filename="${name}.tar.gz"`);
498
1226
  reply.type('application/gzip');
@@ -527,5 +1255,59 @@ export async function modulesPlugin(app) {
527
1255
  throw new ValidationError(`Export failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
528
1256
  }
529
1257
  });
1258
+ // ─────────────────────────────────────────────────────────────────────────────
1259
+ // Story 16.2 — Registry index endpoints
1260
+ // ─────────────────────────────────────────────────────────────────────────────
1261
+ app.get('/api/registry', async (_request, reply) => {
1262
+ if (!('fileStore' in app)) {
1263
+ return reply.send({ ok: false, configured: false, error: 'No project detected' });
1264
+ }
1265
+ const settingsPath = path.join(app.fileStore.studioDir, 'settings.json');
1266
+ if (!fs.existsSync(settingsPath)) {
1267
+ return reply.send({ ok: false, configured: false, error: 'No registry configured' });
1268
+ }
1269
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
1270
+ if (!settings.registry?.repo) {
1271
+ return reply.send({ ok: false, configured: false, error: 'No registry configured' });
1272
+ }
1273
+ let ghSource;
1274
+ try {
1275
+ ghSource = parseGithubSource(settings.registry.repo);
1276
+ }
1277
+ catch (err) {
1278
+ throw new ValidationError(err instanceof Error ? err.message : String(err));
1279
+ }
1280
+ let cached = readCachedRegistryIndex(app.fileStore.studioDir, ghSource.owner, ghSource.repo);
1281
+ if (!cached || isRegistryCacheStale(cached)) {
1282
+ try {
1283
+ cached = await fetchAndCacheRegistryIndex(app.fileStore.studioDir, settings.registry.repo, settings.registry.branch ?? 'main');
1284
+ }
1285
+ catch (err) {
1286
+ throw new ValidationError(err instanceof Error ? err.message : String(err));
1287
+ }
1288
+ }
1289
+ return reply.send({ ok: true, configured: true, index: cached });
1290
+ });
1291
+ app.post('/api/registry/refresh', async (_request, reply) => {
1292
+ if (!('fileStore' in app)) {
1293
+ throw new ValidationError('No project detected');
1294
+ }
1295
+ const settingsPath = path.join(app.fileStore.studioDir, 'settings.json');
1296
+ if (!fs.existsSync(settingsPath)) {
1297
+ throw new ValidationError('No registry configured');
1298
+ }
1299
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
1300
+ if (!settings.registry?.repo) {
1301
+ throw new ValidationError('No registry configured');
1302
+ }
1303
+ let index;
1304
+ try {
1305
+ index = await fetchAndCacheRegistryIndex(app.fileStore.studioDir, settings.registry.repo, settings.registry.branch ?? 'main');
1306
+ }
1307
+ catch (err) {
1308
+ throw new ValidationError(err instanceof Error ? err.message : String(err));
1309
+ }
1310
+ return reply.send({ ok: true, index });
1311
+ });
530
1312
  }
531
1313
  //# sourceMappingURL=modules-plugin.js.map