Compare commits
7 Commits
39655bc65f
...
77812dd3c2
| Author | SHA1 | Date |
|---|---|---|
|
|
77812dd3c2 | |
|
|
36dfb0a612 | |
|
|
28e6dded4d | |
|
|
966ca5db0b | |
|
|
1d49e045db | |
|
|
7851fd8b80 | |
|
|
d83ce5e21f |
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
11
test/fixtures/module-schema/invalid/code-format/number-start-code.module.yaml
vendored
Normal file
11
test/fixtures/module-schema/invalid/code-format/number-start-code.module.yaml
vendored
Normal 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
|
||||
|
||||
11
test/fixtures/module-schema/invalid/code-format/placeholder-code.module.yaml
vendored
Normal file
11
test/fixtures/module-schema/invalid/code-format/placeholder-code.module.yaml
vendored
Normal 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
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
11
test/fixtures/module-schema/invalid/required-fields/missing-code.module.yaml
vendored
Normal file
11
test/fixtures/module-schema/invalid/required-fields/missing-code.module.yaml
vendored
Normal 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
|
||||
|
||||
11
test/fixtures/module-schema/invalid/required-fields/missing-header.module.yaml
vendored
Normal file
11
test/fixtures/module-schema/invalid/required-fields/missing-header.module.yaml
vendored
Normal 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
|
||||
|
||||
11
test/fixtures/module-schema/invalid/required-fields/missing-name.module.yaml
vendored
Normal file
11
test/fixtures/module-schema/invalid/required-fields/missing-name.module.yaml
vendored
Normal 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
|
||||
|
||||
11
test/fixtures/module-schema/invalid/required-fields/missing-subheader.module.yaml
vendored
Normal file
11
test/fixtures/module-schema/invalid/required-fields/missing-subheader.module.yaml
vendored
Normal 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
|
||||
|
||||
|
|
@ -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: " "
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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}
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue