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:minor": "gh workflow run \"Manual Release\" -f version_bump=minor",
|
||||||
"release:patch": "gh workflow run \"Manual Release\" -f version_bump=patch",
|
"release:patch": "gh workflow run \"Manual Release\" -f version_bump=patch",
|
||||||
"release:watch": "gh run watch",
|
"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:coverage": "c8 --reporter=text --reporter=html npm run test:schemas",
|
||||||
"test:install": "node test/test-installation-components.js",
|
"test:install": "node test/test-installation-components.js",
|
||||||
|
"test:modules": "node test/test-module-schema.js",
|
||||||
"test:schemas": "node test/test-agent-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"
|
"validate:schemas": "node tools/validate-agent-schema.js"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
code: bmb
|
code: bmb
|
||||||
name: "BMB: BMad Builder - Agent, Workflow and Module Builder"
|
name: "BMad Builder (BoMB!)"
|
||||||
header: "BMad Optimized Builder (BoMB) Module Configuration"
|
description: "Agent, Workflow and Module Builder"
|
||||||
subheader: "Configure the settings for the BoMB Factory!\nThe agent, workflow and module builder for BMad™ "
|
|
||||||
default_selected: false # This module will not be selected by default for new installations
|
default_selected: false # This module will not be selected by default for new installations
|
||||||
|
|
||||||
# Variables from Core Config inserted:
|
# Variables from Core Config inserted:
|
||||||
|
|
|
||||||
|
|
@ -42,13 +42,13 @@ Load `{moduleYamlConventionsFile}` for reference.
|
||||||
|
|
||||||
Create `{targetLocation}/module.yaml` with:
|
Create `{targetLocation}/module.yaml` with:
|
||||||
|
|
||||||
**Required fields:**
|
**Required fields (replace with actual values):**
|
||||||
```yaml
|
```yaml
|
||||||
code: {module_code}
|
code: "my-module" # kebab-case, 2-20 chars, starts with letter
|
||||||
name: "{module_display_name}"
|
name: "My Module: Description" # human-readable name
|
||||||
header: "{brief_header}"
|
header: "One-line summary" # one-line summary
|
||||||
subheader: "{additional_context}"
|
subheader: "Additional context" # additional context
|
||||||
default_selected: false
|
default_selected: false # typically false for new modules
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note for Extension modules:** `code:` matches base module
|
**Note for Extension modules:** `code:` matches base module
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,11 @@ Read `{targetPath}/module.yaml`
|
||||||
|
|
||||||
### 2. Validate Required Fields
|
### 2. Validate Required Fields
|
||||||
|
|
||||||
Check for required frontmatter:
|
Check required fields (must have actual values, not placeholders):
|
||||||
- [ ] `code:` present and valid (kebab-case, 2-20 chars)
|
- [ ] `code:` present (kebab-case, 2-20 chars, starts with letter)
|
||||||
- [ ] `name:` present
|
- [ ] `name:` present (non-empty string)
|
||||||
- [ ] `header:` present
|
- [ ] `header:` present (non-empty string)
|
||||||
- [ ] `subheader:` present
|
- [ ] `subheader:` present (non-empty string)
|
||||||
- [ ] `default_selected:` present (boolean)
|
- [ ] `default_selected:` present (boolean)
|
||||||
|
|
||||||
### 3. Validate Custom Variables
|
### 3. Validate Custom Variables
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
code: bmm
|
code: bmm
|
||||||
name: "BMM: BMad Method Agile-AI Driven-Development"
|
name: "BMad Method Agile-AI Driven-Development"
|
||||||
header: "BMad Method™: Breakthrough Method of Agile-Ai Driven-Dev"
|
description: "AI-driven agile development framework"
|
||||||
subheader: "Agent and Workflow Configuration for this module"
|
|
||||||
default_selected: true # This module will be selected by default for new installations
|
default_selected: true # This module will be selected by default for new installations
|
||||||
|
|
||||||
# Variables from Core Config inserted:
|
# 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
|
url: https://github.com/bmad-code-org/bmad-module-creative-intelligence-suite
|
||||||
module-definition: src/module.yaml
|
module-definition: src/module.yaml
|
||||||
code: cis
|
code: cis
|
||||||
name: "CIS: Creative Innovation Suite"
|
name: "Creative Innovation Suite"
|
||||||
header: "CIS: Creative Innovation Suite"
|
description: "Creative tools for writing, brainstorming, and more"
|
||||||
subheader: "Unleash your creativity with the BMad CIS!"
|
|
||||||
description: ""
|
|
||||||
defaultSelected: false
|
defaultSelected: false
|
||||||
|
type: bmad-org
|
||||||
|
|
||||||
bmad-game-dev-studio:
|
bmad-game-dev-studio:
|
||||||
url: https://github.com/bmad-code-org/bmad-module-game-dev-studio.git
|
url: https://github.com/bmad-code-org/bmad-module-game-dev-studio.git
|
||||||
module-definition: src/module.yaml
|
module-definition: src/module.yaml
|
||||||
code: BGDS
|
code: BGDS
|
||||||
name: "BGDS: BMad Game Dev Suite"
|
name: "BMad Game Dev Suite"
|
||||||
header: "BGDS: BMad Game Dev Suite"
|
description: "Game development agents and workflows"
|
||||||
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"
|
|
||||||
defaultSelected: false
|
defaultSelected: false
|
||||||
|
type: bmad-org
|
||||||
|
|
||||||
# bmad-whiteport-design-system:
|
whiteport-design-system:
|
||||||
# url: https://github.com/bmad-code-org/bmad-method-wds-expansion
|
url: https://github.com/bmad-code-org/bmad-method-wds-expansion
|
||||||
# module-definition: src/module.yaml
|
module-definition: src/module.yaml
|
||||||
# code: WDS
|
code: WDS
|
||||||
# name: "WDS: Whiteport UX Design System"
|
name: "Whiteport UX Design System"
|
||||||
# header: "WDS: Whiteport UX Design System"
|
description: "UX design framework with Figma integration"
|
||||||
# subheader: "Professional Designer UX Design Module Expansion to the BMad MEthod"
|
defaultSelected: false
|
||||||
# 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."
|
type: community
|
||||||
# defaultSelected: false
|
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ class ExternalModuleManager {
|
||||||
subheader: moduleConfig.subheader,
|
subheader: moduleConfig.subheader,
|
||||||
description: moduleConfig.description || '',
|
description: moduleConfig.description || '',
|
||||||
defaultSelected: moduleConfig.defaultSelected === true,
|
defaultSelected: moduleConfig.defaultSelected === true,
|
||||||
|
type: moduleConfig.type || 'community', // bmad-org or community
|
||||||
isExternal: true,
|
isExternal: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -93,6 +94,7 @@ class ExternalModuleManager {
|
||||||
subheader: moduleConfig.subheader,
|
subheader: moduleConfig.subheader,
|
||||||
description: moduleConfig.description || '',
|
description: moduleConfig.description || '',
|
||||||
defaultSelected: moduleConfig.defaultSelected === true,
|
defaultSelected: moduleConfig.defaultSelected === true,
|
||||||
|
type: moduleConfig.type || 'community', // bmad-org or community
|
||||||
isExternal: true,
|
isExternal: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ const path = require('node:path');
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const yaml = require('yaml');
|
const yaml = require('yaml');
|
||||||
const chalk = require('chalk');
|
const chalk = require('chalk');
|
||||||
|
const ora = require('ora');
|
||||||
const { XmlHandler } = require('../../../lib/xml-handler');
|
const { XmlHandler } = require('../../../lib/xml-handler');
|
||||||
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
|
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
|
||||||
const { filterCustomizationData } = require('../../../lib/agent/compiler');
|
const { filterCustomizationData } = require('../../../lib/agent/compiler');
|
||||||
|
|
@ -414,27 +415,46 @@ class ModuleManager {
|
||||||
// Create cache directory if it doesn't exist
|
// Create cache directory if it doesn't exist
|
||||||
await fs.ensureDir(cacheDir);
|
await fs.ensureDir(cacheDir);
|
||||||
|
|
||||||
|
// Track if we need to install dependencies
|
||||||
|
let needsDependencyInstall = false;
|
||||||
|
let wasNewClone = false;
|
||||||
|
|
||||||
// Check if already cloned
|
// Check if already cloned
|
||||||
if (await fs.pathExists(moduleCacheDir)) {
|
if (await fs.pathExists(moduleCacheDir)) {
|
||||||
// Try to update if it's a git repo
|
// Try to update if it's a git repo
|
||||||
|
const fetchSpinner = ora(`Fetching ${moduleInfo.name}...`).start();
|
||||||
try {
|
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 fetch --depth 1', { cwd: moduleCacheDir, stdio: 'pipe' });
|
||||||
execSync('git checkout -f', { cwd: moduleCacheDir, stdio: 'pipe' });
|
execSync('git checkout -f', { cwd: moduleCacheDir, stdio: 'pipe' });
|
||||||
execSync('git pull --ff-only', { 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 {
|
} catch {
|
||||||
|
fetchSpinner.warn(`Fetch failed, re-downloading ${moduleInfo.name}`);
|
||||||
// If update fails, remove and re-clone
|
// If update fails, remove and re-clone
|
||||||
await fs.remove(moduleCacheDir);
|
await fs.remove(moduleCacheDir);
|
||||||
|
wasNewClone = true;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
wasNewClone = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clone if not exists or was removed
|
// Clone if not exists or was removed
|
||||||
if (!(await fs.pathExists(moduleCacheDir))) {
|
if (wasNewClone) {
|
||||||
console.log(chalk.dim(` Cloning external module: ${moduleInfo.name}`));
|
const fetchSpinner = ora(`Fetching ${moduleInfo.name}...`).start();
|
||||||
try {
|
try {
|
||||||
execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
|
execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
|
||||||
stdio: 'pipe',
|
stdio: 'pipe',
|
||||||
});
|
});
|
||||||
|
fetchSpinner.succeed(`Fetched ${moduleInfo.name}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
fetchSpinner.fail(`Failed to fetch ${moduleInfo.name}`);
|
||||||
throw new Error(`Failed to clone external module '${moduleCode}': ${error.message}`);
|
throw new Error(`Failed to clone external module '${moduleCode}': ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -444,11 +464,25 @@ class ModuleManager {
|
||||||
const nodeModulesPath = path.join(moduleCacheDir, 'node_modules');
|
const nodeModulesPath = path.join(moduleCacheDir, 'node_modules');
|
||||||
if (await fs.pathExists(packageJsonPath)) {
|
if (await fs.pathExists(packageJsonPath)) {
|
||||||
// Install if node_modules doesn't exist, or if package.json is newer (dependencies changed)
|
// Install if node_modules doesn't exist, or if package.json is newer (dependencies changed)
|
||||||
const needsInstall = !(await fs.pathExists(nodeModulesPath));
|
const nodeModulesMissing = !(await fs.pathExists(nodeModulesPath));
|
||||||
let packageJsonNewer = false;
|
|
||||||
|
|
||||||
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
|
// Check if package.json is newer than node_modules
|
||||||
|
let packageJsonNewer = false;
|
||||||
try {
|
try {
|
||||||
const packageStats = await fs.stat(packageJsonPath);
|
const packageStats = await fs.stat(packageJsonPath);
|
||||||
const nodeModulesStats = await fs.stat(nodeModulesPath);
|
const nodeModulesStats = await fs.stat(nodeModulesPath);
|
||||||
|
|
@ -457,18 +491,20 @@ class ModuleManager {
|
||||||
// If stat fails, assume we need to install
|
// If stat fails, assume we need to install
|
||||||
packageJsonNewer = true;
|
packageJsonNewer = true;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (needsInstall || packageJsonNewer) {
|
if (packageJsonNewer) {
|
||||||
console.log(chalk.dim(` Installing dependencies for ${moduleInfo.name}...`));
|
const installSpinner = ora(`Installing dependencies for ${moduleInfo.name}...`).start();
|
||||||
try {
|
try {
|
||||||
execSync('npm install --production --no-audit --no-fund --prefer-offline --no-progress', {
|
execSync('npm install --production --no-audit --no-fund --prefer-offline --no-progress', {
|
||||||
cwd: moduleCacheDir,
|
cwd: moduleCacheDir,
|
||||||
stdio: 'inherit',
|
stdio: 'pipe',
|
||||||
timeout: 120_000, // 2 minute timeout
|
timeout: 120_000, // 2 minute timeout
|
||||||
});
|
});
|
||||||
} catch (error) {
|
installSpinner.succeed(`Installed dependencies for ${moduleInfo.name}`);
|
||||||
console.warn(chalk.yellow(` Warning: Failed to install dependencies for ${moduleInfo.name}: ${error.message}`));
|
} 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(', ')}`));
|
console.log(chalk.dim(` Found existing modules: ${[...installedModuleIds].join(', ')}`));
|
||||||
|
|
||||||
// Ask about BMad Method Module (bmm)
|
// Unified module selection - all modules in one grouped multiselect
|
||||||
const wantsBmm = await prompts.confirm({
|
let selectedModules = await this.selectAllModules(installedModuleIds);
|
||||||
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];
|
|
||||||
|
|
||||||
// After module selection, ask about custom modules
|
// After module selection, ask about custom modules
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
@ -352,36 +309,10 @@ class UI {
|
||||||
// This section is only for new installations (update returns early above)
|
// This section is only for new installations (update returns early above)
|
||||||
const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory);
|
const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory);
|
||||||
|
|
||||||
// Ask about BMad Method Module (this repo)
|
// Unified module selection - all modules in one grouped multiselect
|
||||||
const wantsBmm = await prompts.confirm({
|
let selectedModules = await this.selectAllModules(installedModuleIds);
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ask about BMad Builder Module
|
// Ask about custom content (local modules/agents/workflows)
|
||||||
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
|
|
||||||
const wantsCustomContent = await prompts.confirm({
|
const wantsCustomContent = await prompts.confirm({
|
||||||
message: 'Would you like to install a locally stored custom module (this includes custom agents and workflows also)?',
|
message: 'Would you like to install a locally stored custom module (this includes custom agents and workflows also)?',
|
||||||
default: false,
|
default: false,
|
||||||
|
|
@ -391,19 +322,9 @@ class UI {
|
||||||
customContentConfig = await this.promptCustomContentSource();
|
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
|
// Add custom content modules if any were selected
|
||||||
if (customContentConfig && customContentConfig.selectedModuleIds) {
|
if (customContentConfig && customContentConfig.selectedModuleIds) {
|
||||||
selectedModules = [...selectedModules, ...customContentConfig.selectedModuleIds];
|
selectedModules.push(...customContentConfig.selectedModuleIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedModules = selectedModules.filter((m) => m !== 'core');
|
selectedModules = selectedModules.filter((m) => m !== 'core');
|
||||||
|
|
@ -513,7 +434,7 @@ class UI {
|
||||||
let selectedIdes = [];
|
let selectedIdes = [];
|
||||||
|
|
||||||
selectedIdes = await prompts.groupMultiselect({
|
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,
|
options: groupedOptions,
|
||||||
initialValues: initialValues.length > 0 ? initialValues : undefined,
|
initialValues: initialValues.length > 0 ? initialValues : undefined,
|
||||||
required: true,
|
required: true,
|
||||||
|
|
@ -663,6 +584,7 @@ class UI {
|
||||||
value: `__CUSTOM_CONTENT__${customFile}`, // Unique value for each custom content
|
value: `__CUSTOM_CONTENT__${customFile}`, // Unique value for each custom content
|
||||||
checked: true, // Default to selected since user chose to provide custom content
|
checked: true, // Default to selected since user chose to provide custom content
|
||||||
path: customInfo.path, // Track path to avoid duplicates
|
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)`)}`,
|
name: `${chalk.cyan('✓')} ${mod.name} ${chalk.gray(`(cached)`)}`,
|
||||||
value: mod.id,
|
value: mod.id,
|
||||||
checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id),
|
checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id),
|
||||||
|
hint: mod.description || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -710,6 +633,7 @@ class UI {
|
||||||
name: mod.name,
|
name: mod.name,
|
||||||
value: mod.id,
|
value: mod.id,
|
||||||
checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(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({
|
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,
|
choices: choicesWithSkipOption,
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
|
|
@ -770,6 +694,7 @@ class UI {
|
||||||
name: mod.name,
|
name: mod.name,
|
||||||
value: mod.code, // Use the code (e.g., 'cis') as the value
|
value: mod.code, // Use the code (e.g., 'cis') as the value
|
||||||
checked: mod.defaultSelected || false,
|
checked: mod.defaultSelected || false,
|
||||||
|
hint: mod.description || undefined, // Show description as hint
|
||||||
module: mod, // Store full module info for later use
|
module: mod, // Store full module info for later use
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
@ -783,7 +708,7 @@ class UI {
|
||||||
async selectExternalModules(externalModuleChoices, defaultSelections = []) {
|
async selectExternalModules(externalModuleChoices, defaultSelections = []) {
|
||||||
// Build a message showing available modules
|
// Build a message showing available modules
|
||||||
const availableNames = externalModuleChoices.map((c) => c.name).join(', ');
|
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
|
// Mark choices as checked based on defaultSelections
|
||||||
const choicesWithDefaults = externalModuleChoices.map((choice) => ({
|
const choicesWithDefaults = externalModuleChoices.map((choice) => ({
|
||||||
|
|
@ -819,6 +744,116 @@ class UI {
|
||||||
return selected ? selected.filter((m) => m !== '__NONE__') : [];
|
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
|
* Prompt for directory selection
|
||||||
* @returns {Object} Directory answer from prompt
|
* @returns {Object} Directory answer from prompt
|
||||||
|
|
@ -1372,7 +1407,7 @@ class UI {
|
||||||
];
|
];
|
||||||
|
|
||||||
const keepModules = await prompts.multiselect({
|
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,
|
choices: choicesWithSkip,
|
||||||
required: true,
|
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