diff --git a/test/test-installation-components.js b/test/test-installation-components.js
index 4e5fa7282..b548cbabe 100644
--- a/test/test-installation-components.js
+++ b/test/test-installation-components.js
@@ -14,7 +14,9 @@
const path = require('node:path');
const os = require('node:os');
const fs = require('fs-extra');
+const { Installer } = require('../tools/installer/core/installer');
const { ManifestGenerator } = require('../tools/installer/core/manifest-generator');
+const { OfficialModules } = require('../tools/installer/modules/official-modules');
const { IdeManager } = require('../tools/installer/ide/manager');
const { clearCache, loadPlatformCodes } = require('../tools/installer/ide/platform-codes');
@@ -126,6 +128,56 @@ 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');
+ const moduleSourceDir = path.join(fixtureRoot, 'test-module-source');
+ await fs.ensureDir(configDir);
+ await fs.ensureDir(moduleSourceDir);
+
+ 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(moduleSourceDir, 'module.yaml'), ['code: test-module', 'name: Test Module', ''].join('\n'));
+
+ 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: ${JSON.stringify(moduleSourceDir)}`,
+ 'ides:',
+ ' - codex',
+ '',
+ ].join('\n'),
+ );
+
+ return { root: fixtureRoot, bmadDir, manifestPath: path.join(configDir, 'manifest.yaml'), moduleSourceDir };
+}
+
/**
* Test Suite
*/
@@ -1713,6 +1765,107 @@ async function runTests() {
console.log('');
+ // ============================================================
+ // Suite 33: Main manifest preserves active customModules only
+ // ============================================================
+ console.log(`${colors.yellow}Test Suite 33: Preserve active customModules in main manifest${colors.reset}\n`);
+
+ let customManifestFixture = null;
+ try {
+ customManifestFixture = await createCustomModuleManifestFixture();
+ const yaml = require('yaml');
+ const originalManifest = yaml.parse(await fs.readFile(customManifestFixture.manifestPath, 'utf8'));
+ originalManifest.customModules.push({
+ id: 'removed-module',
+ name: 'Removed Module',
+ sourcePath: path.join(customManifestFixture.root, 'removed-module-source'),
+ });
+ await fs.writeFile(customManifestFixture.manifestPath, yaml.stringify(originalManifest), 'utf8');
+
+ const generator33 = new ManifestGenerator();
+ await generator33.generateManifests(customManifestFixture.bmadDir, ['core', 'test-module'], [], { ides: ['codex'] });
+
+ 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 === customManifestFixture.moduleSourceDir,
+ 'Main manifest preserves custom module sourcePath',
+ );
+ assert(
+ !updatedManifest.customModules?.some((entry) => entry.id === 'removed-module'),
+ 'Main manifest drops stale custom module entries',
+ );
+ } catch (error) {
+ assert(false, 'Main manifest preserves customModules test succeeds', error.message);
+ } finally {
+ if (customManifestFixture?.root) await fs.remove(customManifestFixture.root).catch(() => {});
+ }
+
+ console.log('');
+
+ // ============================================================
+ // Suite 34: Quick update uses manifest-backed custom sources
+ // ============================================================
+ console.log(`${colors.yellow}Test Suite 34: Quick update uses manifest-backed custom module sources${colors.reset}\n`);
+
+ let quickUpdateFixture = null;
+ const originalListAvailable34 = OfficialModules.prototype.listAvailable;
+ const originalLoadExistingConfig34 = OfficialModules.prototype.loadExistingConfig;
+ const originalCollectModuleConfigQuick34 = OfficialModules.prototype.collectModuleConfigQuick;
+ try {
+ quickUpdateFixture = await createCustomModuleManifestFixture();
+ const installer34 = new Installer();
+ installer34.externalModuleManager.hasModule = async () => false;
+ installer34.externalModuleManager.listAvailable = async () => [];
+
+ let capturedInstallConfig34 = null;
+ installer34.install = async (config) => {
+ capturedInstallConfig34 = config;
+ return { success: true };
+ };
+
+ OfficialModules.prototype.listAvailable = async function () {
+ return { modules: [], customModules: [] };
+ };
+ OfficialModules.prototype.loadExistingConfig = async function () {
+ this.collectedConfig = this.collectedConfig || {};
+ };
+ OfficialModules.prototype.collectModuleConfigQuick = async function (moduleName) {
+ this.collectedConfig = this.collectedConfig || {};
+ if (!this.collectedConfig[moduleName]) {
+ this.collectedConfig[moduleName] = {};
+ }
+ return false;
+ };
+
+ await installer34.quickUpdate({
+ directory: quickUpdateFixture.root,
+ skipPrompts: true,
+ });
+
+ const customModule34 = capturedInstallConfig34?._customModuleSources?.get('test-module');
+
+ assert(capturedInstallConfig34 !== null, 'Quick update forwards config to install');
+ assert(customModule34 !== undefined, 'Quick update keeps manifest-backed custom module updateable');
+ assert(customModule34 && customModule34.cached === false, 'Quick update uses manifest-backed source before cache');
+ assert(
+ customModule34 && customModule34.sourcePath === quickUpdateFixture.moduleSourceDir,
+ 'Quick update uses preserved manifest sourcePath for custom modules',
+ );
+ } catch (error) {
+ assert(false, 'Quick update manifest-backed custom source test succeeds', error.message);
+ } finally {
+ OfficialModules.prototype.listAvailable = originalListAvailable34;
+ OfficialModules.prototype.loadExistingConfig = originalLoadExistingConfig34;
+ OfficialModules.prototype.collectModuleConfigQuick = originalCollectModuleConfigQuick34;
+ if (quickUpdateFixture?.root) await fs.remove(quickUpdateFixture.root).catch(() => {});
+ }
+
+ console.log('');
+
// ============================================================
// Summary
// ============================================================
diff --git a/tools/installer/core/installer.js b/tools/installer/core/installer.js
index 111c88b54..a0ea9a66e 100644
--- a/tools/installer/core/installer.js
+++ b/tools/installer/core/installer.js
@@ -1144,59 +1144,12 @@ 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
- const customModuleSources = new Map();
- if (config.customContent?.sources?.length > 0) {
- for (const source of config.customContent.sources) {
- if (source.id && source.path && (await fs.pathExists(source.path))) {
- customModuleSources.set(source.id, {
- id: source.id,
- name: source.name || source.id,
- sourcePath: source.path,
- cached: false, // From CLI, will be re-cached
- });
- }
- }
- }
- const cacheDir = path.join(bmadDir, '_config', 'custom');
- if (await fs.pathExists(cacheDir)) {
- const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true });
-
- for (const cachedModule of cachedModules) {
- const moduleId = cachedModule.name;
- const cachedPath = path.join(cacheDir, moduleId);
-
- // Skip if path doesn't exist (broken symlink, deleted dir) - avoids lstat ENOENT
- if (!(await fs.pathExists(cachedPath))) {
- continue;
- }
- if (!cachedModule.isDirectory()) {
- continue;
- }
-
- // Skip if we already have this module from manifest
- if (customModuleSources.has(moduleId)) {
- continue;
- }
-
- // Check if this is an external official module - skip cache for those
- const isExternal = await this.externalModuleManager.hasModule(moduleId);
- if (isExternal) {
- continue;
- }
-
- // Check if this is actually a custom module (has module.yaml)
- const moduleYamlPath = path.join(cachedPath, 'module.yaml');
- if (await fs.pathExists(moduleYamlPath)) {
- customModuleSources.set(moduleId, {
- id: moduleId,
- name: moduleId,
- sourcePath: cachedPath,
- cached: true,
- });
- }
- }
- }
+ const customModuleSources = await this.customModules.assembleQuickUpdateSources(
+ config,
+ existingInstall,
+ bmadDir,
+ this.externalModuleManager,
+ );
// Get available modules (what we have source for)
const availableModulesData = await new OfficialModules().listAvailable();
diff --git a/tools/installer/core/manifest-generator.js b/tools/installer/core/manifest-generator.js
index 65e0f4ed3..bef6f2d23 100644
--- a/tools/installer/core/manifest-generator.js
+++ b/tools/installer/core/manifest-generator.js
@@ -377,10 +377,12 @@ class ManifestGenerator {
*/
async writeMainManifest(cfgDir) {
const manifestPath = path.join(cfgDir, 'manifest.yaml');
+ const installedModuleSet = new Set(this.modules);
// Read existing manifest to preserve install date
let existingInstallDate = null;
const existingModulesMap = new Map();
+ let existingCustomModules = [];
if (await fs.pathExists(manifestPath)) {
try {
@@ -402,6 +404,12 @@ class ManifestGenerator {
}
}
}
+
+ if (existingManifest.customModules && Array.isArray(existingManifest.customModules)) {
+ // We filter here so manifest regeneration preserves source metadata only for custom modules that
+ // are still installed. Without that, customModules can retain stale entries for modules that were removed.
+ existingCustomModules = existingManifest.customModules.filter((customModule) => installedModuleSet.has(customModule?.id));
+ }
} catch {
// If we can't read existing manifest, continue with defaults
}
@@ -437,6 +445,7 @@ class ManifestGenerator {
lastUpdated: new Date().toISOString(),
},
modules: updatedModules,
+ customModules: existingCustomModules,
ides: this.selectedIdes,
};
diff --git a/tools/installer/modules/custom-modules.js b/tools/installer/modules/custom-modules.js
index b41bf47b1..3f8b793be 100644
--- a/tools/installer/modules/custom-modules.js
+++ b/tools/installer/modules/custom-modules.js
@@ -192,6 +192,111 @@ class CustomModules {
return this.paths;
}
+
+ /**
+ * Assemble quick-update source candidates before install() hands them to discoverPaths().
+ * This exists because discoverPaths() consumes already-prepared quick-update sources,
+ * while quickUpdate() still has to build that source map from manifest, explicit inputs,
+ * and cache conventions.
+ * Precedence: manifest-backed paths, explicit sources override them, then cached modules.
+ * @param {Object} config - Quick update configuration
+ * @param {Object} existingInstall - Existing installation snapshot
+ * @param {string} bmadDir - BMAD directory
+ * @param {Object} externalModuleManager - External module manager
+ * @returns {Promise