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/<module>, 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
This commit is contained in:
Taras Romaniv 2026-03-30 17:28:47 +02:00
parent 2302d9cdc5
commit cd5b5c2a31
3 changed files with 110 additions and 1 deletions

View File

@ -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 = '<agent name="Test" title="T"><persona>p</persona></agent>';
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
// ============================================================

View File

@ -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))) {

View File

@ -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,
};