Compare commits

...

6 Commits

Author SHA1 Message Date
Marcos Fadul 0595b07ce8
Merge c3486a8844 into 1040c3c306 2026-03-27 11:45:28 +01:00
Akhilesh Tyagi 1040c3c306
fix: correctly resolve output_folder paths outside project root (#2132)
* fix(bmad-init): correctly resolve output_folder paths outside project root

  When output_folder was set to an absolute path (e.g. /Users/me/outputs),
  the {project-root}/{value} result template stored it as
  {project-root}//absolute/path. resolve_project_root_placeholder then did
  a naive string replace, producing /project//absolute/path — a broken path
  that workflows could not resolve.

  For relative paths outside the root (e.g. ../../sibling), the same naive
  replace left un-normalized paths like /project/../../sibling in the
  resolved config, which some tools mishandled.

  Fix resolve_project_root_placeholder to strip the {project-root} token,
  detect whether the remainder is absolute (returning it directly) or
  relative (joining with project root and normalizing via os.path.normpath).

  Fix apply_result_template to skip the template entirely when raw_value is
  already an absolute path, and to normalize the result for relative-but-
  outside paths. This covers the bmad-init SKILL write path, which bakes
  the resolved path directly into config.yaml.

  Add 7 tests covering all three path cases (absolute, relative-with-
  traversal, normal in-project) for both functions.

* Address review comments

---------

Co-authored-by: Akhilesh Tyagi <akhilesh.t@nextiva.com>
Co-authored-by: Brian <bmadcode@gmail.com>
2026-03-26 21:46:14 -05:00
Brian ed9dea9058
refactor: consolidate plugin.json metadata into marketplace.json (#2137)
Merge license, homepage, repository, keywords, and author from
plugin.json into marketplace.json and remove the redundant file.
The npx skills installer only reads marketplace.json for skill
discovery — plugin.json contributed no functional value.
2026-03-26 19:48:04 -05:00
Brian 3d8a89c7e1
feat: add .claude-plugin marketplace and plugin metadata (#2136) 2026-03-26 19:12:32 -05:00
Marcos Fadul c3486a8844 fix: address PR review findings for extension module installer (#1667)
- installer.js: use findModuleSource() instead of getModulePath() so
  external official modules are recognized as a base when computing
  hasBaseModule (getModulePath only resolves repo-local src/ paths)
- installer.js: wrap base-module install block in try/finally so the
  custom module path is always restored even if an exception is thrown
- installer.js: pass moduleConfig:{} to fallback moduleManager.install()
  so createModuleDirectories receives the expected config
- test: fix assert() to throw on failure so broken preconditions halt
  the scenario immediately instead of cascading
- test: Scenarios A/B now call manager.install() instead of hand-rolling
  fs.remove/fs.copy, exercising the real filtering and manifest logic
- test: Scenario C tests actual removal behavior (sentinel file) instead
  of grepping manager.js source for the guard string
- test: Scenario D adds manifest verification (exactly one bmm entry)
- test: add Scenario E — user-modified sentinel file is preserved when
  extension overlay is applied with isExtension:true
- ci: add validate-extensions-macos job so extension tests run on macOS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 13:56:35 +01:00
Marcos Fadul 2eb509d246 fix: Extension module with code - bmm replaces base BMM agents directory instead of merging 2026-03-21 13:53:57 +01:00
9 changed files with 609 additions and 7 deletions

View File

@ -0,0 +1,78 @@
{
"name": "bmad-method",
"owner": {
"name": "Brian (BMad) Madison"
},
"description": "Breakthrough Method of Agile AI-driven Development — a full-lifecycle framework with agents and workflows for analysis, planning, architecture, and implementation.",
"license": "MIT",
"homepage": "https://github.com/bmad-code-org/BMAD-METHOD",
"repository": "https://github.com/bmad-code-org/BMAD-METHOD",
"keywords": ["bmad", "agile", "ai", "orchestrator", "development", "methodology", "agents"],
"plugins": [
{
"name": "bmad-pro-skills",
"source": "./",
"description": "Next level skills for power users — advanced prompting techniques, agent management, and more.",
"version": "6.3.0",
"author": {
"name": "Brian (BMad) Madison"
},
"skills": [
"./src/core-skills/bmad-help",
"./src/core-skills/bmad-init",
"./src/core-skills/bmad-brainstorming",
"./src/core-skills/bmad-distillator",
"./src/core-skills/bmad-party-mode",
"./src/core-skills/bmad-shard-doc",
"./src/core-skills/bmad-advanced-elicitation",
"./src/core-skills/bmad-editorial-review-prose",
"./src/core-skills/bmad-editorial-review-structure",
"./src/core-skills/bmad-index-docs",
"./src/core-skills/bmad-review-adversarial-general",
"./src/core-skills/bmad-review-edge-case-hunter"
]
},
{
"name": "bmad-method-lifecycle",
"source": "./",
"description": "Full-lifecycle AI development framework — agents and workflows for product analysis, planning, architecture, and implementation.",
"version": "6.3.0",
"author": {
"name": "Brian (BMad) Madison"
},
"skills": [
"./src/bmm-skills/1-analysis/bmad-product-brief",
"./src/bmm-skills/1-analysis/bmad-agent-analyst",
"./src/bmm-skills/1-analysis/bmad-agent-tech-writer",
"./src/bmm-skills/1-analysis/bmad-document-project",
"./src/bmm-skills/1-analysis/research/bmad-domain-research",
"./src/bmm-skills/1-analysis/research/bmad-market-research",
"./src/bmm-skills/1-analysis/research/bmad-technical-research",
"./src/bmm-skills/2-plan-workflows/bmad-agent-pm",
"./src/bmm-skills/2-plan-workflows/bmad-agent-ux-designer",
"./src/bmm-skills/2-plan-workflows/bmad-create-prd",
"./src/bmm-skills/2-plan-workflows/bmad-edit-prd",
"./src/bmm-skills/2-plan-workflows/bmad-validate-prd",
"./src/bmm-skills/2-plan-workflows/bmad-create-ux-design",
"./src/bmm-skills/3-solutioning/bmad-agent-architect",
"./src/bmm-skills/3-solutioning/bmad-create-architecture",
"./src/bmm-skills/3-solutioning/bmad-check-implementation-readiness",
"./src/bmm-skills/3-solutioning/bmad-create-epics-and-stories",
"./src/bmm-skills/3-solutioning/bmad-generate-project-context",
"./src/bmm-skills/4-implementation/bmad-agent-dev",
"./src/bmm-skills/4-implementation/bmad-agent-sm",
"./src/bmm-skills/4-implementation/bmad-agent-qa",
"./src/bmm-skills/4-implementation/bmad-agent-quick-flow-solo-dev",
"./src/bmm-skills/4-implementation/bmad-dev-story",
"./src/bmm-skills/4-implementation/bmad-quick-dev",
"./src/bmm-skills/4-implementation/bmad-sprint-planning",
"./src/bmm-skills/4-implementation/bmad-sprint-status",
"./src/bmm-skills/4-implementation/bmad-code-review",
"./src/bmm-skills/4-implementation/bmad-create-story",
"./src/bmm-skills/4-implementation/bmad-correct-course",
"./src/bmm-skills/4-implementation/bmad-retrospective",
"./src/bmm-skills/4-implementation/bmad-qa-generate-e2e-tests"
]
}
]
}

View File

@ -106,6 +106,9 @@ jobs:
- name: Test agent compilation components
run: npm run test:install
- name: Test module extension installer
run: npm run test:install:extensions
- name: Validate file references
run: npm run validate:refs

View File

@ -37,7 +37,19 @@ Requires [Node.js](https://nodejs.org) v20+ and `npx` (included with npm).
| `--user-name <name>` | Name for agents to use | System username |
| `--communication-language <lang>` | Agent communication language | English |
| `--document-output-language <lang>` | Document output language | English |
| `--output-folder <path>` | Output folder path | _bmad-output |
| `--output-folder <path>` | Output folder path (see resolution rules below) | `_bmad-output` |
#### Output Folder Path Resolution
The value passed to `--output-folder` (or entered interactively) is resolved according to these rules:
| Input type | Example | Resolved as |
|------------|---------|-------------|
| Relative path (default) | `_bmad-output` | `<project-root>/_bmad-output` |
| Relative path with traversal | `../../shared-outputs` | Normalized absolute path — e.g. `/Users/me/shared-outputs` |
| Absolute path | `/Users/me/shared-outputs` | Used as-is — project root is **not** prepended |
The resolved path is what agents and workflows use at runtime when writing output files. Using an absolute path or a traversal-based relative path lets you direct all generated artifacts to a directory outside your project tree — useful for shared or monorepo setups.
### Other Options
@ -141,6 +153,7 @@ Invalid values will either:
:::tip[Best Practices]
- Use absolute paths for `--directory` to avoid ambiguity
- Use an absolute path for `--output-folder` when you want artifacts written outside the project tree (e.g. a shared monorepo outputs directory)
- Test flags locally before using in CI/CD pipelines
- Combine with `-y` for truly unattended installations
- Use `--debug` if you encounter issues during installation

View File

@ -41,8 +41,9 @@
"prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0",
"quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run validate:refs && npm run validate:skills",
"rebundle": "node tools/cli/bundlers/bundle-web.js rebundle",
"test": "npm run test:refs && npm run test:install && npm run lint && npm run lint:md && npm run format:check",
"test": "npm run test:refs && npm run test:install && npm run test:install:extensions && npm run lint && npm run lint:md && npm run format:check",
"test:install": "node test/test-installation-components.js",
"test:install:extensions": "node test/test-installation-extensions.js",
"test:refs": "node test/test-file-refs-csv.js",
"validate:refs": "node tools/validate-file-refs.js --strict",
"validate:skills": "node tools/validate-skills.js --strict"

View File

@ -166,9 +166,27 @@ def resolve_project_root_placeholder(value, project_root):
"""Replace {project-root} placeholder with actual path."""
if not value or not isinstance(value, str):
return value
if '{project-root}' in value:
return value.replace('{project-root}', str(project_root))
return value
if '{project-root}' not in value:
return value
# Strip the {project-root} token to inspect what remains, so we can
# correctly handle absolute paths stored as "{project-root}//absolute/path"
# (produced by the "{project-root}/{value}" template applied to an absolute value).
suffix = value.replace('{project-root}', '', 1)
# Strip the one path separator that follows the token (if any)
if suffix.startswith('/') or suffix.startswith('\\'):
remainder = suffix[1:]
else:
remainder = suffix
if os.path.isabs(remainder):
# The original value was an absolute path stored with a {project-root}/ prefix.
# Return the absolute path directly — no joining needed.
return remainder
# Relative path: join with project root and normalize to resolve any .. segments.
return os.path.normpath(os.path.join(str(project_root), remainder))
def parse_var_specs(vars_string):
@ -222,9 +240,22 @@ def apply_result_template(var_def, raw_value, context):
if not result_template:
return raw_value
# If the user supplied an absolute path and the template would prefix it with
# "{project-root}/", skip the template entirely to avoid producing a broken path
# like "/my/project//absolute/path".
if isinstance(raw_value, str) and os.path.isabs(raw_value):
return raw_value
ctx = dict(context)
ctx['value'] = raw_value
return expand_template(result_template, ctx)
result = expand_template(result_template, ctx)
# Normalize the resulting path to resolve any ".." segments (e.g. when the user
# entered a relative path such as "../../outside-dir").
if isinstance(result, str) and '{' not in result and os.path.isabs(result):
result = os.path.normpath(result)
return result
# =============================================================================

View File

@ -110,6 +110,37 @@ class TestResolveProjectRootPlaceholder(unittest.TestCase):
def test_non_string(self):
self.assertEqual(resolve_project_root_placeholder(42, Path('/test')), 42)
def test_absolute_path_stored_with_prefix(self):
"""Absolute output_folder entered by user is stored as '{project-root}//abs/path'
by the '{project-root}/{value}' template. It must resolve to '/abs/path', not
'/project//abs/path'."""
result = resolve_project_root_placeholder(
'{project-root}//Users/me/outside', Path('/Users/me/myproject')
)
self.assertEqual(result, '/Users/me/outside')
def test_relative_path_with_traversal_is_normalized(self):
"""A relative path like '../../sibling' produces '{project-root}/../../sibling'
after the template. It must resolve to the normalized absolute path, not the
un-normalized string '/project/../../sibling'."""
result = resolve_project_root_placeholder(
'{project-root}/../../sibling', Path('/Users/me/myproject')
)
self.assertEqual(result, '/Users/sibling')
def test_relative_path_one_level_up(self):
result = resolve_project_root_placeholder(
'{project-root}/../outside-outputs', Path('/project/root')
)
self.assertEqual(result, '/project/outside-outputs')
def test_standard_relative_path_unchanged(self):
"""Normal in-project relative paths continue to work correctly."""
result = resolve_project_root_placeholder(
'{project-root}/_bmad-output', Path('/project/root')
)
self.assertEqual(result, '/project/root/_bmad-output')
class TestExpandTemplate(unittest.TestCase):
@ -147,6 +178,39 @@ class TestApplyResultTemplate(unittest.TestCase):
result = apply_result_template(var_def, 'English', {})
self.assertEqual(result, 'English')
def test_absolute_value_skips_project_root_template(self):
"""When the user enters an absolute path, the '{project-root}/{value}' template
must not be applied doing so would produce '/project//absolute/path'."""
var_def = {'result': '{project-root}/{value}'}
result = apply_result_template(
var_def, '/Users/me/shared-outputs', {'project-root': '/Users/me/myproject'}
)
self.assertEqual(result, '/Users/me/shared-outputs')
def test_relative_traversal_value_is_normalized(self):
"""A relative path like '../../outside' combined with the project-root template
must produce a clean normalized absolute path, not '/project/../../outside'."""
var_def = {'result': '{project-root}/{value}'}
result = apply_result_template(
var_def, '../../outside-dir', {'project-root': '/Users/me/myproject'}
)
self.assertEqual(result, '/Users/outside-dir')
def test_relative_one_level_up_is_normalized(self):
var_def = {'result': '{project-root}/{value}'}
result = apply_result_template(
var_def, '../sibling-outputs', {'project-root': '/project/root'}
)
self.assertEqual(result, '/project/sibling-outputs')
def test_normal_relative_value_unchanged(self):
"""Standard in-project relative paths still produce the expected joined path."""
var_def = {'result': '{project-root}/{value}'}
result = apply_result_template(
var_def, '_bmad-output', {'project-root': '/project/root'}
)
self.assertEqual(result, '/project/root/_bmad-output')
class TestLoadModuleYaml(unittest.TestCase):

View File

@ -0,0 +1,369 @@
/**
* Extension Module Merge Tests Issue #1667
*
* Verifies that a custom extension module with `code: bmm` in its module.yaml
* merges its files into the base BMM installation instead of replacing the
* entire directory.
*
* Expected behavior (file-level merge):
* - Files with the same name extension overrides base
* - Files with unique names extension adds alongside base
*
* Usage: node test/test-installation-extensions.js
*/
const path = require('node:path');
const os = require('node:os');
const fs = require('fs-extra');
const { ModuleManager } = require('../tools/cli/installers/lib/modules/manager');
// ANSI colors (match existing test files)
const colors = {
reset: '\u001B[0m',
green: '\u001B[32m',
red: '\u001B[31m',
yellow: '\u001B[33m',
cyan: '\u001B[36m',
dim: '\u001B[2m',
};
let passed = 0;
let failed = 0;
function assert(condition, testName, errorMessage = '') {
if (condition) {
console.log(`${colors.green}${colors.reset} ${testName}`);
passed++;
} else {
console.log(`${colors.red}${colors.reset} ${testName}`);
if (errorMessage) console.log(` ${colors.dim}${errorMessage}${colors.reset}`);
failed++;
throw new Error(`${testName}${errorMessage ? ': ' + errorMessage : ''}`);
}
}
/**
* Build a minimal compiled agent .md file (no YAML compilation needed).
*/
function minimalAgentMd(name) {
return ['---', `name: "${name}"`, `description: "Test agent: ${name}"`, '---', '', `You are ${name}.`].join('\n');
}
/**
* Create a fake base module source directory that looks like src/bmm/.
* Only files relevant to the merge test are created (agents directory).
*/
async function createBaseModuleSource(tmpDir) {
const src = path.join(tmpDir, 'src-base');
// module.yaml
await fs.ensureDir(src);
await fs.writeFile(path.join(src, 'module.yaml'), 'name: BMM\ncode: bmm\nversion: 6.0.0\n');
// agents: analyst + pm (standard base agents)
await fs.ensureDir(path.join(src, 'agents'));
await fs.writeFile(path.join(src, 'agents', 'analyst.md'), minimalAgentMd('analyst'));
await fs.writeFile(path.join(src, 'agents', 'pm.md'), minimalAgentMd('pm'));
return src;
}
/**
* Create a fake extension module source directory (simulates a user's
* custom module with code: bmm in its module.yaml).
*
* Includes:
* - pm.md should OVERRIDE the base pm.md
* - my-agent.md unique name, should be ADDED alongside base agents
*/
async function createExtensionModuleSource(tmpDir) {
const src = path.join(tmpDir, 'src-extension');
await fs.ensureDir(src);
await fs.writeFile(path.join(src, 'module.yaml'), 'name: My Extension\ncode: bmm\nversion: 1.0.0\n');
await fs.ensureDir(path.join(src, 'agents'));
await fs.writeFile(path.join(src, 'agents', 'pm.md'), minimalAgentMd('pm-override'));
await fs.writeFile(path.join(src, 'agents', 'my-agent.md'), minimalAgentMd('my-agent'));
return src;
}
/**
* Create a minimal manifest so manager.install() can record module metadata.
*/
async function createMinimalManifest(bmadDir) {
await fs.ensureDir(path.join(bmadDir, '_config'));
await fs.writeFile(
path.join(bmadDir, '_config', 'manifest.yaml'),
'installation:\n version: "0.0.0"\n installDate: "2026-01-01T00:00:00.000Z"\n lastUpdated: "2026-01-01T00:00:00.000Z"\nmodules: []\nides: []\n',
);
}
async function runTests() {
console.log(`${colors.cyan}========================================`);
console.log('Extension Module Merge — Issue #1667');
console.log(`========================================${colors.reset}\n`);
// ─────────────────────────────────────────────────────────────────────────
// Test 1: isExtension:false (default) — second install replaces directory
// (confirms the bug existed: without the fix, extension would wipe base)
// ─────────────────────────────────────────────────────────────────────────
console.log(`${colors.yellow}Scenario A: install without isExtension (replacement mode)${colors.reset}\n`);
{
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-ext-test-'));
try {
const baseSrc = await createBaseModuleSource(tmpDir);
const extSrc = await createExtensionModuleSource(tmpDir);
const bmadDir = path.join(tmpDir, '_bmad');
const targetPath = path.join(bmadDir, 'bmm');
await createMinimalManifest(bmadDir);
const manager = new ModuleManager();
// Step 1: Install base module via manager.install()
manager.setCustomModulePaths(new Map([['bmm', baseSrc]]));
await manager.install('bmm', bmadDir, null, { silent: true, skipModuleInstaller: true });
// Step 2: Install extension WITHOUT isExtension flag (old/broken behavior: wipes directory)
manager.setCustomModulePaths(new Map([['bmm', extSrc]]));
await manager.install('bmm', bmadDir, null, { silent: true, skipModuleInstaller: true });
const analystExists = await fs.pathExists(path.join(targetPath, 'agents', 'analyst.md'));
const myAgentExists = await fs.pathExists(path.join(targetPath, 'agents', 'my-agent.md'));
assert(!analystExists, 'Without fix: base analyst.md is GONE (replacement behavior confirmed)');
assert(myAgentExists, 'Without fix: extension my-agent.md is present');
} finally {
await fs.remove(tmpDir);
}
}
console.log('');
// ─────────────────────────────────────────────────────────────────────────
// Test 2: isExtension:true (the fix) — extension merges on top of base
// ─────────────────────────────────────────────────────────────────────────
console.log(`${colors.yellow}Scenario B: install with isExtension:true (merge mode — the fix)${colors.reset}\n`);
{
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-ext-test-'));
try {
const baseSrc = await createBaseModuleSource(tmpDir);
const extSrc = await createExtensionModuleSource(tmpDir);
const bmadDir = path.join(tmpDir, '_bmad');
const targetPath = path.join(bmadDir, 'bmm');
await createMinimalManifest(bmadDir);
const manager = new ModuleManager();
// Step 1: Install base module via manager.install()
manager.setCustomModulePaths(new Map([['bmm', baseSrc]]));
await manager.install('bmm', bmadDir, null, { silent: true, skipModuleInstaller: true });
// Step 2: Install extension with isExtension:true → should MERGE on top of base
manager.setCustomModulePaths(new Map([['bmm', extSrc]]));
await manager.install('bmm', bmadDir, null, { isExtension: true, silent: true, skipModuleInstaller: true });
const analystExists = await fs.pathExists(path.join(targetPath, 'agents', 'analyst.md'));
const myAgentExists = await fs.pathExists(path.join(targetPath, 'agents', 'my-agent.md'));
const pmContent = await fs.readFile(path.join(targetPath, 'agents', 'pm.md'), 'utf8');
assert(analystExists, 'Base analyst.md is PRESERVED after extension install');
assert(myAgentExists, 'Extension my-agent.md is ADDED alongside base agents');
assert(pmContent.includes('pm-override'), 'Extension pm.md OVERRIDES base pm.md (same-name wins)');
} finally {
await fs.remove(tmpDir);
}
}
console.log('');
// ─────────────────────────────────────────────────────────────────────────
// Test 3: ModuleManager.install() with isExtension:true via options
// ─────────────────────────────────────────────────────────────────────────
console.log(`${colors.yellow}Scenario C: ModuleManager.install() respects isExtension option${colors.reset}\n`);
{
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-ext-test-'));
try {
const baseSrc = await createBaseModuleSource(tmpDir);
const bmadDir = path.join(tmpDir, '_bmad');
const targetPath = path.join(bmadDir, 'bmm');
await createMinimalManifest(bmadDir);
const manager = new ModuleManager();
manager.setCustomModulePaths(new Map([['bmm', baseSrc]]));
// Install base so target directory exists
await manager.install('bmm', bmadDir, null, { silent: true, skipModuleInstaller: true });
// Add a sentinel file that simulates a file the user placed in the installed module
await fs.writeFile(path.join(targetPath, 'sentinel.txt'), 'user data');
assert(await fs.pathExists(path.join(targetPath, 'sentinel.txt')), 'Pre-condition: sentinel.txt exists before extension install');
// With isExtension:true → fs.remove is skipped; sentinel must survive
await manager.install('bmm', bmadDir, null, { isExtension: true, silent: true, skipModuleInstaller: true });
assert(
await fs.pathExists(path.join(targetPath, 'sentinel.txt')),
'With isExtension:true: sentinel.txt PRESERVED (fs.remove was skipped)',
);
// Without isExtension → fs.remove runs; sentinel must be gone
await manager.install('bmm', bmadDir, null, { silent: true, skipModuleInstaller: true });
assert(
!(await fs.pathExists(path.join(targetPath, 'sentinel.txt'))),
'Without isExtension: sentinel.txt REMOVED (directory was cleared by fs.remove)',
);
} finally {
await fs.remove(tmpDir);
}
}
console.log('');
// ─────────────────────────────────────────────────────────────────────────
// Test 4 (E2E): installModuleWithDependencies → base install, then
// moduleManager.install with isExtension:true → merge
// ─────────────────────────────────────────────────────────────────────────
console.log(
`${colors.yellow}Scenario D: E2E install — base via installModuleWithDependencies, extension via moduleManager.install${colors.reset}\n`,
);
{
const { Installer } = require('../tools/cli/installers/lib/core/installer');
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-e2e-test-'));
try {
const baseSrc = await createBaseModuleSource(tmpDir);
const extSrc = await createExtensionModuleSource(tmpDir);
const bmadDir = path.join(tmpDir, '_bmad');
// Pre-create a minimal manifest so moduleManager.install can record the module
await createMinimalManifest(bmadDir);
const installer = new Installer();
// Stub the resolver: point 'bmm' at the fake base source
installer.moduleManager.setCustomModulePaths(new Map([['bmm', baseSrc]]));
// Step 1: Install base module via the public installModuleWithDependencies
await installer.installModuleWithDependencies('bmm', bmadDir, {});
const targetPath = path.join(bmadDir, 'bmm');
assert(
await fs.pathExists(path.join(targetPath, 'agents', 'analyst.md')),
'E2E: after base install via installModuleWithDependencies — analyst.md is present',
);
assert(
await fs.pathExists(path.join(targetPath, 'agents', 'pm.md')),
'E2E: after base install via installModuleWithDependencies — pm.md is present',
);
// Step 2: Re-stub the resolver to point to the extension source, then install
// the extension with isExtension:true so it merges on top of the base
installer.moduleManager.setCustomModulePaths(new Map([['bmm', extSrc]]));
await installer.moduleManager.install('bmm', bmadDir, null, {
isExtension: true,
skipModuleInstaller: true,
silent: true,
});
// Assert merged output: base files preserved, extension files added/overridden
assert(
await fs.pathExists(path.join(targetPath, 'agents', 'analyst.md')),
'E2E: base analyst.md is PRESERVED after extension install (merge, not replace)',
);
assert(
await fs.pathExists(path.join(targetPath, 'agents', 'my-agent.md')),
'E2E: extension my-agent.md is ADDED alongside base agents',
);
const pmContent = await fs.readFile(path.join(targetPath, 'agents', 'pm.md'), 'utf8');
assert(pmContent.includes('pm-override'), 'E2E: extension pm.md OVERRIDES base pm.md (same-name wins)');
// Verify manifest has exactly one 'bmm' entry (no duplicates)
const yaml = require('yaml');
const manifestRaw = await fs.readFile(path.join(bmadDir, '_config', 'manifest.yaml'), 'utf8');
const manifest = yaml.parse(manifestRaw);
const bmmEntries = (manifest.modules || []).filter((m) => m.name === 'bmm');
assert(bmmEntries.length === 1, 'E2E: manifest contains exactly one bmm entry (no duplicates)');
} finally {
await fs.remove(tmpDir);
}
}
console.log('');
// ─────────────────────────────────────────────────────────────────────────
// Test 5 (E2E update): user-modified files are preserved when extension
// overlay is applied with isExtension:true
// ─────────────────────────────────────────────────────────────────────────
console.log(`${colors.yellow}Scenario E: user-modified sentinel file is preserved during extension overlay${colors.reset}\n`);
{
const { Installer } = require('../tools/cli/installers/lib/core/installer');
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-e2e-update-'));
try {
const baseSrc = await createBaseModuleSource(tmpDir);
const extSrc = await createExtensionModuleSource(tmpDir);
const bmadDir = path.join(tmpDir, '_bmad');
await createMinimalManifest(bmadDir);
const installer = new Installer();
installer.moduleManager.setCustomModulePaths(new Map([['bmm', baseSrc]]));
// Step 1: Install base module
await installer.installModuleWithDependencies('bmm', bmadDir, {});
const targetPath = path.join(bmadDir, 'bmm');
// Step 2: Simulate a user adding a file to the installed module
await fs.writeFile(path.join(targetPath, 'user-modified.txt'), 'my custom content');
// Also record the original pm.md content so we can confirm the extension overrides it
assert(
await fs.pathExists(path.join(targetPath, 'agents', 'analyst.md')),
'Update pre-condition: base analyst.md is present before extension overlay',
);
// Step 3: Apply extension overlay (isExtension:true → skip fs.remove)
installer.moduleManager.setCustomModulePaths(new Map([['bmm', extSrc]]));
await installer.moduleManager.install('bmm', bmadDir, null, {
isExtension: true,
skipModuleInstaller: true,
silent: true,
});
// User-added file must survive because isExtension skips directory removal
assert(
await fs.pathExists(path.join(targetPath, 'user-modified.txt')),
'Update: user-modified.txt is PRESERVED (extension overlay did not wipe the directory)',
);
// Base file must also survive
assert(
await fs.pathExists(path.join(targetPath, 'agents', 'analyst.md')),
'Update: base analyst.md is PRESERVED after extension overlay',
);
// Extension-specific file must be present
assert(await fs.pathExists(path.join(targetPath, 'agents', 'my-agent.md')), 'Update: extension my-agent.md is ADDED by the overlay');
// Extension pm.md overrides the base version
const pmUpdated = await fs.readFile(path.join(targetPath, 'agents', 'pm.md'), 'utf8');
assert(pmUpdated.includes('pm-override'), 'Update: extension pm.md OVERRIDES base pm.md during overlay');
} finally {
await fs.remove(tmpDir);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Summary
// ─────────────────────────────────────────────────────────────────────────
console.log(`\n${colors.cyan}========================================${colors.reset}`);
console.log(`Results: ${colors.green}${passed} passed${colors.reset}, ${failed > 0 ? colors.red : ''}${failed} failed${colors.reset}`);
console.log(`${colors.cyan}========================================${colors.reset}\n`);
if (failed > 0) process.exit(1);
}
runTests().catch((error) => {
console.error(`${colors.red}Unexpected error:${colors.reset}`, error);
process.exit(1);
});

View File

@ -997,6 +997,45 @@ class Installer {
this.moduleManager.setCustomModulePaths(customModulePaths);
}
// Check if this custom module extends a built-in/external base module.
// If a base source exists at a different path, install it first so the
// extension merges on top instead of replacing the entire directory (#1667).
const customSourcePath = customModulePaths.get(moduleName);
// Temporarily remove the custom path so findModuleSource resolves the real
// base, including external official modules that getModulePath() misses.
customModulePaths.delete(moduleName);
this.moduleManager.setCustomModulePaths(customModulePaths);
let hasBaseModule = false;
try {
const baseSourcePath = await this.moduleManager.findModuleSource(moduleName, { silent: true });
hasBaseModule =
!!customSourcePath &&
!!baseSourcePath &&
baseSourcePath !== customSourcePath &&
(await fs.pathExists(path.join(baseSourcePath, 'module.yaml')));
if (hasBaseModule) {
// Install the base module first (clean install, removes any prior version).
// Custom path is already cleared above so findModuleSource resolves to the base.
if (resolution && resolution.byModule && resolution.byModule[moduleName]) {
await this.installModuleWithDependencies(moduleName, bmadDir, resolution.byModule[moduleName]);
} else {
await this.moduleManager.install(
moduleName,
bmadDir,
(filePath) => {
this.installedFiles.add(filePath);
},
{ installer: this, silent: true, moduleConfig: {} },
);
}
}
} finally {
// Always restore the custom path so the extension overlay resolves correctly.
customModulePaths.set(moduleName, customSourcePath);
this.moduleManager.setCustomModulePaths(customModulePaths);
}
const collectedModuleConfig = moduleConfigs[moduleName] || {};
await this.moduleManager.install(
moduleName,
@ -1006,6 +1045,7 @@ class Installer {
},
{
isCustom: true,
isExtension: hasBaseModule, // merge on top of base when extending
moduleConfig: collectedModuleConfig,
isQuickUpdate: isQuickUpdate,
installer: this,

View File

@ -453,7 +453,10 @@ class ModuleManager {
}
// Check if already installed
if (await fs.pathExists(targetPath)) {
// When isExtension is true, the caller is merging an extension module on top
// of an already-installed base module (same code). Skip removal so that base
// files are preserved and the extension's files overlay at the file level.
if ((await fs.pathExists(targetPath)) && !options.isExtension) {
await fs.remove(targetPath);
}