mirror of
https://github.com/bmad-code-org/BMAD-METHOD.git
synced 2026-08-28 11:10:51 +08:00
feat(installer): make compatibility shims optional (#2728)
This commit is contained in:
@@ -28,16 +28,19 @@ Use `npx bmad-method install` to set up BMad in your project. One command handle
|
||||
npx bmad-method install
|
||||
```
|
||||
|
||||
The interactive flow asks you five things:
|
||||
The interactive flow asks you six things when the selected modules still ship deprecated compatibility shims:
|
||||
|
||||
1. Installation directory (defaults to the current working directory)
|
||||
2. Which modules to install (checkboxes for core, bmm, bmb, cis, gds, tea)
|
||||
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)
|
||||
5. Per-module config (name, language, output folder)
|
||||
6. Whether to install deprecated compatibility shim skills (defaults to No on a new installation)
|
||||
|
||||
Accept the defaults and you land on the latest stable release of every module, configured for your chosen tool.
|
||||
|
||||
Existing installations keep their current shim choice during Quick Update. Modify Install lets you change it explicitly. Once a release no longer contains any shims, the installer stops asking and removes previously installed shims through its normal update cleanup.
|
||||
|
||||
:::tip[Just want the newest prerelease?]
|
||||
|
||||
```bash
|
||||
@@ -126,6 +129,8 @@ Under `--yes`, patch and minor upgrades apply automatically. Majors stay frozen
|
||||
| `--list-tools` | Print all supported tool/IDE IDs (with target directories) and exit. |
|
||||
| `--action <type>` | `install`, `update`, or `quick-update`. Defaults based on existing install state. |
|
||||
| `--custom-source <urls>` | Install custom modules from Git URLs or local paths |
|
||||
| `--shims` | Install deprecated compatibility shim skills when selected modules still provide them |
|
||||
| `--no-shims` | Explicitly omit deprecated compatibility shim skills; on an existing install this removes them during update |
|
||||
| `--channel <stable\|next>` | Apply to all externals (aliased as `--all-stable` / `--all-next`) |
|
||||
| `--all-stable` | Alias for `--channel=stable` |
|
||||
| `--all-next` | Alias for `--channel=next` |
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@
|
||||
"rebundle": "node tools/installer/bundlers/bundle-web.js rebundle",
|
||||
"test": "npm run test:refs && npm run test:install && npm run test:urls && npm run test:site-url && npm run test:channels && npm run test:renderer && npm run test:retrospective && npm run test:sprint-planning && npm run test:skills && npm run lint && npm run lint:md && npm run format:check",
|
||||
"test:channels": "node test/test-installer-channels.js",
|
||||
"test:install": "node test/test-installation-components.js",
|
||||
"test:install": "node test/test-installation-components.js && node test/test-shim-policy.js",
|
||||
"test:refs": "node test/test-file-refs-csv.js",
|
||||
"test:renderer": "uv run --python 3.11 python -m unittest src/scripts/tests/test_config_utils.py src/scripts/tests/test_resolve_config.py src/scripts/tests/test_resolve_customization.py && node test/test-build-auto-renderer.js",
|
||||
"test:retrospective": "uv run --python 3.11 src/bmm-skills/ship/bmad-retrospective/scripts/tests/test_git_evidence.py && uv run --python 3.11 src/bmm-skills/ship/bmad-retrospective/scripts/tests/test_sprint_status.py",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: bmad-generate-project-context
|
||||
description: 'Deprecated — forwards to bmad-project-context. Use when the user says "generate project context" or "create project context"'
|
||||
metadata:
|
||||
lifecycle: shim
|
||||
---
|
||||
|
||||
# DEPRECATED — forwards to bmad-project-context
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: bmad-create-architecture
|
||||
description: 'Deprecated — forwards to bmad-architecture (create intent)'
|
||||
metadata:
|
||||
lifecycle: shim
|
||||
---
|
||||
|
||||
# DEPRECATED — forwards to bmad-architecture (create intent)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: bmad-create-prd
|
||||
description: 'Deprecated — forwards to bmad-prd (create intent)'
|
||||
metadata:
|
||||
lifecycle: shim
|
||||
---
|
||||
|
||||
# DEPRECATED — forwards to bmad-prd (create intent)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: bmad-create-story
|
||||
description: "Deprecated: `bmad-build` is now the official implementation method. Only use this when explicitly invoked by name"
|
||||
metadata:
|
||||
lifecycle: shim
|
||||
---
|
||||
|
||||
# Create Story Workflow
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: bmad-dev-auto
|
||||
description: "Deprecated: forwards to bmad-build-auto. Do not use unless invoked by name"
|
||||
metadata:
|
||||
lifecycle: shim
|
||||
---
|
||||
|
||||
# Deprecated Build Auto Alias
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: bmad-dev-story
|
||||
description: "Deprecated: `bmad-build` is now the official implementation method. Only use this when explicitly invoked by name"
|
||||
metadata:
|
||||
lifecycle: shim
|
||||
---
|
||||
|
||||
# Dev Story Workflow
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: bmad-document-project
|
||||
description: 'Deprecated — forwards to bmad-project-context. Use when the user says "document this project" or "generate project docs"'
|
||||
metadata:
|
||||
lifecycle: shim
|
||||
---
|
||||
|
||||
# DEPRECATED — forwards to bmad-project-context
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: bmad-domain-research
|
||||
description: 'Deprecated — forwards to bmad-deep-recon (domain type)'
|
||||
metadata:
|
||||
lifecycle: shim
|
||||
---
|
||||
|
||||
# DEPRECATED — forwards to bmad-deep-recon (domain type)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: bmad-edit-prd
|
||||
description: 'Deprecated — forwards to bmad-prd (update intent)'
|
||||
metadata:
|
||||
lifecycle: shim
|
||||
---
|
||||
|
||||
# DEPRECATED — forwards to bmad-prd (update intent)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: bmad-market-research
|
||||
description: 'Deprecated — forwards to bmad-deep-recon (market type)'
|
||||
metadata:
|
||||
lifecycle: shim
|
||||
---
|
||||
|
||||
# DEPRECATED — forwards to bmad-deep-recon (market type)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: bmad-quick-dev
|
||||
description: "Deprecated: forwards to bmad-build. Do not use unless invoked by name"
|
||||
metadata:
|
||||
lifecycle: shim
|
||||
---
|
||||
|
||||
# Deprecated Build Alias
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: bmad-sprint-status
|
||||
description: 'Deprecated — forwards to bmad-sprint-planning (status view)'
|
||||
metadata:
|
||||
lifecycle: shim
|
||||
---
|
||||
|
||||
# DEPRECATED — forwards to bmad-sprint-planning (status view)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: bmad-technical-research
|
||||
description: 'Deprecated — forwards to bmad-deep-recon (technical type)'
|
||||
metadata:
|
||||
lifecycle: shim
|
||||
---
|
||||
|
||||
# DEPRECATED — forwards to bmad-deep-recon (technical type)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: bmad-validate-prd
|
||||
description: 'Deprecated — forwards to bmad-prd (validate intent)'
|
||||
metadata:
|
||||
lifecycle: shim
|
||||
---
|
||||
|
||||
# DEPRECATED — forwards to bmad-prd (validate intent)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: bmad-editorial-review-prose
|
||||
description: 'Deprecated — forwards to bmad-review'
|
||||
metadata:
|
||||
lifecycle: shim
|
||||
---
|
||||
|
||||
Merged into `bmad-review`. Invoke the `bmad-review` skill on the same content with only the `prose` lens, passing through the same inputs and any `also_consider` areas. Present the findings in the legacy shape: a three-column markdown table `| Original Text | Revised Text | Changes |` — no Pass column, no preamble above the table. If no issues are found, output exactly: `No editorial issues identified`.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: bmad-editorial-review-structure
|
||||
description: 'Deprecated — forwards to bmad-review'
|
||||
metadata:
|
||||
lifecycle: shim
|
||||
---
|
||||
|
||||
Merged into `bmad-review`. Invoke the `bmad-review` skill on the same content with only the `structure` lens, passing through the same inputs and any `also_consider` areas. Present the findings in the legacy report shape: a `## Document Summary` block (purpose, audience, reader type, structure model, current length), a `## Recommendations` list of numbered `[CUT/MERGE/MOVE/CONDENSE/QUESTION/PRESERVE]` entries each with rationale and word impact, and a closing `## Summary` (total recommendations, estimated reduction) — not the findings table. If no structural issues are found, output exactly: `No substantive changes recommended`.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: bmad-editorial-review
|
||||
description: 'Deprecated — forwards to bmad-review'
|
||||
metadata:
|
||||
lifecycle: shim
|
||||
---
|
||||
|
||||
Merged into `bmad-review`. Invoke the `bmad-review` skill on the same content with the `structure` and `prose` lenses — both, structure first, so prose runs on top of the structure findings — unless the caller asked for a structure-only or prose-only review, in which case pass only that lens. Pass through any `also_consider` areas, and forward this skill's resolved `[workflow]` fields as pre-resolved values — but only those that resolved to something, since an empty value here means no legacy override exists and bmad-review's own default should stand: `reader_type`, `style_guide`, `review_guidance`, `output_preferences`, `persistent_facts`, `activation_steps_prepend`, `activation_steps_append`, `on_complete`, and `review_output_path` as the report path. Present the findings in the legacy shape: the two-pass findings table `| Pass | Original Text | Revised Text | Changes |` with the purpose/audience read above it and, when the structure pass ran, the reduction summary below it — and no other lens's output.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: bmad-review-adversarial-general
|
||||
description: 'Deprecated — forwards to bmad-review'
|
||||
metadata:
|
||||
lifecycle: shim
|
||||
---
|
||||
|
||||
Merged into `bmad-review`. Invoke the `bmad-review` skill on the same content with only the `adversarial` lens, passing through any `also_consider` areas. Present the findings as a Markdown list — descriptions only, no severity, priority, or ranking; no JSON block.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: bmad-review-edge-case-hunter
|
||||
description: 'Deprecated — forwards to bmad-review'
|
||||
metadata:
|
||||
lifecycle: shim
|
||||
---
|
||||
|
||||
Merged into `bmad-review`. Invoke the `bmad-review` skill on the same content with only the `edge-case-hunter` lens, passing through any `also_consider` areas. Output ONLY the raw findings JSON array in the legacy shape: the four standard fields (plus `kind`/`confidence` on deletion findings), no `lens` field, no markdown wrapping, no extra text. `[]` is valid when nothing is found.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: bmad-review-verification-gap
|
||||
description: 'Deprecated — forwards to bmad-review'
|
||||
metadata:
|
||||
lifecycle: shim
|
||||
---
|
||||
|
||||
Merged into `bmad-review`. Invoke the `bmad-review` skill on the same content with only the `verification-gap` lens. Present the markdown rendering only (no JSON block), listing any `gap_shape: "other"` findings under an `## Other findings` heading. When there are no findings at all, output exactly this single line: `No verification gaps found.`
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const yaml = require('yaml');
|
||||
const fs = require('../tools/installer/fs-native');
|
||||
const prompts = require('../tools/installer/prompts');
|
||||
const { ManifestGenerator } = require('../tools/installer/core/manifest-generator');
|
||||
const { OfficialModules } = require('../tools/installer/modules/official-modules');
|
||||
const { UI } = require('../tools/installer/ui');
|
||||
const { discoverShims, inferShimPreference } = require('../tools/installer/core/shim-policy');
|
||||
|
||||
async function writeSkill(directory, name, lifecycle) {
|
||||
await fs.ensureDir(directory);
|
||||
const frontmatter = {
|
||||
name,
|
||||
description: lifecycle === 'shim' ? 'Deprecated compatibility entry' : 'Use when testing active behavior',
|
||||
};
|
||||
if (lifecycle) frontmatter.metadata = { lifecycle };
|
||||
await fs.writeFile(path.join(directory, 'SKILL.md'), `---\n${yaml.stringify(frontmatter)}---\n\nTest skill.\n`);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-shim-policy-'));
|
||||
|
||||
try {
|
||||
const source = path.join(root, 'source');
|
||||
await writeSkill(path.join(source, 'plan', 'active-skill'), 'active-skill');
|
||||
await writeSkill(path.join(source, 'plan', 'legacy-name'), 'legacy-name', 'shim');
|
||||
|
||||
const discovered = await discoverShims(source);
|
||||
assert.deepEqual(
|
||||
discovered.map((entry) => entry.id),
|
||||
['legacy-name'],
|
||||
'shim discovery uses lifecycle metadata rather than directory naming',
|
||||
);
|
||||
|
||||
assert.equal(inferShimPreference({ availableShims: discovered, existing: false }), false, 'fresh installations default shims off');
|
||||
assert.equal(
|
||||
inferShimPreference({ availableShims: discovered, installedSkillIds: new Set(['legacy-name']), existing: true }),
|
||||
true,
|
||||
'legacy installations containing a shim preserve it by default',
|
||||
);
|
||||
assert.equal(
|
||||
inferShimPreference({
|
||||
requested: false,
|
||||
persisted: true,
|
||||
availableShims: discovered,
|
||||
installedSkillIds: new Set(['legacy-name']),
|
||||
existing: true,
|
||||
}),
|
||||
false,
|
||||
'an explicit user choice disables previously installed shims',
|
||||
);
|
||||
assert.equal(
|
||||
inferShimPreference({ persisted: true, availableShims: [], installedSkillIds: new Set(['legacy-name']), existing: true }),
|
||||
false,
|
||||
'a shimless incoming release ignores the old enabled preference',
|
||||
);
|
||||
assert.equal(
|
||||
inferShimPreference({ persisted: false, availableShims: [], installedSkillIds: new Set(), existing: true }),
|
||||
false,
|
||||
'a shimless incoming release also updates installations that already omitted shims',
|
||||
);
|
||||
|
||||
const moduleInstaller = new OfficialModules();
|
||||
const withoutShims = path.join(root, 'without-shims');
|
||||
await moduleInstaller.copyModuleWithFiltering(source, withoutShims, null, {}, { installShims: false });
|
||||
assert.equal(await fs.pathExists(path.join(withoutShims, 'plan', 'active-skill', 'SKILL.md')), true);
|
||||
assert.equal(await fs.pathExists(path.join(withoutShims, 'plan', 'legacy-name')), false);
|
||||
|
||||
const withShims = path.join(root, 'with-shims');
|
||||
await moduleInstaller.copyModuleWithFiltering(source, withShims, null, {}, { installShims: true });
|
||||
assert.equal(await fs.pathExists(path.join(withShims, 'plan', 'active-skill', 'SKILL.md')), true);
|
||||
assert.equal(await fs.pathExists(path.join(withShims, 'plan', 'legacy-name', 'SKILL.md')), true);
|
||||
|
||||
const rootShimSource = path.join(root, 'root-shim-source');
|
||||
await writeSkill(rootShimSource, 'root-shim', 'shim');
|
||||
const rootShimTarget = path.join(root, 'root-shim-target');
|
||||
await moduleInstaller.copyModuleWithFiltering(rootShimSource, rootShimTarget, null, {}, { installShims: false });
|
||||
assert.equal(await fs.pathExists(path.join(rootShimTarget, 'SKILL.md')), false, 'standalone plugin shims are filtered at their root');
|
||||
|
||||
const originalDiscover = OfficialModules.prototype.discoverShims;
|
||||
const originalConfirm = prompts.confirm;
|
||||
let confirmCalls = 0;
|
||||
try {
|
||||
OfficialModules.prototype.discoverShims = async () => [];
|
||||
prompts.confirm = async () => {
|
||||
confirmCalls++;
|
||||
return true;
|
||||
};
|
||||
const selection = await new UI()._selectShimPreference({
|
||||
selectedModules: ['core'],
|
||||
bmadDir: path.join(root, '_bmad'),
|
||||
existing: true,
|
||||
options: {},
|
||||
channelOptions: null,
|
||||
});
|
||||
assert.equal(selection, undefined);
|
||||
assert.equal(confirmCalls, 0, 'no shim prompt is shown when the incoming release has no shims');
|
||||
|
||||
const legacyBmadDir = path.join(root, 'legacy', '_bmad');
|
||||
await fs.ensureDir(path.join(legacyBmadDir, '_config'));
|
||||
await fs.writeFile(
|
||||
path.join(legacyBmadDir, '_config', 'manifest.yaml'),
|
||||
yaml.stringify({ installation: { version: '6.11.0' }, modules: [], ides: [] }),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(legacyBmadDir, '_config', 'skill-manifest.csv'),
|
||||
'canonicalId,name,description,module,path\n"legacy-name","legacy-name","Deprecated","core","_bmad/core/legacy-name/SKILL.md"\n',
|
||||
);
|
||||
|
||||
OfficialModules.prototype.discoverShims = async () => [{ id: 'legacy-name' }];
|
||||
let offeredDefault;
|
||||
prompts.confirm = async (question) => {
|
||||
offeredDefault = question.default;
|
||||
return false;
|
||||
};
|
||||
const disabledByUser = await new UI()._selectShimPreference({
|
||||
selectedModules: ['core'],
|
||||
bmadDir: legacyBmadDir,
|
||||
existing: true,
|
||||
options: {},
|
||||
channelOptions: null,
|
||||
});
|
||||
assert.equal(offeredDefault, true, 'an existing installation containing shims keeps them enabled by default');
|
||||
assert.equal(disabledByUser, false, 'the interactive result records the user explicitly disabling shims');
|
||||
} finally {
|
||||
OfficialModules.prototype.discoverShims = originalDiscover;
|
||||
prompts.confirm = originalConfirm;
|
||||
}
|
||||
|
||||
const manifestDir = path.join(root, 'manifest', '_config');
|
||||
await fs.ensureDir(manifestDir);
|
||||
const generator = new ManifestGenerator();
|
||||
generator.modules = [];
|
||||
generator.selectedIdes = [];
|
||||
generator.bmadDir = path.dirname(manifestDir);
|
||||
generator.shimsAvailable = false;
|
||||
generator.installShims = true;
|
||||
await generator.writeMainManifest(manifestDir);
|
||||
const shimlessManifest = yaml.parse(await fs.readFile(path.join(manifestDir, 'manifest.yaml'), 'utf8'));
|
||||
assert.equal('installShims' in shimlessManifest.installation, false, 'dead shim preference is omitted from a shimless manifest');
|
||||
|
||||
generator.shimsAvailable = true;
|
||||
generator.installShims = false;
|
||||
await generator.writeMainManifest(manifestDir);
|
||||
const availableManifest = yaml.parse(await fs.readFile(path.join(manifestDir, 'manifest.yaml'), 'utf8'));
|
||||
assert.equal(availableManifest.installation.installShims, false, 'the active user preference is persisted while shims exist');
|
||||
|
||||
console.log('Shim installation policy tests passed.');
|
||||
} finally {
|
||||
await fs.remove(root).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -34,6 +34,8 @@ module.exports = {
|
||||
['--document-output-language <lang>', 'Language for document output (default: English)'],
|
||||
['--output-folder <path>', 'Output folder path relative to project root (default: _bmad-output)'],
|
||||
['--custom-source <sources>', 'Comma-separated Git URLs or local paths to install custom modules from'],
|
||||
['--shims', 'Install deprecated compatibility shim skills when the selected modules provide them'],
|
||||
['--no-shims', 'Do not install deprecated compatibility shim skills'],
|
||||
['-y, --yes', 'Accept all defaults and skip prompts where possible'],
|
||||
[
|
||||
'--channel <channel>',
|
||||
|
||||
@@ -15,6 +15,7 @@ class Config {
|
||||
quickUpdate,
|
||||
channelOptions,
|
||||
setOverrides,
|
||||
installShims,
|
||||
}) {
|
||||
this.directory = directory;
|
||||
this.modules = Object.freeze([...modules]);
|
||||
@@ -32,6 +33,7 @@ class Config {
|
||||
// Intentionally NOT integrated with the prompt/template/schema flow; see
|
||||
// `tools/installer/set-overrides.js` for the rationale and tradeoffs.
|
||||
this.setOverrides = setOverrides || {};
|
||||
this.installShims = typeof installShims === 'boolean' ? installShims : undefined;
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
@@ -58,6 +60,7 @@ class Config {
|
||||
quickUpdate: userInput._quickUpdate || false,
|
||||
channelOptions: userInput.channelOptions || null,
|
||||
setOverrides: userInput.setOverrides || {},
|
||||
installShims: userInput.installShims,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ const { InstallPaths } = require('./install-paths');
|
||||
const { ExternalModuleManager } = require('../modules/external-manager');
|
||||
const { resolveModuleVersion } = require('../modules/version-resolver');
|
||||
const { MODULE_HELP_CSV_HEADER } = require('../modules/module-help-schema');
|
||||
const { inferShimPreference, readInstalledSkillIds } = require('./shim-policy');
|
||||
|
||||
const { ExistingInstall } = require('./existing-install');
|
||||
const { warnPreNativeSkillsLegacy } = require('./legacy-warnings');
|
||||
@@ -42,6 +43,21 @@ class Installer {
|
||||
const paths = await InstallPaths.create(config);
|
||||
const officialModules = await OfficialModules.build(config, paths);
|
||||
const existingInstall = await ExistingInstall.detect(paths.bmadDir);
|
||||
const availableShims = await officialModules.discoverShims(config.modules, {
|
||||
channelOptions: config.channelOptions,
|
||||
});
|
||||
const previousManifest = existingInstall.installed ? await this.manifest.read(paths.bmadDir) : null;
|
||||
const installedSkillIds = existingInstall.installed ? await readInstalledSkillIds(paths.bmadDir) : new Set();
|
||||
const shimPolicy = {
|
||||
available: availableShims.length > 0,
|
||||
install: inferShimPreference({
|
||||
requested: config.installShims,
|
||||
persisted: previousManifest?.installShims,
|
||||
availableShims,
|
||||
installedSkillIds,
|
||||
existing: existingInstall.installed,
|
||||
}),
|
||||
};
|
||||
|
||||
try {
|
||||
await warnPreNativeSkillsLegacy({
|
||||
@@ -92,6 +108,7 @@ class Installer {
|
||||
addResult,
|
||||
officialModules,
|
||||
previousSkillManifestRows,
|
||||
shimPolicy,
|
||||
);
|
||||
|
||||
await this._setupIdes(config, allModules, paths, addResult, previousSkillIds);
|
||||
@@ -225,9 +242,11 @@ class Installer {
|
||||
addResult,
|
||||
officialModules,
|
||||
previousSkillManifestRows = [],
|
||||
shimPolicy = null,
|
||||
) {
|
||||
const isQuickUpdate = config.isQuickUpdate();
|
||||
const moduleConfigs = officialModules.moduleConfigs;
|
||||
const resolvedShimPolicy = shimPolicy || { available: false, install: false };
|
||||
|
||||
const dirResults = { createdDirs: [], movedDirs: [], createdWdsFolders: [] };
|
||||
|
||||
@@ -251,6 +270,7 @@ class Installer {
|
||||
await this._installOfficialModules(config, paths, officialModuleIds, addResult, isQuickUpdate, officialModules, {
|
||||
message,
|
||||
installedModuleNames,
|
||||
shimPolicy: resolvedShimPolicy,
|
||||
});
|
||||
|
||||
return `${allModules.length} module(s) ${isQuickUpdate ? 'updated' : 'installed'}`;
|
||||
@@ -325,6 +345,8 @@ class Installer {
|
||||
ides: config.ides || [],
|
||||
preservedModules: modulesForCsvPreserve,
|
||||
moduleConfigs,
|
||||
installShims: resolvedShimPolicy.install,
|
||||
shimsAvailable: resolvedShimPolicy.available,
|
||||
});
|
||||
await this._appendPreservedSkillManifestRows(paths.bmadDir, previousSkillManifestRows, preservedModules);
|
||||
|
||||
@@ -727,7 +749,7 @@ class Installer {
|
||||
* @param {Object} ctx - Shared context: { message, installedModuleNames }
|
||||
*/
|
||||
async _installOfficialModules(config, paths, officialModuleIds, addResult, isQuickUpdate, officialModules, ctx) {
|
||||
const { message, installedModuleNames } = ctx;
|
||||
const { message, installedModuleNames, shimPolicy } = ctx;
|
||||
const { CustomModuleManager } = require('../modules/custom-module-manager');
|
||||
|
||||
for (const moduleName of officialModuleIds) {
|
||||
@@ -749,6 +771,7 @@ class Installer {
|
||||
installer: this,
|
||||
silent: true,
|
||||
channelOptions: config.channelOptions,
|
||||
installShims: shimPolicy.install,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1500,6 +1523,7 @@ class Installer {
|
||||
// (`applySetOverrides`) runs at the end of quick-update too. The
|
||||
// installer.install path applies them after writeCentralConfig.
|
||||
setOverrides: config.setOverrides || {},
|
||||
installShims: config.installShims,
|
||||
actionType: 'install',
|
||||
_quickUpdate: true,
|
||||
_preserveModules: skippedModules,
|
||||
|
||||
@@ -58,6 +58,8 @@ class ManifestGenerator {
|
||||
this.bmadDir = bmadDir;
|
||||
this.bmadFolderName = path.basename(bmadDir); // Get the actual folder name (e.g., '_bmad' or 'bmad')
|
||||
this.allInstalledFiles = installedFiles;
|
||||
this.installShims = options.installShims === true;
|
||||
this.shimsAvailable = options.shimsAvailable === true;
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(options, 'ides')) {
|
||||
throw new Error('ManifestGenerator requires `options.ides` to be provided – installer should supply the selected IDEs array.');
|
||||
@@ -378,6 +380,10 @@ class ManifestGenerator {
|
||||
ides: this.selectedIdes,
|
||||
};
|
||||
|
||||
if (this.shimsAvailable) {
|
||||
manifest.installation.installShims = this.installShims;
|
||||
}
|
||||
|
||||
// Clean the manifest to remove any non-serializable values
|
||||
const cleanManifest = structuredClone(manifest);
|
||||
|
||||
|
||||
@@ -61,6 +61,10 @@ class Manifest {
|
||||
ides: data.ides || [],
|
||||
};
|
||||
|
||||
if (typeof data.installShims === 'boolean') {
|
||||
manifestData.installation.installShims = data.installShims;
|
||||
}
|
||||
|
||||
// Write YAML manifest
|
||||
// Clean the manifest data to remove any non-serializable values
|
||||
const cleanManifestData = structuredClone(manifestData);
|
||||
@@ -106,6 +110,7 @@ class Manifest {
|
||||
version: manifestData.installation?.version,
|
||||
installDate: manifestData.installation?.installDate,
|
||||
lastUpdated: manifestData.installation?.lastUpdated,
|
||||
installShims: manifestData.installation?.installShims,
|
||||
modules: moduleNames, // Simple array of module names for backward compatibility
|
||||
modulesDetailed: hasDetailedModules ? modules : null, // New detailed format
|
||||
ides: manifestData.ides || [],
|
||||
@@ -153,6 +158,7 @@ class Manifest {
|
||||
version: manifest.installation?.version,
|
||||
installDate: manifest.installation?.installDate,
|
||||
lastUpdated: manifest.installation?.lastUpdated,
|
||||
installShims: manifest.installation?.installShims,
|
||||
modules: moduleNames,
|
||||
modulesDetailed: hasDetailedModules ? modules : null,
|
||||
ides: manifest.ides || [],
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
const path = require('node:path');
|
||||
const fs = require('../fs-native');
|
||||
const yaml = require('yaml');
|
||||
const csv = require('csv-parse/sync');
|
||||
|
||||
function parseSkillMetadata(content) {
|
||||
const normalized = content.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
|
||||
const match = normalized.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!match) return null;
|
||||
|
||||
try {
|
||||
const frontmatter = yaml.parse(match[1]);
|
||||
return frontmatter && typeof frontmatter === 'object' ? frontmatter : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isShimSkill(metadata) {
|
||||
return metadata?.metadata?.lifecycle === 'shim';
|
||||
}
|
||||
|
||||
async function discoverShims(modulePath) {
|
||||
const shims = [];
|
||||
|
||||
const walk = async (dir) => {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const skillFile = path.join(dir, 'SKILL.md');
|
||||
if (await fs.pathExists(skillFile)) {
|
||||
const metadata = parseSkillMetadata(await fs.readFile(skillFile, 'utf8'));
|
||||
if (isShimSkill(metadata)) {
|
||||
shims.push({
|
||||
id: metadata.name || path.basename(dir),
|
||||
directory: dir,
|
||||
relativeDirectory: path.relative(modulePath, dir),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
|
||||
await walk(path.join(dir, entry.name));
|
||||
}
|
||||
};
|
||||
|
||||
await walk(modulePath);
|
||||
return shims;
|
||||
}
|
||||
|
||||
async function readInstalledSkillIds(bmadDir) {
|
||||
const ids = new Set();
|
||||
const manifestPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
|
||||
if (!(await fs.pathExists(manifestPath))) return ids;
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(manifestPath, 'utf8');
|
||||
const records = csv.parse(content, { columns: true, skip_empty_lines: true });
|
||||
for (const record of records) {
|
||||
if (record.canonicalId) ids.add(record.canonicalId);
|
||||
}
|
||||
} catch {
|
||||
// A missing or unreadable legacy manifest means there is no reliable
|
||||
// evidence that compatibility shims were installed.
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
function inferShimPreference({ requested, persisted, availableShims = [], installedSkillIds = new Set(), existing = false }) {
|
||||
if (availableShims.length === 0) return false;
|
||||
if (typeof requested === 'boolean') return requested;
|
||||
if (typeof persisted === 'boolean') return persisted;
|
||||
if (!existing) return false;
|
||||
|
||||
return availableShims.some((shim) => installedSkillIds.has(shim.id));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
discoverShims,
|
||||
inferShimPreference,
|
||||
isShimSkill,
|
||||
parseSkillMetadata,
|
||||
readInstalledSkillIds,
|
||||
};
|
||||
@@ -5,6 +5,7 @@ const prompts = require('../prompts');
|
||||
const { getProjectRoot, getSourcePath, getModulePath } = require('../project-root');
|
||||
const { CLIUtils } = require('../cli-utils');
|
||||
const { ExternalModuleManager } = require('./external-manager');
|
||||
const { discoverShims } = require('../core/shim-policy');
|
||||
|
||||
class OfficialModules {
|
||||
constructor(options = {}) {
|
||||
@@ -131,6 +132,23 @@ class OfficialModules {
|
||||
return { modules };
|
||||
}
|
||||
|
||||
async discoverShims(moduleNames = [], options = {}) {
|
||||
const shims = [];
|
||||
|
||||
for (const moduleName of moduleNames) {
|
||||
const sourcePath = await this.findModuleSource(moduleName, {
|
||||
silent: true,
|
||||
channelOptions: options.channelOptions,
|
||||
});
|
||||
if (!sourcePath) continue;
|
||||
|
||||
const moduleShims = await discoverShims(sourcePath);
|
||||
for (const shim of moduleShims) shims.push({ ...shim, module: moduleName });
|
||||
}
|
||||
|
||||
return shims;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get module information from a module path
|
||||
* @param {string} modulePath - Path to the module directory
|
||||
@@ -296,9 +314,13 @@ class OfficialModules {
|
||||
`Aborting to avoid a partial install with no skills.`,
|
||||
);
|
||||
}
|
||||
await this._copyResolvedSkills(pluginResolution, targetPath, fileTrackingCallback, options.moduleConfig);
|
||||
await this._copyResolvedSkills(pluginResolution, targetPath, fileTrackingCallback, options.moduleConfig, {
|
||||
installShims: options.installShims,
|
||||
});
|
||||
} else {
|
||||
await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig);
|
||||
await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig, {
|
||||
installShims: options.installShims,
|
||||
});
|
||||
}
|
||||
|
||||
if (!options.skipModuleInstaller) {
|
||||
@@ -336,7 +358,7 @@ class OfficialModules {
|
||||
* @param {Function} fileTrackingCallback - Optional callback to track installed files
|
||||
* @param {Object} moduleConfig - Module configuration passed to copy filtering
|
||||
*/
|
||||
async _copyResolvedSkills(resolved, targetPath, fileTrackingCallback = null, moduleConfig = {}) {
|
||||
async _copyResolvedSkills(resolved, targetPath, fileTrackingCallback = null, moduleConfig = {}, installOptions = {}) {
|
||||
await fs.ensureDir(targetPath);
|
||||
|
||||
// Copy each skill directory, flattened by leaf name. Leaf names must be
|
||||
@@ -354,7 +376,7 @@ class OfficialModules {
|
||||
}
|
||||
seenLeaves.set(skillDirName, skillPath);
|
||||
const skillTarget = path.join(targetPath, skillDirName);
|
||||
await this.copyModuleWithFiltering(skillPath, skillTarget, fileTrackingCallback, moduleConfig);
|
||||
await this.copyModuleWithFiltering(skillPath, skillTarget, fileTrackingCallback, moduleConfig, installOptions);
|
||||
}
|
||||
|
||||
// Place module-help.csv at the module root.
|
||||
@@ -385,7 +407,9 @@ class OfficialModules {
|
||||
await fs.remove(targetPath);
|
||||
}
|
||||
|
||||
await this._copyResolvedSkills(resolved, targetPath, fileTrackingCallback, options.moduleConfig);
|
||||
await this._copyResolvedSkills(resolved, targetPath, fileTrackingCallback, options.moduleConfig, {
|
||||
installShims: options.installShims,
|
||||
});
|
||||
|
||||
// Create directories declared in module.yaml (strategies 1-4 may have these)
|
||||
if (!options.skipModuleInstaller) {
|
||||
@@ -522,11 +546,19 @@ class OfficialModules {
|
||||
* @param {Function} fileTrackingCallback - Optional callback to track installed files
|
||||
* @param {Object} moduleConfig - Module configuration with conditional flags
|
||||
*/
|
||||
async copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback = null, moduleConfig = {}) {
|
||||
async copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback = null, moduleConfig = {}, installOptions = {}) {
|
||||
// Get all files in source
|
||||
const sourceFiles = await this.getFileList(sourcePath);
|
||||
const shimDirectories =
|
||||
installOptions.installShims === false
|
||||
? (await discoverShims(sourcePath)).map((shim) => shim.relativeDirectory.split(path.sep).join('/'))
|
||||
: [];
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
const normalizedFile = file.split(path.sep).join('/');
|
||||
if (shimDirectories.some((shimDir) => shimDir === '' || normalizedFile === shimDir || normalizedFile.startsWith(`${shimDir}/`))) {
|
||||
continue;
|
||||
}
|
||||
// Skip sub-modules directory - these are IDE-specific and handled separately
|
||||
if (file.startsWith('sub-modules/')) {
|
||||
continue;
|
||||
|
||||
+46
-1
@@ -17,6 +17,7 @@ const {
|
||||
const channelResolver = require('./modules/channel-resolver');
|
||||
const prompts = require('./prompts');
|
||||
const { parseSetEntries } = require('./set-overrides');
|
||||
const { inferShimPreference, readInstalledSkillIds } = require('./core/shim-policy');
|
||||
|
||||
const manifest = new Manifest();
|
||||
|
||||
@@ -110,6 +111,33 @@ async function getModuleVersion(moduleCode, { repoUrl = null, registryDefault =
|
||||
* UI utilities for the installer
|
||||
*/
|
||||
class UI {
|
||||
async _selectShimPreference({ selectedModules, bmadDir, existing, options, channelOptions }) {
|
||||
const { OfficialModules } = require('./modules/official-modules');
|
||||
const officialModules = new OfficialModules({ channelOptions });
|
||||
const availableShims = await officialModules.discoverShims(selectedModules, { channelOptions });
|
||||
|
||||
// The prompt is capability-driven. Once the last shim leaves the incoming
|
||||
// release this becomes an ordinary empty set, regardless of old state.
|
||||
if (availableShims.length === 0) return;
|
||||
|
||||
const previousManifest = existing ? await manifest.read(bmadDir) : null;
|
||||
const installedSkillIds = existing ? await readInstalledSkillIds(bmadDir) : new Set();
|
||||
const currentValue = inferShimPreference({
|
||||
requested: options.shims,
|
||||
persisted: previousManifest?.installShims,
|
||||
availableShims,
|
||||
installedSkillIds,
|
||||
existing,
|
||||
});
|
||||
|
||||
if (typeof options.shims === 'boolean' || options.yes) return currentValue;
|
||||
|
||||
return prompts.confirm({
|
||||
message: `Install ${availableShims.length} deprecated compatibility shim skill(s)?`,
|
||||
default: currentValue,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Warn once for each selected module the registry marks deprecated.
|
||||
*
|
||||
@@ -302,7 +330,7 @@ class UI {
|
||||
throw new Error('No valid actions available for this installation');
|
||||
}
|
||||
const hasQuickUpdate = choices.some((c) => c.value === 'quick-update');
|
||||
const needsFullUpdate = !!options.customSource;
|
||||
const needsFullUpdate = !!options.customSource || typeof options.shims === 'boolean';
|
||||
actionType = hasQuickUpdate && !needsFullUpdate ? 'quick-update' : (choices.find((c) => c.value === 'update') || choices[0]).value;
|
||||
await prompts.log.info(`Non-interactive mode (--yes): defaulting to ${actionType}`);
|
||||
} else {
|
||||
@@ -322,6 +350,7 @@ class UI {
|
||||
actionType: 'quick-update',
|
||||
directory: confirmedDirectory,
|
||||
skipPrompts: options.yes || false,
|
||||
installShims: options.shims,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -406,6 +435,13 @@ class UI {
|
||||
...options,
|
||||
channelOptions,
|
||||
});
|
||||
const installShims = await this._selectShimPreference({
|
||||
selectedModules,
|
||||
bmadDir,
|
||||
existing: true,
|
||||
options,
|
||||
channelOptions,
|
||||
});
|
||||
|
||||
// Warn about --pin/--next flags that refer to modules the user didn't
|
||||
// select, or that target bundled modules (core/bmm) where channel
|
||||
@@ -432,6 +468,7 @@ class UI {
|
||||
skipPrompts: options.yes || false,
|
||||
channelOptions,
|
||||
_preserveModules: preservedModules,
|
||||
installShims,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -487,6 +524,13 @@ class UI {
|
||||
...options,
|
||||
channelOptions,
|
||||
});
|
||||
const installShims = await this._selectShimPreference({
|
||||
selectedModules,
|
||||
bmadDir,
|
||||
existing: false,
|
||||
options,
|
||||
channelOptions,
|
||||
});
|
||||
|
||||
// Warn about --pin/--next flags that refer to modules the user didn't
|
||||
// select, or that target bundled modules (core/bmm) where channel
|
||||
@@ -512,6 +556,7 @@ class UI {
|
||||
setOverrides,
|
||||
skipPrompts: options.yes || false,
|
||||
channelOptions,
|
||||
installShims,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user