Compare commits

...

7 Commits

Author SHA1 Message Date
Noam Brendel 77812dd3c2
Merge 36dfb0a612 into 28e6dded4d 2026-01-18 10:40:37 -06:00
Brian 36dfb0a612
Merge branch 'main' into fix/1288-missing-code-field-validation 2026-01-18 10:40:35 -06:00
Brian Madison 28e6dded4d installation for remote modules now indicates its getching or installing so it does not appear to be hung when caching the remote in the local npm cache 2026-01-18 08:11:35 -06:00
Brian Madison 966ca5db0b indicator when external modules are being downloaded during install so installer does not appear to be frozen / unresponsive. 2026-01-18 02:16:25 -06:00
cx-noam-brendel 1d49e045db fix: exit with code 1 when no test fixtures found
Per CodeRabbit review - quality gate scripts should fail on unexpected states
2026-01-15 08:49:06 +02:00
cx-noam-brendel 7851fd8b80 fix: address PR review comments
Changes per reviewer feedback:
- Remove ⚠️ CRITICAL warnings from documentation (per @alexeyv)
- Replace placeholder YAML examples with concrete values
- Simplify validation checklist text

Technical fixes per CodeRabbit:
- Remove .strict() that contradicted .passthrough()
- Add isCoreModule parameter for conditional default_selected validation
- Enforce mutual exclusivity: inherit/prompt and single-select/multi-select
- Use project_root for consistent path reporting
- Harden test runner with guarded access for error.issues
- Move parseTestMetadata inside try/catch block

All tests pass (52 agent + 17 module + 13 installation)
2026-01-15 07:23:52 +02:00
cx-noam-brendel d83ce5e21f fix(bmb): add module.yaml schema validation and fix #1288
## What
- Add Zod schema validation for module.yaml files
- Create test suite for module schema with 17 test fixtures
- Fix create-module workflow to make 'code' field requirements clearer

## Why
Fixes #1288 - The create-module workflow could produce module.yaml files
without the required 'code' field or with placeholder text like '{module_code}'.

## How
1. Created tools/schema/module.js with Zod validation for:
   - Required fields: code, name, header, subheader
   - Code format: kebab-case, 2-20 chars, starts with letter
   - Variable definitions with prompt/inherit patterns

2. Created tools/validate-module-schema.js CLI validator

3. Added npm scripts:
   - validate:modules - validates all module.yaml files
   - test:modules - runs module schema test suite

4. Updated BMB workflow instructions with explicit warnings about
   required fields in step-03-config.md and step-03-module-yaml.md

5. Added 17 test fixtures covering:
   - Missing required fields (code, name, header, subheader)
   - Invalid code formats (placeholder, uppercase, underscore, etc.)
   - Variable definitions (prompt, inherit, single-select, multi-select)

## Testing
- npm run test:modules - 17/17 tests pass
- npm run validate:modules - All 5 existing modules pass
- npm test - Full test suite passes
2026-01-14 13:28:41 +02:00
29 changed files with 1092 additions and 140 deletions

View File

@ -45,10 +45,12 @@
"release:minor": "gh workflow run \"Manual Release\" -f version_bump=minor",
"release:patch": "gh workflow run \"Manual Release\" -f version_bump=patch",
"release:watch": "gh run watch",
"test": "npm run test:schemas && npm run test:install && npm run validate:schemas && npm run lint && npm run lint:md && npm run format:check",
"test": "npm run test:schemas && npm run test:modules && npm run test:install && npm run validate:schemas && npm run validate:modules && npm run lint && npm run lint:md && npm run format:check",
"test:coverage": "c8 --reporter=text --reporter=html npm run test:schemas",
"test:install": "node test/test-installation-components.js",
"test:modules": "node test/test-module-schema.js",
"test:schemas": "node test/test-agent-schema.js",
"validate:modules": "node tools/validate-module-schema.js",
"validate:schemas": "node tools/validate-agent-schema.js"
},
"lint-staged": {

View File

@ -1,7 +1,6 @@
code: bmb
name: "BMB: BMad Builder - Agent, Workflow and Module Builder"
header: "BMad Optimized Builder (BoMB) Module Configuration"
subheader: "Configure the settings for the BoMB Factory!\nThe agent, workflow and module builder for BMad™ "
name: "BMad Builder (BoMB!)"
description: "Agent, Workflow and Module Builder"
default_selected: false # This module will not be selected by default for new installations
# Variables from Core Config inserted:

View File

@ -42,13 +42,13 @@ Load `{moduleYamlConventionsFile}` for reference.
Create `{targetLocation}/module.yaml` with:
**Required fields:**
**Required fields (replace with actual values):**
```yaml
code: {module_code}
name: "{module_display_name}"
header: "{brief_header}"
subheader: "{additional_context}"
default_selected: false
code: "my-module" # kebab-case, 2-20 chars, starts with letter
name: "My Module: Description" # human-readable name
header: "One-line summary" # one-line summary
subheader: "Additional context" # additional context
default_selected: false # typically false for new modules
```
**Note for Extension modules:** `code:` matches base module

View File

@ -38,11 +38,11 @@ Read `{targetPath}/module.yaml`
### 2. Validate Required Fields
Check for required frontmatter:
- [ ] `code:` present and valid (kebab-case, 2-20 chars)
- [ ] `name:` present
- [ ] `header:` present
- [ ] `subheader:` present
Check required fields (must have actual values, not placeholders):
- [ ] `code:` present (kebab-case, 2-20 chars, starts with letter)
- [ ] `name:` present (non-empty string)
- [ ] `header:` present (non-empty string)
- [ ] `subheader:` present (non-empty string)
- [ ] `default_selected:` present (boolean)
### 3. Validate Custom Variables

View File

@ -1,7 +1,6 @@
code: bmm
name: "BMM: BMad Method Agile-AI Driven-Development"
header: "BMad Method™: Breakthrough Method of Agile-Ai Driven-Dev"
subheader: "Agent and Workflow Configuration for this module"
name: "BMad Method Agile-AI Driven-Development"
description: "AI-driven agile development framework"
default_selected: true # This module will be selected by default for new installations
# Variables from Core Config inserted:

View File

@ -0,0 +1,11 @@
# Test: Code field starts with a number
# Expected: FAIL
# Error code: invalid_string
# Error path: code
code: 123-module
name: Test Module
header: Test Header
subheader: Test Subheader
default_selected: false

View File

@ -0,0 +1,11 @@
# Test: Code field contains placeholder text (the main bug we're fixing)
# Expected: FAIL
# Error code: invalid_string
# Error path: code
code: "{module_code}"
name: Test Module
header: Test Header
subheader: Test Subheader
default_selected: false

View File

@ -0,0 +1,11 @@
# Test: Code field too short (minimum 2 characters)
# Expected: FAIL
# Error code: invalid_string
# Error path: code
code: x
name: Test Module
header: Test Header
subheader: Test Subheader
default_selected: false

View File

@ -0,0 +1,11 @@
# Test: Code field with underscores (should be kebab-case)
# Expected: FAIL
# Error code: invalid_string
# Error path: code
code: test_module
name: Test Module
header: Test Header
subheader: Test Subheader
default_selected: false

View File

@ -0,0 +1,11 @@
# Test: Code field with uppercase letters
# Expected: FAIL
# Error code: invalid_string
# Error path: code
code: TestModule
name: Test Module
header: Test Header
subheader: Test Subheader
default_selected: false

View File

@ -0,0 +1,11 @@
# Test: Missing required code field
# Expected: FAIL
# Error code: invalid_type
# Error path: code
# Error message: Required
name: Test Module
header: Test Header
subheader: Test Subheader
default_selected: false

View File

@ -0,0 +1,11 @@
# Test: Missing required header field
# Expected: FAIL
# Error code: invalid_type
# Error path: header
# Error message: Required
code: test-module
name: Test Module
subheader: Test Subheader
default_selected: false

View File

@ -0,0 +1,11 @@
# Test: Missing required name field
# Expected: FAIL
# Error code: invalid_type
# Error path: name
# Error message: Required
code: test-module
header: Test Header
subheader: Test Subheader
default_selected: false

View File

@ -0,0 +1,11 @@
# Test: Missing required subheader field
# Expected: FAIL
# Error code: invalid_type
# Error path: subheader
# Error message: Required
code: test-module
name: Test Module
header: Test Header
default_selected: false

View File

@ -0,0 +1,15 @@
# Test: Variable with empty prompt
# Expected: FAIL
# Error code: custom
# Error path: my_variable
# Error message: my_variable.prompt must be a non-empty string
code: test-module
name: Test Module
header: Test Header
subheader: Test Subheader
default_selected: false
my_variable:
prompt: " "

View File

@ -0,0 +1,16 @@
# Test: Variable without prompt or inherit
# Expected: FAIL
# Error code: custom
# Error path: my_variable
# Error message: my_variable must have a 'prompt' or 'inherit' field
code: test-module
name: Test Module
header: Test Header
subheader: Test Subheader
default_selected: false
my_variable:
required: true
result: some result

View File

@ -0,0 +1,9 @@
# Test: Valid module with default_selected set to true
# Expected: PASS
code: core-like
name: Core Module
header: Core Header
subheader: Module with default_selected true
default_selected: true

View File

@ -0,0 +1,9 @@
# Test: Valid module with only required fields
# Expected: PASS
code: my-module
name: My Module Name
header: Module Header
subheader: Short description of what this module does
default_selected: false

View File

@ -0,0 +1,9 @@
# Test: Valid module with minimum 2-character code
# Expected: PASS
code: ab
name: Two Letter Module
header: Short Code Header
subheader: The shortest valid code is 2 characters
default_selected: true

View File

@ -0,0 +1,12 @@
# Test: Valid module with inherit variable
# Expected: PASS
code: inherit-mod
name: Inherit Module
header: Module With Inherited Variables
subheader: Uses inherit instead of prompt
default_selected: false
inherited_var:
inherit: other-module.some_var

View File

@ -0,0 +1,14 @@
# Test: Valid module with variable definition (prompt style)
# Expected: PASS
code: var-module
name: Variable Module
header: Module With Variables
subheader: Demonstrates variable definitions
default_selected: false
project_name:
prompt: What is your project name?
required: true
result: The project name is {project_name}

View File

@ -0,0 +1,31 @@
# Test: Valid module with single-select and multi-select
# Expected: PASS
code: select-mod
name: Select Module
header: Module With Selects
subheader: Demonstrates single-select and multi-select
default_selected: false
environment:
prompt: Select your target environment
single-select:
- value: dev
label: Development
- value: staging
label: Staging
- value: prod
label: Production
features:
prompt:
- What features do you want?
- You can select multiple options.
multi-select:
- value: auth
label: Authentication
- value: api
label: REST API
- value: db
label: Database

345
test/test-module-schema.js Normal file
View File

@ -0,0 +1,345 @@
/**
* Module Schema Validation Test Runner
*
* Runs all test fixtures and verifies expected outcomes.
* Reports pass/fail for each test and overall coverage statistics.
*
* Usage: node test/test-module-schema.js
* Exit codes: 0 = all tests pass, 1 = test failures
*/
const fs = require('node:fs');
const path = require('node:path');
const yaml = require('yaml');
const { validateModuleFile } = require('../tools/schema/module.js');
const { glob } = require('glob');
// ANSI color codes
const colors = {
reset: '\u001B[0m',
green: '\u001B[32m',
red: '\u001B[31m',
yellow: '\u001B[33m',
blue: '\u001B[34m',
cyan: '\u001B[36m',
dim: '\u001B[2m',
};
/**
* Parse test metadata from YAML comments
* @param {string} filePath
* @returns {{shouldPass: boolean, errorExpectation?: object}}
*/
function parseTestMetadata(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split('\n');
let shouldPass = true;
const errorExpectation = {};
for (const line of lines) {
if (line.includes('Expected: PASS')) {
shouldPass = true;
} else if (line.includes('Expected: FAIL')) {
shouldPass = false;
}
// Parse error metadata
const codeMatch = line.match(/^# Error code: (.+)$/);
if (codeMatch) {
errorExpectation.code = codeMatch[1].trim();
}
const pathMatch = line.match(/^# Error path: (.+)$/);
if (pathMatch) {
errorExpectation.path = pathMatch[1].trim();
}
const messageMatch = line.match(/^# Error message: (.+)$/);
if (messageMatch) {
errorExpectation.message = messageMatch[1].trim();
}
const minimumMatch = line.match(/^# Error minimum: (\d+)$/);
if (minimumMatch) {
errorExpectation.minimum = parseInt(minimumMatch[1], 10);
}
const expectedMatch = line.match(/^# Error expected: (.+)$/);
if (expectedMatch) {
errorExpectation.expected = expectedMatch[1].trim();
}
const receivedMatch = line.match(/^# Error received: (.+)$/);
if (receivedMatch) {
errorExpectation.received = receivedMatch[1].trim();
}
const keysMatch = line.match(/^# Error keys: \[(.+)\]$/);
if (keysMatch) {
errorExpectation.keys = keysMatch[1].split(',').map((k) => k.trim().replaceAll(/['"]/g, ''));
}
}
return {
shouldPass,
errorExpectation: Object.keys(errorExpectation).length > 0 ? errorExpectation : null,
};
}
/**
* Convert dot-notation path string to array (handles array indices)
* e.g., "module.dependencies[0]" => ["module", "dependencies", 0]
*/
function parsePathString(pathString) {
return pathString
.replaceAll(/\[(\d+)\]/g, '.$1')
.split('.')
.map((part) => {
const num = parseInt(part, 10);
return isNaN(num) ? part : num;
});
}
/**
* Validate error against expectations
* @param {object} error - Zod error issue
* @param {object} expectation - Expected error structure
* @returns {{valid: boolean, reason?: string}}
*/
function validateError(error, expectation) {
if (expectation.code && error.code !== expectation.code) {
return { valid: false, reason: `Expected code "${expectation.code}", got "${error.code}"` };
}
if (expectation.path) {
const expectedPath = parsePathString(expectation.path);
const actualPath = error.path;
if (JSON.stringify(expectedPath) !== JSON.stringify(actualPath)) {
return {
valid: false,
reason: `Expected path ${JSON.stringify(expectedPath)}, got ${JSON.stringify(actualPath)}`,
};
}
}
if (expectation.code === 'custom' && expectation.message && error.message !== expectation.message) {
return {
valid: false,
reason: `Expected message "${expectation.message}", got "${error.message}"`,
};
}
if (expectation.minimum !== undefined && error.minimum !== expectation.minimum) {
return { valid: false, reason: `Expected minimum ${expectation.minimum}, got ${error.minimum}` };
}
if (expectation.expected && error.expected !== expectation.expected) {
return { valid: false, reason: `Expected type "${expectation.expected}", got "${error.expected}"` };
}
if (expectation.received && error.received !== expectation.received) {
return { valid: false, reason: `Expected received "${expectation.received}", got "${error.received}"` };
}
if (expectation.keys) {
const expectedKeys = expectation.keys.sort();
const actualKeys = (error.keys || []).sort();
if (JSON.stringify(expectedKeys) !== JSON.stringify(actualKeys)) {
return {
valid: false,
reason: `Expected keys ${JSON.stringify(expectedKeys)}, got ${JSON.stringify(actualKeys)}`,
};
}
}
return { valid: true };
}
/**
* Run a single test case
* @param {string} filePath
* @returns {{passed: boolean, message: string}}
*/
function runTest(filePath) {
try {
const metadata = parseTestMetadata(filePath);
const { shouldPass, errorExpectation } = metadata;
const fileContent = fs.readFileSync(filePath, 'utf8');
let moduleData;
try {
moduleData = yaml.parse(fileContent);
} catch (parseError) {
if (shouldPass) {
return {
passed: false,
message: `Expected PASS but got YAML parse error: ${parseError.message}`,
};
}
return {
passed: true,
message: 'Got expected YAML parse error',
};
}
const result = validateModuleFile(filePath, moduleData);
if (result.success && shouldPass) {
return {
passed: true,
message: 'Validation passed as expected',
};
}
if (!result.success && !shouldPass) {
const actualError = result.error?.issues?.[0];
if (!actualError) {
return {
passed: false,
message: 'Expected validation error issues, but validator returned none',
};
}
if (errorExpectation) {
const validation = validateError(actualError, errorExpectation);
if (!validation.valid) {
return {
passed: false,
message: `Error validation failed: ${validation.reason}`,
};
}
return {
passed: true,
message: `Got expected error (${errorExpectation.code}): ${actualError.message}`,
};
}
return {
passed: true,
message: `Got expected validation error: ${actualError.message}`,
};
}
if (result.success && !shouldPass) {
return {
passed: false,
message: 'Expected validation to FAIL but it PASSED',
};
}
if (!result.success && shouldPass) {
return {
passed: false,
message: `Expected validation to PASS but it FAILED: ${result.error?.issues?.[0]?.message ?? 'Unknown error'}`,
};
}
return {
passed: false,
message: 'Unexpected test state',
};
} catch (error) {
return {
passed: false,
message: `Test execution error: ${error.message}`,
};
}
}
/**
* Main test runner
*/
async function main() {
console.log(`${colors.cyan}╔═══════════════════════════════════════════════════════════╗${colors.reset}`);
console.log(`${colors.cyan}║ Module Schema Validation Test Suite ║${colors.reset}`);
console.log(`${colors.cyan}╚═══════════════════════════════════════════════════════════╝${colors.reset}\n`);
const testFiles = await glob('test/fixtures/module-schema/**/*.module.yaml', {
cwd: path.join(__dirname, '..'),
absolute: true,
});
if (testFiles.length === 0) {
console.log(`${colors.yellow}⚠️ No test fixtures found${colors.reset}`);
process.exit(1);
}
console.log(`Found ${colors.cyan}${testFiles.length}${colors.reset} test fixture(s)\n`);
// Group tests by category
const categories = {};
for (const testFile of testFiles) {
const relativePath = path.relative(path.join(__dirname, 'fixtures/module-schema'), testFile);
const parts = relativePath.split(path.sep);
const validInvalid = parts[0]; // 'valid' or 'invalid'
const category = parts[1] || 'general';
const categoryKey = `${validInvalid}/${category}`;
if (!categories[categoryKey]) {
categories[categoryKey] = [];
}
categories[categoryKey].push(testFile);
}
// Run tests by category
let totalTests = 0;
let passedTests = 0;
const failures = [];
for (const [categoryKey, files] of Object.entries(categories).sort()) {
const [validInvalid, category] = categoryKey.split('/');
const categoryLabel = category.replaceAll('-', ' ').toUpperCase();
const validLabel = validInvalid === 'valid' ? '✅' : '❌';
console.log(`${colors.blue}${validLabel} ${categoryLabel} (${validInvalid})${colors.reset}`);
for (const testFile of files) {
totalTests++;
const testName = path.basename(testFile, '.module.yaml');
const result = runTest(testFile);
if (result.passed) {
passedTests++;
console.log(` ${colors.green}${colors.reset} ${testName} ${colors.dim}${result.message}${colors.reset}`);
} else {
console.log(` ${colors.red}${colors.reset} ${testName} ${colors.red}${result.message}${colors.reset}`);
failures.push({
file: path.relative(process.cwd(), testFile),
message: result.message,
});
}
}
console.log('');
}
// Summary
console.log(`${colors.cyan}═══════════════════════════════════════════════════════════${colors.reset}`);
console.log(`${colors.cyan}Test Results:${colors.reset}`);
console.log(` Total: ${totalTests}`);
console.log(` Passed: ${colors.green}${passedTests}${colors.reset}`);
console.log(` Failed: ${passedTests === totalTests ? colors.green : colors.red}${totalTests - passedTests}${colors.reset}`);
console.log(`${colors.cyan}═══════════════════════════════════════════════════════════${colors.reset}\n`);
if (failures.length > 0) {
console.log(`${colors.red}❌ FAILED TESTS:${colors.reset}\n`);
for (const failure of failures) {
console.log(`${colors.red}${colors.reset} ${failure.file}`);
console.log(` ${failure.message}\n`);
}
process.exit(1);
}
console.log(`${colors.green}✨ All tests passed!${colors.reset}\n`);
process.exit(0);
}
// Run
main().catch((error) => {
console.error(`${colors.red}Fatal error:${colors.reset}`, error);
process.exit(1);
});

View File

@ -6,28 +6,25 @@ modules:
url: https://github.com/bmad-code-org/bmad-module-creative-intelligence-suite
module-definition: src/module.yaml
code: cis
name: "CIS: Creative Innovation Suite"
header: "CIS: Creative Innovation Suite"
subheader: "Unleash your creativity with the BMad CIS!"
description: ""
name: "Creative Innovation Suite"
description: "Creative tools for writing, brainstorming, and more"
defaultSelected: false
type: bmad-org
bmad-game-dev-studio:
url: https://github.com/bmad-code-org/bmad-module-game-dev-studio.git
module-definition: src/module.yaml
code: BGDS
name: "BGDS: BMad Game Dev Suite"
header: "BGDS: BMad Game Dev Suite"
subheader: "Explore and create the groundwork for your game ideas with the BMad Game Dev suite!"
description: "Very similar to the BMad Method - but a focus on the slightly different needs of Game Development - with multiple platforms and game type specifics included to explore"
name: "BMad Game Dev Suite"
description: "Game development agents and workflows"
defaultSelected: false
type: bmad-org
# bmad-whiteport-design-system:
# url: https://github.com/bmad-code-org/bmad-method-wds-expansion
# module-definition: src/module.yaml
# code: WDS
# name: "WDS: Whiteport UX Design System"
# header: "WDS: Whiteport UX Design System"
# subheader: "Professional Designer UX Design Module Expansion to the BMad MEthod"
# description: "Experienced UX Designers can leverage the WDS with or without the BMad Method to harness their existing skills and tools (such as figma) while also utilizing an industry leading design framework."
# defaultSelected: false
whiteport-design-system:
url: https://github.com/bmad-code-org/bmad-method-wds-expansion
module-definition: src/module.yaml
code: WDS
name: "Whiteport UX Design System"
description: "UX design framework with Figma integration"
defaultSelected: false
type: community

View File

@ -53,6 +53,7 @@ class ExternalModuleManager {
subheader: moduleConfig.subheader,
description: moduleConfig.description || '',
defaultSelected: moduleConfig.defaultSelected === true,
type: moduleConfig.type || 'community', // bmad-org or community
isExternal: true,
});
}
@ -93,6 +94,7 @@ class ExternalModuleManager {
subheader: moduleConfig.subheader,
description: moduleConfig.description || '',
defaultSelected: moduleConfig.defaultSelected === true,
type: moduleConfig.type || 'community', // bmad-org or community
isExternal: true,
};
}

View File

@ -2,6 +2,7 @@ const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('yaml');
const chalk = require('chalk');
const ora = require('ora');
const { XmlHandler } = require('../../../lib/xml-handler');
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
const { filterCustomizationData } = require('../../../lib/agent/compiler');
@ -414,27 +415,46 @@ class ModuleManager {
// Create cache directory if it doesn't exist
await fs.ensureDir(cacheDir);
// Track if we need to install dependencies
let needsDependencyInstall = false;
let wasNewClone = false;
// Check if already cloned
if (await fs.pathExists(moduleCacheDir)) {
// Try to update if it's a git repo
const fetchSpinner = ora(`Fetching ${moduleInfo.name}...`).start();
try {
const currentRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
execSync('git fetch --depth 1', { cwd: moduleCacheDir, stdio: 'pipe' });
execSync('git checkout -f', { cwd: moduleCacheDir, stdio: 'pipe' });
execSync('git pull --ff-only', { cwd: moduleCacheDir, stdio: 'pipe' });
const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
fetchSpinner.succeed(`Fetched ${moduleInfo.name}`);
// Force dependency install if we got new code
if (currentRef !== newRef) {
needsDependencyInstall = true;
}
} catch {
fetchSpinner.warn(`Fetch failed, re-downloading ${moduleInfo.name}`);
// If update fails, remove and re-clone
await fs.remove(moduleCacheDir);
wasNewClone = true;
}
} else {
wasNewClone = true;
}
// Clone if not exists or was removed
if (!(await fs.pathExists(moduleCacheDir))) {
console.log(chalk.dim(` Cloning external module: ${moduleInfo.name}`));
if (wasNewClone) {
const fetchSpinner = ora(`Fetching ${moduleInfo.name}...`).start();
try {
execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
stdio: 'pipe',
});
fetchSpinner.succeed(`Fetched ${moduleInfo.name}`);
} catch (error) {
fetchSpinner.fail(`Failed to fetch ${moduleInfo.name}`);
throw new Error(`Failed to clone external module '${moduleCode}': ${error.message}`);
}
}
@ -444,11 +464,25 @@ class ModuleManager {
const nodeModulesPath = path.join(moduleCacheDir, 'node_modules');
if (await fs.pathExists(packageJsonPath)) {
// Install if node_modules doesn't exist, or if package.json is newer (dependencies changed)
const needsInstall = !(await fs.pathExists(nodeModulesPath));
let packageJsonNewer = false;
const nodeModulesMissing = !(await fs.pathExists(nodeModulesPath));
if (!needsInstall) {
// Force install if we updated or cloned new
if (needsDependencyInstall || wasNewClone || nodeModulesMissing) {
const installSpinner = ora(`Installing dependencies for ${moduleInfo.name}...`).start();
try {
execSync('npm install --production --no-audit --no-fund --prefer-offline --no-progress', {
cwd: moduleCacheDir,
stdio: 'pipe',
timeout: 120_000, // 2 minute timeout
});
installSpinner.succeed(`Installed dependencies for ${moduleInfo.name}`);
} catch (error) {
installSpinner.warn(`Failed to install dependencies for ${moduleInfo.name}`);
console.warn(chalk.yellow(` Warning: ${error.message}`));
}
} else {
// Check if package.json is newer than node_modules
let packageJsonNewer = false;
try {
const packageStats = await fs.stat(packageJsonPath);
const nodeModulesStats = await fs.stat(nodeModulesPath);
@ -457,18 +491,20 @@ class ModuleManager {
// If stat fails, assume we need to install
packageJsonNewer = true;
}
}
if (needsInstall || packageJsonNewer) {
console.log(chalk.dim(` Installing dependencies for ${moduleInfo.name}...`));
try {
execSync('npm install --production --no-audit --no-fund --prefer-offline --no-progress', {
cwd: moduleCacheDir,
stdio: 'inherit',
timeout: 120_000, // 2 minute timeout
});
} catch (error) {
console.warn(chalk.yellow(` Warning: Failed to install dependencies for ${moduleInfo.name}: ${error.message}`));
if (packageJsonNewer) {
const installSpinner = ora(`Installing dependencies for ${moduleInfo.name}...`).start();
try {
execSync('npm install --production --no-audit --no-fund --prefer-offline --no-progress', {
cwd: moduleCacheDir,
stdio: 'pipe',
timeout: 120_000, // 2 minute timeout
});
installSpinner.succeed(`Installed dependencies for ${moduleInfo.name}`);
} catch (error) {
installSpinner.warn(`Failed to install dependencies for ${moduleInfo.name}`);
console.warn(chalk.yellow(` Warning: ${error.message}`));
}
}
}
}

View File

@ -253,51 +253,8 @@ class UI {
console.log(chalk.dim(` Found existing modules: ${[...installedModuleIds].join(', ')}`));
// Ask about BMad Method Module (bmm)
const wantsBmm = await prompts.confirm({
message:
'Select the BMad Method Module for installation?\n ---> This is the Full BMad Method Agile AI Driven Development Framework Including BMad Quick Flow',
default: installedModuleIds.has('bmm'),
});
// Ask about BMad Builder Module (bmb)
const wantsBmb = await prompts.confirm({
message: 'Select the BMad Builder Module for installation?\n ---> Create Your Own Custom BMad Agents, Workflows and Modules',
default: installedModuleIds.has('bmb'),
});
let selectedOfficialModules = [];
if (wantsBmm) {
selectedOfficialModules.push('bmm');
}
if (wantsBmb) {
selectedOfficialModules.push('bmb');
}
// Ask about other external modules
// Check if any external modules are already installed (not bmm, bmb, or core)
const installedExternalModules = [...installedModuleIds].filter((id) => !['bmm', 'bmb', 'core'].includes(id));
let selectedExternalModules = [];
// If external modules are already installed, skip confirm and go straight to selection
// Otherwise ask if they want to choose external modules
if (installedExternalModules.length > 0) {
const externalModuleChoices = await this.getExternalModuleChoices();
selectedExternalModules = await this.selectExternalModules(externalModuleChoices, installedExternalModules);
} else {
const wantsExternalModules = await prompts.confirm({
message: 'Would you like to choose any other Recommended BMad Core Modules for installation?',
default: false,
});
if (wantsExternalModules) {
const externalModuleChoices = await this.getExternalModuleChoices();
selectedExternalModules = await this.selectExternalModules(externalModuleChoices, []);
}
}
// Combine official and external modules
let selectedModules = [...selectedOfficialModules, ...selectedExternalModules];
// Unified module selection - all modules in one grouped multiselect
let selectedModules = await this.selectAllModules(installedModuleIds);
// After module selection, ask about custom modules
console.log('');
@ -352,36 +309,10 @@ class UI {
// This section is only for new installations (update returns early above)
const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory);
// Ask about BMad Method Module (this repo)
const wantsBmm = await prompts.confirm({
message:
'Select the BMad Method Module for installation?\n ---> This is the Full BMad Method Agile AI Driven Development Framework Including BMad Quick Flow',
default: true,
});
// Unified module selection - all modules in one grouped multiselect
let selectedModules = await this.selectAllModules(installedModuleIds);
// Ask about BMad Builder Module
const wantsBmg = await prompts.confirm({
message: 'Select the BMad Builder Module for installation?\n ---> Create Your Own Custom BMad Agents, Workflows and Modules',
default: false,
});
let selectedOfficialModules = [];
if (wantsBmm) {
selectedOfficialModules.push('bmm');
}
const wantsExternalModules = await prompts.confirm({
message: 'Would you like to choose any other Recommended BMad Core Modules for installation?\n',
default: true,
});
let selectedExternalModules = [];
if (wantsExternalModules) {
const externalModuleChoices = await this.getExternalModuleChoices();
selectedExternalModules = await this.selectExternalModules(externalModuleChoices);
}
// Ask about custom content
// Ask about custom content (local modules/agents/workflows)
const wantsCustomContent = await prompts.confirm({
message: 'Would you like to install a locally stored custom module (this includes custom agents and workflows also)?',
default: false,
@ -391,19 +322,9 @@ class UI {
customContentConfig = await this.promptCustomContentSource();
}
// Store the selected modules for later
customContentConfig._selectedOfficialModules = selectedOfficialModules;
customContentConfig._selectedExternalModules = selectedExternalModules;
// Build the final list of selected modules
let selectedModules = [
...(customContentConfig._selectedOfficialModules || []),
...(customContentConfig._selectedExternalModules || []),
];
// Add custom content modules if any were selected
if (customContentConfig && customContentConfig.selectedModuleIds) {
selectedModules = [...selectedModules, ...customContentConfig.selectedModuleIds];
selectedModules.push(...customContentConfig.selectedModuleIds);
}
selectedModules = selectedModules.filter((m) => m !== 'core');
@ -513,7 +434,7 @@ class UI {
let selectedIdes = [];
selectedIdes = await prompts.groupMultiselect({
message: `Select tools to configure ${chalk.dim('(↑/↓ navigates multiselect, SPACE toggles, A to toggles All, ENTER confirm)')}:`,
message: `Select tools to configure ${chalk.dim('(↑/↓ navigates, SPACE toggles, ENTER to confirm)')}:`,
options: groupedOptions,
initialValues: initialValues.length > 0 ? initialValues : undefined,
required: true,
@ -663,6 +584,7 @@ class UI {
value: `__CUSTOM_CONTENT__${customFile}`, // Unique value for each custom content
checked: true, // Default to selected since user chose to provide custom content
path: customInfo.path, // Track path to avoid duplicates
hint: customInfo.description || undefined,
});
}
}
@ -689,6 +611,7 @@ class UI {
name: `${chalk.cyan('✓')} ${mod.name} ${chalk.gray(`(cached)`)}`,
value: mod.id,
checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id),
hint: mod.description || undefined,
});
}
}
@ -710,6 +633,7 @@ class UI {
name: mod.name,
value: mod.id,
checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id),
hint: mod.description || undefined,
});
}
}
@ -741,7 +665,7 @@ class UI {
];
const selected = await prompts.multiselect({
message: `Select modules to install ${chalk.dim('(↑/↓ navigates multiselect, SPACE toggles, A to toggles All, ENTER confirm)')}:`,
message: `Select modules to install ${chalk.dim('(↑/↓ navigates, SPACE toggles, ENTER to confirm)')}:`,
choices: choicesWithSkipOption,
required: true,
});
@ -770,6 +694,7 @@ class UI {
name: mod.name,
value: mod.code, // Use the code (e.g., 'cis') as the value
checked: mod.defaultSelected || false,
hint: mod.description || undefined, // Show description as hint
module: mod, // Store full module info for later use
}));
}
@ -783,7 +708,7 @@ class UI {
async selectExternalModules(externalModuleChoices, defaultSelections = []) {
// Build a message showing available modules
const availableNames = externalModuleChoices.map((c) => c.name).join(', ');
const message = `Select official BMad modules to install ${availableNames ? chalk.dim(`(${availableNames})`) : ''} ${chalk.dim('(↑/↓ navigates multiselect, SPACE toggles, A to toggles All, ENTER confirm)')}:`;
const message = `Select official BMad modules to install ${chalk.dim('(↑/↓ navigates, SPACE toggles, ENTER to confirm)')}:`;
// Mark choices as checked based on defaultSelections
const choicesWithDefaults = externalModuleChoices.map((choice) => ({
@ -819,6 +744,116 @@ class UI {
return selected ? selected.filter((m) => m !== '__NONE__') : [];
}
/**
* Select all modules (core + official + community) using grouped multiselect
* @param {Set} installedModuleIds - Currently installed module IDs
* @returns {Array} Selected module codes
*/
async selectAllModules(installedModuleIds = new Set()) {
const { ModuleManager } = require('../installers/lib/modules/manager');
const moduleManager = new ModuleManager();
const { modules: localModules } = await moduleManager.listAvailable();
// Get external modules
const externalManager = new ExternalModuleManager();
const externalModules = await externalManager.listAvailable();
// Build grouped options
const groupedOptions = {};
const initialValues = [];
// Helper to build module entry with proper sorting and selection
const buildModuleEntry = (mod, value) => {
const isInstalled = installedModuleIds.has(value);
const isDefault = mod.defaultSelected === true;
return {
label: mod.description ? `${mod.name}${mod.description}` : mod.name,
value,
// For sorting: defaultSelected=0, others=1
sortKey: isDefault ? 0 : 1,
// Pre-select if default selected OR already installed
selected: isDefault || isInstalled,
};
};
// Group 1: BMad Core (BMM, BMB)
const coreModules = [];
for (const mod of localModules) {
if (!mod.isCustom && (mod.id === 'bmm' || mod.id === 'bmb')) {
const entry = buildModuleEntry(mod, mod.id);
coreModules.push(entry);
if (entry.selected) {
initialValues.push(mod.id);
}
}
}
// Sort: defaultSelected first, then others
coreModules.sort((a, b) => a.sortKey - b.sortKey);
// Remove sortKey from final entries
if (coreModules.length > 0) {
groupedOptions['BMad Core'] = coreModules.map(({ label, value }) => ({ label, value }));
}
// Group 2: BMad Official Modules (type: bmad-org)
const officialModules = [];
for (const mod of externalModules) {
if (mod.type === 'bmad-org') {
const entry = buildModuleEntry(mod, mod.code);
officialModules.push(entry);
if (entry.selected) {
initialValues.push(mod.code);
}
}
}
officialModules.sort((a, b) => a.sortKey - b.sortKey);
if (officialModules.length > 0) {
groupedOptions['BMad Official Modules'] = officialModules.map(({ label, value }) => ({ label, value }));
}
// Group 3: Community Modules (type: community)
const communityModules = [];
for (const mod of externalModules) {
if (mod.type === 'community') {
const entry = buildModuleEntry(mod, mod.code);
communityModules.push(entry);
if (entry.selected) {
initialValues.push(mod.code);
}
}
}
communityModules.sort((a, b) => a.sortKey - b.sortKey);
if (communityModules.length > 0) {
groupedOptions['Community Modules'] = communityModules.map(({ label, value }) => ({ label, value }));
}
// Add "None" option at the end
groupedOptions[' '] = [
{
label: '⚠ None - Skip module installation',
value: '__NONE__',
},
];
const selected = await prompts.groupMultiselect({
message: `Select modules to install ${chalk.dim('(↑/↓ navigates, SPACE toggles, ENTER to confirm)')}:`,
options: groupedOptions,
initialValues: initialValues.length > 0 ? initialValues : undefined,
required: true,
selectableGroups: false,
});
// If user selected both "__NONE__" and other items, honor the "None" choice
if (selected && selected.includes('__NONE__') && selected.length > 1) {
console.log();
console.log(chalk.yellow('⚠️ "None" was selected, so no modules will be installed.'));
console.log();
return [];
}
// Filter out the special '__NONE__' value
return selected ? selected.filter((m) => m !== '__NONE__') : [];
}
/**
* Prompt for directory selection
* @returns {Object} Directory answer from prompt
@ -1372,7 +1407,7 @@ class UI {
];
const keepModules = await prompts.multiselect({
message: `Select custom modules to keep ${chalk.dim('(↑/↓ navigates multiselect, SPACE toggles, A to toggles All, ENTER confirm)')}:`,
message: `Select custom modules to keep ${chalk.dim('(↑/↓ navigates, SPACE toggles, ENTER to confirm)')}:`,
choices: choicesWithSkip,
required: true,
});

213
tools/schema/module.js Normal file
View File

@ -0,0 +1,213 @@
// Zod schema definition for module.yaml files
const { z } = require('zod');
// Pattern for module code: kebab-case, 2-20 characters, starts with letter
const MODULE_CODE_PATTERN = /^[a-z][a-z0-9-]{1,19}$/;
// Public API ---------------------------------------------------------------
/**
* Validate a module YAML payload against the schema.
*
* @param {string} filePath Path to the module file (used to detect core vs non-core modules).
* @param {unknown} moduleYaml Parsed YAML content.
* @returns {import('zod').SafeParseReturnType<unknown, unknown>} SafeParse result.
*/
function validateModuleFile(filePath, moduleYaml) {
const isCoreModule = typeof filePath === 'string' && filePath.replaceAll('\\', '/').includes('src/core/');
const schema = moduleSchema({ isCoreModule });
return schema.safeParse(moduleYaml);
}
module.exports = { validateModuleFile };
// Internal helpers ---------------------------------------------------------
/**
* Build the Zod schema for validating a module.yaml file.
* @param {{isCoreModule?: boolean}} options - Options for schema validation.
* @returns {import('zod').ZodSchema} Configured Zod schema instance.
*/
function moduleSchema(options) {
const { isCoreModule = false } = options ?? {};
return z
.object({
// Required fields
code: z.string().regex(MODULE_CODE_PATTERN, {
message: 'module.code must be kebab-case, 2-20 characters, starting with a letter',
}),
name: createNonEmptyString('module.name'),
header: createNonEmptyString('module.header'),
subheader: createNonEmptyString('module.subheader'),
// default_selected is optional for core module, required for non-core modules
default_selected: z.boolean().optional(),
// Optional fields
type: createNonEmptyString('module.type').optional(),
global: z.boolean().optional(),
})
.passthrough()
.superRefine((value, ctx) => {
// Enforce default_selected for non-core modules
if (!isCoreModule && !('default_selected' in value)) {
ctx.addIssue({
code: 'custom',
path: ['default_selected'],
message: 'module.default_selected is required for non-core modules',
});
}
// Validate any additional keys as variable definitions
const reservedKeys = new Set(['code', 'name', 'header', 'subheader', 'default_selected', 'type', 'global']);
for (const key of Object.keys(value)) {
if (reservedKeys.has(key)) {
continue;
}
const variableValue = value[key];
// Skip if null/undefined
if (variableValue === null || variableValue === undefined) {
continue;
}
// Validate variable definition
const variableResult = validateVariableDefinition(key, variableValue);
if (!variableResult.valid) {
ctx.addIssue({
code: 'custom',
path: [key],
message: variableResult.error,
});
}
}
});
}
/**
* Validate a variable definition object.
* @param {string} variableName The name of the variable.
* @param {unknown} variableValue The variable definition value.
* @returns {{ valid: boolean, error?: string }}
*/
function validateVariableDefinition(variableName, variableValue) {
// If it's not an object, it's invalid
if (typeof variableValue !== 'object' || variableValue === null) {
return { valid: false, error: `${variableName} must be an object with variable definition properties` };
}
const hasInherit = 'inherit' in variableValue;
const hasPrompt = 'prompt' in variableValue;
// Enforce mutual exclusivity: inherit and prompt cannot coexist
if (hasInherit && hasPrompt) {
return { valid: false, error: `${variableName} must not define both 'inherit' and 'prompt'` };
}
// Check for inherit alias - if present, it's the only required field
if (hasInherit) {
if (typeof variableValue.inherit !== 'string' || variableValue.inherit.trim().length === 0) {
return { valid: false, error: `${variableName}.inherit must be a non-empty string` };
}
return { valid: true };
}
// Otherwise, prompt is required
if (!hasPrompt) {
return { valid: false, error: `${variableName} must have a 'prompt' or 'inherit' field` };
}
// Validate prompt: string or array of strings
const prompt = variableValue.prompt;
if (typeof prompt === 'string') {
if (prompt.trim().length === 0) {
return { valid: false, error: `${variableName}.prompt must be a non-empty string` };
}
} else if (Array.isArray(prompt)) {
if (prompt.length === 0) {
return { valid: false, error: `${variableName}.prompt array must not be empty` };
}
for (const [index, promptItem] of prompt.entries()) {
if (typeof promptItem !== 'string' || promptItem.trim().length === 0) {
return { valid: false, error: `${variableName}.prompt[${index}] must be a non-empty string` };
}
}
} else {
return { valid: false, error: `${variableName}.prompt must be a string or array of strings` };
}
// Enforce mutual exclusivity: single-select and multi-select cannot coexist
const hasSingle = 'single-select' in variableValue;
const hasMulti = 'multi-select' in variableValue;
if (hasSingle && hasMulti) {
return { valid: false, error: `${variableName} must not define both 'single-select' and 'multi-select'` };
}
// Validate optional single-select
if (hasSingle) {
const selectResult = validateSelectOptions(variableName, 'single-select', variableValue['single-select']);
if (!selectResult.valid) {
return selectResult;
}
}
// Validate optional multi-select
if (hasMulti) {
const selectResult = validateSelectOptions(variableName, 'multi-select', variableValue['multi-select']);
if (!selectResult.valid) {
return selectResult;
}
}
// Validate optional required field
if ('required' in variableValue && typeof variableValue.required !== 'boolean') {
return { valid: false, error: `${variableName}.required must be a boolean` };
}
// Validate optional result field
if ('result' in variableValue && (typeof variableValue.result !== 'string' || variableValue.result.trim().length === 0)) {
return { valid: false, error: `${variableName}.result must be a non-empty string` };
}
return { valid: true };
}
/**
* Validate single-select or multi-select options array.
* @param {string} variableName The variable name for error messages.
* @param {string} selectType Either 'single-select' or 'multi-select'.
* @param {unknown} options The options array to validate.
* @returns {{ valid: boolean, error?: string }}
*/
function validateSelectOptions(variableName, selectType, options) {
if (!Array.isArray(options)) {
return { valid: false, error: `${variableName}.${selectType} must be an array` };
}
if (options.length === 0) {
return { valid: false, error: `${variableName}.${selectType} must not be empty` };
}
for (const [index, option] of options.entries()) {
if (typeof option !== 'object' || option === null) {
return { valid: false, error: `${variableName}.${selectType}[${index}] must be an object` };
}
if (!('value' in option) || typeof option.value !== 'string') {
return { valid: false, error: `${variableName}.${selectType}[${index}].value must be a string` };
}
if (!('label' in option) || typeof option.label !== 'string') {
return { valid: false, error: `${variableName}.${selectType}[${index}].label must be a string` };
}
}
return { valid: true };
}
// Primitive validators -----------------------------------------------------
function createNonEmptyString(label) {
return z.string().refine((value) => value.trim().length > 0, {
message: `${label} must be a non-empty string`,
});
}

View File

@ -0,0 +1,110 @@
/**
* Module Schema Validator CLI
*
* Scans all module.yaml files in src/core/ and src/modules/
* and validates them against the Zod schema.
*
* Usage: node tools/validate-module-schema.js [project_root]
* Exit codes: 0 = success, 1 = validation failures
*
* Optional argument:
* project_root - Directory to scan (defaults to BMAD repo root)
*/
const { glob } = require('glob');
const yaml = require('yaml');
const fs = require('node:fs');
const path = require('node:path');
const { validateModuleFile } = require('./schema/module.js');
/**
* Main validation routine
* @param {string} [customProjectRoot] - Optional project root to scan (for testing)
*/
async function main(customProjectRoot) {
console.log('🔍 Scanning for module files...\n');
// Determine project root: use custom path if provided, otherwise default to repo root
const project_root = customProjectRoot || path.join(__dirname, '..');
// Find all module files: core/module.yaml and modules/*/module.yaml
const moduleFiles = await glob('src/{core,modules/*}/module.yaml', {
cwd: project_root,
absolute: true,
});
if (moduleFiles.length === 0) {
console.log('❌ No module files found. This likely indicates a configuration error.');
console.log(' Expected to find module.yaml files in src/core/ and src/modules/*/');
process.exit(1);
}
console.log(`Found ${moduleFiles.length} module file(s)\n`);
const errors = [];
// Validate each file
for (const filePath of moduleFiles) {
const relativePath = path.relative(project_root, filePath).replaceAll('\\', '/');
try {
const fileContent = fs.readFileSync(filePath, 'utf8');
const moduleData = yaml.parse(fileContent);
// Ensure path starts with src/ for core module detection
const srcRelativePath = relativePath.startsWith('src/') ? relativePath : `src/${relativePath}`;
const result = validateModuleFile(srcRelativePath, moduleData);
if (result.success) {
console.log(`${relativePath}`);
} else {
errors.push({
file: relativePath,
issues: result.error.issues,
});
}
} catch (error) {
errors.push({
file: relativePath,
issues: [
{
code: 'parse_error',
message: `Failed to parse YAML: ${error.message}`,
path: [],
},
],
});
}
}
// Report errors
if (errors.length > 0) {
console.log('\n❌ Validation failed for the following files:\n');
for (const { file, issues } of errors) {
console.log(`\n📄 ${file}`);
for (const issue of issues) {
const pathString = issue.path.length > 0 ? issue.path.join('.') : '(root)';
console.log(` Path: ${pathString}`);
console.log(` Error: ${issue.message}`);
if (issue.code) {
console.log(` Code: ${issue.code}`);
}
}
}
console.log(`\n\n💥 ${errors.length} file(s) failed validation`);
process.exit(1);
}
console.log(`\n✨ All ${moduleFiles.length} module file(s) passed validation!\n`);
process.exit(0);
}
// Run with optional command-line argument for project root
const customProjectRoot = process.argv[2];
main(customProjectRoot).catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});