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.
- package/.claude-plugin/marketplace.json +78 -0
- package/package.json +8 -8
- package/src/core-skills/bmad-distillator/resources/distillate-format-reference.md +1 -1
- package/src/core-skills/bmad-init/scripts/bmad_init.py +35 -4
- package/src/core-skills/bmad-init/scripts/tests/test_bmad_init.py +64 -0
- package/tools/{cli → installer}/bmad-cli.js +3 -1
- package/tools/{cli/lib → installer}/cli-utils.js +3 -4
- package/tools/{cli → installer}/commands/install.js +3 -3
- package/tools/{cli → installer}/commands/status.js +4 -4
- package/tools/{cli → installer}/commands/uninstall.js +5 -5
- package/tools/installer/core/config.js +52 -0
- package/tools/{cli/installers/lib → installer}/core/custom-module-cache.js +1 -1
- package/tools/installer/core/existing-install.js +127 -0
- package/tools/installer/core/install-paths.js +129 -0
- package/tools/installer/core/installer.js +1790 -0
- package/tools/{cli/installers/lib → installer}/core/manifest-generator.js +3 -3
- package/tools/{cli/installers/lib → installer}/core/manifest.js +2 -2
- package/tools/{cli/installers/lib/custom/handler.js → installer/custom-handler.js} +1 -1
- package/tools/{cli/installers/lib → installer}/ide/_config-driven.js +30 -397
- package/tools/{cli/installers/lib → installer}/ide/manager.js +1 -53
- package/tools/installer/ide/platform-codes.js +37 -0
- package/tools/installer/ide/platform-codes.yaml +190 -0
- package/tools/{cli/installers/lib → installer}/ide/shared/module-injections.js +1 -1
- package/tools/{cli/installers/lib → installer}/message-loader.js +2 -2
- package/tools/installer/modules/custom-modules.js +197 -0
- package/tools/installer/modules/external-manager.js +323 -0
- package/tools/{cli/installers/lib/core/config-collector.js → installer/modules/official-modules.js} +714 -43
- package/tools/{cli/lib → installer}/ui.js +65 -299
- package/tools/javascript-conventions.md +5 -0
- package/tools/bmad-npx-wrapper.js +0 -38
- package/tools/cli/installers/lib/core/dependency-resolver.js +0 -743
- package/tools/cli/installers/lib/core/detector.js +0 -223
- package/tools/cli/installers/lib/core/ide-config-manager.js +0 -157
- package/tools/cli/installers/lib/core/installer.js +0 -3002
- package/tools/cli/installers/lib/ide/_base-ide.js +0 -657
- package/tools/cli/installers/lib/ide/platform-codes.js +0 -100
- package/tools/cli/installers/lib/ide/platform-codes.yaml +0 -341
- package/tools/cli/installers/lib/modules/external-manager.js +0 -136
- package/tools/cli/installers/lib/modules/manager.js +0 -928
- package/tools/cli/lib/config.js +0 -213
- package/tools/cli/lib/platform-codes.js +0 -116
- package/tools/lib/xml-utils.js +0 -13
- /package/tools/{cli → installer}/README.md +0 -0
- /package/tools/{cli → installer}/external-official-modules.yaml +0 -0
- /package/tools/{cli/lib → installer}/file-ops.js +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/shared/agent-command-generator.js +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/shared/bmad-artifacts.js +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/shared/path-utils.js +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/shared/skill-manifest.js +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/templates/agent-command-template.md +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/templates/combined/antigravity.md +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/templates/combined/default-agent.md +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/templates/combined/default-task.md +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/templates/combined/default-tool.md +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/templates/combined/default-workflow.md +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/templates/combined/gemini-agent.toml +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/templates/combined/gemini-task.toml +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/templates/combined/gemini-tool.toml +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/templates/combined/gemini-workflow-yaml.toml +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/templates/combined/gemini-workflow.toml +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/templates/combined/kiro-agent.md +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/templates/combined/kiro-task.md +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/templates/combined/kiro-tool.md +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/templates/combined/kiro-workflow.md +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/templates/combined/opencode-agent.md +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/templates/combined/opencode-task.md +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/templates/combined/opencode-tool.md +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/templates/combined/opencode-workflow-yaml.md +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/templates/combined/opencode-workflow.md +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/templates/combined/rovodev.md +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/templates/combined/trae.md +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/templates/combined/windsurf-workflow.md +0 -0
- /package/tools/{cli/installers/lib → installer}/ide/templates/split/.gitkeep +0 -0
- /package/tools/{cli/installers → installer}/install-messages.yaml +0 -0
- /package/tools/{cli/lib → installer}/project-root.js +0 -0
- /package/tools/{cli/lib → installer}/prompts.js +0 -0
- /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 };
|