Skip to content

Commit 2f328b3

Browse files
committed
fix(installer): address automator review feedback
1 parent adcffe6 commit 2f328b3

5 files changed

Lines changed: 92 additions & 6 deletions

File tree

docs/how-to/install-bmad.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ The interactive flow asks you five things:
3838

3939
Accept the defaults and you land on the latest stable release of every module, configured for your chosen tool.
4040

41+
:::caution[BMad Automator constraints]
42+
`bma` installs runnable Automator skills only for the Claude Code entrypoint. Codex is supported as a worker target only, and worker sessions currently require `tmux` on macOS.
43+
:::
44+
4145
:::tip[Just want the newest prerelease?]
4246

4347
```bash

test/test-installation-components.js

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ async function createAutomatorBmadFixture() {
116116
path.join(skillDir, 'SKILL.md'),
117117
['---', `name: ${skillName}`, 'description: Automator skill', '---', '', 'Automator body.'].join('\n'),
118118
);
119+
await fs.writeFile(path.join(skillDir, 'workflow.md'), `# ${skillName}\n\nAutomator workflow body.\n`);
119120
}
120121

121122
return fixtureDir;
@@ -2826,6 +2827,28 @@ async function runTests() {
28262827
automatorInfo42.installTargets.length === 1 && automatorInfo42.installTargets.includes('claude-code'),
28272828
'BMad Automator is limited to Claude Code skill installation',
28282829
);
2830+
const normalizedInfo42 = externalManager42._normalizeModule({
2831+
name: 'bad-shapes',
2832+
code: 'bad',
2833+
repository: 'https://example.com/bad.git',
2834+
install_targets: 'claude-code',
2835+
worker_targets: { bad: true },
2836+
requirements: ['tmux', { bad: true }, false],
2837+
});
2838+
assert(
2839+
Array.isArray(normalizedInfo42.installTargets) && normalizedInfo42.installTargets.includes('claude-code'),
2840+
'External module install targets normalize scalar values to arrays',
2841+
);
2842+
assert(
2843+
Array.isArray(normalizedInfo42.workerTargets) && normalizedInfo42.workerTargets.length === 0,
2844+
'External module worker targets drop invalid shapes',
2845+
);
2846+
assert(
2847+
normalizedInfo42.requirements.length === 2 &&
2848+
normalizedInfo42.requirements.includes('tmux') &&
2849+
normalizedInfo42.requirements.includes('false'),
2850+
'External module requirements normalize scalar array entries',
2851+
);
28292852

28302853
tempProjectDir42 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-automator-target-'));
28312854
installedBmadDir42 = await createAutomatorBmadFixture();
@@ -2846,6 +2869,30 @@ async function runTests() {
28462869
!(await fs.pathExists(path.join(tempProjectDir42, '.agents', 'skills', 'bmad-story-automator', 'SKILL.md'))),
28472870
'Codex setup skips Claude Code-only automator skill',
28482871
);
2872+
assert(
2873+
!(await fs.pathExists(path.join(tempProjectDir42, '.agents', 'skills', 'bmad-story-automator-review', 'SKILL.md'))),
2874+
'Codex setup skips Claude Code-only automator review skill',
2875+
);
2876+
2877+
const escapeRoot42 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-source-root-'));
2878+
const escapeRepo42 = path.join(escapeRoot42, 'repo');
2879+
await fs.ensureDir(escapeRepo42);
2880+
const escapeManager42 = new ExternalModuleManager();
2881+
escapeManager42.getModuleByCode = async () => ({
2882+
code: 'escape',
2883+
builtIn: false,
2884+
sourceRoot: '../outside',
2885+
});
2886+
escapeManager42.cloneExternalModule = async () => escapeRepo42;
2887+
let rejectedEscapingSourceRoot42 = false;
2888+
try {
2889+
await escapeManager42.findExternalModuleSource('escape');
2890+
} catch (error) {
2891+
rejectedEscapingSourceRoot42 = error.message.includes('source-root escapes repository');
2892+
} finally {
2893+
await fs.remove(escapeRoot42).catch(() => {});
2894+
}
2895+
assert(rejectedEscapingSourceRoot42, 'External module source-root cannot escape cloned repository');
28492896

28502897
const claudeResult42 = await ideManager42.setup('claude-code', tempProjectDir42, installedBmadDir42, {
28512898
silent: true,
@@ -2860,8 +2907,12 @@ async function runTests() {
28602907
await fs.pathExists(path.join(tempProjectDir42, '.claude', 'skills', 'bmad-story-automator-review', 'SKILL.md')),
28612908
'Claude Code setup installs automator review skill',
28622909
);
2910+
assert(
2911+
await fs.pathExists(path.join(tempProjectDir42, '.claude', 'skills', 'bmad-story-automator-review', 'workflow.md')),
2912+
'Claude Code setup copies automator workflow files',
2913+
);
28632914
} catch (error) {
2864-
assert(false, 'Automator external skill-only module test succeeds', error.message);
2915+
assert(false, `Automator external skill-only module test succeeds: ${error.message}`);
28652916
} finally {
28662917
if (tempProjectDir42) await fs.remove(tempProjectDir42).catch(() => {});
28672918
if (installedBmadDir42) await fs.remove(path.dirname(installedBmadDir42)).catch(() => {});

tools/installer/core/manifest-generator.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -810,8 +810,22 @@ class ManifestGenerator {
810810
const modulePath = path.join(this.bmadDir, moduleName);
811811
if (!(await fs.pathExists(modulePath))) return false;
812812
if (await fs.pathExists(path.join(modulePath, 'module.yaml'))) return false;
813+
if (!(await this._moduleUsesSourceRoot(moduleName))) return false;
813814
return this._hasSkillMdRecursive(modulePath);
814815
}
816+
817+
async _moduleUsesSourceRoot(moduleName) {
818+
if (!this.sourceRootModuleCodes) {
819+
try {
820+
const { ExternalModuleManager } = require('../modules/external-manager');
821+
const externalModules = await new ExternalModuleManager().listAvailable();
822+
this.sourceRootModuleCodes = new Set(externalModules.filter((mod) => mod.sourceRoot).map((mod) => mod.code));
823+
} catch {
824+
this.sourceRootModuleCodes = new Set();
825+
}
826+
}
827+
return this.sourceRootModuleCodes.has(moduleName);
828+
}
815829
}
816830

817831
/**

tools/installer/ide/_config-driven.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ class ConfigDrivenIdeSetup {
218218
const { ExternalModuleManager } = require('../modules/external-manager');
219219
this.externalModuleManager = this.externalModuleManager || new ExternalModuleManager();
220220
const moduleInfo = await this.externalModuleManager.getModuleByCode(moduleName);
221-
const targets = moduleInfo?.installTargets || null;
221+
const targets = moduleInfo?.installTargets?.length ? moduleInfo.installTargets : null;
222222
this.moduleTargetCache.set(moduleName, targets);
223223

224224
return !targets || targets.includes(this.name);

tools/installer/modules/external-manager.js

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ function normalizeChannelName(raw) {
1616
return VALID_CHANNELS.has(lower) ? lower : null;
1717
}
1818

19+
function normalizeStringList(raw) {
20+
if (raw == null || raw === '') return [];
21+
const values = Array.isArray(raw) ? raw : [raw];
22+
return values
23+
.filter((value) => ['string', 'number', 'boolean'].includes(typeof value))
24+
.map((value) => String(value).trim())
25+
.filter(Boolean);
26+
}
27+
1928
/**
2029
* Conservative quoting for tag names passed to git commands. Tags are
2130
* user-typed (--pin) or come from the GitHub API. Only allow the semver
@@ -120,6 +129,9 @@ class ExternalModuleManager {
120129
* @returns {Object} Normalized module info
121130
*/
122131
_normalizeModule(mod, key) {
132+
const installTargets = mod.install_targets ?? mod['install-targets'] ?? mod.installTargets;
133+
const workerTargets = mod.worker_targets ?? mod['worker-targets'] ?? mod.workerTargets;
134+
123135
return {
124136
key: key || mod.name,
125137
url: mod.repository || mod.url,
@@ -131,9 +143,9 @@ class ExternalModuleManager {
131143
defaultSelected: mod.default_selected === true || mod.defaultSelected === true,
132144
type: mod.type || 'bmad-org',
133145
npmPackage: mod.npm_package || mod.npmPackage || null,
134-
installTargets: mod.install_targets || mod['install-targets'] || mod.installTargets || null,
135-
workerTargets: mod.worker_targets || mod['worker-targets'] || mod.workerTargets || [],
136-
requirements: mod.requirements || [],
146+
installTargets: normalizeStringList(installTargets),
147+
workerTargets: normalizeStringList(workerTargets),
148+
requirements: normalizeStringList(mod.requirements),
137149
installNote: mod.install_note || mod['install-note'] || mod.installNote || null,
138150
defaultChannel: normalizeChannelName(mod.default_channel || mod.defaultChannel) || 'stable',
139151
builtIn: mod.built_in === true,
@@ -513,7 +525,12 @@ class ExternalModuleManager {
513525
const cloneDir = await this.cloneExternalModule(moduleCode, options);
514526

515527
if (moduleInfo.sourceRoot) {
516-
const sourceRoot = path.join(cloneDir, moduleInfo.sourceRoot);
528+
const repoRoot = path.resolve(cloneDir);
529+
const sourceRoot = path.resolve(repoRoot, moduleInfo.sourceRoot);
530+
const relativeSourceRoot = path.relative(repoRoot, sourceRoot);
531+
if (relativeSourceRoot === '..' || relativeSourceRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeSourceRoot)) {
532+
throw new Error(`External module '${moduleCode}' source-root escapes repository: ${moduleInfo.sourceRoot}`);
533+
}
517534
if (!(await fs.pathExists(sourceRoot))) {
518535
throw new Error(`External module '${moduleCode}' source-root not found: ${moduleInfo.sourceRoot}`);
519536
}

0 commit comments

Comments
 (0)