diff --git a/tools/cli/installers/lib/core/custom-module-cache.js b/tools/cli/installers/lib/core/custom-module-cache.js index 3ece246d..f47639a8 100644 --- a/tools/cli/installers/lib/core/custom-module-cache.js +++ b/tools/cli/installers/lib/core/custom-module-cache.js @@ -51,7 +51,19 @@ class CustomModuleCache { } /** - * Calculate hash of a file or directory + * Stream a file into the hash to avoid loading entire file into memory + */ + async hashFileStream(filePath, hash) { + return new Promise((resolve, reject) => { + const stream = require('node:fs').createReadStream(filePath); + stream.on('data', (chunk) => hash.update(chunk)); + stream.on('end', resolve); + stream.on('error', reject); + }); + } + + /** + * Calculate hash of a file or directory using streaming to minimize memory usage */ async calculateHash(sourcePath) { const hash = crypto.createHash('sha256'); @@ -76,14 +88,14 @@ class CustomModuleCache { files.sort(); // Ensure consistent order for (const file of files) { - const content = await fs.readFile(file); const relativePath = path.relative(sourcePath, file); - hash.update(relativePath + '|' + content.toString('base64')); + // Hash the path first, then stream file contents + hash.update(relativePath + '|'); + await this.hashFileStream(file, hash); } } else { - // For single files - const content = await fs.readFile(sourcePath); - hash.update(content); + // For single files, stream directly into hash + await this.hashFileStream(sourcePath, hash); } return hash.digest('hex'); diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index fb670d43..44ad48ff 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -39,6 +39,7 @@ const { CLIUtils } = require('../../../lib/cli-utils'); const { ManifestGenerator } = require('./manifest-generator'); const { IdeConfigManager } = require('./ide-config-manager'); const { replaceAgentSidecarFolders } = require('./post-install-sidecar-replacement'); +const { CustomHandler } = require('../custom/handler'); class Installer { constructor() { @@ -407,7 +408,10 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: * @param {string[]} config.ides - IDEs to configure * @param {boolean} config.skipIde - Skip IDE configuration */ - async install(config) { + async install(originalConfig) { + // Clone config to avoid mutating the caller's object + const config = { ...originalConfig }; + // Display BMAD logo CLIUtils.displayLogo(); @@ -440,7 +444,6 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: // Handle selectedFiles (from existing install path or manual directory input) if (config.customContent && config.customContent.selected && config.customContent.selectedFiles) { - const { CustomHandler } = require('../custom/handler'); const customHandler = new CustomHandler(); for (const customFile of config.customContent.selectedFiles) { const customInfo = await customHandler.getCustomInfo(customFile, path.resolve(config.directory)); @@ -837,9 +840,8 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: // Regular custom content from user input (non-cached) if (finalCustomContent && finalCustomContent.selected && finalCustomContent.selectedFiles) { // Add custom modules to the installation list + const customHandler = new CustomHandler(); for (const customFile of finalCustomContent.selectedFiles) { - const { CustomHandler } = require('../custom/handler'); - const customHandler = new CustomHandler(); const customInfo = await customHandler.getCustomInfo(customFile, projectDir); if (customInfo && customInfo.id) { allModules.push(customInfo.id); @@ -929,7 +931,6 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: // Finally check regular custom content if (!isCustomModule && finalCustomContent && finalCustomContent.selected && finalCustomContent.selectedFiles) { - const { CustomHandler } = require('../custom/handler'); const customHandler = new CustomHandler(); for (const customFile of finalCustomContent.selectedFiles) { const info = await customHandler.getCustomInfo(customFile, projectDir); @@ -943,7 +944,6 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: if (isCustomModule && customInfo) { // Install custom module using CustomHandler but as a proper module - const { CustomHandler } = require('../custom/handler'); const customHandler = new CustomHandler(); // Install to module directory instead of custom directory @@ -972,19 +972,39 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: if (await fs.pathExists(customDir)) { // Move contents to module directory const items = await fs.readdir(customDir); - for (const item of items) { - const srcPath = path.join(customDir, item); - const destPath = path.join(moduleTargetPath, item); + const movedItems = []; + try { + for (const item of items) { + const srcPath = path.join(customDir, item); + const destPath = path.join(moduleTargetPath, item); - // If destination exists, remove it first (or we could merge) - if (await fs.pathExists(destPath)) { - await fs.remove(destPath); + // If destination exists, remove it first (or we could merge) + if (await fs.pathExists(destPath)) { + await fs.remove(destPath); + } + + await fs.move(srcPath, destPath); + movedItems.push({ src: srcPath, dest: destPath }); } - - await fs.move(srcPath, destPath); + } catch (moveError) { + // Rollback: restore any successfully moved items + for (const moved of movedItems) { + try { + await fs.move(moved.dest, moved.src); + } catch { + // Best-effort rollback - log if it fails + console.error(`Failed to rollback ${moved.dest} during cleanup`); + } + } + throw new Error(`Failed to move custom module files: ${moveError.message}`); } } - await fs.remove(tempCustomPath); + try { + await fs.remove(tempCustomPath); + } catch (cleanupError) { + // Non-fatal: temp directory cleanup failed but files were moved successfully + console.warn(`Warning: Could not clean up temp directory: ${cleanupError.message}`); + } } // Create module config (include collected config from module.yaml prompts) @@ -1066,9 +1086,8 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: config.customContent.selectedFiles ) { // Filter out custom modules that were already installed + const customHandler = new CustomHandler(); for (const customFile of config.customContent.selectedFiles) { - const { CustomHandler } = require('../custom/handler'); - const customHandler = new CustomHandler(); const customInfo = await customHandler.getCustomInfo(customFile, projectDir); // Skip if this was installed as a module @@ -1080,7 +1099,6 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: if (remainingCustomContent.length > 0) { spinner.start('Installing remaining custom content...'); - const { CustomHandler } = require('../custom/handler'); const customHandler = new CustomHandler(); // Use the remaining files @@ -2581,18 +2599,7 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: installedModules, ); - // Handle both old return format (array) and new format (object) - let validCustomModules = []; - let keptModulesWithoutSources = []; - - if (Array.isArray(customModuleResult)) { - // Old format - just an array - validCustomModules = customModuleResult; - } else if (customModuleResult && typeof customModuleResult === 'object') { - // New format - object with two arrays - validCustomModules = customModuleResult.validCustomModules || []; - keptModulesWithoutSources = customModuleResult.keptModulesWithoutSources || []; - } + const { validCustomModules, keptModulesWithoutSources } = customModuleResult; const customModulesFromManifest = validCustomModules.map((m) => ({ ...m, @@ -3371,7 +3378,10 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: // If no missing sources, return immediately if (customModulesWithMissingSources.length === 0) { - return validCustomModules; + return { + validCustomModules, + keptModulesWithoutSources: [], + }; } // Stop any spinner for interactive prompts diff --git a/tools/cli/installers/lib/modules/manager.js b/tools/cli/installers/lib/modules/manager.js index 3829968b..bd8e538e 100644 --- a/tools/cli/installers/lib/modules/manager.js +++ b/tools/cli/installers/lib/modules/manager.js @@ -391,8 +391,8 @@ class ModuleManager { if (config.code === moduleName) { return modulePath; } - } catch { - // Skip if can't read config + } catch (error) { + throw new Error(`Failed to parse module.yaml at ${configPath}: ${error.message}`); } } } diff --git a/tools/cli/lib/ui.js b/tools/cli/lib/ui.js index 29b5cff7..71c2d5aa 100644 --- a/tools/cli/lib/ui.js +++ b/tools/cli/lib/ui.js @@ -24,6 +24,7 @@ const path = require('node:path'); const os = require('node:os'); const fs = require('fs-extra'); const { CLIUtils } = require('./cli-utils'); +const { CustomHandler } = require('../installers/lib/custom/handler'); /** * UI utilities for the installer @@ -150,7 +151,6 @@ class UI { const { CustomModuleCache } = require('../installers/lib/core/custom-module-cache'); const cache = new CustomModuleCache(bmadDir); - const { CustomHandler } = require('../installers/lib/custom/handler'); const customHandler = new CustomHandler(); const customFiles = await customHandler.findCustomContent(customContentConfig.customPath); @@ -218,7 +218,6 @@ class UI { customContentConfig.selectedFiles = selectedCustomContent.map((mod) => mod.replace('__CUSTOM_CONTENT__', '')); // Convert custom content to module IDs for installation const customContentModuleIds = []; - const { CustomHandler } = require('../installers/lib/custom/handler'); const customHandler = new CustomHandler(); for (const customFile of customContentConfig.selectedFiles) { // Get the module info to extract the ID @@ -637,8 +636,8 @@ class UI { moduleData = yaml.load(yamlContent); foundPath = configPath; break; - } catch { - // Continue to next path + } catch (error) { + throw new Error(`Failed to parse config at ${configPath}: ${error.message}`); } } } @@ -654,20 +653,11 @@ class UI { cached: true, }); } else { - // Debug: show what paths we tried to check - console.log(chalk.dim(`DEBUG: No module config found for ${cachedModule.id}`)); - console.log( - chalk.dim( - `DEBUG: Tried paths:`, - possibleConfigPaths.map((p) => p.replace(cachedModule.cachePath, '.')), - ), - ); - console.log(chalk.dim(`DEBUG: cachedModule:`, JSON.stringify(cachedModule, null, 2))); + // Module config not found - skip silently (non-critical) } } } else if (customContentConfig.customPath) { // Existing installation - show from directory - const { CustomHandler } = require('../installers/lib/custom/handler'); const customHandler = new CustomHandler(); const customFiles = await customHandler.findCustomContent(customContentConfig.customPath); @@ -882,7 +872,6 @@ class UI { expandedPath = this.expandUserPath(directory.trim()); // Check if directory has custom content - const { CustomHandler } = require('../installers/lib/custom/handler'); const customHandler = new CustomHandler(); const customFiles = await customHandler.findCustomContent(expandedPath); @@ -1277,7 +1266,6 @@ class UI { const resolvedPath = CLIUtils.expandPath(customPath); // Find custom content - const { CustomHandler } = require('../installers/lib/custom/handler'); const customHandler = new CustomHandler(); const customFiles = await customHandler.findCustomContent(resolvedPath); @@ -1302,12 +1290,10 @@ class UI { // Display found items console.log(chalk.cyan(`\nFound ${customFiles.length} custom content file(s):`)); - const { CustomHandler: CustomHandler2 } = require('../installers/lib/custom/handler'); - const customHandler2 = new CustomHandler2(); const customContentItems = []; for (const customFile of customFiles) { - const customInfo = await customHandler2.getCustomInfo(customFile); + const customInfo = await customHandler.getCustomInfo(customFile); if (customInfo) { customContentItems.push({ name: `${chalk.cyan('✓')} ${customInfo.name} ${chalk.gray(`(${customInfo.relativePath})`)}`,