bmad-method 6.2.2 → 6.2.3-next.1

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 (77) hide show
  1. package/.claude-plugin/marketplace.json +78 -0
  2. package/package.json +8 -8
  3. package/src/core-skills/bmad-distillator/resources/distillate-format-reference.md +1 -1
  4. package/src/core-skills/bmad-init/scripts/bmad_init.py +35 -4
  5. package/src/core-skills/bmad-init/scripts/tests/test_bmad_init.py +64 -0
  6. package/tools/{cli → installer}/bmad-cli.js +3 -1
  7. package/tools/{cli/lib → installer}/cli-utils.js +3 -4
  8. package/tools/{cli → installer}/commands/install.js +3 -3
  9. package/tools/{cli → installer}/commands/status.js +4 -4
  10. package/tools/{cli → installer}/commands/uninstall.js +5 -5
  11. package/tools/installer/core/config.js +52 -0
  12. package/tools/{cli/installers/lib → installer}/core/custom-module-cache.js +1 -1
  13. package/tools/installer/core/existing-install.js +127 -0
  14. package/tools/installer/core/install-paths.js +129 -0
  15. package/tools/installer/core/installer.js +1790 -0
  16. package/tools/{cli/installers/lib → installer}/core/manifest-generator.js +3 -3
  17. package/tools/{cli/installers/lib → installer}/core/manifest.js +2 -2
  18. package/tools/{cli/installers/lib/custom/handler.js → installer/custom-handler.js} +1 -1
  19. package/tools/{cli/installers/lib → installer}/ide/_config-driven.js +30 -397
  20. package/tools/{cli/installers/lib → installer}/ide/manager.js +1 -53
  21. package/tools/installer/ide/platform-codes.js +37 -0
  22. package/tools/installer/ide/platform-codes.yaml +190 -0
  23. package/tools/{cli/installers/lib → installer}/ide/shared/module-injections.js +1 -1
  24. package/tools/{cli/installers/lib → installer}/message-loader.js +2 -2
  25. package/tools/installer/modules/custom-modules.js +197 -0
  26. package/tools/installer/modules/external-manager.js +323 -0
  27. package/tools/{cli/installers/lib/core/config-collector.js → installer/modules/official-modules.js} +714 -43
  28. package/tools/{cli/lib → installer}/ui.js +65 -299
  29. package/tools/javascript-conventions.md +5 -0
  30. package/tools/bmad-npx-wrapper.js +0 -38
  31. package/tools/cli/installers/lib/core/dependency-resolver.js +0 -743
  32. package/tools/cli/installers/lib/core/detector.js +0 -223
  33. package/tools/cli/installers/lib/core/ide-config-manager.js +0 -157
  34. package/tools/cli/installers/lib/core/installer.js +0 -3002
  35. package/tools/cli/installers/lib/ide/_base-ide.js +0 -657
  36. package/tools/cli/installers/lib/ide/platform-codes.js +0 -100
  37. package/tools/cli/installers/lib/ide/platform-codes.yaml +0 -341
  38. package/tools/cli/installers/lib/modules/external-manager.js +0 -136
  39. package/tools/cli/installers/lib/modules/manager.js +0 -928
  40. package/tools/cli/lib/config.js +0 -213
  41. package/tools/cli/lib/platform-codes.js +0 -116
  42. package/tools/lib/xml-utils.js +0 -13
  43. /package/tools/{cli → installer}/README.md +0 -0
  44. /package/tools/{cli → installer}/external-official-modules.yaml +0 -0
  45. /package/tools/{cli/lib → installer}/file-ops.js +0 -0
  46. /package/tools/{cli/installers/lib → installer}/ide/shared/agent-command-generator.js +0 -0
  47. /package/tools/{cli/installers/lib → installer}/ide/shared/bmad-artifacts.js +0 -0
  48. /package/tools/{cli/installers/lib → installer}/ide/shared/path-utils.js +0 -0
  49. /package/tools/{cli/installers/lib → installer}/ide/shared/skill-manifest.js +0 -0
  50. /package/tools/{cli/installers/lib → installer}/ide/templates/agent-command-template.md +0 -0
  51. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/antigravity.md +0 -0
  52. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/default-agent.md +0 -0
  53. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/default-task.md +0 -0
  54. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/default-tool.md +0 -0
  55. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/default-workflow.md +0 -0
  56. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/gemini-agent.toml +0 -0
  57. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/gemini-task.toml +0 -0
  58. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/gemini-tool.toml +0 -0
  59. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/gemini-workflow-yaml.toml +0 -0
  60. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/gemini-workflow.toml +0 -0
  61. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/kiro-agent.md +0 -0
  62. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/kiro-task.md +0 -0
  63. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/kiro-tool.md +0 -0
  64. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/kiro-workflow.md +0 -0
  65. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/opencode-agent.md +0 -0
  66. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/opencode-task.md +0 -0
  67. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/opencode-tool.md +0 -0
  68. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/opencode-workflow-yaml.md +0 -0
  69. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/opencode-workflow.md +0 -0
  70. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/rovodev.md +0 -0
  71. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/trae.md +0 -0
  72. /package/tools/{cli/installers/lib → installer}/ide/templates/combined/windsurf-workflow.md +0 -0
  73. /package/tools/{cli/installers/lib → installer}/ide/templates/split/.gitkeep +0 -0
  74. /package/tools/{cli/installers → installer}/install-messages.yaml +0 -0
  75. /package/tools/{cli/lib → installer}/project-root.js +0 -0
  76. /package/tools/{cli/lib → installer}/prompts.js +0 -0
  77. /package/tools/{cli/lib → installer}/yaml-format.js +0 -0
@@ -1,928 +0,0 @@
1
- const path = require('node:path');
2
- const fs = require('fs-extra');
3
- const yaml = require('yaml');
4
- const prompts = require('../../../lib/prompts');
5
- const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
6
- const { ExternalModuleManager } = require('./external-manager');
7
- const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
8
-
9
- /**
10
- * Manages the installation, updating, and removal of BMAD modules.
11
- * Handles module discovery, dependency resolution, and configuration processing.
12
- *
13
- * @class ModuleManager
14
- * @requires fs-extra
15
- * @requires yaml
16
- * @requires prompts
17
- *
18
- * @example
19
- * const manager = new ModuleManager();
20
- * const modules = await manager.listAvailable();
21
- * await manager.install('core-module', '/path/to/bmad');
22
- */
23
- class ModuleManager {
24
- constructor(options = {}) {
25
- this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden
26
- this.customModulePaths = new Map(); // Initialize custom module paths
27
- this.externalModuleManager = new ExternalModuleManager(); // For external official modules
28
- }
29
-
30
- /**
31
- * Set the bmad folder name for placeholder replacement
32
- * @param {string} bmadFolderName - The bmad folder name
33
- */
34
- setBmadFolderName(bmadFolderName) {
35
- this.bmadFolderName = bmadFolderName;
36
- }
37
-
38
- /**
39
- * Set the core configuration for access during module installation
40
- * @param {Object} coreConfig - Core configuration object
41
- */
42
- setCoreConfig(coreConfig) {
43
- this.coreConfig = coreConfig;
44
- }
45
-
46
- /**
47
- * Set custom module paths for priority lookup
48
- * @param {Map<string, string>} customModulePaths - Map of module ID to source path
49
- */
50
- setCustomModulePaths(customModulePaths) {
51
- this.customModulePaths = customModulePaths;
52
- }
53
-
54
- /**
55
- * Copy a file to the target location
56
- * @param {string} sourcePath - Source file path
57
- * @param {string} targetPath - Target file path
58
- * @param {boolean} overwrite - Whether to overwrite existing files (default: true)
59
- */
60
- async copyFileWithPlaceholderReplacement(sourcePath, targetPath, overwrite = true) {
61
- await fs.copy(sourcePath, targetPath, { overwrite });
62
- }
63
-
64
- /**
65
- * Copy a directory recursively
66
- * @param {string} sourceDir - Source directory path
67
- * @param {string} targetDir - Target directory path
68
- * @param {boolean} overwrite - Whether to overwrite existing files (default: true)
69
- */
70
- async copyDirectoryWithPlaceholderReplacement(sourceDir, targetDir, overwrite = true) {
71
- await fs.ensureDir(targetDir);
72
- const entries = await fs.readdir(sourceDir, { withFileTypes: true });
73
-
74
- for (const entry of entries) {
75
- const sourcePath = path.join(sourceDir, entry.name);
76
- const targetPath = path.join(targetDir, entry.name);
77
-
78
- if (entry.isDirectory()) {
79
- await this.copyDirectoryWithPlaceholderReplacement(sourcePath, targetPath, overwrite);
80
- } else {
81
- await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath, overwrite);
82
- }
83
- }
84
- }
85
-
86
- /**
87
- * List all available modules (excluding core which is always installed)
88
- * bmm is the only built-in module, directly under src/bmm-skills
89
- * All other modules come from external-official-modules.yaml
90
- * @returns {Object} Object with modules array and customModules array
91
- */
92
- async listAvailable() {
93
- const modules = [];
94
- const customModules = [];
95
-
96
- // Add built-in bmm module (directly under src/bmm-skills)
97
- const bmmPath = getSourcePath('bmm-skills');
98
- if (await fs.pathExists(bmmPath)) {
99
- const bmmInfo = await this.getModuleInfo(bmmPath, 'bmm', 'src/bmm-skills');
100
- if (bmmInfo) {
101
- modules.push(bmmInfo);
102
- }
103
- }
104
-
105
- // Check for cached custom modules in _config/custom/
106
- if (this.bmadDir) {
107
- const customCacheDir = path.join(this.bmadDir, '_config', 'custom');
108
- if (await fs.pathExists(customCacheDir)) {
109
- const cacheEntries = await fs.readdir(customCacheDir, { withFileTypes: true });
110
- for (const entry of cacheEntries) {
111
- if (entry.isDirectory()) {
112
- const cachePath = path.join(customCacheDir, entry.name);
113
- const moduleInfo = await this.getModuleInfo(cachePath, entry.name, '_config/custom');
114
- if (moduleInfo && !modules.some((m) => m.id === moduleInfo.id) && !customModules.some((m) => m.id === moduleInfo.id)) {
115
- moduleInfo.isCustom = true;
116
- moduleInfo.fromCache = true;
117
- customModules.push(moduleInfo);
118
- }
119
- }
120
- }
121
- }
122
- }
123
-
124
- return { modules, customModules };
125
- }
126
-
127
- /**
128
- * Get module information from a module path
129
- * @param {string} modulePath - Path to the module directory
130
- * @param {string} defaultName - Default name for the module
131
- * @param {string} sourceDescription - Description of where the module was found
132
- * @returns {Object|null} Module info or null if not a valid module
133
- */
134
- async getModuleInfo(modulePath, defaultName, sourceDescription) {
135
- // Check for module structure (module.yaml OR custom.yaml)
136
- const moduleConfigPath = path.join(modulePath, 'module.yaml');
137
- const rootCustomConfigPath = path.join(modulePath, 'custom.yaml');
138
- let configPath = null;
139
-
140
- if (await fs.pathExists(moduleConfigPath)) {
141
- configPath = moduleConfigPath;
142
- } else if (await fs.pathExists(rootCustomConfigPath)) {
143
- configPath = rootCustomConfigPath;
144
- }
145
-
146
- // Skip if this doesn't look like a module
147
- if (!configPath) {
148
- return null;
149
- }
150
-
151
- // Mark as custom if it's using custom.yaml OR if it's outside src/bmm or src/core
152
- const isCustomSource =
153
- sourceDescription !== 'src/bmm-skills' && sourceDescription !== 'src/core-skills' && sourceDescription !== 'src/modules';
154
- const moduleInfo = {
155
- id: defaultName,
156
- path: modulePath,
157
- name: defaultName
158
- .split('-')
159
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
160
- .join(' '),
161
- description: 'BMAD Module',
162
- version: '5.0.0',
163
- source: sourceDescription,
164
- isCustom: configPath === rootCustomConfigPath || isCustomSource,
165
- };
166
-
167
- // Read module config for metadata
168
- try {
169
- const configContent = await fs.readFile(configPath, 'utf8');
170
- const config = yaml.parse(configContent);
171
-
172
- // Use the code property as the id if available
173
- if (config.code) {
174
- moduleInfo.id = config.code;
175
- }
176
-
177
- moduleInfo.name = config.name || moduleInfo.name;
178
- moduleInfo.description = config.description || moduleInfo.description;
179
- moduleInfo.version = config.version || moduleInfo.version;
180
- moduleInfo.dependencies = config.dependencies || [];
181
- moduleInfo.defaultSelected = config.default_selected === undefined ? false : config.default_selected;
182
- } catch (error) {
183
- await prompts.log.warn(`Failed to read config for ${defaultName}: ${error.message}`);
184
- }
185
-
186
- return moduleInfo;
187
- }
188
-
189
- /**
190
- * Find the source path for a module by searching all possible locations
191
- * @param {string} moduleCode - Code of the module to find (from module.yaml)
192
- * @returns {string|null} Path to the module source or null if not found
193
- */
194
- async findModuleSource(moduleCode, options = {}) {
195
- const projectRoot = getProjectRoot();
196
-
197
- // First check custom module paths if they exist
198
- if (this.customModulePaths && this.customModulePaths.has(moduleCode)) {
199
- return this.customModulePaths.get(moduleCode);
200
- }
201
-
202
- // Check for built-in bmm module (directly under src/bmm-skills)
203
- if (moduleCode === 'bmm') {
204
- const bmmPath = getSourcePath('bmm-skills');
205
- if (await fs.pathExists(bmmPath)) {
206
- return bmmPath;
207
- }
208
- }
209
-
210
- // Check external official modules
211
- const externalSource = await this.findExternalModuleSource(moduleCode, options);
212
- if (externalSource) {
213
- return externalSource;
214
- }
215
-
216
- return null;
217
- }
218
-
219
- /**
220
- * Check if a module is an external official module
221
- * @param {string} moduleCode - Code of the module to check
222
- * @returns {boolean} True if the module is external
223
- */
224
- async isExternalModule(moduleCode) {
225
- return await this.externalModuleManager.hasModule(moduleCode);
226
- }
227
-
228
- /**
229
- * Get the cache directory for external modules
230
- * @returns {string} Path to the external modules cache directory
231
- */
232
- getExternalCacheDir() {
233
- const os = require('node:os');
234
- const cacheDir = path.join(os.homedir(), '.bmad', 'cache', 'external-modules');
235
- return cacheDir;
236
- }
237
-
238
- /**
239
- * Clone an external module repository to cache
240
- * @param {string} moduleCode - Code of the external module
241
- * @returns {string} Path to the cloned repository
242
- */
243
- async cloneExternalModule(moduleCode, options = {}) {
244
- const { execSync } = require('node:child_process');
245
- const moduleInfo = await this.externalModuleManager.getModuleByCode(moduleCode);
246
-
247
- if (!moduleInfo) {
248
- throw new Error(`External module '${moduleCode}' not found in external-official-modules.yaml`);
249
- }
250
-
251
- const cacheDir = this.getExternalCacheDir();
252
- const moduleCacheDir = path.join(cacheDir, moduleCode);
253
- const silent = options.silent || false;
254
-
255
- // Create cache directory if it doesn't exist
256
- await fs.ensureDir(cacheDir);
257
-
258
- // Helper to create a spinner or a no-op when silent
259
- const createSpinner = async () => {
260
- if (silent) {
261
- return {
262
- start() {},
263
- stop() {},
264
- error() {},
265
- message() {},
266
- cancel() {},
267
- clear() {},
268
- get isSpinning() {
269
- return false;
270
- },
271
- get isCancelled() {
272
- return false;
273
- },
274
- };
275
- }
276
- return await prompts.spinner();
277
- };
278
-
279
- // Track if we need to install dependencies
280
- let needsDependencyInstall = false;
281
- let wasNewClone = false;
282
-
283
- // Check if already cloned
284
- if (await fs.pathExists(moduleCacheDir)) {
285
- // Try to update if it's a git repo
286
- const fetchSpinner = await createSpinner();
287
- fetchSpinner.start(`Fetching ${moduleInfo.name}...`);
288
- try {
289
- const currentRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
290
- // Fetch and reset to remote - works better with shallow clones than pull
291
- execSync('git fetch origin --depth 1', {
292
- cwd: moduleCacheDir,
293
- stdio: ['ignore', 'pipe', 'pipe'],
294
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
295
- });
296
- execSync('git reset --hard origin/HEAD', {
297
- cwd: moduleCacheDir,
298
- stdio: ['ignore', 'pipe', 'pipe'],
299
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
300
- });
301
- const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
302
-
303
- fetchSpinner.stop(`Fetched ${moduleInfo.name}`);
304
- // Force dependency install if we got new code
305
- if (currentRef !== newRef) {
306
- needsDependencyInstall = true;
307
- }
308
- } catch {
309
- fetchSpinner.error(`Fetch failed, re-downloading ${moduleInfo.name}`);
310
- // If update fails, remove and re-clone
311
- await fs.remove(moduleCacheDir);
312
- wasNewClone = true;
313
- }
314
- } else {
315
- wasNewClone = true;
316
- }
317
-
318
- // Clone if not exists or was removed
319
- if (wasNewClone) {
320
- const fetchSpinner = await createSpinner();
321
- fetchSpinner.start(`Fetching ${moduleInfo.name}...`);
322
- try {
323
- execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
324
- stdio: ['ignore', 'pipe', 'pipe'],
325
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
326
- });
327
- fetchSpinner.stop(`Fetched ${moduleInfo.name}`);
328
- } catch (error) {
329
- fetchSpinner.error(`Failed to fetch ${moduleInfo.name}`);
330
- throw new Error(`Failed to clone external module '${moduleCode}': ${error.message}`);
331
- }
332
- }
333
-
334
- // Install dependencies if package.json exists
335
- const packageJsonPath = path.join(moduleCacheDir, 'package.json');
336
- const nodeModulesPath = path.join(moduleCacheDir, 'node_modules');
337
- if (await fs.pathExists(packageJsonPath)) {
338
- // Install if node_modules doesn't exist, or if package.json is newer (dependencies changed)
339
- const nodeModulesMissing = !(await fs.pathExists(nodeModulesPath));
340
-
341
- // Force install if we updated or cloned new
342
- if (needsDependencyInstall || wasNewClone || nodeModulesMissing) {
343
- const installSpinner = await createSpinner();
344
- installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`);
345
- try {
346
- execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
347
- cwd: moduleCacheDir,
348
- stdio: ['ignore', 'pipe', 'pipe'],
349
- timeout: 120_000, // 2 minute timeout
350
- });
351
- installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`);
352
- } catch (error) {
353
- installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`);
354
- if (!silent) await prompts.log.warn(` ${error.message}`);
355
- }
356
- } else {
357
- // Check if package.json is newer than node_modules
358
- let packageJsonNewer = false;
359
- try {
360
- const packageStats = await fs.stat(packageJsonPath);
361
- const nodeModulesStats = await fs.stat(nodeModulesPath);
362
- packageJsonNewer = packageStats.mtime > nodeModulesStats.mtime;
363
- } catch {
364
- // If stat fails, assume we need to install
365
- packageJsonNewer = true;
366
- }
367
-
368
- if (packageJsonNewer) {
369
- const installSpinner = await createSpinner();
370
- installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`);
371
- try {
372
- execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
373
- cwd: moduleCacheDir,
374
- stdio: ['ignore', 'pipe', 'pipe'],
375
- timeout: 120_000, // 2 minute timeout
376
- });
377
- installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`);
378
- } catch (error) {
379
- installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`);
380
- if (!silent) await prompts.log.warn(` ${error.message}`);
381
- }
382
- }
383
- }
384
- }
385
-
386
- return moduleCacheDir;
387
- }
388
-
389
- /**
390
- * Find the source path for an external module
391
- * @param {string} moduleCode - Code of the external module
392
- * @returns {string|null} Path to the module source or null if not found
393
- */
394
- async findExternalModuleSource(moduleCode, options = {}) {
395
- const moduleInfo = await this.externalModuleManager.getModuleByCode(moduleCode);
396
-
397
- if (!moduleInfo) {
398
- return null;
399
- }
400
-
401
- // Clone the external module repo
402
- const cloneDir = await this.cloneExternalModule(moduleCode, options);
403
-
404
- // The module-definition specifies the path to module.yaml relative to repo root
405
- // We need to return the directory containing module.yaml
406
- const moduleDefinitionPath = moduleInfo.moduleDefinition; // e.g., 'src/module.yaml'
407
- const moduleDir = path.dirname(path.join(cloneDir, moduleDefinitionPath));
408
-
409
- return moduleDir;
410
- }
411
-
412
- /**
413
- * Install a module
414
- * @param {string} moduleName - Code of the module to install (from module.yaml)
415
- * @param {string} bmadDir - Target bmad directory
416
- * @param {Function} fileTrackingCallback - Optional callback to track installed files
417
- * @param {Object} options - Additional installation options
418
- * @param {Array<string>} options.installedIDEs - Array of IDE codes that were installed
419
- * @param {Object} options.moduleConfig - Module configuration from config collector
420
- * @param {Object} options.logger - Logger instance for output
421
- */
422
- async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) {
423
- const sourcePath = await this.findModuleSource(moduleName, { silent: options.silent });
424
- const targetPath = path.join(bmadDir, moduleName);
425
-
426
- // Check if source module exists
427
- if (!sourcePath) {
428
- // Provide a more user-friendly error message
429
- throw new Error(
430
- `Source for module '${moduleName}' is not available. It will be retained but cannot be updated without its source files.`,
431
- );
432
- }
433
-
434
- // Check if this is a custom module and read its custom.yaml values
435
- let customConfig = null;
436
- const rootCustomConfigPath = path.join(sourcePath, 'custom.yaml');
437
-
438
- if (await fs.pathExists(rootCustomConfigPath)) {
439
- try {
440
- const customContent = await fs.readFile(rootCustomConfigPath, 'utf8');
441
- customConfig = yaml.parse(customContent);
442
- } catch (error) {
443
- await prompts.log.warn(`Failed to read custom.yaml for ${moduleName}: ${error.message}`);
444
- }
445
- }
446
-
447
- // If this is a custom module, merge its values into the module config
448
- if (customConfig) {
449
- options.moduleConfig = { ...options.moduleConfig, ...customConfig };
450
- if (options.logger) {
451
- await options.logger.log(` Merged custom configuration for ${moduleName}`);
452
- }
453
- }
454
-
455
- // Check if already installed
456
- if (await fs.pathExists(targetPath)) {
457
- await fs.remove(targetPath);
458
- }
459
-
460
- // Copy module files with filtering
461
- await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig);
462
-
463
- // Create directories declared in module.yaml (unless explicitly skipped)
464
- if (!options.skipModuleInstaller) {
465
- await this.createModuleDirectories(moduleName, bmadDir, options);
466
- }
467
-
468
- // Capture version info for manifest
469
- const { Manifest } = require('../core/manifest');
470
- const manifestObj = new Manifest();
471
- const versionInfo = await manifestObj.getModuleVersionInfo(moduleName, bmadDir, sourcePath);
472
-
473
- await manifestObj.addModule(bmadDir, moduleName, {
474
- version: versionInfo.version,
475
- source: versionInfo.source,
476
- npmPackage: versionInfo.npmPackage,
477
- repoUrl: versionInfo.repoUrl,
478
- });
479
-
480
- return {
481
- success: true,
482
- module: moduleName,
483
- path: targetPath,
484
- versionInfo,
485
- };
486
- }
487
-
488
- /**
489
- * Update an existing module
490
- * @param {string} moduleName - Name of the module to update
491
- * @param {string} bmadDir - Target bmad directory
492
- * @param {boolean} force - Force update (overwrite modifications)
493
- */
494
- async update(moduleName, bmadDir, force = false, options = {}) {
495
- const sourcePath = await this.findModuleSource(moduleName);
496
- const targetPath = path.join(bmadDir, moduleName);
497
-
498
- // Check if source module exists
499
- if (!sourcePath) {
500
- throw new Error(`Module '${moduleName}' not found in any source location`);
501
- }
502
-
503
- // Check if module is installed
504
- if (!(await fs.pathExists(targetPath))) {
505
- throw new Error(`Module '${moduleName}' is not installed`);
506
- }
507
-
508
- if (force) {
509
- // Force update - remove and reinstall
510
- await fs.remove(targetPath);
511
- return await this.install(moduleName, bmadDir, null, { installer: options.installer });
512
- } else {
513
- // Selective update - preserve user modifications
514
- await this.syncModule(sourcePath, targetPath);
515
- }
516
-
517
- return {
518
- success: true,
519
- module: moduleName,
520
- path: targetPath,
521
- };
522
- }
523
-
524
- /**
525
- * Remove a module
526
- * @param {string} moduleName - Name of the module to remove
527
- * @param {string} bmadDir - Target bmad directory
528
- */
529
- async remove(moduleName, bmadDir) {
530
- const targetPath = path.join(bmadDir, moduleName);
531
-
532
- if (!(await fs.pathExists(targetPath))) {
533
- throw new Error(`Module '${moduleName}' is not installed`);
534
- }
535
-
536
- await fs.remove(targetPath);
537
-
538
- return {
539
- success: true,
540
- module: moduleName,
541
- };
542
- }
543
-
544
- /**
545
- * Check if a module is installed
546
- * @param {string} moduleName - Name of the module
547
- * @param {string} bmadDir - Target bmad directory
548
- * @returns {boolean} True if module is installed
549
- */
550
- async isInstalled(moduleName, bmadDir) {
551
- const targetPath = path.join(bmadDir, moduleName);
552
- return await fs.pathExists(targetPath);
553
- }
554
-
555
- /**
556
- * Get installed module info
557
- * @param {string} moduleName - Name of the module
558
- * @param {string} bmadDir - Target bmad directory
559
- * @returns {Object|null} Module info or null if not installed
560
- */
561
- async getInstalledInfo(moduleName, bmadDir) {
562
- const targetPath = path.join(bmadDir, moduleName);
563
-
564
- if (!(await fs.pathExists(targetPath))) {
565
- return null;
566
- }
567
-
568
- const configPath = path.join(targetPath, 'config.yaml');
569
- const moduleInfo = {
570
- id: moduleName,
571
- path: targetPath,
572
- installed: true,
573
- };
574
-
575
- if (await fs.pathExists(configPath)) {
576
- try {
577
- const configContent = await fs.readFile(configPath, 'utf8');
578
- const config = yaml.parse(configContent);
579
- Object.assign(moduleInfo, config);
580
- } catch (error) {
581
- await prompts.log.warn(`Failed to read installed module config: ${error.message}`);
582
- }
583
- }
584
-
585
- return moduleInfo;
586
- }
587
-
588
- /**
589
- * Copy module with filtering for localskip agents and conditional content
590
- * @param {string} sourcePath - Source module path
591
- * @param {string} targetPath - Target module path
592
- * @param {Function} fileTrackingCallback - Optional callback to track installed files
593
- * @param {Object} moduleConfig - Module configuration with conditional flags
594
- */
595
- async copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback = null, moduleConfig = {}) {
596
- // Get all files in source
597
- const sourceFiles = await this.getFileList(sourcePath);
598
-
599
- for (const file of sourceFiles) {
600
- // Skip sub-modules directory - these are IDE-specific and handled separately
601
- if (file.startsWith('sub-modules/')) {
602
- continue;
603
- }
604
-
605
- // Skip sidecar directories - these contain agent-specific assets not needed at install time
606
- const isInSidecarDirectory = path
607
- .dirname(file)
608
- .split('/')
609
- .some((dir) => dir.toLowerCase().endsWith('-sidecar'));
610
-
611
- if (isInSidecarDirectory) {
612
- continue;
613
- }
614
-
615
- // Skip module.yaml at root - it's only needed at install time
616
- if (file === 'module.yaml') {
617
- continue;
618
- }
619
-
620
- // Skip module root config.yaml only - generated by config collector with actual values
621
- // Workflow-level config.yaml (e.g. workflows/orchestrate-story/config.yaml) must be copied
622
- // for custom modules that use workflow-specific configuration
623
- if (file === 'config.yaml') {
624
- continue;
625
- }
626
-
627
- const sourceFile = path.join(sourcePath, file);
628
- const targetFile = path.join(targetPath, file);
629
-
630
- // Check if this is an agent file
631
- if (file.startsWith('agents/') && file.endsWith('.md')) {
632
- // Read the file to check for localskip
633
- const content = await fs.readFile(sourceFile, 'utf8');
634
-
635
- // Check for localskip="true" in the agent tag
636
- const agentMatch = content.match(/<agent[^>]*\slocalskip="true"[^>]*>/);
637
- if (agentMatch) {
638
- await prompts.log.message(` Skipping web-only agent: ${path.basename(file)}`);
639
- continue; // Skip this agent
640
- }
641
- }
642
-
643
- // Copy the file with placeholder replacement
644
- await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile);
645
-
646
- // Track the file if callback provided
647
- if (fileTrackingCallback) {
648
- fileTrackingCallback(targetFile);
649
- }
650
- }
651
- }
652
-
653
- /**
654
- * Find all .md agent files recursively in a directory
655
- * @param {string} dir - Directory to search
656
- * @returns {Array} List of .md agent file paths
657
- */
658
- async findAgentMdFiles(dir) {
659
- const agentFiles = [];
660
-
661
- async function searchDirectory(searchDir) {
662
- const entries = await fs.readdir(searchDir, { withFileTypes: true });
663
-
664
- for (const entry of entries) {
665
- const fullPath = path.join(searchDir, entry.name);
666
-
667
- if (entry.isFile() && entry.name.endsWith('.md')) {
668
- agentFiles.push(fullPath);
669
- } else if (entry.isDirectory()) {
670
- await searchDirectory(fullPath);
671
- }
672
- }
673
- }
674
-
675
- await searchDirectory(dir);
676
- return agentFiles;
677
- }
678
-
679
- /**
680
- * Create directories declared in module.yaml's `directories` key
681
- * This replaces the security-risky module installer pattern with declarative config
682
- * During updates, if a directory path changed, moves the old directory to the new path
683
- * @param {string} moduleName - Name of the module
684
- * @param {string} bmadDir - Target bmad directory
685
- * @param {Object} options - Installation options
686
- * @param {Object} options.moduleConfig - Module configuration from config collector
687
- * @param {Object} options.existingModuleConfig - Previous module config (for detecting path changes during updates)
688
- * @param {Object} options.coreConfig - Core configuration
689
- * @returns {Promise<{createdDirs: string[], movedDirs: string[], createdWdsFolders: string[]}>} Created directories info
690
- */
691
- async createModuleDirectories(moduleName, bmadDir, options = {}) {
692
- const moduleConfig = options.moduleConfig || {};
693
- const existingModuleConfig = options.existingModuleConfig || {};
694
- const projectRoot = path.dirname(bmadDir);
695
- const emptyResult = { createdDirs: [], movedDirs: [], createdWdsFolders: [] };
696
-
697
- // Special handling for core module - it's in src/core-skills not src/modules
698
- let sourcePath;
699
- if (moduleName === 'core') {
700
- sourcePath = getSourcePath('core-skills');
701
- } else {
702
- sourcePath = await this.findModuleSource(moduleName, { silent: true });
703
- if (!sourcePath) {
704
- return emptyResult; // No source found, skip
705
- }
706
- }
707
-
708
- // Read module.yaml to find the `directories` key
709
- const moduleYamlPath = path.join(sourcePath, 'module.yaml');
710
- if (!(await fs.pathExists(moduleYamlPath))) {
711
- return emptyResult; // No module.yaml, skip
712
- }
713
-
714
- let moduleYaml;
715
- try {
716
- const yamlContent = await fs.readFile(moduleYamlPath, 'utf8');
717
- moduleYaml = yaml.parse(yamlContent);
718
- } catch {
719
- return emptyResult; // Invalid YAML, skip
720
- }
721
-
722
- if (!moduleYaml || !moduleYaml.directories) {
723
- return emptyResult; // No directories declared, skip
724
- }
725
-
726
- const directories = moduleYaml.directories;
727
- const wdsFolders = moduleYaml.wds_folders || [];
728
- const createdDirs = [];
729
- const movedDirs = [];
730
- const createdWdsFolders = [];
731
-
732
- for (const dirRef of directories) {
733
- // Parse variable reference like "{design_artifacts}"
734
- const varMatch = dirRef.match(/^\{([^}]+)\}$/);
735
- if (!varMatch) {
736
- // Not a variable reference, skip
737
- continue;
738
- }
739
-
740
- const configKey = varMatch[1];
741
- const dirValue = moduleConfig[configKey];
742
- if (!dirValue || typeof dirValue !== 'string') {
743
- continue; // No value or not a string, skip
744
- }
745
-
746
- // Strip {project-root}/ prefix if present
747
- let dirPath = dirValue.replace(/^\{project-root\}\/?/, '');
748
-
749
- // Handle remaining {project-root} anywhere in the path
750
- dirPath = dirPath.replaceAll('{project-root}', '');
751
-
752
- // Resolve to absolute path
753
- const fullPath = path.join(projectRoot, dirPath);
754
-
755
- // Validate path is within project root (prevent directory traversal)
756
- const normalizedPath = path.normalize(fullPath);
757
- const normalizedRoot = path.normalize(projectRoot);
758
- if (!normalizedPath.startsWith(normalizedRoot + path.sep) && normalizedPath !== normalizedRoot) {
759
- const color = await prompts.getColor();
760
- await prompts.log.warn(color.yellow(`${configKey} path escapes project root, skipping: ${dirPath}`));
761
- continue;
762
- }
763
-
764
- // Check if directory path changed from previous config (update/modify scenario)
765
- const oldDirValue = existingModuleConfig[configKey];
766
- let oldFullPath = null;
767
- let oldDirPath = null;
768
- if (oldDirValue && typeof oldDirValue === 'string') {
769
- // F3: Normalize both values before comparing to avoid false negatives
770
- // from trailing slashes, separator differences, or prefix format variations
771
- let normalizedOld = oldDirValue.replace(/^\{project-root\}\/?/, '');
772
- normalizedOld = path.normalize(normalizedOld.replaceAll('{project-root}', ''));
773
- const normalizedNew = path.normalize(dirPath);
774
-
775
- if (normalizedOld !== normalizedNew) {
776
- oldDirPath = normalizedOld;
777
- oldFullPath = path.join(projectRoot, oldDirPath);
778
- const normalizedOldAbsolute = path.normalize(oldFullPath);
779
- if (!normalizedOldAbsolute.startsWith(normalizedRoot + path.sep) && normalizedOldAbsolute !== normalizedRoot) {
780
- oldFullPath = null; // Old path escapes project root, ignore it
781
- }
782
-
783
- // F13: Prevent parent/child move (e.g. docs/planning → docs/planning/v2)
784
- if (oldFullPath) {
785
- const normalizedNewAbsolute = path.normalize(fullPath);
786
- if (
787
- normalizedOldAbsolute.startsWith(normalizedNewAbsolute + path.sep) ||
788
- normalizedNewAbsolute.startsWith(normalizedOldAbsolute + path.sep)
789
- ) {
790
- const color = await prompts.getColor();
791
- await prompts.log.warn(
792
- color.yellow(
793
- `${configKey}: cannot move between parent/child paths (${oldDirPath} / ${dirPath}), creating new directory instead`,
794
- ),
795
- );
796
- oldFullPath = null;
797
- }
798
- }
799
- }
800
- }
801
-
802
- const dirName = configKey.replaceAll('_', ' ');
803
-
804
- if (oldFullPath && (await fs.pathExists(oldFullPath)) && !(await fs.pathExists(fullPath))) {
805
- // Path changed and old dir exists → move old to new location
806
- // F1: Use fs.move() instead of fs.rename() for cross-device/volume support
807
- // F2: Wrap in try/catch — fallback to creating new dir on failure
808
- try {
809
- await fs.ensureDir(path.dirname(fullPath));
810
- await fs.move(oldFullPath, fullPath);
811
- movedDirs.push(`${dirName}: ${oldDirPath} → ${dirPath}`);
812
- } catch (moveError) {
813
- const color = await prompts.getColor();
814
- await prompts.log.warn(
815
- color.yellow(
816
- `Failed to move ${oldDirPath} → ${dirPath}: ${moveError.message}\n Creating new directory instead. Please move contents from the old directory manually.`,
817
- ),
818
- );
819
- await fs.ensureDir(fullPath);
820
- createdDirs.push(`${dirName}: ${dirPath}`);
821
- }
822
- } else if (oldFullPath && (await fs.pathExists(oldFullPath)) && (await fs.pathExists(fullPath))) {
823
- // F5: Both old and new directories exist — warn user about potential orphaned documents
824
- const color = await prompts.getColor();
825
- await prompts.log.warn(
826
- color.yellow(
827
- `${dirName}: path changed but both directories exist:\n Old: ${oldDirPath}\n New: ${dirPath}\n Old directory may contain orphaned documents — please review and merge manually.`,
828
- ),
829
- );
830
- } else if (!(await fs.pathExists(fullPath))) {
831
- // New directory doesn't exist yet → create it
832
- createdDirs.push(`${dirName}: ${dirPath}`);
833
- await fs.ensureDir(fullPath);
834
- }
835
-
836
- // Create WDS subfolders if this is the design_artifacts directory
837
- if (configKey === 'design_artifacts' && wdsFolders.length > 0) {
838
- for (const subfolder of wdsFolders) {
839
- const subPath = path.join(fullPath, subfolder);
840
- if (!(await fs.pathExists(subPath))) {
841
- await fs.ensureDir(subPath);
842
- createdWdsFolders.push(subfolder);
843
- }
844
- }
845
- }
846
- }
847
-
848
- return { createdDirs, movedDirs, createdWdsFolders };
849
- }
850
-
851
- /**
852
- * Private: Process module configuration
853
- * @param {string} modulePath - Path to installed module
854
- * @param {string} moduleName - Module name
855
- */
856
- async processModuleConfig(modulePath, moduleName) {
857
- const configPath = path.join(modulePath, 'config.yaml');
858
-
859
- if (await fs.pathExists(configPath)) {
860
- try {
861
- let configContent = await fs.readFile(configPath, 'utf8');
862
-
863
- // Replace path placeholders
864
- configContent = configContent.replaceAll('{project-root}', `bmad/${moduleName}`);
865
- configContent = configContent.replaceAll('{module}', moduleName);
866
-
867
- await fs.writeFile(configPath, configContent, 'utf8');
868
- } catch (error) {
869
- await prompts.log.warn(`Failed to process module config: ${error.message}`);
870
- }
871
- }
872
- }
873
-
874
- /**
875
- * Private: Sync module files (preserving user modifications)
876
- * @param {string} sourcePath - Source module path
877
- * @param {string} targetPath - Target module path
878
- */
879
- async syncModule(sourcePath, targetPath) {
880
- // Get list of all source files
881
- const sourceFiles = await this.getFileList(sourcePath);
882
-
883
- for (const file of sourceFiles) {
884
- const sourceFile = path.join(sourcePath, file);
885
- const targetFile = path.join(targetPath, file);
886
-
887
- // Check if target file exists and has been modified
888
- if (await fs.pathExists(targetFile)) {
889
- const sourceStats = await fs.stat(sourceFile);
890
- const targetStats = await fs.stat(targetFile);
891
-
892
- // Skip if target is newer (user modified)
893
- if (targetStats.mtime > sourceStats.mtime) {
894
- continue;
895
- }
896
- }
897
-
898
- // Copy file with placeholder replacement
899
- await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile);
900
- }
901
- }
902
-
903
- /**
904
- * Private: Get list of all files in a directory
905
- * @param {string} dir - Directory path
906
- * @param {string} baseDir - Base directory for relative paths
907
- * @returns {Array} List of relative file paths
908
- */
909
- async getFileList(dir, baseDir = dir) {
910
- const files = [];
911
- const entries = await fs.readdir(dir, { withFileTypes: true });
912
-
913
- for (const entry of entries) {
914
- const fullPath = path.join(dir, entry.name);
915
-
916
- if (entry.isDirectory()) {
917
- const subFiles = await this.getFileList(fullPath, baseDir);
918
- files.push(...subFiles);
919
- } else {
920
- files.push(path.relative(baseDir, fullPath));
921
- }
922
- }
923
-
924
- return files;
925
- }
926
- }
927
-
928
- module.exports = { ModuleManager };