From cd5b5c2a31fae29de27ef09899f5330b34465ea6 Mon Sep 17 00:00:00 2001 From: Taras Romaniv Date: Mon, 30 Mar 2026 17:28:47 +0200 Subject: [PATCH] fix: preserve local custom module sources during quick update Keep customModules in the generated main manifest so local custom module source paths survive update runs. Load those preserved source paths during stock quick update before falling back to the custom cache directory. This fixes the case where BMAD would drop customModules, lose the original source path for a local module, and then skip the module or try to re-cache from _bmad/_config/custom/, which could fail with ENOENT after the cache directory was removed. Also adds an installation component regression test to verify customModules and sourcePath are preserved in manifest generation. Fixes #1582 --- test/test-installation-components.js | 76 ++++++++++++++++++++++ tools/installer/core/installer.js | 29 ++++++++- tools/installer/core/manifest-generator.js | 6 ++ 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 4e5fa7282..0b792cef7 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -126,6 +126,53 @@ async function createSkillCollisionFixture() { return { root: fixtureRoot, bmadDir: fixtureDir }; } +async function createCustomModuleManifestFixture() { + const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-custom-manifest-')); + const bmadDir = path.join(fixtureRoot, '_bmad'); + const configDir = path.join(bmadDir, '_config'); + await fs.ensureDir(configDir); + + const minimalAgent = 'p'; + await fs.ensureDir(path.join(bmadDir, 'core', 'agents')); + await fs.writeFile(path.join(bmadDir, 'core', 'agents', 'test.md'), minimalAgent); + await fs.ensureDir(path.join(bmadDir, 'test-module', 'agents')); + await fs.writeFile(path.join(bmadDir, 'test-module', 'agents', 'test.md'), minimalAgent); + + await fs.writeFile( + path.join(configDir, 'manifest.yaml'), + [ + 'installation:', + ' version: 6.2.2', + ' installDate: 2026-03-30T00:00:00.000Z', + ' lastUpdated: 2026-03-30T00:00:00.000Z', + 'modules:', + ' - name: core', + ' version: 6.2.2', + ' installDate: 2026-03-30T00:00:00.000Z', + ' lastUpdated: 2026-03-30T00:00:00.000Z', + ' source: built-in', + ' npmPackage: null', + ' repoUrl: null', + ' - name: test-module', + ' version: null', + ' installDate: 2026-03-30T00:00:00.000Z', + ' lastUpdated: 2026-03-30T00:00:00.000Z', + ' source: custom', + ' npmPackage: null', + ' repoUrl: null', + 'customModules:', + ' - id: test-module', + ' name: "Test Module"', + ' sourcePath: "/tmp/test-module-source"', + 'ides:', + ' - codex', + '', + ].join('\n'), + ); + + return { root: fixtureRoot, bmadDir, manifestPath: path.join(configDir, 'manifest.yaml') }; +} + /** * Test Suite */ @@ -1713,6 +1760,35 @@ async function runTests() { console.log(''); + // ============================================================ + // Suite 33: Main manifest preserves customModules + // ============================================================ + console.log(`${colors.yellow}Test Suite 33: Preserve customModules in main manifest${colors.reset}\n`); + + let customManifestFixture = null; + try { + customManifestFixture = await createCustomModuleManifestFixture(); + const generator33 = new ManifestGenerator(); + await generator33.generateManifests(customManifestFixture.bmadDir, ['core', 'test-module'], [], { ides: ['codex'] }); + + const yaml = require('yaml'); + const updatedManifest = yaml.parse(await fs.readFile(customManifestFixture.manifestPath, 'utf8')); + const customModule = updatedManifest.customModules?.find((entry) => entry.id === 'test-module'); + + assert(Array.isArray(updatedManifest.customModules), 'Main manifest keeps customModules array'); + assert(customModule !== undefined, 'Main manifest preserves existing custom module entry'); + assert( + customModule && customModule.sourcePath === '/tmp/test-module-source', + 'Main manifest preserves custom module sourcePath', + ); + } catch (error) { + assert(false, 'Main manifest preserves customModules test succeeds', error.message); + } finally { + if (customManifestFixture?.root) await fs.remove(customManifestFixture.root).catch(() => { }); + } + + console.log(''); + // ============================================================ // Summary // ============================================================ diff --git a/tools/installer/core/installer.js b/tools/installer/core/installer.js index 111c88b54..7beeb847d 100644 --- a/tools/installer/core/installer.js +++ b/tools/installer/core/installer.js @@ -1144,8 +1144,35 @@ class Installer { const configuredIdes = existingInstall.ides; const projectRoot = path.dirname(bmadDir); - // Get custom module sources: first from --custom-content (re-cache from source), then from cache + // Get custom module sources: first from manifest, then from --custom-content, then from cache const customModuleSources = new Map(); + if (existingInstall.customModules) { + for (const customModule of existingInstall.customModules) { + if (!customModule?.id) continue; + + let sourcePath = customModule.sourcePath; + if (sourcePath && sourcePath.startsWith('_config')) { + sourcePath = path.join(bmadDir, sourcePath); + } else if (!sourcePath && customModule.relativePath) { + sourcePath = path.resolve(projectRoot, customModule.relativePath); + } else if (sourcePath && !path.isAbsolute(sourcePath)) { + sourcePath = path.resolve(sourcePath); + } + + if (!sourcePath || !(await fs.pathExists(sourcePath))) { + continue; + } + + customModuleSources.set(customModule.id, { + id: customModule.id, + name: customModule.name || customModule.id, + sourcePath, + relativePath: customModule.relativePath, + cached: false, + }); + } + } + if (config.customContent?.sources?.length > 0) { for (const source of config.customContent.sources) { if (source.id && source.path && (await fs.pathExists(source.path))) { diff --git a/tools/installer/core/manifest-generator.js b/tools/installer/core/manifest-generator.js index 65e0f4ed3..f96081494 100644 --- a/tools/installer/core/manifest-generator.js +++ b/tools/installer/core/manifest-generator.js @@ -381,6 +381,7 @@ class ManifestGenerator { // Read existing manifest to preserve install date let existingInstallDate = null; const existingModulesMap = new Map(); + let existingCustomModules = []; if (await fs.pathExists(manifestPath)) { try { @@ -402,6 +403,10 @@ class ManifestGenerator { } } } + + if (existingManifest.customModules && Array.isArray(existingManifest.customModules)) { + existingCustomModules = existingManifest.customModules; + } } catch { // If we can't read existing manifest, continue with defaults } @@ -437,6 +442,7 @@ class ManifestGenerator { lastUpdated: new Date().toISOString(), }, modules: updatedModules, + customModules: existingCustomModules, ides: this.selectedIdes, };