bmad-studio 0.2.0 → 1.1.0

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