Compare commits
4 Commits
ebd63fb054
...
ecbb07ebb9
| Author | SHA1 | Date |
|---|---|---|
|
|
ecbb07ebb9 | |
|
|
2395b0e2ed | |
|
|
914c4edd6b | |
|
|
1a85069b75 |
|
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
name: validate-workflow
|
||||||
|
description: "Run a checklist against a document with thorough analysis and produce a validation report"
|
||||||
|
---
|
||||||
|
|
||||||
|
Follow the instructions in [workflow.md](workflow.md).
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
type: skill
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
# Validate Workflow Output
|
||||||
|
|
||||||
|
**Goal:** Run a checklist against a document with thorough analysis and produce a validation report.
|
||||||
|
|
||||||
|
**Inputs:**
|
||||||
|
|
||||||
|
- **workflow** (required) — Workflow path containing `checklist.md`
|
||||||
|
- **checklist** (optional) — Checklist to validate against (defaults to the workflow's `checklist.md`)
|
||||||
|
- **document** (optional) — Document to validate (ask user if not specified)
|
||||||
|
|
||||||
|
## STEPS
|
||||||
|
|
||||||
|
### Step 1: Setup
|
||||||
|
|
||||||
|
- If checklist not provided, load `checklist.md` from the workflow location
|
||||||
|
- Try to fuzzy-match files similar to the input document name; if document not provided or unsure, ask user: "Which document should I validate?"
|
||||||
|
- Load both the checklist and document
|
||||||
|
|
||||||
|
### Step 2: Validate (CRITICAL)
|
||||||
|
|
||||||
|
**For EVERY checklist item, WITHOUT SKIPPING ANY:**
|
||||||
|
|
||||||
|
1. Read the requirement carefully
|
||||||
|
2. Search the document for evidence along with any ancillary loaded documents or artifacts (quotes with line numbers)
|
||||||
|
3. Analyze deeply — look for explicit AND implied coverage
|
||||||
|
|
||||||
|
**Mark each item as:**
|
||||||
|
|
||||||
|
- **PASS** `✓` — Requirement fully met (provide evidence)
|
||||||
|
- **PARTIAL** `⚠` — Some coverage but incomplete (explain gaps)
|
||||||
|
- **FAIL** `✗` — Not met or severely deficient (explain why)
|
||||||
|
- **N/A** `➖` — Not applicable (explain reason)
|
||||||
|
|
||||||
|
**DO NOT SKIP ANY SECTIONS OR ITEMS.**
|
||||||
|
|
||||||
|
### Step 3: Generate Report
|
||||||
|
|
||||||
|
Create `validation-report-{timestamp}.md` in the document's folder with the following format:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Validation Report
|
||||||
|
|
||||||
|
**Document:** {document-path}
|
||||||
|
**Checklist:** {checklist-path}
|
||||||
|
**Date:** {timestamp}
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- Overall: X/Y passed (Z%)
|
||||||
|
- Critical Issues: {count}
|
||||||
|
|
||||||
|
## Section Results
|
||||||
|
|
||||||
|
### {Section Name}
|
||||||
|
|
||||||
|
Pass Rate: X/Y (Z%)
|
||||||
|
|
||||||
|
[MARK] {Item description}
|
||||||
|
Evidence: {Quote with line# or explanation}
|
||||||
|
{If FAIL/PARTIAL: Impact: {why this matters}}
|
||||||
|
|
||||||
|
## Failed Items
|
||||||
|
|
||||||
|
{All ✗ items with recommendations}
|
||||||
|
|
||||||
|
## Partial Items
|
||||||
|
|
||||||
|
{All ⚠ items with what's missing}
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
1. Must Fix: {critical failures}
|
||||||
|
2. Should Improve: {important gaps}
|
||||||
|
3. Consider: {minor improvements}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Summary for User
|
||||||
|
|
||||||
|
- Present section-by-section summary
|
||||||
|
- Highlight all critical issues
|
||||||
|
- Provide path to saved report
|
||||||
|
- **HALT** — do not continue unless user asks
|
||||||
|
|
||||||
|
## HALT CONDITIONS
|
||||||
|
|
||||||
|
- HALT after presenting summary in Step 4
|
||||||
|
- HALT with error if no checklist is found and none is provided
|
||||||
|
- HALT with error if no document is found and user does not specify one
|
||||||
|
|
@ -2256,6 +2256,374 @@ async function runTests() {
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Test Suite 38: External-Module Agent Resolution
|
||||||
|
// ============================================================
|
||||||
|
console.log(`${colors.yellow}Test Suite 38: External-Module Agent Resolution${colors.reset}\n`);
|
||||||
|
|
||||||
|
{
|
||||||
|
// Scenario: external official modules (bmb, cis, gds, ...) are cloned into
|
||||||
|
// ~/.bmad/cache/external-modules/<name>/ — NOT copied into src/modules/.
|
||||||
|
// collectAgentsFromModuleYaml must resolve them from the cache or their
|
||||||
|
// agent roster silently vanishes from config.toml.
|
||||||
|
const tempCacheDir38 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-ext-cache-'));
|
||||||
|
const tempBmadDir38 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-ext-install-'));
|
||||||
|
const priorCacheEnv = process.env.BMAD_EXTERNAL_MODULES_CACHE;
|
||||||
|
process.env.BMAD_EXTERNAL_MODULES_CACHE = tempCacheDir38;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Seed a fake external module with agents at cache/<mod>/src/module.yaml —
|
||||||
|
// matches the real CIS layout.
|
||||||
|
const extSrcDir = path.join(tempCacheDir38, 'fake-ext', 'src');
|
||||||
|
await fs.ensureDir(extSrcDir);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(extSrcDir, 'module.yaml'),
|
||||||
|
[
|
||||||
|
'code: fake-ext',
|
||||||
|
'name: "Fake External Module"',
|
||||||
|
'agents:',
|
||||||
|
' - code: bmad-fake-ext-agent-one',
|
||||||
|
' name: Ext-One',
|
||||||
|
' title: External Agent One',
|
||||||
|
' icon: "🧪"',
|
||||||
|
' team: fake',
|
||||||
|
' description: "First fake external agent."',
|
||||||
|
' - code: bmad-fake-ext-agent-two',
|
||||||
|
' name: Ext-Two',
|
||||||
|
' title: External Agent Two',
|
||||||
|
' icon: "🧬"',
|
||||||
|
' team: fake',
|
||||||
|
' description: "Second fake external agent."',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Second fake module at cache/<mod>/skills/module.yaml — matches bmb layout.
|
||||||
|
const extSkillsDir = path.join(tempCacheDir38, 'fake-skills', 'skills');
|
||||||
|
await fs.ensureDir(extSkillsDir);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(extSkillsDir, 'module.yaml'),
|
||||||
|
[
|
||||||
|
'code: fake-skills',
|
||||||
|
'name: "Fake Skills-Layout Module"',
|
||||||
|
'agents:',
|
||||||
|
' - code: bmad-fake-skills-agent',
|
||||||
|
' name: SkillsHero',
|
||||||
|
' title: Skills Layout Agent',
|
||||||
|
' icon: "🛠️"',
|
||||||
|
' team: fake-skills',
|
||||||
|
' description: "Lives under skills/ not src/."',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const generator38 = new ManifestGenerator();
|
||||||
|
generator38.bmadDir = tempBmadDir38;
|
||||||
|
generator38.bmadFolderName = path.basename(tempBmadDir38);
|
||||||
|
generator38.updatedModules = ['core', 'bmm', 'fake-ext', 'fake-skills'];
|
||||||
|
|
||||||
|
await generator38.collectAgentsFromModuleYaml();
|
||||||
|
|
||||||
|
const byCode = new Map(generator38.agents.map((a) => [a.code, a]));
|
||||||
|
assert(byCode.has('bmad-fake-ext-agent-one'), 'external module at cache/<name>/src resolves and contributes agent one');
|
||||||
|
assert(byCode.has('bmad-fake-ext-agent-two'), 'external module at cache/<name>/src resolves and contributes agent two');
|
||||||
|
assert(byCode.has('bmad-fake-skills-agent'), 'external module at cache/<name>/skills layout also resolves');
|
||||||
|
assert(byCode.get('bmad-fake-ext-agent-one').module === 'fake-ext', 'agent.module matches the owning external module name');
|
||||||
|
assert(byCode.get('bmad-fake-ext-agent-one').team === 'fake', 'explicit team from module.yaml is preserved');
|
||||||
|
|
||||||
|
await generator38.writeCentralConfig(tempBmadDir38, {
|
||||||
|
core: {},
|
||||||
|
bmm: {},
|
||||||
|
'fake-ext': {},
|
||||||
|
'fake-skills': {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamContent = await fs.readFile(path.join(tempBmadDir38, 'config.toml'), 'utf8');
|
||||||
|
assert(teamContent.includes('[agents.bmad-fake-ext-agent-one]'), 'external-module agents land in config.toml [agents.*] section');
|
||||||
|
assert(teamContent.includes('[agents.bmad-fake-skills-agent]'), 'skills-layout external module agents also land in config.toml');
|
||||||
|
assert(teamContent.includes('First fake external agent.'), 'agent description from external module.yaml is written');
|
||||||
|
} finally {
|
||||||
|
if (priorCacheEnv === undefined) {
|
||||||
|
delete process.env.BMAD_EXTERNAL_MODULES_CACHE;
|
||||||
|
} else {
|
||||||
|
process.env.BMAD_EXTERNAL_MODULES_CACHE = priorCacheEnv;
|
||||||
|
}
|
||||||
|
await fs.remove(tempCacheDir38).catch(() => {});
|
||||||
|
await fs.remove(tempBmadDir38).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Test Suite 39: Module Version Resolution
|
||||||
|
// ============================================================
|
||||||
|
console.log(`${colors.yellow}Test Suite 39: Module Version Resolution${colors.reset}\n`);
|
||||||
|
|
||||||
|
// --- package.json beats module.yaml and marketplace.json for cached external modules ---
|
||||||
|
{
|
||||||
|
const { resolveModuleVersion } = require('../tools/installer/modules/version-resolver');
|
||||||
|
const tempCacheDir39 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-version-cache-'));
|
||||||
|
const priorCacheEnv39 = process.env.BMAD_EXTERNAL_MODULES_CACHE;
|
||||||
|
process.env.BMAD_EXTERNAL_MODULES_CACHE = tempCacheDir39;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const moduleRoot = path.join(tempCacheDir39, 'tea');
|
||||||
|
const moduleSrc = path.join(moduleRoot, 'src');
|
||||||
|
await fs.ensureDir(path.join(moduleRoot, '.claude-plugin'));
|
||||||
|
await fs.ensureDir(moduleSrc);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(moduleRoot, 'package.json'),
|
||||||
|
JSON.stringify({ name: 'bmad-method-test-architecture-enterprise', version: '1.12.3' }, null, 2) + '\n',
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(moduleSrc, 'module.yaml'),
|
||||||
|
['code: tea', 'name: Test Architect', 'module_version: 1.11.0', ''].join('\n'),
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(moduleRoot, '.claude-plugin', 'marketplace.json'),
|
||||||
|
JSON.stringify({ plugins: [{ name: 'tea', version: '1.7.2' }] }, null, 2) + '\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
const versionInfo = await resolveModuleVersion('tea');
|
||||||
|
assert(versionInfo.version === '1.12.3', 'resolver prefers cached package.json over stale marketplace metadata for external modules');
|
||||||
|
assert(versionInfo.source === 'package.json', 'resolver reports package.json as the winning metadata source');
|
||||||
|
} finally {
|
||||||
|
if (priorCacheEnv39 === undefined) {
|
||||||
|
delete process.env.BMAD_EXTERNAL_MODULES_CACHE;
|
||||||
|
} else {
|
||||||
|
process.env.BMAD_EXTERNAL_MODULES_CACHE = priorCacheEnv39;
|
||||||
|
}
|
||||||
|
await fs.remove(tempCacheDir39).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- module.yaml is used when package.json is absent ---
|
||||||
|
{
|
||||||
|
const { resolveModuleVersion } = require('../tools/installer/modules/version-resolver');
|
||||||
|
const tempRepo39 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-version-module-yaml-'));
|
||||||
|
const tempCacheDir39 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-version-module-yaml-cache-'));
|
||||||
|
const priorCacheEnv39 = process.env.BMAD_EXTERNAL_MODULES_CACHE;
|
||||||
|
process.env.BMAD_EXTERNAL_MODULES_CACHE = tempCacheDir39;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const moduleDir = path.join(tempRepo39, 'src');
|
||||||
|
await fs.ensureDir(path.join(tempRepo39, '.claude-plugin'));
|
||||||
|
await fs.ensureDir(moduleDir);
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(moduleDir, 'module.yaml'), ['code: sample-mod', 'module_version: 2.4.0', ''].join('\n'));
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(tempRepo39, '.claude-plugin', 'marketplace.json'),
|
||||||
|
JSON.stringify({ plugins: [{ name: 'sample-mod', version: '1.7.2' }] }, null, 2) + '\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
const versionInfo = await resolveModuleVersion('sample-mod', { moduleSourcePath: moduleDir });
|
||||||
|
assert(versionInfo.version === '2.4.0', 'resolver falls back to module.yaml when package.json is missing');
|
||||||
|
assert(versionInfo.source === 'module.yaml', 'resolver reports module.yaml when it provides the selected version');
|
||||||
|
} finally {
|
||||||
|
if (priorCacheEnv39 === undefined) {
|
||||||
|
delete process.env.BMAD_EXTERNAL_MODULES_CACHE;
|
||||||
|
} else {
|
||||||
|
process.env.BMAD_EXTERNAL_MODULES_CACHE = priorCacheEnv39;
|
||||||
|
}
|
||||||
|
await fs.remove(tempRepo39).catch(() => {});
|
||||||
|
await fs.remove(tempCacheDir39).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- marketplace fallback uses semver-aware comparison ---
|
||||||
|
{
|
||||||
|
const { resolveModuleVersion } = require('../tools/installer/modules/version-resolver');
|
||||||
|
const tempRepo39 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-version-marketplace-'));
|
||||||
|
const tempCacheDir39 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-version-marketplace-cache-'));
|
||||||
|
const priorCacheEnv39 = process.env.BMAD_EXTERNAL_MODULES_CACHE;
|
||||||
|
process.env.BMAD_EXTERNAL_MODULES_CACHE = tempCacheDir39;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const moduleDir = path.join(tempRepo39, 'src');
|
||||||
|
await fs.ensureDir(path.join(tempRepo39, '.claude-plugin'));
|
||||||
|
await fs.ensureDir(moduleDir);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(tempRepo39, '.claude-plugin', 'marketplace.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
plugins: [
|
||||||
|
{ name: 'older-plugin', version: '1.7.2' },
|
||||||
|
{ name: 'newer-plugin', version: '1.12.3' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
) + '\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
const versionInfo = await resolveModuleVersion('missing-plugin', { moduleSourcePath: moduleDir });
|
||||||
|
assert(
|
||||||
|
versionInfo.version === '1.12.3',
|
||||||
|
'resolver picks the highest marketplace fallback version using semver instead of string comparison',
|
||||||
|
);
|
||||||
|
assert(versionInfo.source === 'marketplace.json', 'resolver reports marketplace.json when it is the only usable metadata source');
|
||||||
|
} finally {
|
||||||
|
if (priorCacheEnv39 === undefined) {
|
||||||
|
delete process.env.BMAD_EXTERNAL_MODULES_CACHE;
|
||||||
|
} else {
|
||||||
|
process.env.BMAD_EXTERNAL_MODULES_CACHE = priorCacheEnv39;
|
||||||
|
}
|
||||||
|
await fs.remove(tempRepo39).catch(() => {});
|
||||||
|
await fs.remove(tempCacheDir39).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- package.json lookup must not escape the module repo boundary ---
|
||||||
|
{
|
||||||
|
const { resolveModuleVersion } = require('../tools/installer/modules/version-resolver');
|
||||||
|
const tempHost39 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-version-boundary-host-'));
|
||||||
|
const tempCacheDir39 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-version-boundary-cache-'));
|
||||||
|
const priorCacheEnv39 = process.env.BMAD_EXTERNAL_MODULES_CACHE;
|
||||||
|
process.env.BMAD_EXTERNAL_MODULES_CACHE = tempCacheDir39;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const moduleRoot = path.join(tempHost39, 'nested-module');
|
||||||
|
const moduleDir = path.join(moduleRoot, 'src');
|
||||||
|
await fs.ensureDir(path.join(moduleRoot, '.claude-plugin'));
|
||||||
|
await fs.ensureDir(moduleDir);
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(tempHost39, 'package.json'), JSON.stringify({ name: 'host-project', version: '9.9.9' }, null, 2) + '\n');
|
||||||
|
await fs.writeFile(path.join(moduleDir, 'module.yaml'), ['code: sample-mod', 'module_version: 2.4.0', ''].join('\n'));
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(moduleRoot, '.claude-plugin', 'marketplace.json'),
|
||||||
|
JSON.stringify({ plugins: [{ name: 'sample-mod', version: '1.7.2' }] }, null, 2) + '\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
const versionInfo = await resolveModuleVersion('sample-mod', { moduleSourcePath: moduleDir });
|
||||||
|
assert(versionInfo.version === '2.4.0', 'resolver does not read a host project package.json outside the module repo boundary');
|
||||||
|
assert(versionInfo.source === 'module.yaml', 'resolver stops at the module repo boundary before climbing into host project metadata');
|
||||||
|
} finally {
|
||||||
|
if (priorCacheEnv39 === undefined) {
|
||||||
|
delete process.env.BMAD_EXTERNAL_MODULES_CACHE;
|
||||||
|
} else {
|
||||||
|
process.env.BMAD_EXTERNAL_MODULES_CACHE = priorCacheEnv39;
|
||||||
|
}
|
||||||
|
await fs.remove(tempHost39).catch(() => {});
|
||||||
|
await fs.remove(tempCacheDir39).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Manifest uses the shared resolver for external modules ---
|
||||||
|
{
|
||||||
|
const { Manifest } = require('../tools/installer/core/manifest');
|
||||||
|
const { ExternalModuleManager } = require('../tools/installer/modules/external-manager');
|
||||||
|
const tempCacheDir39 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-manifest-version-cache-'));
|
||||||
|
const tempBmadDir39 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-manifest-version-install-'));
|
||||||
|
const priorCacheEnv39 = process.env.BMAD_EXTERNAL_MODULES_CACHE;
|
||||||
|
const originalLoadConfig39 = ExternalModuleManager.prototype.loadExternalModulesConfig;
|
||||||
|
process.env.BMAD_EXTERNAL_MODULES_CACHE = tempCacheDir39;
|
||||||
|
|
||||||
|
ExternalModuleManager.prototype.loadExternalModulesConfig = async function () {
|
||||||
|
return {
|
||||||
|
modules: [
|
||||||
|
{
|
||||||
|
code: 'tea',
|
||||||
|
name: 'Test Architect',
|
||||||
|
repository: 'https://example.com/tea.git',
|
||||||
|
module_definition: 'src/module.yaml',
|
||||||
|
npm_package: 'bmad-method-test-architecture-enterprise',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const moduleRoot = path.join(tempCacheDir39, 'tea');
|
||||||
|
const moduleSrc = path.join(moduleRoot, 'src');
|
||||||
|
await fs.ensureDir(path.join(moduleRoot, '.claude-plugin'));
|
||||||
|
await fs.ensureDir(moduleSrc);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(moduleRoot, 'package.json'),
|
||||||
|
JSON.stringify({ name: 'bmad-method-test-architecture-enterprise', version: '1.12.3' }, null, 2) + '\n',
|
||||||
|
);
|
||||||
|
await fs.writeFile(path.join(moduleSrc, 'module.yaml'), ['code: tea', 'module_version: 1.11.0', ''].join('\n'));
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(moduleRoot, '.claude-plugin', 'marketplace.json'),
|
||||||
|
JSON.stringify({ plugins: [{ name: 'tea', version: '1.7.2' }] }, null, 2) + '\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
const manifest39 = new Manifest();
|
||||||
|
const versionInfo = await manifest39.getModuleVersionInfo('tea', tempBmadDir39, moduleSrc);
|
||||||
|
|
||||||
|
assert(versionInfo.version === '1.12.3', 'manifest version info prefers external package.json over stale marketplace metadata');
|
||||||
|
assert(versionInfo.source === 'external', 'manifest preserves external source classification while using the shared resolver');
|
||||||
|
assert(
|
||||||
|
versionInfo.npmPackage === 'bmad-method-test-architecture-enterprise',
|
||||||
|
'manifest preserves npm package metadata for external modules',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
ExternalModuleManager.prototype.loadExternalModulesConfig = originalLoadConfig39;
|
||||||
|
if (priorCacheEnv39 === undefined) {
|
||||||
|
delete process.env.BMAD_EXTERNAL_MODULES_CACHE;
|
||||||
|
} else {
|
||||||
|
process.env.BMAD_EXTERNAL_MODULES_CACHE = priorCacheEnv39;
|
||||||
|
}
|
||||||
|
await fs.remove(tempCacheDir39).catch(() => {});
|
||||||
|
await fs.remove(tempBmadDir39).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Update checks should not advertise npm downgrades when source installs are newer ---
|
||||||
|
{
|
||||||
|
const { Manifest } = require('../tools/installer/core/manifest');
|
||||||
|
const manifest39 = new Manifest();
|
||||||
|
const originalGetAllModuleVersions39 = manifest39.getAllModuleVersions.bind(manifest39);
|
||||||
|
const originalFetchNpmVersion39 = manifest39.fetchNpmVersion.bind(manifest39);
|
||||||
|
|
||||||
|
manifest39.getAllModuleVersions = async () => [
|
||||||
|
{
|
||||||
|
name: 'tea',
|
||||||
|
version: '1.12.3',
|
||||||
|
npmPackage: 'bmad-method-test-architecture-enterprise',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
manifest39.fetchNpmVersion = async () => '1.7.2';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updates = await manifest39.checkForUpdates('/unused');
|
||||||
|
assert(updates.length === 0, 'update check ignores older npm versions when installed source metadata is newer');
|
||||||
|
} finally {
|
||||||
|
manifest39.getAllModuleVersions = originalGetAllModuleVersions39;
|
||||||
|
manifest39.fetchNpmVersion = originalFetchNpmVersion39;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Update checks ignore non-semver version strings instead of flagging false positives ---
|
||||||
|
{
|
||||||
|
const { Manifest } = require('../tools/installer/core/manifest');
|
||||||
|
const manifest39 = new Manifest();
|
||||||
|
const originalGetAllModuleVersions39 = manifest39.getAllModuleVersions.bind(manifest39);
|
||||||
|
const originalFetchNpmVersion39 = manifest39.fetchNpmVersion.bind(manifest39);
|
||||||
|
|
||||||
|
manifest39.getAllModuleVersions = async () => [
|
||||||
|
{
|
||||||
|
name: 'tea',
|
||||||
|
version: 'workspace-build',
|
||||||
|
npmPackage: 'bmad-method-test-architecture-enterprise',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
manifest39.fetchNpmVersion = async () => 'latest-build';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updates = await manifest39.checkForUpdates('/unused');
|
||||||
|
assert(updates.length === 0, 'update check ignores non-semver version strings instead of reporting misleading updates');
|
||||||
|
} finally {
|
||||||
|
manifest39.getAllModuleVersions = originalGetAllModuleVersions39;
|
||||||
|
manifest39.fetchNpmVersion = originalFetchNpmVersion39;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Summary
|
// Summary
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ const prompts = require('../prompts');
|
||||||
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
|
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
|
||||||
const { InstallPaths } = require('./install-paths');
|
const { InstallPaths } = require('./install-paths');
|
||||||
const { ExternalModuleManager } = require('../modules/external-manager');
|
const { ExternalModuleManager } = require('../modules/external-manager');
|
||||||
|
const { resolveModuleVersion } = require('../modules/version-resolver');
|
||||||
|
|
||||||
const { ExistingInstall } = require('./existing-install');
|
const { ExistingInstall } = require('./existing-install');
|
||||||
|
|
||||||
|
|
@ -24,44 +25,6 @@ class Installer {
|
||||||
this.bmadFolderName = BMAD_FOLDER_NAME;
|
this.bmadFolderName = BMAD_FOLDER_NAME;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the module version from .claude-plugin/marketplace.json
|
|
||||||
* Walks up from sourcePath looking for .claude-plugin/marketplace.json
|
|
||||||
* @param {string} sourcePath - Module source directory
|
|
||||||
* @returns {string} Version string or empty string
|
|
||||||
*/
|
|
||||||
async _getMarketplaceVersion(sourcePath) {
|
|
||||||
let dir = sourcePath;
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
const marketplacePath = path.join(dir, '.claude-plugin', 'marketplace.json');
|
|
||||||
if (await fs.pathExists(marketplacePath)) {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
|
|
||||||
return this._extractMarketplaceVersion(data);
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const parent = path.dirname(dir);
|
|
||||||
if (parent === dir) break;
|
|
||||||
dir = parent;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the highest version from marketplace.json plugins array
|
|
||||||
*/
|
|
||||||
_extractMarketplaceVersion(data) {
|
|
||||||
const plugins = data?.plugins;
|
|
||||||
if (!Array.isArray(plugins) || plugins.length === 0) return '';
|
|
||||||
let best = '';
|
|
||||||
for (const p of plugins) {
|
|
||||||
if (p.version && (!best || p.version > best)) best = p.version;
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main installation method
|
* Main installation method
|
||||||
* @param {Object} config - Installation configuration
|
* @param {Object} config - Installation configuration
|
||||||
|
|
@ -641,15 +604,18 @@ class Installer {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get display name from source module.yaml; version from resolution cache or marketplace.json
|
// Get display name from source module.yaml and resolve the freshest version metadata we can find locally.
|
||||||
const sourcePath = await officialModules.findModuleSource(moduleName, { silent: true });
|
const sourcePath = await officialModules.findModuleSource(moduleName, { silent: true });
|
||||||
const moduleInfo = sourcePath ? await officialModules.getModuleInfo(sourcePath, moduleName, '') : null;
|
const moduleInfo = sourcePath ? await officialModules.getModuleInfo(sourcePath, moduleName, '') : null;
|
||||||
const displayName = moduleInfo?.name || moduleName;
|
const displayName = moduleInfo?.name || moduleName;
|
||||||
|
|
||||||
// Prefer version from resolution cache (accurate for custom/local modules),
|
|
||||||
// fall back to marketplace.json walk-up for official modules
|
|
||||||
const cachedResolution = CustomModuleManager._resolutionCache.get(moduleName);
|
const cachedResolution = CustomModuleManager._resolutionCache.get(moduleName);
|
||||||
const version = cachedResolution?.version || (sourcePath ? await this._getMarketplaceVersion(sourcePath) : '');
|
const versionInfo = await resolveModuleVersion(moduleName, {
|
||||||
|
moduleSourcePath: sourcePath,
|
||||||
|
fallbackVersion: cachedResolution?.version,
|
||||||
|
marketplacePluginNames: cachedResolution?.pluginName ? [cachedResolution.pluginName] : [],
|
||||||
|
});
|
||||||
|
const version = versionInfo.version || '';
|
||||||
addResult(displayName, 'ok', '', { moduleCode: moduleName, newVersion: version });
|
addResult(displayName, 'ok', '', { moduleCode: moduleName, newVersion: version });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ const path = require('node:path');
|
||||||
const fs = require('../fs-native');
|
const fs = require('../fs-native');
|
||||||
const yaml = require('yaml');
|
const yaml = require('yaml');
|
||||||
const crypto = require('node:crypto');
|
const crypto = require('node:crypto');
|
||||||
const { getModulePath } = require('../project-root');
|
const { resolveInstalledModuleYaml } = require('../project-root');
|
||||||
const prompts = require('../prompts');
|
const prompts = require('../prompts');
|
||||||
|
|
||||||
// Load package.json for version info
|
// Load package.json for version info
|
||||||
|
|
@ -244,8 +244,17 @@ class ManifestGenerator {
|
||||||
const debug = process.env.BMAD_DEBUG_MANIFEST === 'true';
|
const debug = process.env.BMAD_DEBUG_MANIFEST === 'true';
|
||||||
|
|
||||||
for (const moduleName of this.updatedModules) {
|
for (const moduleName of this.updatedModules) {
|
||||||
const moduleYamlPath = path.join(getModulePath(moduleName), 'module.yaml');
|
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
|
||||||
if (!(await fs.pathExists(moduleYamlPath))) continue;
|
if (!moduleYamlPath) {
|
||||||
|
// External modules live in ~/.bmad/cache/external-modules, not src/modules.
|
||||||
|
// Warn rather than silently skip so missing agent rosters don't vanish
|
||||||
|
// from config.toml without notice.
|
||||||
|
console.warn(
|
||||||
|
`[warn] collectAgentsFromModuleYaml: could not locate module.yaml for '${moduleName}'. ` +
|
||||||
|
`Agents declared by this module will not be written to config.toml.`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let moduleDef;
|
let moduleDef;
|
||||||
try {
|
try {
|
||||||
|
|
@ -271,7 +280,9 @@ class ManifestGenerator {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (debug) {
|
if (debug) {
|
||||||
console.log(`[DEBUG] collectAgentsFromModuleYaml: ${moduleName} contributed ${moduleDef.agents.length} agents`);
|
console.log(
|
||||||
|
`[DEBUG] collectAgentsFromModuleYaml: ${moduleName} contributed ${moduleDef.agents.length} agents from ${moduleYamlPath}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -410,8 +421,14 @@ class ManifestGenerator {
|
||||||
// team config, so the operator should notice.
|
// team config, so the operator should notice.
|
||||||
const scopeByModuleKey = {};
|
const scopeByModuleKey = {};
|
||||||
for (const moduleName of this.updatedModules) {
|
for (const moduleName of this.updatedModules) {
|
||||||
const moduleYamlPath = path.join(getModulePath(moduleName), 'module.yaml');
|
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
|
||||||
if (!(await fs.pathExists(moduleYamlPath))) continue;
|
if (!moduleYamlPath) {
|
||||||
|
console.warn(
|
||||||
|
`[warn] writeCentralConfig: could not locate module.yaml for '${moduleName}'. ` +
|
||||||
|
`Answers from this module will default to team scope — user-scoped keys may mis-file into config.toml.`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = yaml.parse(await fs.readFile(moduleYamlPath, 'utf8'));
|
const parsed = yaml.parse(await fs.readFile(moduleYamlPath, 'utf8'));
|
||||||
if (!parsed || typeof parsed !== 'object') continue;
|
if (!parsed || typeof parsed !== 'object') continue;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const fs = require('../fs-native');
|
const fs = require('../fs-native');
|
||||||
const crypto = require('node:crypto');
|
const crypto = require('node:crypto');
|
||||||
const { getProjectRoot } = require('../project-root');
|
const { resolveModuleVersion } = require('../modules/version-resolver');
|
||||||
const prompts = require('../prompts');
|
const prompts = require('../prompts');
|
||||||
|
|
||||||
class Manifest {
|
class Manifest {
|
||||||
|
|
@ -258,13 +258,11 @@ class Manifest {
|
||||||
* @returns {Object} Version info object with version, source, npmPackage, repoUrl
|
* @returns {Object} Version info object with version, source, npmPackage, repoUrl
|
||||||
*/
|
*/
|
||||||
async getModuleVersionInfo(moduleName, bmadDir, moduleSourcePath = null) {
|
async getModuleVersionInfo(moduleName, bmadDir, moduleSourcePath = null) {
|
||||||
const yaml = require('yaml');
|
|
||||||
|
|
||||||
// Resolve source type first, then read version with the correct path context
|
// Resolve source type first, then read version with the correct path context
|
||||||
if (['core', 'bmm'].includes(moduleName)) {
|
if (['core', 'bmm'].includes(moduleName)) {
|
||||||
const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
|
const versionInfo = await resolveModuleVersion(moduleName, { moduleSourcePath });
|
||||||
return {
|
return {
|
||||||
version,
|
version: versionInfo.version,
|
||||||
source: 'built-in',
|
source: 'built-in',
|
||||||
npmPackage: null,
|
npmPackage: null,
|
||||||
repoUrl: null,
|
repoUrl: null,
|
||||||
|
|
@ -277,10 +275,9 @@ class Manifest {
|
||||||
const moduleInfo = await extMgr.getModuleByCode(moduleName);
|
const moduleInfo = await extMgr.getModuleByCode(moduleName);
|
||||||
|
|
||||||
if (moduleInfo) {
|
if (moduleInfo) {
|
||||||
// External module: use moduleSourcePath if provided, otherwise fall back to cache
|
const versionInfo = await resolveModuleVersion(moduleName, { moduleSourcePath });
|
||||||
const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
|
|
||||||
return {
|
return {
|
||||||
version,
|
version: versionInfo.version,
|
||||||
source: 'external',
|
source: 'external',
|
||||||
npmPackage: moduleInfo.npmPackage || null,
|
npmPackage: moduleInfo.npmPackage || null,
|
||||||
repoUrl: moduleInfo.url || null,
|
repoUrl: moduleInfo.url || null,
|
||||||
|
|
@ -292,9 +289,12 @@ class Manifest {
|
||||||
const communityMgr = new CommunityModuleManager();
|
const communityMgr = new CommunityModuleManager();
|
||||||
const communityInfo = await communityMgr.getModuleByCode(moduleName);
|
const communityInfo = await communityMgr.getModuleByCode(moduleName);
|
||||||
if (communityInfo) {
|
if (communityInfo) {
|
||||||
const communityVersion = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
|
const versionInfo = await resolveModuleVersion(moduleName, {
|
||||||
|
moduleSourcePath,
|
||||||
|
fallbackVersion: communityInfo.version,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
version: communityVersion || communityInfo.version,
|
version: versionInfo.version || communityInfo.version,
|
||||||
source: 'community',
|
source: 'community',
|
||||||
npmPackage: communityInfo.npmPackage || null,
|
npmPackage: communityInfo.npmPackage || null,
|
||||||
repoUrl: communityInfo.url || null,
|
repoUrl: communityInfo.url || null,
|
||||||
|
|
@ -307,9 +307,13 @@ class Manifest {
|
||||||
const resolved = customMgr.getResolution(moduleName);
|
const resolved = customMgr.getResolution(moduleName);
|
||||||
const customSource = await customMgr.findModuleSourceByCode(moduleName, { bmadDir });
|
const customSource = await customMgr.findModuleSourceByCode(moduleName, { bmadDir });
|
||||||
if (customSource || resolved) {
|
if (customSource || resolved) {
|
||||||
const customVersion = resolved?.version || (await this._readMarketplaceVersion(moduleName, moduleSourcePath));
|
const versionInfo = await resolveModuleVersion(moduleName, {
|
||||||
|
moduleSourcePath: moduleSourcePath || customSource,
|
||||||
|
fallbackVersion: resolved?.version,
|
||||||
|
marketplacePluginNames: resolved?.pluginName ? [resolved.pluginName] : [],
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
version: customVersion,
|
version: versionInfo.version,
|
||||||
source: 'custom',
|
source: 'custom',
|
||||||
npmPackage: null,
|
npmPackage: null,
|
||||||
repoUrl: resolved?.repoUrl || null,
|
repoUrl: resolved?.repoUrl || null,
|
||||||
|
|
@ -318,64 +322,15 @@ class Manifest {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unknown module
|
// Unknown module
|
||||||
const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
|
const versionInfo = await resolveModuleVersion(moduleName, { moduleSourcePath });
|
||||||
return {
|
return {
|
||||||
version,
|
version: versionInfo.version,
|
||||||
source: 'unknown',
|
source: 'unknown',
|
||||||
npmPackage: null,
|
npmPackage: null,
|
||||||
repoUrl: null,
|
repoUrl: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Read version from .claude-plugin/marketplace.json for a module
|
|
||||||
* @param {string} moduleName - Module code
|
|
||||||
* @returns {string|null} Version or null
|
|
||||||
*/
|
|
||||||
async _readMarketplaceVersion(moduleName, moduleSourcePath = null) {
|
|
||||||
const os = require('node:os');
|
|
||||||
let marketplacePath;
|
|
||||||
|
|
||||||
if (['core', 'bmm'].includes(moduleName)) {
|
|
||||||
marketplacePath = path.join(getProjectRoot(), '.claude-plugin', 'marketplace.json');
|
|
||||||
} else if (moduleSourcePath) {
|
|
||||||
// Walk up from source path to find marketplace.json
|
|
||||||
let dir = moduleSourcePath;
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
const candidate = path.join(dir, '.claude-plugin', 'marketplace.json');
|
|
||||||
if (await fs.pathExists(candidate)) {
|
|
||||||
marketplacePath = candidate;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const parent = path.dirname(dir);
|
|
||||||
if (parent === dir) break;
|
|
||||||
dir = parent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to external module cache
|
|
||||||
if (!marketplacePath) {
|
|
||||||
const cacheDir = path.join(os.homedir(), '.bmad', 'cache', 'external-modules', moduleName);
|
|
||||||
marketplacePath = path.join(cacheDir, '.claude-plugin', 'marketplace.json');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (await fs.pathExists(marketplacePath)) {
|
|
||||||
const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
|
|
||||||
const plugins = data?.plugins;
|
|
||||||
if (!Array.isArray(plugins) || plugins.length === 0) return null;
|
|
||||||
let best = null;
|
|
||||||
for (const p of plugins) {
|
|
||||||
if (p.version && (!best || p.version > best)) best = p.version;
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch latest version from npm for a package
|
* Fetch latest version from npm for a package
|
||||||
* @param {string} packageName - npm package name
|
* @param {string} packageName - npm package name
|
||||||
|
|
@ -424,6 +379,7 @@ class Manifest {
|
||||||
* @returns {Array} Array of update info objects
|
* @returns {Array} Array of update info objects
|
||||||
*/
|
*/
|
||||||
async checkForUpdates(bmadDir) {
|
async checkForUpdates(bmadDir) {
|
||||||
|
const semver = require('semver');
|
||||||
const modules = await this.getAllModuleVersions(bmadDir);
|
const modules = await this.getAllModuleVersions(bmadDir);
|
||||||
const updates = [];
|
const updates = [];
|
||||||
|
|
||||||
|
|
@ -437,7 +393,10 @@ class Manifest {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (module.version !== latestVersion) {
|
const installedVersion = semver.valid(module.version) || semver.valid(semver.coerce(module.version || ''));
|
||||||
|
const availableVersion = semver.valid(latestVersion) || semver.valid(semver.coerce(latestVersion));
|
||||||
|
|
||||||
|
if (installedVersion && availableVersion && semver.gt(availableVersion, installedVersion)) {
|
||||||
updates.push({
|
updates.push({
|
||||||
name: module.name,
|
name: module.name,
|
||||||
installedVersion: module.version,
|
installedVersion: module.version,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,336 @@
|
||||||
|
const path = require('node:path');
|
||||||
|
const semver = require('semver');
|
||||||
|
const yaml = require('yaml');
|
||||||
|
const fs = require('../fs-native');
|
||||||
|
const { getExternalModuleCachePath, getModulePath, resolveInstalledModuleYaml } = require('../project-root');
|
||||||
|
|
||||||
|
const DEFAULT_PARENT_DEPTH = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a module version from authoritative on-disk metadata.
|
||||||
|
* Preference order:
|
||||||
|
* 1. package.json nearest the module source/cache root
|
||||||
|
* 2. module.yaml in the module source directory
|
||||||
|
* 3. .claude-plugin/marketplace.json
|
||||||
|
* 4. caller-provided fallback version
|
||||||
|
*
|
||||||
|
* @param {string} moduleName - Module code/name
|
||||||
|
* @param {Object} [options]
|
||||||
|
* @param {string} [options.moduleSourcePath] - Directory containing module.yaml
|
||||||
|
* @param {string} [options.fallbackVersion] - Final fallback when no metadata is found
|
||||||
|
* @param {string[]} [options.marketplacePluginNames] - Preferred marketplace plugin names
|
||||||
|
* @returns {Promise<{version: string|null, source: string|null, path: string|null}>}
|
||||||
|
*/
|
||||||
|
async function resolveModuleVersion(moduleName, options = {}) {
|
||||||
|
const moduleSourcePath = await normalizeDirectoryPath(options.moduleSourcePath);
|
||||||
|
const packageJsonPath = await findPackageJsonPath(moduleName, moduleSourcePath);
|
||||||
|
|
||||||
|
if (packageJsonPath) {
|
||||||
|
const packageVersion = await readPackageJsonVersion(packageJsonPath);
|
||||||
|
if (packageVersion) {
|
||||||
|
return {
|
||||||
|
version: packageVersion,
|
||||||
|
source: 'package.json',
|
||||||
|
path: packageJsonPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const moduleYamlPath = await findModuleYamlPath(moduleName, moduleSourcePath);
|
||||||
|
if (moduleYamlPath) {
|
||||||
|
const moduleVersion = await readModuleYamlVersion(moduleYamlPath);
|
||||||
|
if (moduleVersion) {
|
||||||
|
return {
|
||||||
|
version: moduleVersion,
|
||||||
|
source: 'module.yaml',
|
||||||
|
path: moduleYamlPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const marketplaceVersion = await findMarketplaceVersion(moduleName, moduleSourcePath, options.marketplacePluginNames || []);
|
||||||
|
if (marketplaceVersion) {
|
||||||
|
return marketplaceVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackVersion = normalizeVersion(options.fallbackVersion);
|
||||||
|
if (fallbackVersion) {
|
||||||
|
return {
|
||||||
|
version: fallbackVersion,
|
||||||
|
source: 'fallback',
|
||||||
|
path: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: null,
|
||||||
|
source: null,
|
||||||
|
path: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findPackageJsonPath(moduleName, moduleSourcePath) {
|
||||||
|
const roots = await buildSearchRoots(moduleName, moduleSourcePath);
|
||||||
|
|
||||||
|
for (const root of roots) {
|
||||||
|
const packageJsonPath = await findNearestUpwardFile(root.searchDir, 'package.json', { boundaryDir: root.boundaryDir });
|
||||||
|
if (packageJsonPath) {
|
||||||
|
return packageJsonPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findModuleYamlPath(moduleName, moduleSourcePath) {
|
||||||
|
if (moduleSourcePath) {
|
||||||
|
const directModuleYamlPath = path.join(moduleSourcePath, 'module.yaml');
|
||||||
|
if (await fs.pathExists(directModuleYamlPath)) {
|
||||||
|
return directModuleYamlPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveInstalledModuleYaml(moduleName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findMarketplaceVersion(moduleName, moduleSourcePath, marketplacePluginNames) {
|
||||||
|
const roots = await buildSearchRoots(moduleName, moduleSourcePath);
|
||||||
|
|
||||||
|
for (const root of roots) {
|
||||||
|
const marketplacePath = await findNearestUpwardFile(root.searchDir, path.join('.claude-plugin', 'marketplace.json'), {
|
||||||
|
boundaryDir: root.boundaryDir,
|
||||||
|
});
|
||||||
|
if (!marketplacePath) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await readJsonFile(marketplacePath);
|
||||||
|
if (!data) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = extractMarketplaceVersion(data, moduleName, marketplacePluginNames);
|
||||||
|
if (version) {
|
||||||
|
return {
|
||||||
|
version,
|
||||||
|
source: 'marketplace.json',
|
||||||
|
path: marketplacePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildSearchRoots(moduleName, moduleSourcePath) {
|
||||||
|
const roots = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
const addRoot = async (candidate) => {
|
||||||
|
const normalized = await normalizeExistingDirectory(candidate);
|
||||||
|
if (!normalized || seen.has(normalized)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(normalized);
|
||||||
|
roots.push({
|
||||||
|
searchDir: normalized,
|
||||||
|
boundaryDir: await findSearchBoundary(normalized),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
await addRoot(moduleSourcePath);
|
||||||
|
|
||||||
|
if (moduleName === 'core' || moduleName === 'bmm') {
|
||||||
|
await addRoot(getModulePath(moduleName));
|
||||||
|
} else {
|
||||||
|
await addRoot(getExternalModuleCachePath(moduleName));
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findNearestUpwardFile(startDir, relativeFilePath, options = {}) {
|
||||||
|
const normalizedStartDir = await normalizeExistingDirectory(startDir);
|
||||||
|
if (!normalizedStartDir) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxDepth = options.maxDepth ?? DEFAULT_PARENT_DEPTH;
|
||||||
|
const normalizedBoundaryDir = await normalizeDirectoryPath(options.boundaryDir);
|
||||||
|
let currentDir = normalizedStartDir;
|
||||||
|
for (let depth = 0; depth <= maxDepth; depth++) {
|
||||||
|
const candidate = path.join(currentDir, relativeFilePath);
|
||||||
|
if (await fs.pathExists(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedBoundaryDir && currentDir === normalizedBoundaryDir) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentDir = path.dirname(currentDir);
|
||||||
|
if (parentDir === currentDir) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
currentDir = parentDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findSearchBoundary(startDir) {
|
||||||
|
const normalizedStartDir = await normalizeExistingDirectory(startDir);
|
||||||
|
if (!normalizedStartDir) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentDir = normalizedStartDir;
|
||||||
|
for (let depth = 0; depth <= DEFAULT_PARENT_DEPTH; depth++) {
|
||||||
|
if (
|
||||||
|
(await fs.pathExists(path.join(currentDir, 'package.json'))) ||
|
||||||
|
(await fs.pathExists(path.join(currentDir, '.claude-plugin', 'marketplace.json'))) ||
|
||||||
|
(await fs.pathExists(path.join(currentDir, '.git')))
|
||||||
|
) {
|
||||||
|
return currentDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentDir = path.dirname(currentDir);
|
||||||
|
if (parentDir === currentDir) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
currentDir = parentDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedStartDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function normalizeDirectoryPath(candidate) {
|
||||||
|
if (!candidate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPath = path.resolve(candidate);
|
||||||
|
try {
|
||||||
|
const stats = await fs.stat(resolvedPath);
|
||||||
|
return stats.isDirectory() ? resolvedPath : path.dirname(resolvedPath);
|
||||||
|
} catch {
|
||||||
|
return resolvedPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function normalizeExistingDirectory(candidate) {
|
||||||
|
const normalized = await normalizeDirectoryPath(candidate);
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(normalized))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPackageJsonVersion(packageJsonPath) {
|
||||||
|
const data = await readJsonFile(packageJsonPath);
|
||||||
|
return normalizeVersion(data?.version);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readModuleYamlVersion(moduleYamlPath) {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(moduleYamlPath, 'utf8');
|
||||||
|
const data = yaml.parse(content);
|
||||||
|
return normalizeVersion(data?.version || data?.module_version || data?.moduleVersion);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonFile(filePath) {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(filePath, 'utf8');
|
||||||
|
return JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMarketplaceVersion(data, moduleName, marketplacePluginNames = []) {
|
||||||
|
const plugins = Array.isArray(data?.plugins) ? data.plugins : [];
|
||||||
|
if (plugins.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferredNames = new Set(
|
||||||
|
[moduleName, ...marketplacePluginNames]
|
||||||
|
.filter((value) => typeof value === 'string')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
|
||||||
|
const exactMatches = [];
|
||||||
|
const fallbackVersions = [];
|
||||||
|
|
||||||
|
for (const plugin of plugins) {
|
||||||
|
const version = normalizeVersion(plugin?.version);
|
||||||
|
if (!version) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
fallbackVersions.push(version);
|
||||||
|
|
||||||
|
const pluginNames = [plugin?.name, plugin?.code].filter((value) => typeof value === 'string').map((value) => value.trim());
|
||||||
|
if (pluginNames.some((name) => preferredNames.has(name))) {
|
||||||
|
exactMatches.push(version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pickBestVersion(exactMatches.length > 0 ? exactMatches : fallbackVersions);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBestVersion(versions) {
|
||||||
|
const candidates = versions.map(normalizeVersion).filter(Boolean);
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.sort(compareVersionsDescending);
|
||||||
|
return candidates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareVersionsDescending(left, right) {
|
||||||
|
const leftSemver = normalizeSemver(left);
|
||||||
|
const rightSemver = normalizeSemver(right);
|
||||||
|
|
||||||
|
if (leftSemver && rightSemver) {
|
||||||
|
return semver.rcompare(leftSemver, rightSemver);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leftSemver) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rightSemver) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return right.localeCompare(left, undefined, { numeric: true, sensitivity: 'base' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSemver(version) {
|
||||||
|
return semver.valid(version) || semver.valid(semver.coerce(version));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVersion(version) {
|
||||||
|
if (typeof version !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = version.trim();
|
||||||
|
return trimmed || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
resolveModuleVersion,
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
const fs = require('./fs-native');
|
const fs = require('./fs-native');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -69,9 +70,62 @@ function getModulePath(moduleName, ...segments) {
|
||||||
return getSourcePath('modules', moduleName, ...segments);
|
return getSourcePath('modules', moduleName, ...segments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path to the local external-module clone cache.
|
||||||
|
* External official modules (bmb, cis, gds, tea, wds, etc.) are cloned here
|
||||||
|
* by ExternalModuleManager during install and are not copied into <src>/modules/.
|
||||||
|
*/
|
||||||
|
function getExternalModuleCachePath(moduleName, ...segments) {
|
||||||
|
const base = process.env.BMAD_EXTERNAL_MODULES_CACHE || path.join(os.homedir(), '.bmad', 'cache', 'external-modules');
|
||||||
|
return path.join(base, moduleName, ...segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locate an installed module's `module.yaml` by filesystem lookup only.
|
||||||
|
*
|
||||||
|
* Built-in modules (core, bmm) live under <src>. External official modules are
|
||||||
|
* cloned into ~/.bmad/cache/external-modules/<name>/ with varying internal
|
||||||
|
* layouts (some at src/module.yaml, some at skills/module.yaml, some nested).
|
||||||
|
* This mirrors the candidate-path search in
|
||||||
|
* ExternalModuleManager.findExternalModuleSource but performs no git/network
|
||||||
|
* work, which keeps it safe to call during manifest writing.
|
||||||
|
*
|
||||||
|
* @param {string} moduleName
|
||||||
|
* @returns {Promise<string|null>} Absolute path to module.yaml, or null if not found.
|
||||||
|
*/
|
||||||
|
async function resolveInstalledModuleYaml(moduleName) {
|
||||||
|
const builtIn = path.join(getModulePath(moduleName), 'module.yaml');
|
||||||
|
if (await fs.pathExists(builtIn)) return builtIn;
|
||||||
|
|
||||||
|
const cacheRoot = getExternalModuleCachePath(moduleName);
|
||||||
|
if (!(await fs.pathExists(cacheRoot))) return null;
|
||||||
|
|
||||||
|
for (const dir of ['skills', 'src']) {
|
||||||
|
const direct = path.join(cacheRoot, dir, 'module.yaml');
|
||||||
|
if (await fs.pathExists(direct)) return direct;
|
||||||
|
|
||||||
|
const dirPath = path.join(cacheRoot, dir);
|
||||||
|
if (await fs.pathExists(dirPath)) {
|
||||||
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const nested = path.join(dirPath, entry.name, 'module.yaml');
|
||||||
|
if (await fs.pathExists(nested)) return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const atRoot = path.join(cacheRoot, 'module.yaml');
|
||||||
|
if (await fs.pathExists(atRoot)) return atRoot;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getProjectRoot,
|
getProjectRoot,
|
||||||
getSourcePath,
|
getSourcePath,
|
||||||
getModulePath,
|
getModulePath,
|
||||||
|
getExternalModuleCachePath,
|
||||||
|
resolveInstalledModuleYaml,
|
||||||
findProjectRoot,
|
findProjectRoot,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,48 +3,17 @@ const os = require('node:os');
|
||||||
const fs = require('./fs-native');
|
const fs = require('./fs-native');
|
||||||
const { CLIUtils } = require('./cli-utils');
|
const { CLIUtils } = require('./cli-utils');
|
||||||
const { ExternalModuleManager } = require('./modules/external-manager');
|
const { ExternalModuleManager } = require('./modules/external-manager');
|
||||||
const { getProjectRoot } = require('./project-root');
|
const { resolveModuleVersion } = require('./modules/version-resolver');
|
||||||
const prompts = require('./prompts');
|
const prompts = require('./prompts');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read module version from .claude-plugin/marketplace.json
|
* Read a module version from the freshest local metadata available.
|
||||||
* @param {string} moduleCode - Module code (e.g., 'core', 'bmm', 'cis')
|
* @param {string} moduleCode - Module code (e.g., 'core', 'bmm', 'cis')
|
||||||
* @returns {string} Version string or empty string
|
* @returns {string} Version string or empty string
|
||||||
*/
|
*/
|
||||||
async function getMarketplaceVersion(moduleCode) {
|
async function getModuleVersion(moduleCode) {
|
||||||
let marketplacePath;
|
const versionInfo = await resolveModuleVersion(moduleCode);
|
||||||
if (moduleCode === 'core' || moduleCode === 'bmm') {
|
return versionInfo.version || '';
|
||||||
marketplacePath = path.join(getProjectRoot(), '.claude-plugin', 'marketplace.json');
|
|
||||||
} else {
|
|
||||||
const cacheDir = path.join(os.homedir(), '.bmad', 'cache', 'external-modules', moduleCode);
|
|
||||||
marketplacePath = path.join(cacheDir, '.claude-plugin', 'marketplace.json');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (await fs.pathExists(marketplacePath)) {
|
|
||||||
const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
|
|
||||||
return _extractMarketplaceVersion(data);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the highest version from marketplace.json plugins array.
|
|
||||||
* Handles multiple plugins per file safely.
|
|
||||||
* @param {Object} data - Parsed marketplace.json
|
|
||||||
* @returns {string} Version string or empty string
|
|
||||||
*/
|
|
||||||
function _extractMarketplaceVersion(data) {
|
|
||||||
const plugins = data?.plugins;
|
|
||||||
if (!Array.isArray(plugins) || plugins.length === 0) return '';
|
|
||||||
// Use the highest version across all plugins in the file
|
|
||||||
let best = '';
|
|
||||||
for (const p of plugins) {
|
|
||||||
if (p.version && (!best || p.version > best)) best = p.version;
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -644,7 +613,7 @@ class UI {
|
||||||
|
|
||||||
const buildModuleEntry = async (code, name, description, isDefault) => {
|
const buildModuleEntry = async (code, name, description, isDefault) => {
|
||||||
const isInstalled = installedModuleIds.has(code);
|
const isInstalled = installedModuleIds.has(code);
|
||||||
const version = await getMarketplaceVersion(code);
|
const version = await getModuleVersion(code);
|
||||||
const label = version ? `${name} (v${version})` : name;
|
const label = version ? `${name} (v${version})` : name;
|
||||||
return {
|
return {
|
||||||
label,
|
label,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue