From 97f31f8a7e75e1bb48143319dc3d02cf4671529b Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Mon, 18 May 2026 09:08:26 +0100 Subject: [PATCH] fix(installer): preserve stale installed modules on update --- test/test-installation-components.js | 33 +++++++++++++++++++ tools/installer/core/installer.js | 7 ++-- tools/installer/ui.js | 48 ++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 808ee6faa..e2a4e8a69 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -164,6 +164,39 @@ async function runTests() { console.log(''); + // ============================================================ + // Test 4b: Preserve installed modules with no source + // ============================================================ + console.log(`${colors.yellow}Test Suite 4b: Preserve Installed Modules Without Source${colors.reset}\n`); + + let staleInstallRoot; + try { + staleInstallRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-stale-module-')); + const staleBmadDir = path.join(staleInstallRoot, '_bmad'); + const staleModuleDir = path.join(staleBmadDir, 'baut'); + await fs.ensureDir(staleModuleDir); + await fs.writeFile(path.join(staleModuleDir, 'marker.txt'), 'keep me\n', 'utf8'); + + const ui = new (require('../tools/installer/ui').UI)(); + const unavailable = await ui._findUnavailableInstalledModules(new Set(['core', 'bmm', 'baut']), staleBmadDir); + assert(unavailable.length === 1 && unavailable[0] === 'baut', 'UI detects stale installed modules with no current source'); + + const installer = new Installer(); + await installer._removeDeselectedModules( + { moduleIds: ['core', 'bmm', 'baut'] }, + { modules: ['core', 'bmm'] }, + { moduleDir: (moduleId) => path.join(staleBmadDir, moduleId) }, + ['baut'], + ); + assert(await fs.pathExists(path.join(staleModuleDir, 'marker.txt')), 'Preserved modules are not removed during modify/update installs'); + } catch (error) { + assert(false, 'Installed modules with no source are preserved during update', error.message); + } finally { + if (staleInstallRoot) await fs.remove(staleInstallRoot).catch(() => {}); + } + + console.log(''); + // ============================================================ // Test 5: Kiro Native Skills Install // ============================================================ diff --git a/tools/installer/core/installer.js b/tools/installer/core/installer.js index e2962a5df..685cedd1b 100644 --- a/tools/installer/core/installer.js +++ b/tools/installer/core/installer.js @@ -54,7 +54,7 @@ class Installer { } if (existingInstall.installed) { - await this._removeDeselectedModules(existingInstall, config, paths); + await this._removeDeselectedModules(existingInstall, config, paths, originalConfig._preserveModules || []); updateState = await this._prepareUpdateState(paths, config, existingInstall, officialModules); await this._removeDeselectedIdes(existingInstall, config, paths); } @@ -144,10 +144,11 @@ class Installer { * Remove modules that were previously installed but are no longer selected. * No confirmation — the user's module selection is the decision. */ - async _removeDeselectedModules(existingInstall, config, paths) { + async _removeDeselectedModules(existingInstall, config, paths, preservedModules = []) { const previouslyInstalled = new Set(existingInstall.moduleIds); const newlySelected = new Set(config.modules || []); - const toRemove = [...previouslyInstalled].filter((m) => !newlySelected.has(m) && m !== 'core'); + const preserved = new Set(preservedModules); + const toRemove = [...previouslyInstalled].filter((m) => !newlySelected.has(m) && m !== 'core' && !preserved.has(m)); for (const moduleId of toRemove) { const modulePath = paths.moduleDir(moduleId); diff --git a/tools/installer/ui.js b/tools/installer/ui.js index 618a2145b..b50a14a57 100644 --- a/tools/installer/ui.js +++ b/tools/installer/ui.js @@ -110,6 +110,44 @@ async function getModuleVersion(moduleCode, { repoUrl = null, registryDefault = * UI utilities for the installer */ class UI { + async _findUnavailableInstalledModules(installedModuleIds = new Set(), bmadDir) { + const { OfficialModules } = require('./modules/official-modules'); + const availableModulesData = await new OfficialModules().listAvailable(); + const availableModules = [...availableModulesData.modules]; + + const externalManager = new ExternalModuleManager(); + const externalModules = await externalManager.listAvailable(); + for (const externalModule of externalModules) { + if (installedModuleIds.has(externalModule.code) && !availableModules.some((m) => m.id === externalModule.code)) { + availableModules.push({ + id: externalModule.code, + name: externalModule.name, + isExternal: true, + fromExternal: true, + }); + } + } + + const { CustomModuleManager } = require('./modules/custom-module-manager'); + const customMgr = new CustomModuleManager(); + for (const moduleId of installedModuleIds) { + if (!availableModules.some((m) => m.id === moduleId)) { + const customSource = await customMgr.findModuleSourceByCode(moduleId, { bmadDir }); + if (customSource) { + availableModules.push({ + id: moduleId, + name: moduleId, + isExternal: true, + fromCustom: true, + }); + } + } + } + + const availableModuleIds = new Set(availableModules.map((m) => m.id)); + return [...installedModuleIds].filter((id) => !availableModuleIds.has(id)); + } + /** * Prompt for installation configuration * @param {Object} options - Command-line options from install command @@ -273,6 +311,15 @@ class UI { selectedModules.unshift('core'); } + const preservedModules = await this._findUnavailableInstalledModules(installedModuleIds, bmadDir); + if (preservedModules.length > 0) { + const preservedSet = new Set(preservedModules); + selectedModules = selectedModules.filter((moduleName) => !preservedSet.has(moduleName)); + await prompts.log.warn( + `Retaining ${preservedModules.length} installed module(s) with no source available: ${preservedModules.join(', ')}`, + ); + } + // For existing installs, resolve per-module update decisions BEFORE // we clone anything. Reads the existing manifest's recorded channel // per module and prompts the user on available upgrades (patch/minor @@ -317,6 +364,7 @@ class UI { setOverrides, skipPrompts: options.yes || false, channelOptions, + _preserveModules: preservedModules, }; } }