feat(installer): package automator runtime from source
This commit is contained in:
parent
9debc165aa
commit
da90e1b2e2
|
|
@ -31,13 +31,17 @@ npx bmad-method install
|
||||||
The interactive flow asks you five things:
|
The interactive flow asks you five things:
|
||||||
|
|
||||||
1. Installation directory (defaults to the current working directory)
|
1. Installation directory (defaults to the current working directory)
|
||||||
2. Which modules to install (checkboxes for core, bmm, bmb, cis, gds, tea)
|
2. Which modules to install (checkboxes for core, bmm, bmb, cis, gds, tea, bma)
|
||||||
3. **"Ready to install (all stable)?"** — Yes accepts the latest released tag for every external module
|
3. **"Ready to install (all stable)?"** — Yes accepts the latest released tag for every external module
|
||||||
4. Which AI tools/IDEs to integrate with (claude-code, cursor, and others)
|
4. Which AI tools/IDEs to integrate with (claude-code, cursor, and others)
|
||||||
5. Per-module config (name, language, output folder)
|
5. Per-module config (name, language, output folder)
|
||||||
|
|
||||||
Accept the defaults and you land on the latest stable release of every module, configured for your chosen tool.
|
Accept the defaults and you land on the latest stable release of every module, configured for your chosen tool.
|
||||||
|
|
||||||
|
:::caution[BMad Automator constraints]
|
||||||
|
`bma` is experimental and sourced from `bmad-code-org/bmad-automator`. BMAD-METHOD installs the packaged skills; Automator runtime behavior should be changed in the Automator repo.
|
||||||
|
:::
|
||||||
|
|
||||||
:::tip[Just want the newest prerelease?]
|
:::tip[Just want the newest prerelease?]
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -53,7 +57,7 @@ Two independent axes control what ends up on disk.
|
||||||
|
|
||||||
### Axis 1: external module channels
|
### Axis 1: external module channels
|
||||||
|
|
||||||
Every external module — bmb, cis, gds, tea, and any community module — installs on one of three channels:
|
Every external module — bmb, cis, gds, tea, bma, and any community module — installs on one of three channels:
|
||||||
|
|
||||||
| Channel | What gets installed | Who picks this |
|
| Channel | What gets installed | Who picks this |
|
||||||
| ------------------ | ---------------------------------------------------------------------------- | --------------------------------------- |
|
| ------------------ | ---------------------------------------------------------------------------- | --------------------------------------- |
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,24 @@ Enterprise-grade test strategy, automation guidance, and release gate decisions
|
||||||
- NFR assessment, CI setup, and framework scaffolding
|
- NFR assessment, CI setup, and framework scaffolding
|
||||||
- P0-P3 prioritization with optional Playwright Utils and MCP integrations
|
- P0-P3 prioritization with optional Playwright Utils and MCP integrations
|
||||||
|
|
||||||
|
## BMad Automator (Experimental)
|
||||||
|
|
||||||
|
Automates the BMad story build loop with a skill bundle sourced from the separate Automator repository.
|
||||||
|
|
||||||
|
- **Code:** `bma`
|
||||||
|
- **npm:** [`bmad-story-automator`](https://www.npmjs.com/package/bmad-story-automator)
|
||||||
|
- **GitHub:** [bmad-code-org/bmad-automator](https://github.com/bmad-code-org/bmad-automator)
|
||||||
|
|
||||||
|
:::caution[Experimental runtime boundary]
|
||||||
|
BMad Automator runtime behavior is owned by `bmad-automator`. BMAD-METHOD only registers and packages the module for installer targets. Claude Code remains the supported orchestration path; Codex orchestration is experimental while completion-level validation settles upstream.
|
||||||
|
:::
|
||||||
|
|
||||||
|
**Provides:**
|
||||||
|
|
||||||
|
- Story build-cycle automation across story creation, development, QA automation, review, and retrospective
|
||||||
|
- Resumable tmux orchestration state
|
||||||
|
- Claude Code plus experimental Codex runtime packaging from the Automator source repo
|
||||||
|
|
||||||
## Community Modules
|
## Community Modules
|
||||||
|
|
||||||
Community modules and a module marketplace are coming. Check the [BMad GitHub organization](https://github.com/bmad-code-org) for updates.
|
Community modules and a module marketplace are coming. Check the [BMad GitHub organization](https://github.com/bmad-code-org) for updates.
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ const { Installer } = require('../tools/installer/core/installer');
|
||||||
const { ManifestGenerator } = require('../tools/installer/core/manifest-generator');
|
const { ManifestGenerator } = require('../tools/installer/core/manifest-generator');
|
||||||
const { OfficialModules } = require('../tools/installer/modules/official-modules');
|
const { OfficialModules } = require('../tools/installer/modules/official-modules');
|
||||||
const { IdeManager } = require('../tools/installer/ide/manager');
|
const { IdeManager } = require('../tools/installer/ide/manager');
|
||||||
|
const { ExternalModuleManager } = require('../tools/installer/modules/external-manager');
|
||||||
|
const { installBmaRuntimePackage } = require('../tools/installer/modules/bma-runtime-package');
|
||||||
const { clearCache, loadPlatformCodes } = require('../tools/installer/ide/platform-codes');
|
const { clearCache, loadPlatformCodes } = require('../tools/installer/ide/platform-codes');
|
||||||
|
|
||||||
// ANSI colors
|
// ANSI colors
|
||||||
|
|
@ -85,6 +87,42 @@ async function createTestBmadFixture() {
|
||||||
return fixtureDir;
|
return fixtureDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createAutomatorBmadFixture() {
|
||||||
|
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-automator-fixture-'));
|
||||||
|
const fixtureDir = path.join(fixtureRoot, '_bmad');
|
||||||
|
await fs.ensureDir(path.join(fixtureDir, '_config'));
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(fixtureDir, '_config', 'skill-manifest.csv'),
|
||||||
|
[
|
||||||
|
'canonicalId,name,description,module,path',
|
||||||
|
'"bmad-master","bmad-master","Minimal core skill","core","_bmad/core/bmad-master/SKILL.md"',
|
||||||
|
'"bmad-story-automator","bmad-story-automator","Automator skill","bma","_bmad/bma/bmad-story-automator/SKILL.md"',
|
||||||
|
'"bmad-story-automator-review","bmad-story-automator-review","Automator review skill","bma","_bmad/bma/bmad-story-automator-review/SKILL.md"',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const coreSkillDir = path.join(fixtureDir, 'core', 'bmad-master');
|
||||||
|
await fs.ensureDir(coreSkillDir);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(coreSkillDir, 'SKILL.md'),
|
||||||
|
['---', 'name: bmad-master', 'description: Minimal core skill', '---', '', 'Core skill body.'].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const skillName of ['bmad-story-automator', 'bmad-story-automator-review']) {
|
||||||
|
const skillDir = path.join(fixtureDir, 'bma', skillName);
|
||||||
|
await fs.ensureDir(skillDir);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(skillDir, 'SKILL.md'),
|
||||||
|
['---', `name: ${skillName}`, 'description: Automator skill', '---', '', 'Automator body.'].join('\n'),
|
||||||
|
);
|
||||||
|
await fs.writeFile(path.join(skillDir, 'workflow.md'), `# ${skillName}\n\nAutomator workflow body.\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fixtureDir;
|
||||||
|
}
|
||||||
|
|
||||||
async function createSkillCollisionFixture() {
|
async function createSkillCollisionFixture() {
|
||||||
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-skill-collision-'));
|
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-skill-collision-'));
|
||||||
const fixtureDir = path.join(fixtureRoot, '_bmad');
|
const fixtureDir = path.join(fixtureRoot, '_bmad');
|
||||||
|
|
@ -3520,6 +3558,144 @@ async function runTests() {
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Test Suite 45: Automator External Skill-Only Module
|
||||||
|
// ============================================================
|
||||||
|
console.log(`${colors.yellow}Test Suite 45: Automator External Skill-Only Module${colors.reset}\n`);
|
||||||
|
|
||||||
|
let tempProjectDir45;
|
||||||
|
let installedBmadDir45;
|
||||||
|
let originalLoadExternalModules45;
|
||||||
|
try {
|
||||||
|
const yaml45 = require('yaml');
|
||||||
|
const fallbackConfig45 = yaml45.parse(
|
||||||
|
await fs.readFile(path.join(__dirname, '..', 'tools', 'installer', 'modules', 'registry-fallback.yaml'), 'utf8'),
|
||||||
|
);
|
||||||
|
originalLoadExternalModules45 = ExternalModuleManager.prototype.loadExternalModulesConfig;
|
||||||
|
ExternalModuleManager.prototype.loadExternalModulesConfig = async function loadFallbackOnlyForTest() {
|
||||||
|
return fallbackConfig45;
|
||||||
|
};
|
||||||
|
|
||||||
|
const externalManager45 = new ExternalModuleManager();
|
||||||
|
const automatorInfo45 = await externalManager45.getModuleByCode('bma');
|
||||||
|
assert(automatorInfo45 !== null, 'BMad Automator is registered as an external module');
|
||||||
|
assert(automatorInfo45.type === 'experimental', 'BMad Automator is marked experimental');
|
||||||
|
assert(automatorInfo45.sourceRoot === 'payload/.claude/skills', 'BMad Automator uses source-root for pure skill payload');
|
||||||
|
assert(
|
||||||
|
automatorInfo45.installTargets.includes('claude-code') && automatorInfo45.installTargets.includes('codex'),
|
||||||
|
'BMad Automator declares Claude Code and Codex install targets',
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalizedInfo45 = externalManager45._normalizeModule({
|
||||||
|
name: 'bad-shapes',
|
||||||
|
code: 'bad',
|
||||||
|
repository: 'https://example.com/bad.git',
|
||||||
|
install_targets: 'claude-code',
|
||||||
|
worker_targets: { bad: true },
|
||||||
|
requirements: ['tmux', { bad: true }, false],
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
Array.isArray(normalizedInfo45.installTargets) && normalizedInfo45.installTargets.includes('claude-code'),
|
||||||
|
'External module install targets normalize scalar values to arrays',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
Array.isArray(normalizedInfo45.workerTargets) && normalizedInfo45.workerTargets.length === 0,
|
||||||
|
'External module worker targets drop invalid shapes',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
normalizedInfo45.requirements.length === 2 &&
|
||||||
|
normalizedInfo45.requirements.includes('tmux') &&
|
||||||
|
normalizedInfo45.requirements.includes('false'),
|
||||||
|
'External module requirements normalize scalar array entries',
|
||||||
|
);
|
||||||
|
|
||||||
|
tempProjectDir45 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-automator-target-'));
|
||||||
|
installedBmadDir45 = await createAutomatorBmadFixture();
|
||||||
|
|
||||||
|
const ideManager45 = new IdeManager();
|
||||||
|
await ideManager45.ensureInitialized();
|
||||||
|
|
||||||
|
const codexResult45 = await ideManager45.setup('codex', tempProjectDir45, installedBmadDir45, {
|
||||||
|
silent: true,
|
||||||
|
selectedModules: ['core', 'bma'],
|
||||||
|
});
|
||||||
|
assert(codexResult45.success === true, 'Codex setup succeeds with automator module selected');
|
||||||
|
assert(
|
||||||
|
await fs.pathExists(path.join(tempProjectDir45, '.agents', 'skills', 'bmad-master', 'SKILL.md')),
|
||||||
|
'Codex setup installs supported core skills',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
await fs.pathExists(path.join(tempProjectDir45, '.agents', 'skills', 'bmad-story-automator', 'SKILL.md')),
|
||||||
|
'Codex setup installs automator skill because Codex is an explicit target',
|
||||||
|
);
|
||||||
|
|
||||||
|
const escapeRoot45 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-source-root-'));
|
||||||
|
const escapeRepo45 = path.join(escapeRoot45, 'repo');
|
||||||
|
await fs.ensureDir(escapeRepo45);
|
||||||
|
const escapeManager45 = new ExternalModuleManager();
|
||||||
|
escapeManager45.getModuleByCode = async () => ({
|
||||||
|
code: 'escape',
|
||||||
|
builtIn: false,
|
||||||
|
sourceRoot: '../outside',
|
||||||
|
});
|
||||||
|
escapeManager45.cloneExternalModule = async () => escapeRepo45;
|
||||||
|
let rejectedEscapingSourceRoot45 = false;
|
||||||
|
try {
|
||||||
|
await escapeManager45.findExternalModuleSource('escape');
|
||||||
|
} catch (error) {
|
||||||
|
rejectedEscapingSourceRoot45 = error.message.includes('source-root escapes repository');
|
||||||
|
} finally {
|
||||||
|
await fs.remove(escapeRoot45).catch(() => {});
|
||||||
|
}
|
||||||
|
assert(rejectedEscapingSourceRoot45, 'External module source-root cannot escape cloned repository');
|
||||||
|
|
||||||
|
const fakeRepo45 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-runtime-package-'));
|
||||||
|
const fakePayload45 = path.join(fakeRepo45, 'payload', '.claude', 'skills');
|
||||||
|
const fakeStoryPayload45 = path.join(fakePayload45, 'bmad-story-automator');
|
||||||
|
const fakeReviewPayload45 = path.join(fakePayload45, 'bmad-story-automator-review');
|
||||||
|
const fakeSource45 = path.join(fakeRepo45, 'source');
|
||||||
|
await fs.ensureDir(fakeStoryPayload45);
|
||||||
|
await fs.ensureDir(fakeReviewPayload45);
|
||||||
|
await fs.ensureDir(fakeSource45);
|
||||||
|
await fs.writeFile(path.join(fakeStoryPayload45, 'SKILL.md'), '# bmad-story-automator\n');
|
||||||
|
await fs.writeFile(path.join(fakeReviewPayload45, 'SKILL.md'), '# bmad-story-automator-review\n');
|
||||||
|
await fs.writeFile(path.join(fakeSource45, 'pyproject.toml'), '[project]\nname = "story-automator"\n');
|
||||||
|
await fs.writeFile(path.join(fakeSource45, 'README.md'), '# Automator\n');
|
||||||
|
await fs.writeFile(path.join(fakeSource45, 'LICENSE'), 'MIT\n');
|
||||||
|
await fs.ensureDir(path.join(fakeSource45, 'scripts'));
|
||||||
|
await fs.writeFile(path.join(fakeSource45, 'scripts', 'story-automator'), '#!/usr/bin/env bash\n');
|
||||||
|
await fs.ensureDir(path.join(fakeSource45, 'src', 'story_automator', 'core'));
|
||||||
|
await fs.writeFile(path.join(fakeSource45, 'src', 'story_automator', 'core', 'runtime_layout.py'), '# canonical runtime layout\n');
|
||||||
|
|
||||||
|
const fakeTarget45 = path.join(fakeRepo45, 'installed', 'bma');
|
||||||
|
await fs.copy(fakePayload45, fakeTarget45);
|
||||||
|
const tracked45 = [];
|
||||||
|
const packaged45 = await installBmaRuntimePackage('bma', fakePayload45, fakeTarget45, (file) => tracked45.push(file));
|
||||||
|
assert(packaged45 === true, 'BMA runtime package hook runs for bma module');
|
||||||
|
assert(
|
||||||
|
(await fs.readFile(
|
||||||
|
path.join(fakeTarget45, 'bmad-story-automator', 'src', 'story_automator', 'core', 'runtime_layout.py'),
|
||||||
|
'utf8',
|
||||||
|
)) === '# canonical runtime layout\n',
|
||||||
|
'BMA runtime package copies Automator runtime source without patching it',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
tracked45.some((file) => file.endsWith(path.join('scripts', 'story-automator'))),
|
||||||
|
'BMA runtime package tracks copied helper files',
|
||||||
|
);
|
||||||
|
await fs.remove(fakeRepo45).catch(() => {});
|
||||||
|
} catch (error) {
|
||||||
|
assert(false, `Automator external skill-only module test succeeds: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
if (originalLoadExternalModules45) {
|
||||||
|
ExternalModuleManager.prototype.loadExternalModulesConfig = originalLoadExternalModules45;
|
||||||
|
}
|
||||||
|
if (tempProjectDir45) await fs.remove(tempProjectDir45).catch(() => {});
|
||||||
|
if (installedBmadDir45) await fs.remove(path.dirname(installedBmadDir45)).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Summary
|
// Summary
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,14 @@
|
||||||
|
|
||||||
## Installing external repo BMad official modules
|
## Installing external repo BMad official modules
|
||||||
|
|
||||||
For external official modules to be discoverable during install, ensure an entry for the external repo is added to external-official-modules.yaml.
|
For external official modules to be discoverable during install, ensure an entry for the external repo is added to the marketplace `registry/official.yaml` source of truth. Add the same entry to `modules/registry-fallback.yaml` only when BMAD-METHOD needs a bundled fallback or a staged registry supplement.
|
||||||
|
|
||||||
For community modules - this will be handled in a different way. This file is only for registration of modules under the bmad-code-org.
|
For community modules - this is handled through the marketplace community registry.
|
||||||
|
|
||||||
|
Use `module-definition` for conventional module repos with `module.yaml`.
|
||||||
|
Use `source-root` for pure skill bundles that should be copied directly into `_bmad/<module-code>/`.
|
||||||
|
|
||||||
|
Some modules also need packaging files that live outside the skill payload. BMad Automator (`bma`) is the current example: BMAD-METHOD copies its runtime files from `source/`, but the runtime behavior itself remains owned by `bmad-automator`.
|
||||||
|
|
||||||
## Post-Install Notes
|
## Post-Install Notes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,9 @@ class ManifestGenerator {
|
||||||
for (const moduleName of this.updatedModules) {
|
for (const moduleName of this.updatedModules) {
|
||||||
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
|
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
|
||||||
if (!moduleYamlPath) {
|
if (!moduleYamlPath) {
|
||||||
|
if (await this._isSkillOnlyModule(moduleName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// External modules live in ~/.bmad/cache/external-modules, not src/modules.
|
// External modules live in ~/.bmad/cache/external-modules, not src/modules.
|
||||||
// Warn rather than silently skip so missing agent rosters don't vanish
|
// Warn rather than silently skip so missing agent rosters don't vanish
|
||||||
// from config.toml without notice.
|
// from config.toml without notice.
|
||||||
|
|
@ -441,6 +444,9 @@ class ManifestGenerator {
|
||||||
for (const moduleName of this.updatedModules) {
|
for (const moduleName of this.updatedModules) {
|
||||||
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
|
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
|
||||||
if (!moduleYamlPath) {
|
if (!moduleYamlPath) {
|
||||||
|
if (await this._isSkillOnlyModule(moduleName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
console.warn(
|
console.warn(
|
||||||
`[warn] writeCentralConfig: could not locate module.yaml for '${moduleName}'. ` +
|
`[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.`,
|
`Answers from this module will default to team scope — user-scoped keys may mis-file into config.toml.`,
|
||||||
|
|
@ -799,6 +805,27 @@ class ManifestGenerator {
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _isSkillOnlyModule(moduleName) {
|
||||||
|
const modulePath = path.join(this.bmadDir, moduleName);
|
||||||
|
if (!(await fs.pathExists(modulePath))) return false;
|
||||||
|
if (await fs.pathExists(path.join(modulePath, 'module.yaml'))) return false;
|
||||||
|
if (!(await this._moduleUsesSourceRoot(moduleName))) return false;
|
||||||
|
return this._hasSkillMdRecursive(modulePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _moduleUsesSourceRoot(moduleName) {
|
||||||
|
if (!this.sourceRootModuleCodes) {
|
||||||
|
try {
|
||||||
|
const { ExternalModuleManager } = require('../modules/external-manager');
|
||||||
|
const externalModules = await new ExternalModuleManager().listAvailable();
|
||||||
|
this.sourceRootModuleCodes = new Set(externalModules.filter((mod) => mod.sourceRoot).map((mod) => mod.code));
|
||||||
|
} catch {
|
||||||
|
this.sourceRootModuleCodes = new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.sourceRootModuleCodes.has(moduleName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,8 @@ class ConfigDrivenIdeSetup {
|
||||||
this.platformConfig = platformConfig;
|
this.platformConfig = platformConfig;
|
||||||
this.installerConfig = platformConfig.installer || null;
|
this.installerConfig = platformConfig.installer || null;
|
||||||
this.bmadFolderName = BMAD_FOLDER_NAME;
|
this.bmadFolderName = BMAD_FOLDER_NAME;
|
||||||
|
this.externalModuleManager = null;
|
||||||
|
this.moduleTargetCache = new Map();
|
||||||
|
|
||||||
// Set configDir from target_dir so detect() works
|
// Set configDir from target_dir so detect() works
|
||||||
this.configDir = this.installerConfig?.target_dir || null;
|
this.configDir = this.installerConfig?.target_dir || null;
|
||||||
|
|
@ -248,9 +250,11 @@ class ConfigDrivenIdeSetup {
|
||||||
await fs.ensureDir(targetPath);
|
await fs.ensureDir(targetPath);
|
||||||
|
|
||||||
this.skillWriteTracker = new Set();
|
this.skillWriteTracker = new Set();
|
||||||
|
this.skippedUnsupported = 0;
|
||||||
const results = { skills: 0 };
|
const results = { skills: 0 };
|
||||||
|
|
||||||
results.skills = await this.installVerbatimSkills(projectDir, bmadDir, targetPath, config);
|
results.skills = await this.installVerbatimSkills(projectDir, bmadDir, targetPath, config);
|
||||||
|
results.skippedUnsupported = this.skippedUnsupported || 0;
|
||||||
results.skillDirectories = this.skillWriteTracker.size;
|
results.skillDirectories = this.skillWriteTracker.size;
|
||||||
|
|
||||||
if (config.commands_target_dir) {
|
if (config.commands_target_dir) {
|
||||||
|
|
@ -259,6 +263,7 @@ class ConfigDrivenIdeSetup {
|
||||||
|
|
||||||
await this.printSummary(results, target_dir, options);
|
await this.printSummary(results, target_dir, options);
|
||||||
this.skillWriteTracker = null;
|
this.skillWriteTracker = null;
|
||||||
|
this.skippedUnsupported = 0;
|
||||||
return { success: true, results };
|
return { success: true, results };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -320,6 +325,10 @@ class ConfigDrivenIdeSetup {
|
||||||
const canonicalId = record.canonicalId;
|
const canonicalId = record.canonicalId;
|
||||||
if (!canonicalId) continue;
|
if (!canonicalId) continue;
|
||||||
|
|
||||||
|
if (!(await this.shouldInstallSkillRecord(record))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Defensive basename validation. canonicalId comes from a trusted
|
// Defensive basename validation. canonicalId comes from a trusted
|
||||||
// manifest today, but the value flows directly into a file path —
|
// manifest today, but the value flows directly into a file path —
|
||||||
// reject anything that could escape commands_target_dir.
|
// reject anything that could escape commands_target_dir.
|
||||||
|
|
@ -433,6 +442,11 @@ class ConfigDrivenIdeSetup {
|
||||||
const canonicalId = record.canonicalId;
|
const canonicalId = record.canonicalId;
|
||||||
if (!canonicalId) continue;
|
if (!canonicalId) continue;
|
||||||
|
|
||||||
|
if (!(await this.shouldInstallSkillRecord(record))) {
|
||||||
|
this.skippedUnsupported = (this.skippedUnsupported || 0) + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Derive source directory from path column
|
// Derive source directory from path column
|
||||||
// path is like "_bmad/bmm/workflows/bmad-quick-flow/bmad-quick-dev-new-preview/SKILL.md"
|
// path is like "_bmad/bmm/workflows/bmad-quick-flow/bmad-quick-dev-new-preview/SKILL.md"
|
||||||
// Strip bmadFolderName prefix and join with bmadDir, then get dirname
|
// Strip bmadFolderName prefix and join with bmadDir, then get dirname
|
||||||
|
|
@ -467,6 +481,24 @@ class ConfigDrivenIdeSetup {
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async shouldInstallSkillRecord(record) {
|
||||||
|
const moduleName = record.module;
|
||||||
|
if (!moduleName) return true;
|
||||||
|
|
||||||
|
if (this.moduleTargetCache.has(moduleName)) {
|
||||||
|
const targets = this.moduleTargetCache.get(moduleName);
|
||||||
|
return !targets || targets.includes(this.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ExternalModuleManager } = require('../modules/external-manager');
|
||||||
|
this.externalModuleManager = this.externalModuleManager || new ExternalModuleManager();
|
||||||
|
const moduleInfo = await this.externalModuleManager.getModuleByCode(moduleName);
|
||||||
|
const targets = moduleInfo?.installTargets?.length ? moduleInfo.installTargets : null;
|
||||||
|
this.moduleTargetCache.set(moduleName, targets);
|
||||||
|
|
||||||
|
return !targets || targets.includes(this.name);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Print installation summary
|
* Print installation summary
|
||||||
* @param {Object} results - Installation results
|
* @param {Object} results - Installation results
|
||||||
|
|
@ -478,6 +510,9 @@ class ConfigDrivenIdeSetup {
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
await prompts.log.success(`${this.name} configured: ${count} skills → ${targetDir}`);
|
await prompts.log.success(`${this.name} configured: ${count} skills → ${targetDir}`);
|
||||||
}
|
}
|
||||||
|
if (results.skippedUnsupported > 0) {
|
||||||
|
await prompts.log.warn(`${this.name}: skipped ${results.skippedUnsupported} skill(s) that do not support this IDE`);
|
||||||
|
}
|
||||||
const cmd = results.commands;
|
const cmd = results.commands;
|
||||||
if (cmd && (cmd.created > 0 || cmd.updated > 0) && this.installerConfig?.commands_target_dir) {
|
if (cmd && (cmd.created > 0 || cmd.updated > 0) && this.installerConfig?.commands_target_dir) {
|
||||||
const total = cmd.created + cmd.updated;
|
const total = cmd.created + cmd.updated;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
const path = require('node:path');
|
||||||
|
const fs = require('../fs-native');
|
||||||
|
|
||||||
|
const STORY_SKILL = 'bmad-story-automator';
|
||||||
|
const REVIEW_SKILL = 'bmad-story-automator-review';
|
||||||
|
|
||||||
|
async function installBmaRuntimePackage(moduleName, sourcePath, targetPath, fileTrackingCallback = null) {
|
||||||
|
if (moduleName !== 'bma') return false;
|
||||||
|
|
||||||
|
const storyTarget = path.join(targetPath, STORY_SKILL);
|
||||||
|
const reviewTarget = path.join(targetPath, REVIEW_SKILL);
|
||||||
|
if (!(await fs.pathExists(path.join(storyTarget, 'SKILL.md')))) {
|
||||||
|
throw new Error(`BMad Automator payload missing installed skill: ${STORY_SKILL}`);
|
||||||
|
}
|
||||||
|
if (!(await fs.pathExists(path.join(reviewTarget, 'SKILL.md')))) {
|
||||||
|
throw new Error(`BMad Automator payload missing installed skill: ${REVIEW_SKILL}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoRoot = await findAutomatorRepoRoot(sourcePath);
|
||||||
|
const sourceRoot = path.join(repoRoot, 'source');
|
||||||
|
const runtimeFiles = {
|
||||||
|
pyproject: path.join(sourceRoot, 'pyproject.toml'),
|
||||||
|
readme: path.join(sourceRoot, 'README.md'),
|
||||||
|
license: path.join(sourceRoot, 'LICENSE'),
|
||||||
|
scripts: path.join(sourceRoot, 'scripts'),
|
||||||
|
src: path.join(sourceRoot, 'src'),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [label, requiredPath] of Object.entries(runtimeFiles)) {
|
||||||
|
if (!(await fs.pathExists(requiredPath))) {
|
||||||
|
throw new Error(`BMad Automator runtime ${label} missing: ${requiredPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await copyTracked(runtimeFiles.pyproject, path.join(storyTarget, 'pyproject.toml'), fileTrackingCallback);
|
||||||
|
await copyTracked(runtimeFiles.readme, path.join(storyTarget, 'README.md'), fileTrackingCallback);
|
||||||
|
await copyTracked(runtimeFiles.license, path.join(storyTarget, 'LICENSE'), fileTrackingCallback);
|
||||||
|
await copyTracked(runtimeFiles.scripts, path.join(storyTarget, 'scripts'), fileTrackingCallback);
|
||||||
|
await copyTracked(runtimeFiles.src, path.join(storyTarget, 'src'), fileTrackingCallback);
|
||||||
|
await fs.chmod(path.join(storyTarget, 'scripts', 'story-automator'), 0o755);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findAutomatorRepoRoot(sourcePath) {
|
||||||
|
let current = path.resolve(sourcePath);
|
||||||
|
for (let depth = 0; depth < 8; depth += 1) {
|
||||||
|
if (
|
||||||
|
(await fs.pathExists(path.join(current, 'source', 'scripts', 'story-automator'))) &&
|
||||||
|
(await fs.pathExists(path.join(current, 'payload', '.claude', 'skills', STORY_SKILL, 'SKILL.md')))
|
||||||
|
) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
const parent = path.dirname(current);
|
||||||
|
if (parent === current) break;
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
throw new Error(`BMad Automator runtime source not found above: ${sourcePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyTracked(source, target, fileTrackingCallback) {
|
||||||
|
await fs.remove(target);
|
||||||
|
await fs.copy(source, target, { overwrite: true });
|
||||||
|
await trackRecursive(target, fileTrackingCallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trackRecursive(target, fileTrackingCallback) {
|
||||||
|
if (!fileTrackingCallback) return;
|
||||||
|
const stat = await fs.stat(target);
|
||||||
|
if (stat.isFile()) {
|
||||||
|
fileTrackingCallback(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!stat.isDirectory()) return;
|
||||||
|
const entries = await fs.readdir(target, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
await trackRecursive(path.join(target, entry.name), fileTrackingCallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
installBmaRuntimePackage,
|
||||||
|
};
|
||||||
|
|
@ -16,6 +16,15 @@ function normalizeChannelName(raw) {
|
||||||
return VALID_CHANNELS.has(lower) ? lower : null;
|
return VALID_CHANNELS.has(lower) ? lower : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeStringList(raw) {
|
||||||
|
if (raw == null || raw === '') return [];
|
||||||
|
const values = Array.isArray(raw) ? raw : [raw];
|
||||||
|
return values
|
||||||
|
.filter((value) => ['string', 'number', 'boolean'].includes(typeof value))
|
||||||
|
.map((value) => String(value).trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Conservative quoting for tag names passed to git commands. Tags are
|
* Conservative quoting for tag names passed to git commands. Tags are
|
||||||
* user-typed (--pin) or come from the GitHub API. Only allow the semver
|
* user-typed (--pin) or come from the GitHub API. Only allow the semver
|
||||||
|
|
@ -120,22 +129,41 @@ class ExternalModuleManager {
|
||||||
* @returns {Object} Normalized module info
|
* @returns {Object} Normalized module info
|
||||||
*/
|
*/
|
||||||
_normalizeModule(mod, key) {
|
_normalizeModule(mod, key) {
|
||||||
|
const installTargets = mod.install_targets ?? mod['install-targets'] ?? mod.installTargets;
|
||||||
|
const workerTargets = mod.worker_targets ?? mod['worker-targets'] ?? mod.workerTargets;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: key || mod.name,
|
key: key || mod.name,
|
||||||
url: mod.repository || mod.url,
|
url: mod.repository || mod.url,
|
||||||
moduleDefinition: mod.module_definition || mod['module-definition'],
|
moduleDefinition: mod.module_definition || mod['module-definition'],
|
||||||
|
sourceRoot: mod.source_root || mod['source-root'] || mod.sourceRoot || null,
|
||||||
code: mod.code,
|
code: mod.code,
|
||||||
name: mod.display_name || mod.name,
|
name: mod.display_name || mod.name,
|
||||||
description: mod.description || '',
|
description: mod.description || '',
|
||||||
defaultSelected: mod.default_selected === true || mod.defaultSelected === true,
|
defaultSelected: mod.default_selected === true || mod.defaultSelected === true,
|
||||||
type: mod.type || 'bmad-org',
|
type: mod.type || 'bmad-org',
|
||||||
npmPackage: mod.npm_package || mod.npmPackage || null,
|
npmPackage: mod.npm_package || mod.npmPackage || null,
|
||||||
|
installTargets: normalizeStringList(installTargets),
|
||||||
|
workerTargets: normalizeStringList(workerTargets),
|
||||||
|
requirements: normalizeStringList(mod.requirements),
|
||||||
|
installNote: mod.install_note || mod['install-note'] || mod.installNote || null,
|
||||||
defaultChannel: normalizeChannelName(mod.default_channel || mod.defaultChannel) || 'stable',
|
defaultChannel: normalizeChannelName(mod.default_channel || mod.defaultChannel) || 'stable',
|
||||||
builtIn: mod.built_in === true,
|
builtIn: mod.built_in === true,
|
||||||
isExternal: mod.built_in !== true,
|
isExternal: mod.built_in !== true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _loadFallbackModules() {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(FALLBACK_CONFIG_PATH, 'utf8');
|
||||||
|
const config = yaml.parse(content);
|
||||||
|
if (Array.isArray(config.modules)) return config.modules.map((mod) => this._normalizeModule(mod));
|
||||||
|
return Object.entries(config.modules || {}).map(([key, mod]) => this._normalizeModule(mod, key));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get list of available modules from the registry
|
* Get list of available modules from the registry
|
||||||
* @returns {Array<Object>} Array of module info objects
|
* @returns {Array<Object>} Array of module info objects
|
||||||
|
|
@ -145,7 +173,14 @@ class ExternalModuleManager {
|
||||||
|
|
||||||
// Remote format: modules is an array
|
// Remote format: modules is an array
|
||||||
if (Array.isArray(config.modules)) {
|
if (Array.isArray(config.modules)) {
|
||||||
return config.modules.map((mod) => this._normalizeModule(mod));
|
const modules = config.modules.map((mod) => this._normalizeModule(mod));
|
||||||
|
const seenCodes = new Set(modules.map((mod) => mod.code));
|
||||||
|
for (const fallbackMod of await this._loadFallbackModules()) {
|
||||||
|
if (!seenCodes.has(fallbackMod.code)) {
|
||||||
|
modules.push(fallbackMod);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return modules;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy bundled format: modules is an object map
|
// Legacy bundled format: modules is an object map
|
||||||
|
|
@ -489,6 +524,19 @@ class ExternalModuleManager {
|
||||||
// Clone the external module repo
|
// Clone the external module repo
|
||||||
const cloneDir = await this.cloneExternalModule(moduleCode, options);
|
const cloneDir = await this.cloneExternalModule(moduleCode, options);
|
||||||
|
|
||||||
|
if (moduleInfo.sourceRoot) {
|
||||||
|
const repoRoot = path.resolve(cloneDir);
|
||||||
|
const sourceRoot = path.resolve(repoRoot, moduleInfo.sourceRoot);
|
||||||
|
const relativeSourceRoot = path.relative(repoRoot, sourceRoot);
|
||||||
|
if (relativeSourceRoot === '..' || relativeSourceRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeSourceRoot)) {
|
||||||
|
throw new Error(`External module '${moduleCode}' source-root escapes repository: ${moduleInfo.sourceRoot}`);
|
||||||
|
}
|
||||||
|
if (!(await fs.pathExists(sourceRoot))) {
|
||||||
|
throw new Error(`External module '${moduleCode}' source-root not found: ${moduleInfo.sourceRoot}`);
|
||||||
|
}
|
||||||
|
return sourceRoot;
|
||||||
|
}
|
||||||
|
|
||||||
// The module-definition specifies the path to module.yaml relative to repo root
|
// The module-definition specifies the path to module.yaml relative to repo root
|
||||||
// We need to return the directory containing module.yaml
|
// We need to return the directory containing module.yaml
|
||||||
const moduleDefinitionPath = moduleInfo.moduleDefinition; // e.g., 'skills/module.yaml'
|
const moduleDefinitionPath = moduleInfo.moduleDefinition; // e.g., 'skills/module.yaml'
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ const prompts = require('../prompts');
|
||||||
const { getProjectRoot, getSourcePath, getModulePath } = require('../project-root');
|
const { getProjectRoot, getSourcePath, getModulePath } = require('../project-root');
|
||||||
const { CLIUtils } = require('../cli-utils');
|
const { CLIUtils } = require('../cli-utils');
|
||||||
const { ExternalModuleManager } = require('./external-manager');
|
const { ExternalModuleManager } = require('./external-manager');
|
||||||
|
const { installBmaRuntimePackage } = require('./bma-runtime-package');
|
||||||
|
|
||||||
class OfficialModules {
|
class OfficialModules {
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
|
|
@ -301,6 +302,7 @@ class OfficialModules {
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig);
|
await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig);
|
||||||
|
await installBmaRuntimePackage(moduleName, sourcePath, targetPath, fileTrackingCallback);
|
||||||
|
|
||||||
if (!options.skipModuleInstaller) {
|
if (!options.skipModuleInstaller) {
|
||||||
await this.createModuleDirectories(moduleName, bmadDir, options);
|
await this.createModuleDirectories(moduleName, bmadDir, options);
|
||||||
|
|
|
||||||
|
|
@ -50,3 +50,27 @@ modules:
|
||||||
type: bmad-org
|
type: bmad-org
|
||||||
npmPackage: bmad-method-test-architecture-enterprise
|
npmPackage: bmad-method-test-architecture-enterprise
|
||||||
default_channel: stable
|
default_channel: stable
|
||||||
|
|
||||||
|
bmad-automator:
|
||||||
|
url: https://github.com/bmad-code-org/bmad-automator
|
||||||
|
source-root: payload/.claude/skills
|
||||||
|
code: bma
|
||||||
|
name: "BMad Automator (Experimental)"
|
||||||
|
description: "Experimental pure-skill story automation. Claude Code remains the supported orchestrator; Codex orchestration is limited to create/dev/review validation while support settles upstream."
|
||||||
|
defaultSelected: false
|
||||||
|
type: experimental
|
||||||
|
npmPackage: bmad-story-automator
|
||||||
|
default_channel: stable
|
||||||
|
install-targets:
|
||||||
|
- claude-code
|
||||||
|
- codex
|
||||||
|
worker-targets:
|
||||||
|
- claude-code
|
||||||
|
- codex
|
||||||
|
requirements:
|
||||||
|
- Claude Code entrypoint for supported orchestration and retrospectives
|
||||||
|
- Codex entrypoint for experimental create/dev/review validation
|
||||||
|
- Claude Code or Codex worker sessions
|
||||||
|
- tmux
|
||||||
|
- macOS
|
||||||
|
install-note: "Experimental: Automator runtime support lives in bmad-automator. Codex orchestration should be treated as create/dev/review validation until completion-level behavior is proven."
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,7 @@ class UI {
|
||||||
} else {
|
} else {
|
||||||
selectedModules = await this.selectAllModules(installedModuleIds, installedModuleVersions, channelOptions);
|
selectedModules = await this.selectAllModules(installedModuleIds, installedModuleVersions, channelOptions);
|
||||||
}
|
}
|
||||||
|
await this.showSelectedExternalModuleNotes(selectedModules);
|
||||||
|
|
||||||
// Resolve custom sources from --custom-source flag
|
// Resolve custom sources from --custom-source flag
|
||||||
if (options.customSource) {
|
if (options.customSource) {
|
||||||
|
|
@ -287,6 +288,7 @@ class UI {
|
||||||
|
|
||||||
// Get tool selection
|
// Get tool selection
|
||||||
const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
||||||
|
await this.showSelectedModuleIdeWarnings(selectedModules, toolSelection.ides);
|
||||||
|
|
||||||
const { moduleConfigs, setOverrides } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
|
const { moduleConfigs, setOverrides } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
|
||||||
...options,
|
...options,
|
||||||
|
|
@ -343,6 +345,7 @@ class UI {
|
||||||
} else {
|
} else {
|
||||||
selectedModules = await this.selectAllModules(installedModuleIds, installedModuleVersions, channelOptions);
|
selectedModules = await this.selectAllModules(installedModuleIds, installedModuleVersions, channelOptions);
|
||||||
}
|
}
|
||||||
|
await this.showSelectedExternalModuleNotes(selectedModules);
|
||||||
|
|
||||||
// Resolve custom sources from --custom-source flag
|
// Resolve custom sources from --custom-source flag
|
||||||
if (options.customSource) {
|
if (options.customSource) {
|
||||||
|
|
@ -366,6 +369,7 @@ class UI {
|
||||||
await this._interactiveChannelGate({ options, channelOptions, selectedModules });
|
await this._interactiveChannelGate({ options, channelOptions, selectedModules });
|
||||||
|
|
||||||
let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
||||||
|
await this.showSelectedModuleIdeWarnings(selectedModules, toolSelection.ides);
|
||||||
const { moduleConfigs, setOverrides } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
|
const { moduleConfigs, setOverrides } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
|
||||||
...options,
|
...options,
|
||||||
channelOptions,
|
channelOptions,
|
||||||
|
|
@ -954,6 +958,41 @@ class UI {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async showSelectedExternalModuleNotes(selectedModuleIds, externalModules = null) {
|
||||||
|
if (!externalModules) {
|
||||||
|
const externalManager = new ExternalModuleManager();
|
||||||
|
externalModules = await externalManager.listAvailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
const notes = externalModules
|
||||||
|
.filter((mod) => selectedModuleIds.includes(mod.code) && mod.installNote)
|
||||||
|
.map((mod) => `${mod.name}: ${mod.installNote}`);
|
||||||
|
|
||||||
|
for (const note of notes) {
|
||||||
|
await prompts.log.warn(note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async showSelectedModuleIdeWarnings(selectedModuleIds, selectedIdes = []) {
|
||||||
|
const externalManager = new ExternalModuleManager();
|
||||||
|
const externalModules = await externalManager.listAvailable();
|
||||||
|
|
||||||
|
for (const mod of externalModules) {
|
||||||
|
if (!selectedModuleIds.includes(mod.code) || !mod.installTargets || mod.installTargets.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasInstallTarget = mod.installTargets.some((target) => selectedIdes.includes(target));
|
||||||
|
if (!hasInstallTarget) {
|
||||||
|
await prompts.log.warn(
|
||||||
|
`${mod.name}: runnable skills are installed only for ${mod.installTargets.join(
|
||||||
|
', ',
|
||||||
|
)}. Add that tool selection to use this module.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Browse and select community modules using category drill-down.
|
* Browse and select community modules using category drill-down.
|
||||||
* Featured/promoted modules appear at the top.
|
* Featured/promoted modules appear at the top.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue