diff --git a/docs/how-to/monorepo-setup.md b/docs/how-to/monorepo-setup.md index dd89edb94..90d753f9c 100644 --- a/docs/how-to/monorepo-setup.md +++ b/docs/how-to/monorepo-setup.md @@ -11,7 +11,7 @@ BMAD supports running multiple independent projects within a single installation ## How It Works -A `.current_project` file in your `_bmad/` directory stores the active project name. When set, all artifact output paths are redirected to project-specific subdirectories. +A `.current_project` file in your `_bmad/` directory stores the active project name. This file is mutable local state: it should never be committed, because committing it can silently flip the active project context for other contributors and CI. Add `_bmad/.current_project` to your VCS ignore file, for example `.gitignore`. When set, all artifact output paths are redirected to project-specific subdirectories. ## Quick Start @@ -45,4 +45,4 @@ Temporarily target a different project without changing the global context: - Path traversal (`..`) is rejected - Absolute paths are rejected -- Only alphanumeric characters, dots, dashes, underscores, and forward slashes are allowed +- Only alphanumeric characters, dots, dashes, and underscores are allowed diff --git a/src/bmm/module.yaml b/src/bmm/module.yaml index 2ab00e71c..ca1cf3d58 100644 --- a/src/bmm/module.yaml +++ b/src/bmm/module.yaml @@ -58,8 +58,9 @@ directories: # 2. FILE FALLBACK: If no inline override, check if {project-root}/_bmad/.current_project exists. # If so, read its single-line content as project_suffix (trim whitespace/newlines). # 3. VALIDATE (if project_suffix is set): -# - REJECT if empty, contains '..', starts with '/' or drive letter (e.g. C:) -# - REJECT if fails whitelist regex: ^[a-zA-Z0-9._\-/]+$ +# - REJECT if empty, equals '.', contains '..', starts with '/' or drive letter (e.g. C:) +# - REJECT if it contains '/' or '\' because project_suffix must be a single directory name +# - REJECT if fails whitelist regex: ^[a-zA-Z0-9._-]+$ # - On rejection: output "Security Error: Invalid project context" and HALT # 4. OVERRIDE PATHS (if valid project_suffix): # - output_folder = {project-root}/_bmad-output/{project_suffix} @@ -77,6 +78,6 @@ monorepo_context: - "#project:" - "#p:" validation: - whitelist_regex: "^[a-zA-Z0-9._\\-/]+$" + whitelist_regex: "^[a-zA-Z0-9._-]+$" reject_traversal: true reject_absolute: true diff --git a/src/bmm/workflows/0-context/bmad-project-list/workflow.md b/src/bmm/workflows/0-context/bmad-project-list/workflow.md index b49bffce5..9f4d7b2f0 100644 --- a/src/bmm/workflows/0-context/bmad-project-list/workflow.md +++ b/src/bmm/workflows/0-context/bmad-project-list/workflow.md @@ -8,15 +8,17 @@ ### 1. Identify Projects -- Use your file listing or system capabilities to examine the `{project-root}/_bmad-output/` directory. -- Identify all subdirectories within this path. Each subdirectory represents an available project context, except: +- First check whether `{project-root}/_bmad-output/` exists and is readable. +- If it does not exist, or listing it returns an `ENOENT`-style not-found error, treat the available project list as empty and continue with only `default (root)`. +- If it exists, use your file listing or system capabilities to examine the `{project-root}/_bmad-output/` directory. +- Identify all direct child subdirectories within this path. Each subdirectory represents an available project context, except: - Ignore hidden directories starting with `.` - Ignore standard BMAD artifact directories: `planning-artifacts`, `implementation-artifacts`, `test-artifacts`, `knowledge`, `docs`, `assets` - The root `_bmad-output/` directory itself represents the `default (root)` unset project context. ### 2. Output Results -1. Display a formatted numbered list of the existing projects you found. +1. Display a formatted numbered list of the existing projects you found. If none exist yet, show only `default (root)`. 2. Clearly indicate the `default (root)` project context, usually as `[0]`. 3. Check whether an active project is currently set by reading the active project selector file in `{project-root}/_bmad/` (or as configured in `monorepo_context.context_file`). If it exists, indicate which project from the list is active, for example by adding `(ACTIVE)` next to the entry. 4. Inform the user that they can switch projects with PS (Project Switch) or create a new one with PN (Project New). diff --git a/src/bmm/workflows/0-context/bmad-project-new/workflow.md b/src/bmm/workflows/0-context/bmad-project-new/workflow.md index 210c343d4..0348f996d 100644 --- a/src/bmm/workflows/0-context/bmad-project-new/workflow.md +++ b/src/bmm/workflows/0-context/bmad-project-new/workflow.md @@ -20,13 +20,15 @@ The context file is the default hidden project selector file in `{project-root}/ - Ask: `What should the new project be called?` - Wait for input. 3. **Validate Project Name** - - **Cleanup**: Remove leading and trailing slashes and any occurrences of `_bmad-output/`. + - **Validate - No Absolute**: Check the raw user input before cleanup. Reject if it starts with `/`, starts with `\\`, or starts with a drive letter such as `C:`. + - **Cleanup**: If the raw value starts with `_bmad-output/`, remove that single leading prefix once. Then trim leading and trailing slashes. - **Validate - No Traversal**: Reject if path contains `..`. - - **Validate - No Absolute**: Reject if path starts with `/` or a drive letter such as `C:`. - - **Validate - Empty/Whitespace**: Reject if empty or only whitespace. - - **Validate - Whitelist**: Match against regex `^[-a-zA-Z0-9._/]+$`. + - **Validate - No Separators**: Reject if the sanitized value still contains `/` or `\`. Project names must be single directory names, not nested paths. + - **Validate - Empty/Whitespace**: Reject if empty, only whitespace, or exactly `.`. + - **Validate - Whitelist**: Match against regex `^[-a-zA-Z0-9._]+$`. + - **Validate - Reserved Names**: Reject if the sanitized value is any reserved BMAD artifact directory name: `planning-artifacts`, `implementation-artifacts`, `test-artifacts`, `knowledge`, `docs`, `assets`. - If invalid: - - Output: `Error: Invalid project name — must be a relative path and contain only alphanumeric characters, dots, dashes, underscores, or slashes. Traversal (..) is strictly forbidden.` + - Output: `Error: Invalid project name — use a single directory name containing only alphanumeric characters, dots, dashes, or underscores. Nested paths, reserved artifact names, absolute paths, and traversal (..) are forbidden.` - Halt. 4. **Check for Existing Project** - Check if `{project-root}/_bmad-output/` already exists. diff --git a/src/bmm/workflows/0-context/bmad-project-switch/workflow.md b/src/bmm/workflows/0-context/bmad-project-switch/workflow.md index f144d8ec7..9e323c3ce 100644 --- a/src/bmm/workflows/0-context/bmad-project-switch/workflow.md +++ b/src/bmm/workflows/0-context/bmad-project-switch/workflow.md @@ -17,9 +17,11 @@ The context file is the default hidden project selector file in `{project-root}/ 1. **Analyze Request**: Determine the requested project name from the user's initial invocation, such as "switch project my-app". 2. **Wait for Input (If Missing)**: If the user did not provide a project name: - - Use your file listing capabilities to examine the `{project-root}/_bmad-output/` directory. + - First check whether `{project-root}/_bmad-output/` exists and is readable. + - If it exists, use your file listing capabilities to examine that directory. - Output a formatted list of the existing projects you found, explicitly noting the `default (root)` project context. Exclude hidden directories and standard BMAD artifact folders: `planning-artifacts`, `implementation-artifacts`, `test-artifacts`, `knowledge`, `docs`, `assets`. + - If `{project-root}/_bmad-output/` does not exist or is unreadable because it is missing, show only the `default (root)` project context. - Present options in a numbered list format: ```text @@ -44,17 +46,19 @@ Enter a number to select, type an existing project name, or enter 'CLEAR' to res - If the number is out of range, show an error and ask again. - If valid, use that project name as the path. - **Case: Path Provided** (text input) - - **Cleanup**: Remove leading and trailing slashes and any occurrences of `_bmad-output/`. + - **Validate - No Absolute**: Check the raw user input before cleanup. Reject if it starts with `/`, starts with `\\`, or starts with a drive letter such as `C:`. + - **Cleanup**: If the raw value starts with `_bmad-output/`, remove that single leading prefix once. Then trim leading and trailing slashes. - **Validate - No Traversal**: Reject if path contains `..`. - - **Validate - No Absolute**: Reject if path starts with `/` or a drive letter such as `C:`. - - **Validate - Empty/Whitespace**: Reject if empty or only whitespace. - - **Validate - Whitelist**: Match against regex `^[-a-zA-Z0-9._/]+$`. + - **Validate - No Separators**: Reject if the sanitized value still contains `/` or `\`. Project names must be single directory names, not nested paths. + - **Validate - Empty/Whitespace**: Reject if empty, only whitespace, or exactly `.`. + - **Validate - Whitelist**: Match against regex `^[-a-zA-Z0-9._]+$`. + - **Validate - Reserved Names**: Reject if the sanitized value is any reserved BMAD artifact directory name: `planning-artifacts`, `implementation-artifacts`, `test-artifacts`, `knowledge`, `docs`, `assets`. - **Check Results** - If invalid: - - Output: `Error: Invalid project name — must be a relative path and contain only alphanumeric characters, dots, dashes, underscores, or slashes. Traversal (..) is strictly forbidden.` + - Output: `Error: Invalid project name — use a single directory name containing only alphanumeric characters, dots, dashes, or underscores. Nested paths, reserved artifact names, absolute paths, and traversal (..) are forbidden.` - Halt. - - **Validate Existence**: Check if `{project-root}/_bmad-output/` exists on disk. - - If it does not exist: + - **Validate Existence**: Check if `{project-root}/_bmad-output/` exists and is a directory. + - If it does not exist, or exists but is not a directory: - Output: `Error: Project does not exist. Use PN (Project New) to create it first, or PL (Project List) to see available projects.` - Halt. - Write the active project selector file in `{project-root}/_bmad/` with content `` diff --git a/test/test-installation-components.js b/test/test-installation-components.js index d927160f5..3a5e07588 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -1915,6 +1915,11 @@ async function runTests() { generatedConfig32.includes('inline_override_patterns:') && generatedConfig32.includes('- "#p:"'), 'Generated bmm config preserves monorepo override patterns', ); + assert(generatedConfig32.includes('context_file:'), 'Generated bmm config preserves monorepo context_file'); + assert(generatedConfig32.includes('reject_traversal: true'), 'Generated bmm config preserves traversal protection'); + assert(generatedConfig32.includes('reject_absolute: true'), 'Generated bmm config preserves absolute-path protection'); + assert(generatedConfig32.includes('whitelist_regex: "^[a-zA-Z0-9._-]+$"'), 'Generated bmm config preserves monorepo whitelist regex'); + assert(!generatedConfig32.includes('\ncurrent_project:'), 'Generated bmm config does not materialize mutable current project state'); } catch (error) { assert(false, 'Monorepo config generation test succeeds', error.message); } finally { @@ -1924,59 +1929,132 @@ async function runTests() { console.log(''); // ============================================================ - // Test 33: Context skills are discoverable as skills + // Test 33: Installer manages monorepo helper files safely // ============================================================ - console.log(`${colors.yellow}Test Suite 33: Context Skill Discovery${colors.reset}\n`); + console.log(`${colors.yellow}Test Suite 33: Installer Monorepo Helper Files${colors.reset}\n`); let tempFixture33; try { - tempFixture33 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-context-skills-')); - await fs.ensureDir(path.join(tempFixture33, '_config')); - await fs.ensureDir(path.join(tempFixture33, 'bmm', 'agents')); - await fs.writeFile(path.join(tempFixture33, 'bmm', 'agents', 'test.md'), 'p'); + tempFixture33 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-monorepo-helpers-')); + const installer33 = new Installer(); + const projectDir33 = path.join(tempFixture33, 'project'); + await fs.ensureDir(projectDir33); - const sourceContextDir33 = path.join(projectRoot, 'src', 'bmm', 'workflows', '0-context'); - const targetContextDir33 = path.join(tempFixture33, 'bmm', 'workflows', '0-context'); - await fs.copy(sourceContextDir33, targetContextDir33); + await installer33.ensureProjectGitignore(projectDir33); + const gitignore33 = await fs.readFile(path.join(projectDir33, '.gitignore'), 'utf8'); + assert(gitignore33.includes('_bmad/.current_project'), 'Installer creates a .gitignore entry for local monorepo state'); - const generator33 = new ManifestGenerator(); - await generator33.generateManifests(tempFixture33, ['bmm'], [], { ides: [] }); - - const newProjectSkill33 = generator33.skills.find((skill) => skill.canonicalId === 'bmad-project-new'); - const setProjectSkill33 = generator33.skills.find((skill) => skill.canonicalId === 'bmad-project-switch'); - const listProjectsSkill33 = generator33.skills.find((skill) => skill.canonicalId === 'bmad-project-list'); - - assert(newProjectSkill33 !== undefined, 'New project skill appears in skills[]'); - assert(setProjectSkill33 !== undefined, 'Set project context skill appears in skills[]'); - assert(listProjectsSkill33 !== undefined, 'List projects skill appears in skills[]'); + await installer33.ensureProjectGitignore(projectDir33); + const gitignore33Repeat = await fs.readFile(path.join(projectDir33, '.gitignore'), 'utf8'); assert( - newProjectSkill33 && newProjectSkill33.path.includes('workflows/0-context/bmad-project-new/SKILL.md'), + gitignore33Repeat.match(/^_bmad\/\.current_project$/gm)?.length === 1, + 'Installer does not duplicate the .gitignore entry on repeat runs', + ); + + const syntheticModule33 = `header: test + +# --- Monorepo / Multi-Project Context Support --- +monorepo_context: + enabled: true + context_file: ".current_project" + +# --- Future Section --- +future_key: true +`; + + const extractedBlock33 = installer33.extractMonorepoContextBlock(syntheticModule33); + assert(extractedBlock33 && extractedBlock33.includes('monorepo_context:'), 'Monorepo block extraction keeps the monorepo section'); + assert(extractedBlock33 && !extractedBlock33.includes('future_key: true'), 'Monorepo block extraction stops before later sections'); + assert( + installer33.extractMonorepoContextBlock('header: test\n# --- Monorepo / Multi-Project Context Support ---\n# comment only\n') === + null, + 'Monorepo block extraction ignores comment-only sections without a top-level monorepo_context key', + ); + + const customModuleDir33 = path.join(tempFixture33, 'custom-bmm'); + await fs.ensureDir(customModuleDir33); + await fs.writeFile(path.join(customModuleDir33, 'module.yaml'), syntheticModule33, 'utf8'); + installer33.moduleManager.setCustomModulePaths(new Map([['custom-bmm', customModuleDir33]])); + const resolvedSchemaPath33 = await installer33.resolveModuleSchemaPath('custom-bmm'); + assert( + resolvedSchemaPath33 === path.join(customModuleDir33, 'module.yaml'), + 'resolveModuleSchemaPath prefers live moduleManager custom module paths', + ); + + await fs.ensureDir(path.join(tempFixture33, 'custom-bmm')); + await installer33.generateModuleConfigs(tempFixture33, { core: {} }); + const generatedConfig33 = await fs.readFile(path.join(tempFixture33, 'custom-bmm', 'config.yaml'), 'utf8'); + assert(generatedConfig33.includes('monorepo_context:'), 'Static config blocks are still emitted for custom modules'); + assert( + !generatedConfig33.includes('\n{}\n'), + 'Static config blocks replace serialized empty-object YAML instead of appending after {}', + ); + } catch (error) { + assert(false, 'Installer monorepo helper-file test succeeds', error.message); + } finally { + if (tempFixture33) await fs.remove(tempFixture33).catch(() => {}); + } + + console.log(''); + + // ============================================================ + // Test 34: Context skills are discoverable as skills + // ============================================================ + console.log(`${colors.yellow}Test Suite 34: Context Skill Discovery${colors.reset}\n`); + + let tempFixture34; + try { + tempFixture34 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-context-skills-')); + await fs.ensureDir(path.join(tempFixture34, '_config')); + await fs.ensureDir(path.join(tempFixture34, 'bmm', 'agents')); + await fs.writeFile(path.join(tempFixture34, 'bmm', 'agents', 'test.md'), 'p'); + + const sourceContextDir34 = path.join(projectRoot, 'src', 'bmm', 'workflows', '0-context'); + const targetContextDir34 = path.join(tempFixture34, 'bmm', 'workflows', '0-context'); + await fs.copy(sourceContextDir34, targetContextDir34); + + const generator34 = new ManifestGenerator(); + await generator34.generateManifests(tempFixture34, ['bmm'], [], { ides: [] }); + + const newProjectSkill34 = generator34.skills.find((skill) => skill.canonicalId === 'bmad-project-new'); + const setProjectSkill34 = generator34.skills.find((skill) => skill.canonicalId === 'bmad-project-switch'); + const listProjectsSkill34 = generator34.skills.find((skill) => skill.canonicalId === 'bmad-project-list'); + + assert(newProjectSkill34 !== undefined, 'New project skill appears in skills[]'); + assert(setProjectSkill34 !== undefined, 'Set project context skill appears in skills[]'); + assert(listProjectsSkill34 !== undefined, 'List projects skill appears in skills[]'); + assert( + newProjectSkill34 && newProjectSkill34.path.includes('workflows/0-context/bmad-project-new/SKILL.md'), 'New project skill keeps its workflow directory path', ); assert( - setProjectSkill33 && setProjectSkill33.path.includes('workflows/0-context/bmad-project-switch/SKILL.md'), + setProjectSkill34 && setProjectSkill34.path.includes('workflows/0-context/bmad-project-switch/SKILL.md'), 'Set project skill keeps its workflow directory path', ); assert( - listProjectsSkill33 && listProjectsSkill33.path.includes('workflows/0-context/bmad-project-list/SKILL.md'), + listProjectsSkill34 && listProjectsSkill34.path.includes('workflows/0-context/bmad-project-list/SKILL.md'), 'List projects skill keeps its workflow directory path', ); assert( - !generator33.workflows.some((workflow) => workflow.path.includes('0-context/bmad-project-new')), + !generator34.workflows.some((workflow) => workflow.path.includes('0-context/bmad-project-new')), 'New project skill does not appear in workflows[]', ); assert( - !generator33.workflows.some((workflow) => workflow.path.includes('0-context/bmad-project-switch')), + !generator34.workflows.some((workflow) => workflow.path.includes('0-context/bmad-project-switch')), 'Set project context skill does not appear in workflows[]', ); assert( - !generator33.workflows.some((workflow) => workflow.path.includes('0-context/bmad-project-list')), + !generator34.workflows.some((workflow) => workflow.path.includes('0-context/bmad-project-list')), 'List projects skill does not appear in workflows[]', ); + assert( + !generator34.skills.some((skill) => skill.canonicalId === '.current_project'), + 'Manifest generation is unchanged when no mutable current-project file exists in the fixture', + ); } catch (error) { assert(false, 'Context skill discovery test succeeds', error.message); } finally { - if (tempFixture33) await fs.remove(tempFixture33).catch(() => {}); + if (tempFixture34) await fs.remove(tempFixture34).catch(() => {}); } console.log(''); diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index 6426f74a3..c835f98fa 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -816,6 +816,7 @@ class Installer { // Create bmad directory structure spinner.message('Creating directory structure...'); await this.createDirectoryStructure(bmadDir); + await this.ensureProjectGitignore(projectDir); // Cache custom modules if any if (customModulePaths && customModulePaths.size > 0) { @@ -1455,6 +1456,8 @@ class Installer { throw new Error(`No BMAD installation found at ${bmadDir}`); } + await this.ensureProjectGitignore(projectDir); + spinner.message('Analyzing update requirements...'); // Compare versions and determine what needs updating @@ -1981,6 +1984,35 @@ class Installer { await fs.ensureDir(path.join(bmadDir, '_config', 'custom')); } + /** + * Ensure local-only BMAD state is ignored by version control. + * @param {string} projectDir - Project root directory + */ + async ensureProjectGitignore(projectDir) { + const gitignorePath = path.join(projectDir, '.gitignore'); + const ignoreEntry = `${BMAD_FOLDER_NAME}/.current_project`; + + let existingContent = ''; + if (await fs.pathExists(gitignorePath)) { + existingContent = await fs.readFile(gitignorePath, 'utf8'); + } + + const existingLines = new Set( + existingContent + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean), + ); + + if (existingLines.has(ignoreEntry) || existingLines.has(`/${ignoreEntry}`)) { + return; + } + + const separator = existingContent === '' || existingContent.endsWith('\n') ? '' : '\n'; + const nextContent = `${existingContent}${separator}${ignoreEntry}\n`; + await fs.writeFile(gitignorePath, nextContent, 'utf8'); + } + /** * Generate clean config.yaml files for each installed module * @param {string} bmadDir - BMAD installation directory @@ -2069,8 +2101,13 @@ class Installer { const staticConfigBlocks = await this.getStaticConfigBlocks(moduleName); if (staticConfigBlocks.length > 0) { - yamlContent = yamlContent.trimEnd(); - yamlContent += `\n\n${staticConfigBlocks.join('\n\n')}\n`; + const trimmedYamlContent = yamlContent.trim(); + if (trimmedYamlContent === '' || trimmedYamlContent === '{}') { + yamlContent = `${staticConfigBlocks.join('\n\n')}\n`; + } else { + yamlContent = yamlContent.trimEnd(); + yamlContent += `\n\n${staticConfigBlocks.join('\n\n')}\n`; + } } // Write the clean config file with POSIX-compliant final newline @@ -2106,6 +2143,10 @@ class Installer { * @returns {Promise} Path to module.yaml or null if not found */ async resolveModuleSchemaPath(moduleName) { + if (this.moduleManager.customModulePaths?.has(moduleName)) { + return path.join(this.moduleManager.customModulePaths.get(moduleName), 'module.yaml'); + } + if (this.configCollector.customModulePaths?.has(moduleName)) { return path.join(this.configCollector.customModulePaths.get(moduleName), 'module.yaml'); } @@ -2125,17 +2166,17 @@ class Installer { * @returns {string|null} Preserved block or null when absent */ extractMonorepoContextBlock(content) { - // Capture from the header comment through to the end of the monorepo_context YAML block. - // The block is expected at the end of the file, after any other config sections. const headerIndex = content.indexOf('# --- Monorepo / Multi-Project Context Support ---'); if (headerIndex === -1) { return null; } - const block = content.slice(headerIndex).trimEnd(); + const afterHeader = content.slice(headerIndex); + const nextSectionMatch = afterHeader.slice(1).match(/\n# --- .+ ---/); + const endIndex = nextSectionMatch ? headerIndex + 1 + nextSectionMatch.index : content.length; + const block = content.slice(headerIndex, endIndex).trimEnd(); - // Sanity check: the block must contain the structured YAML key - if (!block.includes('monorepo_context:')) { + if (!/^\s*monorepo_context\s*:/m.test(block)) { return null; }