From 513f440a23c91591fe79335359d362eacad3da26 Mon Sep 17 00:00:00 2001 From: Alex Verkhovsky Date: Fri, 27 Mar 2026 06:50:07 -0600 Subject: [PATCH] refactor(installer): restructure installer with clean separation of concerns (#2129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(installer): restructure installer with clean separation of concerns Move tools/cli/ to tools/installer/ with major structural cleanup: - InstallPaths async factory for path resolution and directory creation - Config value object (frozen) replaces mutable config bag - ExistingInstall value object replaces stateful Detector class - OfficialModules + CustomModules + ExternalModuleManager replace monolithic ModuleManager - install() is prompt-free; all user interaction in ui.js - Update state returned explicitly instead of mutating customConfig - Delete dead code: dependency-resolver, _base-ide, IdeConfigManager, platform-codes helpers, npx wrapper, xml-utils - Flatten directory structure: custom/handler → custom-handler, tools/cli/ → tools/installer/, lib/ directories removed - Update all path references in package.json, tests, CI, and docs * fix(installer): guard ExistingInstall.version and surface module.yaml errors Guard ExistingInstall.version access with .installed check in uninstall.js, ui.js, and installer.js to prevent throwing on empty/partial _bmad dirs. Surface invalid module.yaml parse errors as warnings instead of silently returning empty results. --- .github/workflows/publish.yaml | 2 +- .../fr/how-to/non-interactive-installation.md | 2 +- docs/how-to/non-interactive-installation.md | 2 +- .../how-to/non-interactive-installation.md | 2 +- package.json | 14 +- .../resources/distillate-format-reference.md | 2 +- test/test-install-to-bmad.js | 2 +- test/test-installation-components.js | 218 +- test/test-workflow-path-regex.js | 2 +- tools/bmad-npx-wrapper.js | 38 - .../lib/core/dependency-resolver.js | 743 ---- tools/cli/installers/lib/core/detector.js | 223 -- .../installers/lib/core/ide-config-manager.js | 157 - tools/cli/installers/lib/core/installer.js | 3002 ----------------- tools/cli/installers/lib/ide/_base-ide.js | 657 ---- .../cli/installers/lib/ide/platform-codes.js | 100 - .../installers/lib/ide/platform-codes.yaml | 341 -- .../combined/claude-workflow-yaml.md | 1 - .../lib/modules/external-manager.js | 136 - tools/cli/installers/lib/modules/manager.js | 928 ----- tools/cli/lib/config.js | 213 -- tools/cli/lib/platform-codes.js | 116 - tools/docs/_prompt-external-modules-page.md | 2 +- tools/{cli => installer}/README.md | 0 tools/{cli => installer}/bmad-cli.js | 4 +- tools/{cli/lib => installer}/cli-utils.js | 7 +- tools/{cli => installer}/commands/install.js | 6 +- tools/{cli => installer}/commands/status.js | 8 +- .../{cli => installer}/commands/uninstall.js | 10 +- tools/installer/core/config.js | 52 + .../core/custom-module-cache.js | 2 +- tools/installer/core/existing-install.js | 127 + tools/installer/core/install-paths.js | 129 + tools/installer/core/installer.js | 1790 ++++++++++ .../core/manifest-generator.js | 6 +- .../lib => installer}/core/manifest.js | 4 +- .../custom-handler.js} | 2 +- .../external-official-modules.yaml | 0 tools/{cli/lib => installer}/file-ops.js | 0 .../lib => installer}/ide/_config-driven.js | 427 +-- .../lib => installer}/ide/manager.js | 54 +- tools/installer/ide/platform-codes.js | 37 + tools/installer/ide/platform-codes.yaml | 190 ++ .../ide/shared/agent-command-generator.js | 0 .../ide/shared/bmad-artifacts.js | 0 .../ide/shared/module-injections.js | 2 +- .../ide/shared/path-utils.js | 0 .../ide/shared/skill-manifest.js | 0 .../ide/templates/agent-command-template.md | 0 .../ide/templates/combined/antigravity.md | 0 .../ide/templates/combined/claude-agent.md | 0 .../ide/templates/combined/claude-workflow.md | 0 .../ide/templates/combined/default-agent.md | 0 .../ide/templates/combined/default-task.md | 0 .../ide/templates/combined/default-tool.md | 0 .../templates/combined/default-workflow.md | 0 .../ide/templates/combined/gemini-agent.toml | 0 .../ide/templates/combined/gemini-task.toml | 0 .../ide/templates/combined/gemini-tool.toml | 0 .../combined/gemini-workflow-yaml.toml | 0 .../templates/combined/gemini-workflow.toml | 0 .../ide/templates/combined/kiro-agent.md | 0 .../ide/templates/combined/kiro-task.md | 0 .../ide/templates/combined/kiro-tool.md | 0 .../ide/templates/combined/kiro-workflow.md | 0 .../ide/templates/combined/opencode-agent.md | 0 .../ide/templates/combined/opencode-task.md | 0 .../ide/templates/combined/opencode-tool.md | 0 .../combined/opencode-workflow-yaml.md | 0 .../templates/combined/opencode-workflow.md | 0 .../ide/templates/combined/rovodev.md | 0 .../ide/templates/combined/trae.md | 0 .../templates/combined/windsurf-workflow.md | 0 .../ide/templates/split/.gitkeep | 0 .../install-messages.yaml | 0 .../lib => installer}/message-loader.js | 4 +- tools/installer/modules/custom-modules.js | 197 ++ tools/installer/modules/external-manager.js | 323 ++ .../modules/official-modules.js} | 757 ++++- tools/{cli/lib => installer}/project-root.js | 0 tools/{cli/lib => installer}/prompts.js | 0 tools/{cli/lib => installer}/ui.js | 364 +- tools/{cli/lib => installer}/yaml-format.js | 0 tools/javascript-conventions.md | 5 + tools/lib/xml-utils.js | 13 - 85 files changed, 3706 insertions(+), 7717 deletions(-) delete mode 100755 tools/bmad-npx-wrapper.js delete mode 100644 tools/cli/installers/lib/core/dependency-resolver.js delete mode 100644 tools/cli/installers/lib/core/detector.js delete mode 100644 tools/cli/installers/lib/core/ide-config-manager.js delete mode 100644 tools/cli/installers/lib/core/installer.js delete mode 100644 tools/cli/installers/lib/ide/_base-ide.js delete mode 100644 tools/cli/installers/lib/ide/platform-codes.js delete mode 100644 tools/cli/installers/lib/ide/platform-codes.yaml delete mode 120000 tools/cli/installers/lib/ide/templates/combined/claude-workflow-yaml.md delete mode 100644 tools/cli/installers/lib/modules/external-manager.js delete mode 100644 tools/cli/installers/lib/modules/manager.js delete mode 100644 tools/cli/lib/config.js delete mode 100644 tools/cli/lib/platform-codes.js rename tools/{cli => installer}/README.md (100%) rename tools/{cli => installer}/bmad-cli.js (98%) rename tools/{cli/lib => installer}/cli-utils.js (96%) rename tools/{cli => installer}/commands/install.js (95%) rename tools/{cli => installer}/commands/status.js (89%) rename tools/{cli => installer}/commands/uninstall.js (94%) create mode 100644 tools/installer/core/config.js rename tools/{cli/installers/lib => installer}/core/custom-module-cache.js (99%) create mode 100644 tools/installer/core/existing-install.js create mode 100644 tools/installer/core/install-paths.js create mode 100644 tools/installer/core/installer.js rename tools/{cli/installers/lib => installer}/core/manifest-generator.js (99%) rename tools/{cli/installers/lib => installer}/core/manifest.js (99%) rename tools/{cli/installers/lib/custom/handler.js => installer/custom-handler.js} (98%) rename tools/{cli => installer}/external-official-modules.yaml (100%) rename tools/{cli/lib => installer}/file-ops.js (100%) rename tools/{cli/installers/lib => installer}/ide/_config-driven.js (54%) rename tools/{cli/installers/lib => installer}/ide/manager.js (82%) create mode 100644 tools/installer/ide/platform-codes.js create mode 100644 tools/installer/ide/platform-codes.yaml rename tools/{cli/installers/lib => installer}/ide/shared/agent-command-generator.js (100%) rename tools/{cli/installers/lib => installer}/ide/shared/bmad-artifacts.js (100%) rename tools/{cli/installers/lib => installer}/ide/shared/module-injections.js (98%) rename tools/{cli/installers/lib => installer}/ide/shared/path-utils.js (100%) rename tools/{cli/installers/lib => installer}/ide/shared/skill-manifest.js (100%) rename tools/{cli/installers/lib => installer}/ide/templates/agent-command-template.md (100%) rename tools/{cli/installers/lib => installer}/ide/templates/combined/antigravity.md (100%) rename tools/{cli/installers/lib => installer}/ide/templates/combined/claude-agent.md (100%) rename tools/{cli/installers/lib => installer}/ide/templates/combined/claude-workflow.md (100%) rename tools/{cli/installers/lib => installer}/ide/templates/combined/default-agent.md (100%) rename tools/{cli/installers/lib => installer}/ide/templates/combined/default-task.md (100%) rename tools/{cli/installers/lib => installer}/ide/templates/combined/default-tool.md (100%) rename tools/{cli/installers/lib => installer}/ide/templates/combined/default-workflow.md (100%) rename tools/{cli/installers/lib => installer}/ide/templates/combined/gemini-agent.toml (100%) rename tools/{cli/installers/lib => installer}/ide/templates/combined/gemini-task.toml (100%) rename tools/{cli/installers/lib => installer}/ide/templates/combined/gemini-tool.toml (100%) rename tools/{cli/installers/lib => installer}/ide/templates/combined/gemini-workflow-yaml.toml (100%) rename tools/{cli/installers/lib => installer}/ide/templates/combined/gemini-workflow.toml (100%) rename tools/{cli/installers/lib => installer}/ide/templates/combined/kiro-agent.md (100%) rename tools/{cli/installers/lib => installer}/ide/templates/combined/kiro-task.md (100%) rename tools/{cli/installers/lib => installer}/ide/templates/combined/kiro-tool.md (100%) rename tools/{cli/installers/lib => installer}/ide/templates/combined/kiro-workflow.md (100%) rename tools/{cli/installers/lib => installer}/ide/templates/combined/opencode-agent.md (100%) rename tools/{cli/installers/lib => installer}/ide/templates/combined/opencode-task.md (100%) rename tools/{cli/installers/lib => installer}/ide/templates/combined/opencode-tool.md (100%) rename tools/{cli/installers/lib => installer}/ide/templates/combined/opencode-workflow-yaml.md (100%) rename tools/{cli/installers/lib => installer}/ide/templates/combined/opencode-workflow.md (100%) rename tools/{cli/installers/lib => installer}/ide/templates/combined/rovodev.md (100%) rename tools/{cli/installers/lib => installer}/ide/templates/combined/trae.md (100%) rename tools/{cli/installers/lib => installer}/ide/templates/combined/windsurf-workflow.md (100%) rename tools/{cli/installers/lib => installer}/ide/templates/split/.gitkeep (100%) rename tools/{cli/installers => installer}/install-messages.yaml (100%) rename tools/{cli/installers/lib => installer}/message-loader.js (93%) create mode 100644 tools/installer/modules/custom-modules.js create mode 100644 tools/installer/modules/external-manager.js rename tools/{cli/installers/lib/core/config-collector.js => installer/modules/official-modules.js} (64%) rename tools/{cli/lib => installer}/project-root.js (100%) rename tools/{cli/lib => installer}/prompts.js (100%) rename tools/{cli/lib => installer}/ui.js (81%) rename tools/{cli/lib => installer}/yaml-format.js (100%) create mode 100644 tools/javascript-conventions.md delete mode 100644 tools/lib/xml-utils.js diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 63c797803..0079a5e81 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -5,7 +5,7 @@ on: branches: [main] paths: - "src/**" - - "tools/cli/**" + - "tools/installer/**" - "package.json" workflow_dispatch: inputs: diff --git a/docs/fr/how-to/non-interactive-installation.md b/docs/fr/how-to/non-interactive-installation.md index 46e8ad4dc..0fe6588f9 100644 --- a/docs/fr/how-to/non-interactive-installation.md +++ b/docs/fr/how-to/non-interactive-installation.md @@ -61,7 +61,7 @@ IDs d'outils disponibles pour l’option `--tools` : **Recommandés :** `claude-code`, `cursor` -Exécutez `npx bmad-method install` de manière interactive une fois pour voir la liste complète actuelle des outils pris en charge, ou consultez la [configuration des codes de la plateforme](https://github.com/bmad-code-org/BMAD-METHOD/blob/main/tools/cli/installers/lib/ide/platform-codes.yaml). +Exécutez `npx bmad-method install` de manière interactive une fois pour voir la liste complète actuelle des outils pris en charge, ou consultez la [configuration des codes de la plateforme](https://github.com/bmad-code-org/BMAD-METHOD/blob/main/tools/installer/ide/platform-codes.yaml). ## Modes d'installation diff --git a/docs/how-to/non-interactive-installation.md b/docs/how-to/non-interactive-installation.md index eb72dfef4..64687c0a1 100644 --- a/docs/how-to/non-interactive-installation.md +++ b/docs/how-to/non-interactive-installation.md @@ -73,7 +73,7 @@ Available tool IDs for the `--tools` flag: **Preferred:** `claude-code`, `cursor` -Run `npx bmad-method install` interactively once to see the full current list of supported tools, or check the [platform codes configuration](https://github.com/bmad-code-org/BMAD-METHOD/blob/main/tools/cli/installers/lib/ide/platform-codes.yaml). +Run `npx bmad-method install` interactively once to see the full current list of supported tools, or check the [platform codes configuration](https://github.com/bmad-code-org/BMAD-METHOD/blob/main/tools/installer/ide/platform-codes.yaml). ## Installation Modes diff --git a/docs/zh-cn/how-to/non-interactive-installation.md b/docs/zh-cn/how-to/non-interactive-installation.md index fdcfbc9fd..df7259d97 100644 --- a/docs/zh-cn/how-to/non-interactive-installation.md +++ b/docs/zh-cn/how-to/non-interactive-installation.md @@ -61,7 +61,7 @@ sidebar: **推荐:** `claude-code`、`cursor` -运行一次 `npx bmad-method install` 交互式安装以查看完整的当前支持工具列表,或查看 [平台代码配置](https://github.com/bmad-code-org/BMAD-METHOD/blob/main/tools/cli/installers/lib/ide/platform-codes.yaml)。 +运行一次 `npx bmad-method install` 交互式安装以查看完整的当前支持工具列表,或查看 [平台代码配置](https://github.com/bmad-code-org/BMAD-METHOD/blob/main/tools/installer/ide/platform-codes.yaml)。 ## 安装模式 diff --git a/package.json b/package.json index 13825fe87..38f4d913e 100644 --- a/package.json +++ b/package.json @@ -18,14 +18,14 @@ }, "license": "MIT", "author": "Brian (BMad) Madison", - "main": "tools/cli/bmad-cli.js", + "main": "tools/installer/bmad-cli.js", "bin": { - "bmad": "tools/bmad-npx-wrapper.js", - "bmad-method": "tools/bmad-npx-wrapper.js" + "bmad": "tools/installer/bmad-cli.js", + "bmad-method": "tools/installer/bmad-cli.js" }, "scripts": { - "bmad:install": "node tools/cli/bmad-cli.js install", - "bmad:uninstall": "node tools/cli/bmad-cli.js uninstall", + "bmad:install": "node tools/installer/bmad-cli.js install", + "bmad:uninstall": "node tools/installer/bmad-cli.js uninstall", "docs:build": "node tools/build-docs.mjs", "docs:dev": "astro dev --root website", "docs:fix-links": "node tools/fix-doc-links.js", @@ -34,13 +34,13 @@ "format:check": "prettier --check \"**/*.{js,cjs,mjs,json,yaml}\"", "format:fix": "prettier --write \"**/*.{js,cjs,mjs,json,yaml}\"", "format:fix:staged": "prettier --write", - "install:bmad": "node tools/cli/bmad-cli.js install", + "install:bmad": "node tools/installer/bmad-cli.js install", "lint": "eslint . --ext .js,.cjs,.mjs,.yaml --max-warnings=0", "lint:fix": "eslint . --ext .js,.cjs,.mjs,.yaml --fix", "lint:md": "markdownlint-cli2 \"**/*.md\"", "prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0", "quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run validate:refs && npm run validate:skills", - "rebundle": "node tools/cli/bundlers/bundle-web.js rebundle", + "rebundle": "node tools/installer/bundlers/bundle-web.js rebundle", "test": "npm run test:refs && npm run test:install && npm run lint && npm run lint:md && npm run format:check", "test:install": "node test/test-installation-components.js", "test:refs": "node test/test-file-refs-csv.js", diff --git a/src/core-skills/bmad-distillator/resources/distillate-format-reference.md b/src/core-skills/bmad-distillator/resources/distillate-format-reference.md index 11ffac526..3c21d3598 100644 --- a/src/core-skills/bmad-distillator/resources/distillate-format-reference.md +++ b/src/core-skills/bmad-distillator/resources/distillate-format-reference.md @@ -172,7 +172,7 @@ parts: 1 - Deferred: CI/CD integration, telemetry for module authors, air-gapped enterprise install, zip bundle integrity verification (checksums/signing), deeper non-technical platform integrations ## Current Installer (migration context) -- Entry: `tools/cli/bmad-cli.js` (Commander.js) → `tools/cli/installers/lib/core/installer.js` +- Entry: `tools/installer/bmad-cli.js` (Commander.js) → `tools/installer/core/installer.js` - Platforms: `platform-codes.yaml` (~20 platforms with target dirs, legacy dirs, template types, special flags) - Manifests: CSV files (skill/workflow/agent-manifest.csv) are current source of truth, not JSON - External modules: `external-official-modules.yaml` (CIS, GDS, TEA, WDS) from npm with semver diff --git a/test/test-install-to-bmad.js b/test/test-install-to-bmad.js index 0367dbe93..d33218eb8 100644 --- a/test/test-install-to-bmad.js +++ b/test/test-install-to-bmad.js @@ -15,7 +15,7 @@ const path = require('node:path'); const os = require('node:os'); const fs = require('fs-extra'); -const { loadSkillManifest, getInstallToBmad } = require('../tools/cli/installers/lib/ide/shared/skill-manifest'); +const { loadSkillManifest, getInstallToBmad } = require('../tools/installer/ide/shared/skill-manifest'); // ANSI colors const colors = { diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 8b6f505de..38da1eba4 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -14,10 +14,9 @@ const path = require('node:path'); const os = require('node:os'); const fs = require('fs-extra'); -const { ConfigCollector } = require('../tools/cli/installers/lib/core/config-collector'); -const { ManifestGenerator } = require('../tools/cli/installers/lib/core/manifest-generator'); -const { IdeManager } = require('../tools/cli/installers/lib/ide/manager'); -const { clearCache, loadPlatformCodes } = require('../tools/cli/installers/lib/ide/platform-codes'); +const { ManifestGenerator } = require('../tools/installer/core/manifest-generator'); +const { IdeManager } = require('../tools/installer/ide/manager'); +const { clearCache, loadPlatformCodes } = require('../tools/installer/ide/platform-codes'); // ANSI colors const colors = { @@ -149,8 +148,6 @@ async function runTests() { assert(windsurfInstaller?.target_dir === '.windsurf/skills', 'Windsurf target_dir uses native skills path'); - assert(windsurfInstaller?.skill_format === true, 'Windsurf installer enables native skill output'); - assert( Array.isArray(windsurfInstaller?.legacy_targets) && windsurfInstaller.legacy_targets.includes('.windsurf/workflows'), 'Windsurf installer cleans legacy workflow output', @@ -197,8 +194,6 @@ async function runTests() { assert(kiroInstaller?.target_dir === '.kiro/skills', 'Kiro target_dir uses native skills path'); - assert(kiroInstaller?.skill_format === true, 'Kiro installer enables native skill output'); - assert( Array.isArray(kiroInstaller?.legacy_targets) && kiroInstaller.legacy_targets.includes('.kiro/steering'), 'Kiro installer cleans legacy steering output', @@ -245,8 +240,6 @@ async function runTests() { assert(antigravityInstaller?.target_dir === '.agent/skills', 'Antigravity target_dir uses native skills path'); - assert(antigravityInstaller?.skill_format === true, 'Antigravity installer enables native skill output'); - assert( Array.isArray(antigravityInstaller?.legacy_targets) && antigravityInstaller.legacy_targets.includes('.agent/workflows'), 'Antigravity installer cleans legacy workflow output', @@ -293,8 +286,6 @@ async function runTests() { assert(auggieInstaller?.target_dir === '.augment/skills', 'Auggie target_dir uses native skills path'); - assert(auggieInstaller?.skill_format === true, 'Auggie installer enables native skill output'); - assert( Array.isArray(auggieInstaller?.legacy_targets) && auggieInstaller.legacy_targets.includes('.augment/commands'), 'Auggie installer cleans legacy command output', @@ -346,8 +337,6 @@ async function runTests() { assert(opencodeInstaller?.target_dir === '.opencode/skills', 'OpenCode target_dir uses native skills path'); - assert(opencodeInstaller?.skill_format === true, 'OpenCode installer enables native skill output'); - assert(opencodeInstaller?.ancestor_conflict_check === true, 'OpenCode installer enables ancestor conflict checks'); assert( @@ -412,8 +401,6 @@ async function runTests() { assert(claudeInstaller?.target_dir === '.claude/skills', 'Claude Code target_dir uses native skills path'); - assert(claudeInstaller?.skill_format === true, 'Claude Code installer enables native skill output'); - assert(claudeInstaller?.ancestor_conflict_check === true, 'Claude Code installer enables ancestor conflict checks'); assert( @@ -505,8 +492,6 @@ async function runTests() { assert(codexInstaller?.target_dir === '.agents/skills', 'Codex target_dir uses native skills path'); - assert(codexInstaller?.skill_format === true, 'Codex installer enables native skill output'); - assert(codexInstaller?.ancestor_conflict_check === true, 'Codex installer enables ancestor conflict checks'); assert( @@ -595,8 +580,6 @@ async function runTests() { assert(cursorInstaller?.target_dir === '.cursor/skills', 'Cursor target_dir uses native skills path'); - assert(cursorInstaller?.skill_format === true, 'Cursor installer enables native skill output'); - assert( Array.isArray(cursorInstaller?.legacy_targets) && cursorInstaller.legacy_targets.includes('.cursor/commands'), 'Cursor installer cleans legacy command output', @@ -649,8 +632,6 @@ async function runTests() { assert(rooInstaller?.target_dir === '.roo/skills', 'Roo target_dir uses native skills path'); - assert(rooInstaller?.skill_format === true, 'Roo installer enables native skill output'); - assert( Array.isArray(rooInstaller?.legacy_targets) && rooInstaller.legacy_targets.includes('.roo/commands'), 'Roo installer cleans legacy command output', @@ -757,8 +738,6 @@ async function runTests() { assert(copilotInstaller?.target_dir === '.github/skills', 'GitHub Copilot target_dir uses native skills path'); - assert(copilotInstaller?.skill_format === true, 'GitHub Copilot installer enables native skill output'); - assert( Array.isArray(copilotInstaller?.legacy_targets) && copilotInstaller.legacy_targets.includes('.github/agents'), 'GitHub Copilot installer cleans legacy agents output', @@ -839,8 +818,6 @@ async function runTests() { assert(clineInstaller?.target_dir === '.cline/skills', 'Cline target_dir uses native skills path'); - assert(clineInstaller?.skill_format === true, 'Cline installer enables native skill output'); - assert( Array.isArray(clineInstaller?.legacy_targets) && clineInstaller.legacy_targets.includes('.clinerules/workflows'), 'Cline installer cleans legacy workflow output', @@ -901,8 +878,6 @@ async function runTests() { assert(codebuddyInstaller?.target_dir === '.codebuddy/skills', 'CodeBuddy target_dir uses native skills path'); - assert(codebuddyInstaller?.skill_format === true, 'CodeBuddy installer enables native skill output'); - assert( Array.isArray(codebuddyInstaller?.legacy_targets) && codebuddyInstaller.legacy_targets.includes('.codebuddy/commands'), 'CodeBuddy installer cleans legacy command output', @@ -961,8 +936,6 @@ async function runTests() { assert(crushInstaller?.target_dir === '.crush/skills', 'Crush target_dir uses native skills path'); - assert(crushInstaller?.skill_format === true, 'Crush installer enables native skill output'); - assert( Array.isArray(crushInstaller?.legacy_targets) && crushInstaller.legacy_targets.includes('.crush/commands'), 'Crush installer cleans legacy command output', @@ -1021,8 +994,6 @@ async function runTests() { assert(traeInstaller?.target_dir === '.trae/skills', 'Trae target_dir uses native skills path'); - assert(traeInstaller?.skill_format === true, 'Trae installer enables native skill output'); - assert( Array.isArray(traeInstaller?.legacy_targets) && traeInstaller.legacy_targets.includes('.trae/rules'), 'Trae installer cleans legacy rules output', @@ -1138,8 +1109,6 @@ async function runTests() { assert(geminiInstaller?.target_dir === '.gemini/skills', 'Gemini target_dir uses native skills path'); - assert(geminiInstaller?.skill_format === true, 'Gemini installer enables native skill output'); - assert( Array.isArray(geminiInstaller?.legacy_targets) && geminiInstaller.legacy_targets.includes('.gemini/commands'), 'Gemini installer cleans legacy commands output', @@ -1196,7 +1165,6 @@ async function runTests() { const iflowInstaller = platformCodes24.platforms.iflow?.installer; assert(iflowInstaller?.target_dir === '.iflow/skills', 'iFlow target_dir uses native skills path'); - assert(iflowInstaller?.skill_format === true, 'iFlow installer enables native skill output'); assert( Array.isArray(iflowInstaller?.legacy_targets) && iflowInstaller.legacy_targets.includes('.iflow/commands'), 'iFlow installer cleans legacy commands output', @@ -1246,7 +1214,6 @@ async function runTests() { const qwenInstaller = platformCodes25.platforms.qwen?.installer; assert(qwenInstaller?.target_dir === '.qwen/skills', 'QwenCoder target_dir uses native skills path'); - assert(qwenInstaller?.skill_format === true, 'QwenCoder installer enables native skill output'); assert( Array.isArray(qwenInstaller?.legacy_targets) && qwenInstaller.legacy_targets.includes('.qwen/commands'), 'QwenCoder installer cleans legacy commands output', @@ -1296,7 +1263,6 @@ async function runTests() { const rovoInstaller = platformCodes26.platforms['rovo-dev']?.installer; assert(rovoInstaller?.target_dir === '.rovodev/skills', 'Rovo Dev target_dir uses native skills path'); - assert(rovoInstaller?.skill_format === true, 'Rovo Dev installer enables native skill output'); assert( Array.isArray(rovoInstaller?.legacy_targets) && rovoInstaller.legacy_targets.includes('.rovodev/workflows'), 'Rovo Dev installer cleans legacy workflows output', @@ -1432,8 +1398,6 @@ async function runTests() { const piInstaller = platformCodes28.platforms.pi?.installer; assert(piInstaller?.target_dir === '.pi/skills', 'Pi target_dir uses native skills path'); - assert(piInstaller?.skill_format === true, 'Pi installer enables native skill output'); - assert(piInstaller?.template_type === 'default', 'Pi installer uses default skill template'); tempProjectDir28 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-pi-test-')); installedBmadDir28 = await createTestBmadFixture(); @@ -1648,93 +1612,6 @@ async function runTests() { // skill-manifest.csv should include the native agent entrypoint const skillManifestCsv29 = await fs.readFile(path.join(tempFixture29, '_config', 'skill-manifest.csv'), 'utf8'); assert(skillManifestCsv29.includes('bmad-tea'), 'skill-manifest.csv includes native type:agent SKILL.md entrypoint'); - - // --- Agents at non-agents/ paths (regression test for BMM/CIS layouts) --- - // Create a second fixture with agents at paths like bmm/1-analysis/bmad-agent-analyst/ - const tempFixture29b = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-agent-paths-')); - await fs.ensureDir(path.join(tempFixture29b, '_config')); - - // Agent at bmm-style path: bmm/1-analysis/bmad-agent-analyst/ - const bmmAgentDir = path.join(tempFixture29b, 'bmm', '1-analysis', 'bmad-agent-analyst'); - await fs.ensureDir(bmmAgentDir); - await fs.writeFile( - path.join(bmmAgentDir, 'bmad-skill-manifest.yaml'), - [ - 'type: agent', - 'name: bmad-agent-analyst', - 'displayName: Mary', - 'title: Business Analyst', - 'role: Strategic Business Analyst', - 'module: bmm', - ].join('\n') + '\n', - ); - await fs.writeFile( - path.join(bmmAgentDir, 'SKILL.md'), - '---\nname: bmad-agent-analyst\ndescription: Business Analyst agent\n---\n\nAnalyst agent.\n', - ); - - // Agent at cis-style path: cis/skills/bmad-cis-agent-brainstorming-coach/ - const cisAgentDir = path.join(tempFixture29b, 'cis', 'skills', 'bmad-cis-agent-brainstorming-coach'); - await fs.ensureDir(cisAgentDir); - await fs.writeFile( - path.join(cisAgentDir, 'bmad-skill-manifest.yaml'), - [ - 'type: agent', - 'name: bmad-cis-agent-brainstorming-coach', - 'displayName: Carson', - 'title: Brainstorming Specialist', - 'role: Master Facilitator', - 'module: cis', - ].join('\n') + '\n', - ); - await fs.writeFile( - path.join(cisAgentDir, 'SKILL.md'), - '---\nname: bmad-cis-agent-brainstorming-coach\ndescription: Brainstorming coach\n---\n\nCoach.\n', - ); - - // Agent at standard agents/ path (GDS-style): gds/agents/gds-agent-game-dev/ - const gdsAgentDir = path.join(tempFixture29b, 'gds', 'agents', 'gds-agent-game-dev'); - await fs.ensureDir(gdsAgentDir); - await fs.writeFile( - path.join(gdsAgentDir, 'bmad-skill-manifest.yaml'), - [ - 'type: agent', - 'name: gds-agent-game-dev', - 'displayName: Link', - 'title: Game Developer', - 'role: Senior Game Dev', - 'module: gds', - ].join('\n') + '\n', - ); - await fs.writeFile( - path.join(gdsAgentDir, 'SKILL.md'), - '---\nname: gds-agent-game-dev\ndescription: Game developer agent\n---\n\nGame dev.\n', - ); - - const generator29b = new ManifestGenerator(); - await generator29b.generateManifests(tempFixture29b, ['bmm', 'cis', 'gds'], [], { ides: [] }); - - // All three agents should appear in agents[] regardless of directory layout - const bmmAgent = generator29b.agents.find((a) => a.name === 'bmad-agent-analyst'); - assert(bmmAgent !== undefined, 'Agent at bmm/1-analysis/ path appears in agents[]'); - assert(bmmAgent && bmmAgent.module === 'bmm', 'BMM agent module field comes from manifest file'); - assert(bmmAgent && bmmAgent.path.includes('bmm/1-analysis/bmad-agent-analyst'), 'BMM agent path reflects actual directory layout'); - - const cisAgent = generator29b.agents.find((a) => a.name === 'bmad-cis-agent-brainstorming-coach'); - assert(cisAgent !== undefined, 'Agent at cis/skills/ path appears in agents[]'); - assert(cisAgent && cisAgent.module === 'cis', 'CIS agent module field comes from manifest file'); - - const gdsAgent = generator29b.agents.find((a) => a.name === 'gds-agent-game-dev'); - assert(gdsAgent !== undefined, 'Agent at gds/agents/ path appears in agents[]'); - assert(gdsAgent && gdsAgent.module === 'gds', 'GDS agent module field comes from manifest file'); - - // agent-manifest.csv should contain all three - const agentCsv29b = await fs.readFile(path.join(tempFixture29b, '_config', 'agent-manifest.csv'), 'utf8'); - assert(agentCsv29b.includes('bmad-agent-analyst'), 'agent-manifest.csv includes BMM-layout agent'); - assert(agentCsv29b.includes('bmad-cis-agent-brainstorming-coach'), 'agent-manifest.csv includes CIS-layout agent'); - assert(agentCsv29b.includes('gds-agent-game-dev'), 'agent-manifest.csv includes GDS-layout agent'); - - await fs.remove(tempFixture29b).catch(() => {}); } catch (error) { assert(false, 'Unified skill scanner test succeeds', error.message); } finally { @@ -1861,8 +1738,6 @@ async function runTests() { const onaInstaller = platformCodes32.platforms.ona?.installer; assert(onaInstaller?.target_dir === '.ona/skills', 'Ona target_dir uses native skills path'); - assert(onaInstaller?.skill_format === true, 'Ona installer enables native skill output'); - assert(onaInstaller?.template_type === 'default', 'Ona installer uses default skill template'); tempProjectDir32 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-ona-test-')); installedBmadDir32 = await createTestBmadFixture(); @@ -1941,93 +1816,6 @@ async function runTests() { console.log(''); - // ============================================================ - // Test Suite 33: ConfigCollector Prompt Normalization - // ============================================================ - console.log(`${colors.yellow}Test Suite 33: ConfigCollector Prompt Normalization${colors.reset}\n`); - - try { - const teaModuleConfig33 = { - test_artifacts: { - default: '_bmad-output/test-artifacts', - }, - test_design_output: { - prompt: 'Where should test design documents be stored?', - default: 'test-design', - result: '{test_artifacts}/{value}', - }, - test_review_output: { - prompt: 'Where should test review reports be stored?', - default: 'test-reviews', - result: '{test_artifacts}/{value}', - }, - trace_output: { - prompt: 'Where should traceability reports be stored?', - default: 'traceability', - result: '{test_artifacts}/{value}', - }, - }; - - const collector33 = new ConfigCollector(); - collector33.currentProjectDir = path.join(os.tmpdir(), 'bmad-config-normalization'); - collector33.allAnswers = {}; - collector33.collectedConfig = { - tea: { - test_artifacts: '_bmad-output/test-artifacts', - }, - }; - collector33.existingConfig = { - tea: { - test_artifacts: '_bmad-output/test-artifacts', - test_design_output: '_bmad-output/test-artifacts/test-design', - test_review_output: '_bmad-output/test-artifacts/test-reviews', - trace_output: '_bmad-output/test-artifacts/traceability', - }, - }; - - const testDesignQuestion33 = await collector33.buildQuestion( - 'tea', - 'test_design_output', - teaModuleConfig33.test_design_output, - teaModuleConfig33, - ); - const testReviewQuestion33 = await collector33.buildQuestion( - 'tea', - 'test_review_output', - teaModuleConfig33.test_review_output, - teaModuleConfig33, - ); - const traceQuestion33 = await collector33.buildQuestion('tea', 'trace_output', teaModuleConfig33.trace_output, teaModuleConfig33); - - assert(testDesignQuestion33.default === 'test-design', 'ConfigCollector normalizes existing test_design_output prompt default'); - assert(testReviewQuestion33.default === 'test-reviews', 'ConfigCollector normalizes existing test_review_output prompt default'); - assert(traceQuestion33.default === 'traceability', 'ConfigCollector normalizes existing trace_output prompt default'); - - collector33.allAnswers = { - tea_test_artifacts: '_bmad-output/test-artifacts', - }; - - assert( - collector33.processResultTemplate(teaModuleConfig33.test_design_output.result, testDesignQuestion33.default) === - '_bmad-output/test-artifacts/test-design', - 'ConfigCollector re-applies test_design_output template without duplicating prefix', - ); - assert( - collector33.processResultTemplate(teaModuleConfig33.test_review_output.result, testReviewQuestion33.default) === - '_bmad-output/test-artifacts/test-reviews', - 'ConfigCollector re-applies test_review_output template without duplicating prefix', - ); - assert( - collector33.processResultTemplate(teaModuleConfig33.trace_output.result, traceQuestion33.default) === - '_bmad-output/test-artifacts/traceability', - 'ConfigCollector re-applies trace_output template without duplicating prefix', - ); - } catch (error) { - assert(false, 'ConfigCollector prompt normalization test succeeds', error.message); - } - - console.log(''); - // ============================================================ // Summary // ============================================================ diff --git a/test/test-workflow-path-regex.js b/test/test-workflow-path-regex.js index 5f57a0ab9..f05ea1a34 100644 --- a/test/test-workflow-path-regex.js +++ b/test/test-workflow-path-regex.js @@ -34,7 +34,7 @@ function assert(condition, testName, errorMessage = '') { // --------------------------------------------------------------------------- // These regexes are extracted from ModuleManager.vendorWorkflowDependencies() -// in tools/cli/installers/lib/modules/manager.js +// in tools/installer/modules/manager.js // --------------------------------------------------------------------------- // Source regex (line ~1081) — uses non-capturing group for _bmad diff --git a/tools/bmad-npx-wrapper.js b/tools/bmad-npx-wrapper.js deleted file mode 100755 index c6f578b2d..000000000 --- a/tools/bmad-npx-wrapper.js +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env node - -/** - * BMad Method CLI - Direct execution wrapper for npx - * This file ensures proper execution when run via npx from GitHub or npm registry - */ - -const { execFileSync } = require('node:child_process'); -const path = require('node:path'); -const fs = require('node:fs'); - -// Check if we're running in an npx temporary directory -const isNpxExecution = __dirname.includes('_npx') || __dirname.includes('.npm'); - -if (isNpxExecution) { - // Running via npx - spawn child process to preserve user's working directory - const args = process.argv.slice(2); - const bmadCliPath = path.join(__dirname, 'cli', 'bmad-cli.js'); - - if (!fs.existsSync(bmadCliPath)) { - console.error('Error: Could not find bmad-cli.js at', bmadCliPath); - console.error('Current directory:', __dirname); - process.exit(1); - } - - try { - // Execute CLI from user's working directory (process.cwd()), not npm cache - execFileSync('node', [bmadCliPath, ...args], { - stdio: 'inherit', - cwd: process.cwd(), // This preserves the user's working directory - }); - } catch (error) { - process.exit(error.status || 1); - } -} else { - // Local execution - use require - require('./cli/bmad-cli.js'); -} diff --git a/tools/cli/installers/lib/core/dependency-resolver.js b/tools/cli/installers/lib/core/dependency-resolver.js deleted file mode 100644 index 8b0971bf1..000000000 --- a/tools/cli/installers/lib/core/dependency-resolver.js +++ /dev/null @@ -1,743 +0,0 @@ -const fs = require('fs-extra'); -const path = require('node:path'); -const glob = require('glob'); -const yaml = require('yaml'); -const prompts = require('../../../lib/prompts'); - -/** - * Dependency Resolver for BMAD modules - * Handles cross-module dependencies and ensures all required files are included - */ -class DependencyResolver { - constructor() { - this.dependencies = new Map(); - this.resolvedFiles = new Set(); - this.missingDependencies = new Set(); - } - - /** - * Resolve all dependencies for selected modules - * @param {string} bmadDir - BMAD installation directory - * @param {Array} selectedModules - Modules explicitly selected by user - * @param {Object} options - Resolution options - * @returns {Object} Resolution results with all required files - */ - async resolve(bmadDir, selectedModules = [], options = {}) { - if (options.verbose) { - await prompts.log.info('Resolving module dependencies...'); - } - - // Always include core as base - const modulesToProcess = new Set(['core', ...selectedModules]); - - // First pass: collect all explicitly selected files - const primaryFiles = await this.collectPrimaryFiles(bmadDir, modulesToProcess, options); - - // Second pass: parse and resolve dependencies - const allDependencies = await this.parseDependencies(primaryFiles); - - // Third pass: resolve dependency paths and collect files - const resolvedDeps = await this.resolveDependencyPaths(bmadDir, allDependencies); - - // Fourth pass: check for transitive dependencies - const transitiveDeps = await this.resolveTransitiveDependencies(bmadDir, resolvedDeps); - - // Combine all files - const allFiles = new Set([...primaryFiles.map((f) => f.path), ...resolvedDeps, ...transitiveDeps]); - - // Organize by module - const organizedFiles = this.organizeByModule(bmadDir, allFiles); - - // Report results (only in verbose mode) - if (options.verbose) { - await this.reportResults(organizedFiles, selectedModules); - } - - return { - primaryFiles, - dependencies: resolvedDeps, - transitiveDependencies: transitiveDeps, - allFiles: [...allFiles], - byModule: organizedFiles, - missing: [...this.missingDependencies], - }; - } - - /** - * Collect primary files from selected modules - */ - async collectPrimaryFiles(bmadDir, modules, options = {}) { - const files = []; - const { moduleManager } = options; - - for (const module of modules) { - // Skip external modules - they're installed from cache, not from source - if (moduleManager && (await moduleManager.isExternalModule(module))) { - continue; - } - - // Handle both source (src/) and installed (bmad/) directory structures - let moduleDir; - - // Check if this is a source directory (has 'src' subdirectory) - const srcDir = path.join(bmadDir, 'src'); - if (await fs.pathExists(srcDir)) { - // Source directory structure: src/core-skills or src/bmm-skills - if (module === 'core') { - moduleDir = path.join(srcDir, 'core-skills'); - } else if (module === 'bmm') { - moduleDir = path.join(srcDir, 'bmm-skills'); - } - } - - if (!moduleDir) { - continue; - } - - if (!(await fs.pathExists(moduleDir))) { - await prompts.log.warn('Module directory not found: ' + moduleDir); - continue; - } - - // Collect agents - const agentsDir = path.join(moduleDir, 'agents'); - if (await fs.pathExists(agentsDir)) { - const agentFiles = await glob.glob('*.md', { cwd: agentsDir }); - for (const file of agentFiles) { - const agentPath = path.join(agentsDir, file); - - // Check for localskip attribute - const content = await fs.readFile(agentPath, 'utf8'); - const hasLocalSkip = content.match(/]*\slocalskip="true"[^>]*>/); - if (hasLocalSkip) { - continue; // Skip agents marked for web-only - } - - files.push({ - path: agentPath, - type: 'agent', - module, - name: path.basename(file, '.md'), - }); - } - } - - // Collect tasks - const tasksDir = path.join(moduleDir, 'tasks'); - if (await fs.pathExists(tasksDir)) { - const taskFiles = await glob.glob('*.md', { cwd: tasksDir }); - for (const file of taskFiles) { - files.push({ - path: path.join(tasksDir, file), - type: 'task', - module, - name: path.basename(file, '.md'), - }); - } - } - } - - return files; - } - - /** - * Parse dependencies from file content - */ - async parseDependencies(files) { - const allDeps = new Set(); - - for (const file of files) { - const content = await fs.readFile(file.path, 'utf8'); - - // Parse YAML frontmatter for explicit dependencies - const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); - if (frontmatterMatch) { - try { - // Pre-process to handle backticks in YAML values - let yamlContent = frontmatterMatch[1]; - // Quote values with backticks to make them valid YAML - yamlContent = yamlContent.replaceAll(/: `([^`]+)`/g, ': "$1"'); - - const frontmatter = yaml.parse(yamlContent); - if (frontmatter.dependencies) { - const deps = Array.isArray(frontmatter.dependencies) ? frontmatter.dependencies : [frontmatter.dependencies]; - - for (const dep of deps) { - allDeps.add({ - from: file.path, - dependency: dep, - type: 'explicit', - }); - } - } - - // Check for template dependencies - if (frontmatter.template) { - const templates = Array.isArray(frontmatter.template) ? frontmatter.template : [frontmatter.template]; - for (const template of templates) { - allDeps.add({ - from: file.path, - dependency: template, - type: 'template', - }); - } - } - } catch (error) { - await prompts.log.warn('Failed to parse frontmatter in ' + file.name + ': ' + error.message); - } - } - - // Parse content for command references (cross-module dependencies) - const commandRefs = this.parseCommandReferences(content); - for (const ref of commandRefs) { - allDeps.add({ - from: file.path, - dependency: ref, - type: 'command', - }); - } - - // Parse for file path references - const fileRefs = this.parseFileReferences(content); - for (const ref of fileRefs) { - // Determine type based on path format - // Paths starting with bmad/ are absolute references to the bmad installation - const depType = ref.startsWith('bmad/') ? 'bmad-path' : 'file'; - allDeps.add({ - from: file.path, - dependency: ref, - type: depType, - }); - } - } - - return allDeps; - } - - /** - * Parse command references from content - */ - parseCommandReferences(content) { - const refs = new Set(); - - // Match @task-{name} or @agent-{name} or @{module}-{type}-{name} - const commandPattern = /@(task-|agent-|bmad-)([a-z0-9-]+)/g; - let match; - - while ((match = commandPattern.exec(content)) !== null) { - refs.add(match[0]); - } - - // Match file paths like bmad/core/agents/analyst - const pathPattern = /bmad\/(core|bmm|cis)\/(agents|tasks)\/([a-z0-9-]+)/g; - - while ((match = pathPattern.exec(content)) !== null) { - refs.add(match[0]); - } - - return [...refs]; - } - - /** - * Parse file path references from content - */ - parseFileReferences(content) { - const refs = new Set(); - - // Match relative paths like ../templates/file.yaml or ./data/file.md - const relativePattern = /['"](\.\.?\/[^'"]+\.(md|yaml|yml|xml|json|txt|csv))['"]/g; - let match; - - while ((match = relativePattern.exec(content)) !== null) { - refs.add(match[1]); - } - - // Parse exec attributes in command tags - const execPattern = /exec="([^"]+)"/g; - while ((match = execPattern.exec(content)) !== null) { - let execPath = match[1]; - if (execPath && execPath !== '*') { - // Remove {project-root} prefix to get the actual path - // Usage is like {project-root}/bmad/core/tasks/foo.md - if (execPath.includes('{project-root}')) { - execPath = execPath.replace('{project-root}', ''); - } - refs.add(execPath); - } - } - - // Parse tmpl attributes in command tags - const tmplPattern = /tmpl="([^"]+)"/g; - while ((match = tmplPattern.exec(content)) !== null) { - let tmplPath = match[1]; - if (tmplPath && tmplPath !== '*') { - // Remove {project-root} prefix to get the actual path - // Usage is like {project-root}/bmad/core/tasks/foo.md - if (tmplPath.includes('{project-root}')) { - tmplPath = tmplPath.replace('{project-root}', ''); - } - refs.add(tmplPath); - } - } - - return [...refs]; - } - - /** - * Resolve dependency paths to actual files - */ - async resolveDependencyPaths(bmadDir, dependencies) { - const resolved = new Set(); - - for (const dep of dependencies) { - const resolvedPaths = await this.resolveSingleDependency(bmadDir, dep); - for (const path of resolvedPaths) { - resolved.add(path); - } - } - - return resolved; - } - - /** - * Resolve a single dependency to file paths - */ - async resolveSingleDependency(bmadDir, dep) { - const paths = []; - - switch (dep.type) { - case 'explicit': - case 'file': { - let depPath = dep.dependency; - - // Handle {project-root} prefix if present - if (depPath.includes('{project-root}')) { - // Remove {project-root} and resolve as bmad path - depPath = depPath.replace('{project-root}', ''); - - if (depPath.startsWith('bmad/')) { - const bmadPath = depPath.replace(/^bmad\//, ''); - - // Handle glob patterns - if (depPath.includes('*')) { - // Extract the base path and pattern - const pathParts = bmadPath.split('/'); - const module = pathParts[0]; - const filePattern = pathParts.at(-1); - const middlePath = pathParts.slice(1, -1).join('/'); - - let basePath; - if (module === 'core') { - basePath = path.join(bmadDir, 'core', middlePath); - } else { - basePath = path.join(bmadDir, 'modules', module, middlePath); - } - - if (await fs.pathExists(basePath)) { - const files = await glob.glob(filePattern, { cwd: basePath }); - for (const file of files) { - paths.push(path.join(basePath, file)); - } - } - } else { - // Direct path - if (bmadPath.startsWith('core/')) { - const corePath = path.join(bmadDir, bmadPath); - if (await fs.pathExists(corePath)) { - paths.push(corePath); - } - } else { - const parts = bmadPath.split('/'); - const module = parts[0]; - const rest = parts.slice(1).join('/'); - const modulePath = path.join(bmadDir, 'modules', module, rest); - - if (await fs.pathExists(modulePath)) { - paths.push(modulePath); - } - } - } - } - } else { - // Regular relative path handling - const sourceDir = path.dirname(dep.from); - - // Handle glob patterns - if (depPath.includes('*')) { - const basePath = path.resolve(sourceDir, path.dirname(depPath)); - const pattern = path.basename(depPath); - - if (await fs.pathExists(basePath)) { - const files = await glob.glob(pattern, { cwd: basePath }); - for (const file of files) { - paths.push(path.join(basePath, file)); - } - } - } else { - // Direct file reference - const fullPath = path.resolve(sourceDir, depPath); - if (await fs.pathExists(fullPath)) { - paths.push(fullPath); - } else { - this.missingDependencies.add(`${depPath} (referenced by ${path.basename(dep.from)})`); - } - } - } - - break; - } - case 'command': { - // Resolve command references to actual files - const commandPath = await this.resolveCommandToPath(bmadDir, dep.dependency); - if (commandPath) { - paths.push(commandPath); - } - - break; - } - case 'bmad-path': { - // Resolve bmad/ paths (from {project-root}/bmad/... references) - // These are paths relative to the src directory structure - const bmadPath = dep.dependency.replace(/^bmad\//, ''); - - // Try to resolve as if it's in src structure - // bmad/core/tasks/foo.md -> src/core-skills/tasks/foo.md - // bmad/bmm/tasks/bar.md -> src/bmm-skills/tasks/bar.md (bmm is directly under src/) - // bmad/cis/agents/bar.md -> src/modules/cis/agents/bar.md - - if (bmadPath.startsWith('core/')) { - const corePath = path.join(bmadDir, bmadPath); - if (await fs.pathExists(corePath)) { - paths.push(corePath); - } else { - // Not found, but don't report as missing since it might be installed later - } - } else { - // It's a module path like bmm/tasks/foo.md or cis/agents/bar.md - const parts = bmadPath.split('/'); - const module = parts[0]; - const rest = parts.slice(1).join('/'); - let modulePath; - if (module === 'bmm') { - // bmm is directly under src/ - modulePath = path.join(bmadDir, module, rest); - } else { - // Other modules are under modules/ - modulePath = path.join(bmadDir, 'modules', module, rest); - } - - if (await fs.pathExists(modulePath)) { - paths.push(modulePath); - } else { - // Not found, but don't report as missing since it might be installed later - } - } - - break; - } - case 'template': { - // Resolve template references - let templateDep = dep.dependency; - - // Handle {project-root} prefix if present - if (templateDep.includes('{project-root}')) { - // Remove {project-root} and treat as bmad-path - templateDep = templateDep.replace('{project-root}', ''); - - // Now resolve as a bmad path - if (templateDep.startsWith('bmad/')) { - const bmadPath = templateDep.replace(/^bmad\//, ''); - - if (bmadPath.startsWith('core/')) { - const corePath = path.join(bmadDir, bmadPath); - if (await fs.pathExists(corePath)) { - paths.push(corePath); - } - } else { - // Module path like cis/templates/brainstorm.md - const parts = bmadPath.split('/'); - const module = parts[0]; - const rest = parts.slice(1).join('/'); - const modulePath = path.join(bmadDir, 'modules', module, rest); - - if (await fs.pathExists(modulePath)) { - paths.push(modulePath); - } - } - } - } else { - // Regular relative template path - const sourceDir = path.dirname(dep.from); - const templatePath = path.resolve(sourceDir, templateDep); - - if (await fs.pathExists(templatePath)) { - paths.push(templatePath); - } else { - this.missingDependencies.add(`Template: ${dep.dependency}`); - } - } - - break; - } - // No default - } - - return paths; - } - - /** - * Resolve command reference to file path - */ - async resolveCommandToPath(bmadDir, command) { - // Parse command format: @task-name or @agent-name or bmad/module/type/name - - if (command.startsWith('@task-')) { - const taskName = command.slice(6); - // Search all modules for this task - for (const module of ['core', 'bmm', 'cis']) { - const taskPath = - module === 'core' - ? path.join(bmadDir, 'core', 'tasks', `${taskName}.md`) - : path.join(bmadDir, 'modules', module, 'tasks', `${taskName}.md`); - if (await fs.pathExists(taskPath)) { - return taskPath; - } - } - } else if (command.startsWith('@agent-')) { - const agentName = command.slice(7); - // Search all modules for this agent - for (const module of ['core', 'bmm', 'cis']) { - const agentPath = - module === 'core' - ? path.join(bmadDir, 'core', 'agents', `${agentName}.md`) - : path.join(bmadDir, 'modules', module, 'agents', `${agentName}.md`); - if (await fs.pathExists(agentPath)) { - return agentPath; - } - } - } else if (command.startsWith('bmad/')) { - // Direct path reference - const parts = command.split('/'); - if (parts.length >= 4) { - const [, module, type, ...nameParts] = parts; - const name = nameParts.join('/'); // Handle nested paths - - // Check if name already has extension - const fileName = name.endsWith('.md') ? name : `${name}.md`; - - const filePath = - module === 'core' ? path.join(bmadDir, 'core', type, fileName) : path.join(bmadDir, 'modules', module, type, fileName); - if (await fs.pathExists(filePath)) { - return filePath; - } - } - } - - // Don't report as missing if it's a self-reference within the module being installed - if (!command.includes('cis') || command.includes('brain')) { - // Only report missing if it's a true external dependency - // this.missingDependencies.add(`Command: ${command}`); - } - return null; - } - - /** - * Resolve transitive dependencies (dependencies of dependencies) - */ - async resolveTransitiveDependencies(bmadDir, directDeps) { - const transitive = new Set(); - const processed = new Set(); - - // Process each direct dependency - for (const depPath of directDeps) { - if (processed.has(depPath)) continue; - processed.add(depPath); - - // Only process markdown and YAML files for transitive deps - if ((depPath.endsWith('.md') || depPath.endsWith('.yaml') || depPath.endsWith('.yml')) && (await fs.pathExists(depPath))) { - const content = await fs.readFile(depPath, 'utf8'); - const subDeps = await this.parseDependencies([ - { - path: depPath, - type: 'dependency', - module: this.getModuleFromPath(bmadDir, depPath), - name: path.basename(depPath), - }, - ]); - - const resolvedSubDeps = await this.resolveDependencyPaths(bmadDir, subDeps); - for (const subDep of resolvedSubDeps) { - if (!directDeps.has(subDep)) { - transitive.add(subDep); - } - } - } - } - - return transitive; - } - - /** - * Get module name from file path - */ - getModuleFromPath(bmadDir, filePath) { - const relative = path.relative(bmadDir, filePath); - const parts = relative.split(path.sep); - - // Handle source directory structure (src/core-skills, src/bmm-skills, or src/modules/xxx) - if (parts[0] === 'src') { - if (parts[1] === 'core-skills') { - return 'core'; - } else if (parts[1] === 'bmm-skills') { - return 'bmm'; - } else if (parts[1] === 'modules' && parts.length > 2) { - return parts[2]; - } - } - - // Check if it's in modules directory (installed structure) - if (parts[0] === 'modules' && parts.length > 1) { - return parts[1]; - } - - // Otherwise return the first part (core, etc.) - // But don't return 'src' as a module name - if (parts[0] === 'src') { - return 'unknown'; - } - return parts[0] || 'unknown'; - } - - /** - * Organize files by module - */ - organizeByModule(bmadDir, files) { - const organized = {}; - - for (const file of files) { - const module = this.getModuleFromPath(bmadDir, file); - if (!organized[module]) { - organized[module] = { - agents: [], - tasks: [], - tools: [], - templates: [], - data: [], - other: [], - }; - } - - // Get relative path correctly based on module structure - let moduleBase; - - // Check if file is in source directory structure - if (file.includes('/src/core-skills/') || file.includes('/src/bmm-skills/')) { - if (module === 'core') { - moduleBase = path.join(bmadDir, 'src', 'core-skills'); - } else if (module === 'bmm') { - moduleBase = path.join(bmadDir, 'src', 'bmm-skills'); - } - } else { - moduleBase = module === 'core' ? path.join(bmadDir, 'core') : path.join(bmadDir, 'modules', module); - } - - const relative = path.relative(moduleBase, file); - - if (relative.startsWith('agents/') || file.includes('/agents/')) { - organized[module].agents.push(file); - } else if (relative.startsWith('tasks/') || file.includes('/tasks/')) { - organized[module].tasks.push(file); - } else if (relative.startsWith('tools/') || file.includes('/tools/')) { - organized[module].tools.push(file); - } else if (relative.includes('data/')) { - organized[module].data.push(file); - } else { - organized[module].other.push(file); - } - } - - return organized; - } - - /** - * Report resolution results - */ - async reportResults(organized, selectedModules) { - await prompts.log.success('Dependency resolution complete'); - - for (const [module, files] of Object.entries(organized)) { - const isSelected = selectedModules.includes(module) || module === 'core'; - const totalFiles = - files.agents.length + files.tasks.length + files.tools.length + files.templates.length + files.data.length + files.other.length; - - if (totalFiles > 0) { - await prompts.log.info(` ${module.toUpperCase()} module:`); - await prompts.log.message(` Status: ${isSelected ? 'Selected' : 'Dependencies only'}`); - - if (files.agents.length > 0) { - await prompts.log.message(` Agents: ${files.agents.length}`); - } - if (files.tasks.length > 0) { - await prompts.log.message(` Tasks: ${files.tasks.length}`); - } - if (files.templates.length > 0) { - await prompts.log.message(` Templates: ${files.templates.length}`); - } - if (files.data.length > 0) { - await prompts.log.message(` Data files: ${files.data.length}`); - } - if (files.other.length > 0) { - await prompts.log.message(` Other files: ${files.other.length}`); - } - } - } - - if (this.missingDependencies.size > 0) { - await prompts.log.warn('Missing dependencies:'); - for (const missing of this.missingDependencies) { - await prompts.log.warn(` - ${missing}`); - } - } - } - - /** - * Create a bundle for web deployment - * @param {Object} resolution - Resolution results from resolve() - * @returns {Object} Bundle data ready for web - */ - async createWebBundle(resolution) { - const bundle = { - metadata: { - created: new Date().toISOString(), - modules: Object.keys(resolution.byModule), - totalFiles: resolution.allFiles.length, - }, - agents: {}, - tasks: {}, - templates: {}, - data: {}, - }; - - // Bundle all files by type - for (const filePath of resolution.allFiles) { - if (!(await fs.pathExists(filePath))) continue; - - const content = await fs.readFile(filePath, 'utf8'); - const relative = path.relative(path.dirname(resolution.primaryFiles[0]?.path || '.'), filePath); - - if (filePath.includes('/agents/')) { - bundle.agents[relative] = content; - } else if (filePath.includes('/tasks/')) { - bundle.tasks[relative] = content; - } else if (filePath.includes('template')) { - bundle.templates[relative] = content; - } else { - bundle.data[relative] = content; - } - } - - return bundle; - } -} - -module.exports = { DependencyResolver }; diff --git a/tools/cli/installers/lib/core/detector.js b/tools/cli/installers/lib/core/detector.js deleted file mode 100644 index 9bb736589..000000000 --- a/tools/cli/installers/lib/core/detector.js +++ /dev/null @@ -1,223 +0,0 @@ -const path = require('node:path'); -const fs = require('fs-extra'); -const yaml = require('yaml'); -const { Manifest } = require('./manifest'); - -class Detector { - /** - * Detect existing BMAD installation - * @param {string} bmadDir - Path to bmad directory - * @returns {Object} Installation status and details - */ - async detect(bmadDir) { - const result = { - installed: false, - path: bmadDir, - version: null, - hasCore: false, - modules: [], - ides: [], - customModules: [], - manifest: null, - }; - - // Check if bmad directory exists - if (!(await fs.pathExists(bmadDir))) { - return result; - } - - // Check for manifest using the Manifest class - const manifest = new Manifest(); - const manifestData = await manifest.read(bmadDir); - if (manifestData) { - result.manifest = manifestData; - result.version = manifestData.version; - result.installed = true; - // Copy custom modules if they exist - if (manifestData.customModules) { - result.customModules = manifestData.customModules; - } - } - - // Check for core - const corePath = path.join(bmadDir, 'core'); - if (await fs.pathExists(corePath)) { - result.hasCore = true; - - // Try to get core version from config - const coreConfigPath = path.join(corePath, 'config.yaml'); - if (await fs.pathExists(coreConfigPath)) { - try { - const configContent = await fs.readFile(coreConfigPath, 'utf8'); - const config = yaml.parse(configContent); - if (!result.version && config.version) { - result.version = config.version; - } - } catch { - // Ignore config read errors - } - } - } - - // Check for modules - // If manifest exists, use it as the source of truth for installed modules - // Otherwise fall back to directory scanning (legacy installations) - if (manifestData && manifestData.modules && manifestData.modules.length > 0) { - // Use manifest module list - these are officially installed modules - for (const moduleId of manifestData.modules) { - const modulePath = path.join(bmadDir, moduleId); - const moduleConfigPath = path.join(modulePath, 'config.yaml'); - - const moduleInfo = { - id: moduleId, - path: modulePath, - version: 'unknown', - }; - - if (await fs.pathExists(moduleConfigPath)) { - try { - const configContent = await fs.readFile(moduleConfigPath, 'utf8'); - const config = yaml.parse(configContent); - moduleInfo.version = config.version || 'unknown'; - moduleInfo.name = config.name || moduleId; - moduleInfo.description = config.description; - } catch { - // Ignore config read errors - } - } - - result.modules.push(moduleInfo); - } - } else { - // Fallback: scan directory for modules (legacy installations without manifest) - const entries = await fs.readdir(bmadDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_config') { - const modulePath = path.join(bmadDir, entry.name); - const moduleConfigPath = path.join(modulePath, 'config.yaml'); - - // Only treat it as a module if it has a config.yaml - if (await fs.pathExists(moduleConfigPath)) { - const moduleInfo = { - id: entry.name, - path: modulePath, - version: 'unknown', - }; - - try { - const configContent = await fs.readFile(moduleConfigPath, 'utf8'); - const config = yaml.parse(configContent); - moduleInfo.version = config.version || 'unknown'; - moduleInfo.name = config.name || entry.name; - moduleInfo.description = config.description; - } catch { - // Ignore config read errors - } - - result.modules.push(moduleInfo); - } - } - } - } - - // Check for IDE configurations from manifest - if (result.manifest && result.manifest.ides) { - // Filter out any undefined/null values - result.ides = result.manifest.ides.filter((ide) => ide && typeof ide === 'string'); - } - - // Mark as installed if we found core or modules - if (result.hasCore || result.modules.length > 0) { - result.installed = true; - } - - return result; - } - - /** - * Detect legacy installation (_bmad-method, .bmm, .cis) - * @param {string} projectDir - Project directory to check - * @returns {Object} Legacy installation details - */ - async detectLegacy(projectDir) { - const result = { - hasLegacy: false, - legacyCore: false, - legacyModules: [], - paths: [], - }; - - // Check for legacy core (_bmad-method) - const legacyCorePath = path.join(projectDir, '_bmad-method'); - if (await fs.pathExists(legacyCorePath)) { - result.hasLegacy = true; - result.legacyCore = true; - result.paths.push(legacyCorePath); - } - - // Check for legacy modules (directories starting with .) - const entries = await fs.readdir(projectDir, { withFileTypes: true }); - for (const entry of entries) { - if ( - entry.isDirectory() && - entry.name.startsWith('.') && - entry.name !== '_bmad-method' && - !entry.name.startsWith('.git') && - !entry.name.startsWith('.vscode') && - !entry.name.startsWith('.idea') - ) { - const modulePath = path.join(projectDir, entry.name); - const moduleManifestPath = path.join(modulePath, 'install-manifest.yaml'); - - // Check if it's likely a BMAD module - if ((await fs.pathExists(moduleManifestPath)) || (await fs.pathExists(path.join(modulePath, 'config.yaml')))) { - result.hasLegacy = true; - result.legacyModules.push({ - name: entry.name.slice(1), // Remove leading dot - path: modulePath, - }); - result.paths.push(modulePath); - } - } - } - - return result; - } - - /** - * Check if migration from legacy is needed - * @param {string} projectDir - Project directory - * @returns {Object} Migration requirements - */ - async checkMigrationNeeded(projectDir) { - const bmadDir = path.join(projectDir, 'bmad'); - const current = await this.detect(bmadDir); - const legacy = await this.detectLegacy(projectDir); - - return { - needed: legacy.hasLegacy && !current.installed, - canMigrate: legacy.hasLegacy, - legacy: legacy, - current: current, - }; - } - - /** - * Detect legacy BMAD v4 .bmad-method folder - * @param {string} projectDir - Project directory to check - * @returns {{ hasLegacyV4: boolean, offenders: string[] }} - */ - async detectLegacyV4(projectDir) { - const offenders = []; - - // Check for .bmad-method folder - const bmadMethodPath = path.join(projectDir, '.bmad-method'); - if (await fs.pathExists(bmadMethodPath)) { - offenders.push(bmadMethodPath); - } - - return { hasLegacyV4: offenders.length > 0, offenders }; - } -} - -module.exports = { Detector }; diff --git a/tools/cli/installers/lib/core/ide-config-manager.js b/tools/cli/installers/lib/core/ide-config-manager.js deleted file mode 100644 index c00c00d48..000000000 --- a/tools/cli/installers/lib/core/ide-config-manager.js +++ /dev/null @@ -1,157 +0,0 @@ -const path = require('node:path'); -const fs = require('fs-extra'); -const yaml = require('yaml'); -const prompts = require('../../../lib/prompts'); - -/** - * Manages IDE configuration persistence - * Saves and loads IDE-specific configurations to/from bmad/_config/ides/ - */ -class IdeConfigManager { - constructor() {} - - /** - * Get path to IDE config directory - * @param {string} bmadDir - BMAD installation directory - * @returns {string} Path to IDE config directory - */ - getIdeConfigDir(bmadDir) { - return path.join(bmadDir, '_config', 'ides'); - } - - /** - * Get path to specific IDE config file - * @param {string} bmadDir - BMAD installation directory - * @param {string} ideName - IDE name (e.g., 'claude-code') - * @returns {string} Path to IDE config file - */ - getIdeConfigPath(bmadDir, ideName) { - return path.join(this.getIdeConfigDir(bmadDir), `${ideName}.yaml`); - } - - /** - * Save IDE configuration - * @param {string} bmadDir - BMAD installation directory - * @param {string} ideName - IDE name - * @param {Object} configuration - IDE-specific configuration object - */ - async saveIdeConfig(bmadDir, ideName, configuration) { - const configDir = this.getIdeConfigDir(bmadDir); - await fs.ensureDir(configDir); - - const configPath = this.getIdeConfigPath(bmadDir, ideName); - const now = new Date().toISOString(); - - // Check if config already exists to preserve configured_date - let configuredDate = now; - if (await fs.pathExists(configPath)) { - try { - const existing = await this.loadIdeConfig(bmadDir, ideName); - if (existing && existing.configured_date) { - configuredDate = existing.configured_date; - } - } catch { - // Ignore errors reading existing config - } - } - - const configData = { - ide: ideName, - configured_date: configuredDate, - last_updated: now, - configuration: configuration || {}, - }; - - // Clean the config to remove any non-serializable values (like functions) - const cleanConfig = structuredClone(configData); - - const yamlContent = yaml.stringify(cleanConfig, { - indent: 2, - lineWidth: 0, - sortKeys: false, - }); - - // Ensure POSIX-compliant final newline - const content = yamlContent.endsWith('\n') ? yamlContent : yamlContent + '\n'; - await fs.writeFile(configPath, content, 'utf8'); - } - - /** - * Load IDE configuration - * @param {string} bmadDir - BMAD installation directory - * @param {string} ideName - IDE name - * @returns {Object|null} IDE configuration or null if not found - */ - async loadIdeConfig(bmadDir, ideName) { - const configPath = this.getIdeConfigPath(bmadDir, ideName); - - if (!(await fs.pathExists(configPath))) { - return null; - } - - try { - const content = await fs.readFile(configPath, 'utf8'); - const config = yaml.parse(content); - return config; - } catch (error) { - await prompts.log.warn(`Failed to load IDE config for ${ideName}: ${error.message}`); - return null; - } - } - - /** - * Load all IDE configurations - * @param {string} bmadDir - BMAD installation directory - * @returns {Object} Map of IDE name to configuration - */ - async loadAllIdeConfigs(bmadDir) { - const configDir = this.getIdeConfigDir(bmadDir); - const configs = {}; - - if (!(await fs.pathExists(configDir))) { - return configs; - } - - try { - const files = await fs.readdir(configDir); - for (const file of files) { - if (file.endsWith('.yaml')) { - const ideName = file.replace('.yaml', ''); - const config = await this.loadIdeConfig(bmadDir, ideName); - if (config) { - configs[ideName] = config.configuration; - } - } - } - } catch (error) { - await prompts.log.warn(`Failed to load IDE configs: ${error.message}`); - } - - return configs; - } - - /** - * Check if IDE has saved configuration - * @param {string} bmadDir - BMAD installation directory - * @param {string} ideName - IDE name - * @returns {boolean} True if configuration exists - */ - async hasIdeConfig(bmadDir, ideName) { - const configPath = this.getIdeConfigPath(bmadDir, ideName); - return await fs.pathExists(configPath); - } - - /** - * Delete IDE configuration - * @param {string} bmadDir - BMAD installation directory - * @param {string} ideName - IDE name - */ - async deleteIdeConfig(bmadDir, ideName) { - const configPath = this.getIdeConfigPath(bmadDir, ideName); - if (await fs.pathExists(configPath)) { - await fs.remove(configPath); - } - } -} - -module.exports = { IdeConfigManager }; diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js deleted file mode 100644 index 217da91ec..000000000 --- a/tools/cli/installers/lib/core/installer.js +++ /dev/null @@ -1,3002 +0,0 @@ -const path = require('node:path'); -const fs = require('fs-extra'); -const { Detector } = require('./detector'); -const { Manifest } = require('./manifest'); -const { ModuleManager } = require('../modules/manager'); -const { IdeManager } = require('../ide/manager'); -const { FileOps } = require('../../../lib/file-ops'); -const { Config } = require('../../../lib/config'); -const { DependencyResolver } = require('./dependency-resolver'); -const { ConfigCollector } = require('./config-collector'); -const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root'); -const { CLIUtils } = require('../../../lib/cli-utils'); -const { ManifestGenerator } = require('./manifest-generator'); -const { IdeConfigManager } = require('./ide-config-manager'); -const { CustomHandler } = require('../custom/handler'); -const prompts = require('../../../lib/prompts'); -const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils'); - -class Installer { - constructor() { - this.detector = new Detector(); - this.manifest = new Manifest(); - this.moduleManager = new ModuleManager(); - this.ideManager = new IdeManager(); - this.fileOps = new FileOps(); - this.config = new Config(); - this.dependencyResolver = new DependencyResolver(); - this.configCollector = new ConfigCollector(); - this.ideConfigManager = new IdeConfigManager(); - this.installedFiles = new Set(); // Track all installed files - this.bmadFolderName = BMAD_FOLDER_NAME; - } - - /** - * Find the bmad installation directory in a project - * Always uses the standard _bmad folder name - * Also checks for legacy _cfg folder for migration - * @param {string} projectDir - Project directory - * @returns {Promise} { bmadDir: string, hasLegacyCfg: boolean } - */ - async findBmadDir(projectDir) { - const bmadDir = path.join(projectDir, BMAD_FOLDER_NAME); - - // Check if project directory exists - if (!(await fs.pathExists(projectDir))) { - // Project doesn't exist yet, return default - return { bmadDir, hasLegacyCfg: false }; - } - - // Check for legacy _cfg folder if bmad directory exists - let hasLegacyCfg = false; - if (await fs.pathExists(bmadDir)) { - const legacyCfgPath = path.join(bmadDir, '_cfg'); - if (await fs.pathExists(legacyCfgPath)) { - hasLegacyCfg = true; - } - } - - return { bmadDir, hasLegacyCfg }; - } - - /** - * @function copyFileWithPlaceholderReplacement - * @intent Copy files from BMAD source to installation directory with dynamic content transformation - * @why Enables installation-time customization: _bmad replacement - * @param {string} sourcePath - Absolute path to source file in BMAD repository - * @param {string} targetPath - Absolute path to destination file in user's project - * @param {string} bmadFolderName - User's chosen bmad folder name (default: 'bmad') - * @returns {Promise} Resolves when file copy and transformation complete - * @sideeffects Writes transformed file to targetPath, creates parent directories if needed - * @edgecases Binary files bypass transformation, falls back to raw copy if UTF-8 read fails - * @calledby installCore(), installModule(), IDE installers during file vendoring - * @calls fs.readFile(), fs.writeFile(), fs.copy() - * - - * - * 3. Document marker in instructions.md (if applicable) - */ - async copyFileWithPlaceholderReplacement(sourcePath, targetPath) { - // List of text file extensions that should have placeholder replacement - const textExtensions = ['.md', '.yaml', '.yml', '.txt', '.json', '.js', '.ts', '.html', '.css', '.sh', '.bat', '.csv', '.xml']; - const ext = path.extname(sourcePath).toLowerCase(); - - // Check if this is a text file that might contain placeholders - if (textExtensions.includes(ext)) { - try { - // Read the file content - let content = await fs.readFile(sourcePath, 'utf8'); - - // Write to target with replaced content - await fs.ensureDir(path.dirname(targetPath)); - await fs.writeFile(targetPath, content, 'utf8'); - } catch { - // If reading as text fails (might be binary despite extension), fall back to regular copy - await fs.copy(sourcePath, targetPath, { overwrite: true }); - } - } else { - // Binary file or other file type - just copy directly - await fs.copy(sourcePath, targetPath, { overwrite: true }); - } - } - - /** - * Collect Tool/IDE configurations after module configuration - * @param {string} projectDir - Project directory - * @param {Array} selectedModules - Selected modules from configuration - * @param {boolean} isFullReinstall - Whether this is a full reinstall - * @param {Array} previousIdes - Previously configured IDEs (for reinstalls) - * @param {Array} preSelectedIdes - Pre-selected IDEs from early prompt (optional) - * @param {boolean} skipPrompts - Skip prompts and use defaults (for --yes flag) - * @returns {Object} Tool/IDE selection and configurations - */ - async collectToolConfigurations( - projectDir, - selectedModules, - isFullReinstall = false, - previousIdes = [], - preSelectedIdes = null, - skipPrompts = false, - ) { - // Use pre-selected IDEs if provided, otherwise prompt - let toolConfig; - if (preSelectedIdes === null) { - // Fallback: prompt for tool selection (backwards compatibility) - const { UI } = require('../../../lib/ui'); - const ui = new UI(); - toolConfig = await ui.promptToolSelection(projectDir); - } else { - // IDEs were already selected during initial prompts - toolConfig = { - ides: preSelectedIdes, - skipIde: !preSelectedIdes || preSelectedIdes.length === 0, - }; - } - - // Check for already configured IDEs - const { Detector } = require('./detector'); - const detector = new Detector(); - const bmadDir = path.join(projectDir, BMAD_FOLDER_NAME); - - // During full reinstall, use the saved previous IDEs since bmad dir was deleted - // Otherwise detect from existing installation - let previouslyConfiguredIdes; - if (isFullReinstall) { - // During reinstall, treat all IDEs as new (need configuration) - previouslyConfiguredIdes = []; - } else { - const existingInstall = await detector.detect(bmadDir); - previouslyConfiguredIdes = existingInstall.ides || []; - } - - // Load saved IDE configurations for already-configured IDEs - const savedIdeConfigs = await this.ideConfigManager.loadAllIdeConfigs(bmadDir); - - // Collect IDE-specific configurations if any were selected - const ideConfigurations = {}; - - // First, add saved configs for already-configured IDEs - for (const ide of toolConfig.ides || []) { - if (previouslyConfiguredIdes.includes(ide) && savedIdeConfigs[ide]) { - ideConfigurations[ide] = savedIdeConfigs[ide]; - } - } - - if (!toolConfig.skipIde && toolConfig.ides && toolConfig.ides.length > 0) { - // Ensure IDE manager is initialized - await this.ideManager.ensureInitialized(); - - // Determine which IDEs are newly selected (not previously configured) - const newlySelectedIdes = toolConfig.ides.filter((ide) => !previouslyConfiguredIdes.includes(ide)); - - if (newlySelectedIdes.length > 0) { - // Collect configuration for IDEs that support it - for (const ide of newlySelectedIdes) { - try { - const handler = this.ideManager.handlers.get(ide); - - if (!handler) { - await prompts.log.warn(`Warning: IDE '${ide}' handler not found`); - continue; - } - - // Check if this IDE handler has a collectConfiguration method - // (custom installers like Codex, Kilo may have this) - if (typeof handler.collectConfiguration === 'function') { - await prompts.log.info(`Configuring ${ide}...`); - ideConfigurations[ide] = await handler.collectConfiguration({ - selectedModules: selectedModules || [], - projectDir, - bmadDir, - skipPrompts, - }); - } else { - // Config-driven IDEs don't need configuration - mark as ready - ideConfigurations[ide] = { _noConfigNeeded: true }; - } - } catch (error) { - // IDE doesn't support configuration or has an error - await prompts.log.warn(`Warning: Could not load configuration for ${ide}: ${error.message}`); - } - } - } - - // Log which IDEs are already configured and being kept - const keptIdes = toolConfig.ides.filter((ide) => previouslyConfiguredIdes.includes(ide)); - if (keptIdes.length > 0) { - await prompts.log.message(`Keeping existing configuration for: ${keptIdes.join(', ')}`); - } - } - - return { - ides: toolConfig.ides, - skipIde: toolConfig.skipIde, - configurations: ideConfigurations, - }; - } - - /** - * Main installation method - * @param {Object} config - Installation configuration - * @param {string} config.directory - Target directory - * @param {boolean} config.installCore - Whether to install core - * @param {string[]} config.modules - Modules to install - * @param {string[]} config.ides - IDEs to configure - * @param {boolean} config.skipIde - Skip IDE configuration - */ - async install(originalConfig) { - // Clone config to avoid mutating the caller's object - const config = { ...originalConfig }; - - // Check if core config was already collected in UI - const hasCoreConfig = config.coreConfig && Object.keys(config.coreConfig).length > 0; - - // Only display logo if core config wasn't already collected (meaning we're not continuing from UI) - if (!hasCoreConfig) { - // Display BMAD logo - await CLIUtils.displayLogo(); - - // Display welcome message - await CLIUtils.displaySection('BMad™ Installation', 'Version ' + require(path.join(getProjectRoot(), 'package.json')).version); - } - - // Note: Legacy V4 detection now happens earlier in UI.promptInstall() - // before any config collection, so we don't need to check again here - - const projectDir = path.resolve(config.directory); - const bmadDir = path.join(projectDir, BMAD_FOLDER_NAME); - - // If core config was pre-collected (from interactive mode), use it - if (config.coreConfig && Object.keys(config.coreConfig).length > 0) { - this.configCollector.collectedConfig.core = config.coreConfig; - // Also store in allAnswers for cross-referencing - this.configCollector.allAnswers = {}; - for (const [key, value] of Object.entries(config.coreConfig)) { - this.configCollector.allAnswers[`core_${key}`] = value; - } - } - - // Collect configurations for modules (skip if quick update already collected them) - let moduleConfigs; - let customModulePaths = new Map(); - - if (config._quickUpdate) { - // Quick update already collected all configs, use them directly - moduleConfigs = this.configCollector.collectedConfig; - - // For quick update, populate customModulePaths from _customModuleSources - if (config._customModuleSources) { - for (const [moduleId, customInfo] of config._customModuleSources) { - customModulePaths.set(moduleId, customInfo.sourcePath); - } - } - } else { - // For regular updates (modify flow), check manifest for custom module sources - if (config._isUpdate && config._existingInstall && config._existingInstall.customModules) { - for (const customModule of config._existingInstall.customModules) { - // Ensure we have an absolute sourcePath - let absoluteSourcePath = customModule.sourcePath; - - // Check if sourcePath is a cache-relative path (starts with _config) - if (absoluteSourcePath && absoluteSourcePath.startsWith('_config')) { - // Convert cache-relative path to absolute path - absoluteSourcePath = path.join(bmadDir, absoluteSourcePath); - } - // If no sourcePath but we have relativePath, convert it - else if (!absoluteSourcePath && customModule.relativePath) { - // relativePath is relative to the project root (parent of bmad dir) - absoluteSourcePath = path.resolve(projectDir, customModule.relativePath); - } - // Ensure sourcePath is absolute for anything else - else if (absoluteSourcePath && !path.isAbsolute(absoluteSourcePath)) { - absoluteSourcePath = path.resolve(absoluteSourcePath); - } - - if (absoluteSourcePath) { - customModulePaths.set(customModule.id, absoluteSourcePath); - } - } - } - - // Build custom module paths map from customContent - - // Handle selectedFiles (from existing install path or manual directory input) - if (config.customContent && config.customContent.selected && config.customContent.selectedFiles) { - const customHandler = new CustomHandler(); - for (const customFile of config.customContent.selectedFiles) { - const customInfo = await customHandler.getCustomInfo(customFile, path.resolve(config.directory)); - if (customInfo && customInfo.id) { - customModulePaths.set(customInfo.id, customInfo.path); - } - } - } - - // Handle new custom content sources from UI - if (config.customContent && config.customContent.sources) { - for (const source of config.customContent.sources) { - customModulePaths.set(source.id, source.path); - } - } - - // Handle cachedModules (from new install path where modules are cached) - // Only include modules that were actually selected for installation - if (config.customContent && config.customContent.cachedModules) { - // Get selected cached module IDs (if available) - const selectedCachedIds = config.customContent.selectedCachedModules || []; - // If no selection info, include all cached modules (for backward compatibility) - const shouldIncludeAll = selectedCachedIds.length === 0 && config.customContent.selected; - - for (const cachedModule of config.customContent.cachedModules) { - // For cached modules, the path is the cachePath which contains the module.yaml - if ( - cachedModule.id && - cachedModule.cachePath && // Include if selected or if we should include all - (shouldIncludeAll || selectedCachedIds.includes(cachedModule.id)) - ) { - customModulePaths.set(cachedModule.id, cachedModule.cachePath); - } - } - } - - // Get list of all modules including custom modules - // Order: core first, then official modules, then custom modules - const allModulesForConfig = ['core']; - - // Add official modules (excluding core and any custom modules) - const officialModules = (config.modules || []).filter((m) => m !== 'core' && !customModulePaths.has(m)); - allModulesForConfig.push(...officialModules); - - // Add custom modules at the end - for (const [moduleId] of customModulePaths) { - if (!allModulesForConfig.includes(moduleId)) { - allModulesForConfig.push(moduleId); - } - } - - // Check if core was already collected in UI - if (config.coreConfig && Object.keys(config.coreConfig).length > 0) { - // Core already collected, skip it in config collection - const modulesWithoutCore = allModulesForConfig.filter((m) => m !== 'core'); - moduleConfigs = await this.configCollector.collectAllConfigurations(modulesWithoutCore, path.resolve(config.directory), { - customModulePaths, - skipPrompts: config.skipPrompts, - }); - } else { - // Core not collected yet, include it - moduleConfigs = await this.configCollector.collectAllConfigurations(allModulesForConfig, path.resolve(config.directory), { - customModulePaths, - skipPrompts: config.skipPrompts, - }); - } - } - - // Set bmad folder name on module manager and IDE manager for placeholder replacement - this.moduleManager.setBmadFolderName(BMAD_FOLDER_NAME); - this.moduleManager.setCoreConfig(moduleConfigs.core || {}); - this.moduleManager.setCustomModulePaths(customModulePaths); - this.ideManager.setBmadFolderName(BMAD_FOLDER_NAME); - - // Tool selection will be collected after we determine if it's a reinstall/update/new install - - const spinner = await prompts.spinner(); - spinner.start('Preparing installation...'); - - try { - // Create a project directory if it doesn't exist (user already confirmed) - if (!(await fs.pathExists(projectDir))) { - spinner.message('Creating installation directory...'); - try { - // fs.ensureDir handles platform-specific directory creation - // It will recursively create all necessary parent directories - await fs.ensureDir(projectDir); - } catch (error) { - spinner.error('Failed to create installation directory'); - await prompts.log.error(`Error: ${error.message}`); - // More detailed error for common issues - if (error.code === 'EACCES') { - await prompts.log.error('Permission denied. Check parent directory permissions.'); - } else if (error.code === 'ENOSPC') { - await prompts.log.error('No space left on device.'); - } - throw new Error(`Cannot create directory: ${projectDir}`); - } - } - - // Check existing installation - spinner.message('Checking for existing installation...'); - const existingInstall = await this.detector.detect(bmadDir); - - if (existingInstall.installed && !config.force && !config._quickUpdate) { - spinner.stop('Existing installation detected'); - - // Check if user already decided what to do (from early menu in ui.js) - let action = null; - if (config.actionType === 'update') { - action = 'update'; - } else if (config.skipPrompts) { - // Non-interactive mode: default to update - action = 'update'; - } else { - // Fallback: Ask the user (backwards compatibility for other code paths) - await prompts.log.warn('Existing BMAD installation detected'); - await prompts.log.message(` Location: ${bmadDir}`); - await prompts.log.message(` Version: ${existingInstall.version}`); - - const promptResult = await this.promptUpdateAction(); - action = promptResult.action; - } - - if (action === 'update') { - // Store that we're updating for later processing - config._isUpdate = true; - config._existingInstall = existingInstall; - - // Detect modules that were previously installed but are NOT in the new selection (to be removed) - const previouslyInstalledModules = new Set(existingInstall.modules.map((m) => m.id)); - const newlySelectedModules = new Set(config.modules || []); - - // Find modules to remove (installed but not in new selection) - // Exclude 'core' from being removable - const modulesToRemove = [...previouslyInstalledModules].filter((m) => !newlySelectedModules.has(m) && m !== 'core'); - - // If there are modules to remove, ask for confirmation - if (modulesToRemove.length > 0) { - if (config.skipPrompts) { - // Non-interactive mode: preserve modules (matches prompt default: false) - for (const moduleId of modulesToRemove) { - if (!config.modules) config.modules = []; - config.modules.push(moduleId); - } - spinner.start('Preparing update...'); - } else { - if (spinner.isSpinning) { - spinner.stop('Module changes reviewed'); - } - - await prompts.log.warn('Modules to be removed:'); - for (const moduleId of modulesToRemove) { - const moduleInfo = existingInstall.modules.find((m) => m.id === moduleId); - const displayName = moduleInfo?.name || moduleId; - const modulePath = path.join(bmadDir, moduleId); - await prompts.log.error(` - ${displayName} (${modulePath})`); - } - - const confirmRemoval = await prompts.confirm({ - message: `Remove ${modulesToRemove.length} module(s) from BMAD installation?`, - default: false, - }); - - if (confirmRemoval) { - // Remove module folders - for (const moduleId of modulesToRemove) { - const modulePath = path.join(bmadDir, moduleId); - try { - if (await fs.pathExists(modulePath)) { - await fs.remove(modulePath); - await prompts.log.message(` Removed: ${moduleId}`); - } - } catch (error) { - await prompts.log.warn(` Warning: Failed to remove ${moduleId}: ${error.message}`); - } - } - await prompts.log.success(` Removed ${modulesToRemove.length} module(s)`); - } else { - await prompts.log.message(' Module removal cancelled'); - // Add the modules back to the selection since user cancelled removal - for (const moduleId of modulesToRemove) { - if (!config.modules) config.modules = []; - config.modules.push(moduleId); - } - } - - spinner.start('Preparing update...'); - } - } - - // Detect custom and modified files BEFORE updating (compare current files vs files-manifest.csv) - const existingFilesManifest = await this.readFilesManifest(bmadDir); - const { customFiles, modifiedFiles } = await this.detectCustomFiles(bmadDir, existingFilesManifest); - - config._customFiles = customFiles; - config._modifiedFiles = modifiedFiles; - - // Preserve existing core configuration during updates - // Read the current core config.yaml to maintain user's settings - const coreConfigPath = path.join(bmadDir, 'core', 'config.yaml'); - if ((await fs.pathExists(coreConfigPath)) && (!config.coreConfig || Object.keys(config.coreConfig).length === 0)) { - try { - const yaml = require('yaml'); - const coreConfigContent = await fs.readFile(coreConfigPath, 'utf8'); - const existingCoreConfig = yaml.parse(coreConfigContent); - - // Store in config.coreConfig so it's preserved through the installation - config.coreConfig = existingCoreConfig; - - // Also store in configCollector for use during config collection - this.configCollector.collectedConfig.core = existingCoreConfig; - } catch (error) { - await prompts.log.warn(`Warning: Could not read existing core config: ${error.message}`); - } - } - - // Also check cache directory for custom modules (like quick update does) - const cacheDir = path.join(bmadDir, '_config', 'custom'); - if (await fs.pathExists(cacheDir)) { - const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true }); - - for (const cachedModule of cachedModules) { - const moduleId = cachedModule.name; - const cachedPath = path.join(cacheDir, moduleId); - - // Skip if path doesn't exist (broken symlink, deleted dir) - avoids lstat ENOENT - if (!(await fs.pathExists(cachedPath)) || !cachedModule.isDirectory()) { - continue; - } - - // Skip if we already have this module from manifest - if (customModulePaths.has(moduleId)) { - continue; - } - - // Check if this is an external official module - skip cache for those - const isExternal = await this.moduleManager.isExternalModule(moduleId); - if (isExternal) { - // External modules are handled via cloneExternalModule, not from cache - continue; - } - - // Check if this is actually a custom module (has module.yaml) - const moduleYamlPath = path.join(cachedPath, 'module.yaml'); - if (await fs.pathExists(moduleYamlPath)) { - customModulePaths.set(moduleId, cachedPath); - } - } - - // Update module manager with the new custom module paths from cache - this.moduleManager.setCustomModulePaths(customModulePaths); - } - - // If there are custom files, back them up temporarily - if (customFiles.length > 0) { - const tempBackupDir = path.join(projectDir, '_bmad-custom-backup-temp'); - await fs.ensureDir(tempBackupDir); - - spinner.start(`Backing up ${customFiles.length} custom files...`); - for (const customFile of customFiles) { - const relativePath = path.relative(bmadDir, customFile); - const backupPath = path.join(tempBackupDir, relativePath); - await fs.ensureDir(path.dirname(backupPath)); - await fs.copy(customFile, backupPath); - } - spinner.stop(`Backed up ${customFiles.length} custom files`); - - config._tempBackupDir = tempBackupDir; - } - - // For modified files, back them up to temp directory (will be restored as .bak files after install) - if (modifiedFiles.length > 0) { - const tempModifiedBackupDir = path.join(projectDir, '_bmad-modified-backup-temp'); - await fs.ensureDir(tempModifiedBackupDir); - - spinner.start(`Backing up ${modifiedFiles.length} modified files...`); - for (const modifiedFile of modifiedFiles) { - const relativePath = path.relative(bmadDir, modifiedFile.path); - const tempBackupPath = path.join(tempModifiedBackupDir, relativePath); - await fs.ensureDir(path.dirname(tempBackupPath)); - await fs.copy(modifiedFile.path, tempBackupPath, { overwrite: true }); - } - spinner.stop(`Backed up ${modifiedFiles.length} modified files`); - - config._tempModifiedBackupDir = tempModifiedBackupDir; - } - } - } else if (existingInstall.installed && config._quickUpdate) { - // Quick update mode - automatically treat as update without prompting - spinner.message('Preparing quick update...'); - config._isUpdate = true; - config._existingInstall = existingInstall; - - // Detect custom and modified files BEFORE updating - const existingFilesManifest = await this.readFilesManifest(bmadDir); - const { customFiles, modifiedFiles } = await this.detectCustomFiles(bmadDir, existingFilesManifest); - - config._customFiles = customFiles; - config._modifiedFiles = modifiedFiles; - - // Also check cache directory for custom modules (like quick update does) - const cacheDir = path.join(bmadDir, '_config', 'custom'); - if (await fs.pathExists(cacheDir)) { - const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true }); - - for (const cachedModule of cachedModules) { - const moduleId = cachedModule.name; - const cachedPath = path.join(cacheDir, moduleId); - - // Skip if path doesn't exist (broken symlink, deleted dir) - avoids lstat ENOENT - if (!(await fs.pathExists(cachedPath)) || !cachedModule.isDirectory()) { - continue; - } - - // Skip if we already have this module from manifest - if (customModulePaths.has(moduleId)) { - continue; - } - - // Check if this is an external official module - skip cache for those - const isExternal = await this.moduleManager.isExternalModule(moduleId); - if (isExternal) { - // External modules are handled via cloneExternalModule, not from cache - continue; - } - - // Check if this is actually a custom module (has module.yaml) - const moduleYamlPath = path.join(cachedPath, 'module.yaml'); - if (await fs.pathExists(moduleYamlPath)) { - customModulePaths.set(moduleId, cachedPath); - } - } - - // Update module manager with the new custom module paths from cache - this.moduleManager.setCustomModulePaths(customModulePaths); - } - - // Back up custom files - if (customFiles.length > 0) { - const tempBackupDir = path.join(projectDir, '_bmad-custom-backup-temp'); - await fs.ensureDir(tempBackupDir); - - spinner.start(`Backing up ${customFiles.length} custom files...`); - for (const customFile of customFiles) { - const relativePath = path.relative(bmadDir, customFile); - const backupPath = path.join(tempBackupDir, relativePath); - await fs.ensureDir(path.dirname(backupPath)); - await fs.copy(customFile, backupPath); - } - spinner.stop(`Backed up ${customFiles.length} custom files`); - config._tempBackupDir = tempBackupDir; - } - - // Back up modified files - if (modifiedFiles.length > 0) { - const tempModifiedBackupDir = path.join(projectDir, '_bmad-modified-backup-temp'); - await fs.ensureDir(tempModifiedBackupDir); - - spinner.start(`Backing up ${modifiedFiles.length} modified files...`); - for (const modifiedFile of modifiedFiles) { - const relativePath = path.relative(bmadDir, modifiedFile.path); - const tempBackupPath = path.join(tempModifiedBackupDir, relativePath); - await fs.ensureDir(path.dirname(tempBackupPath)); - await fs.copy(modifiedFile.path, tempBackupPath, { overwrite: true }); - } - spinner.stop(`Backed up ${modifiedFiles.length} modified files`); - config._tempModifiedBackupDir = tempModifiedBackupDir; - } - } - - // Now collect tool configurations after we know if it's a reinstall - // Skip for quick update since we already have the IDE list - spinner.stop('Pre-checks complete'); - let toolSelection; - if (config._quickUpdate) { - // Quick update already has IDEs configured, use saved configurations - const preConfiguredIdes = {}; - const savedIdeConfigs = config._savedIdeConfigs || {}; - - for (const ide of config.ides || []) { - // Use saved config if available, otherwise mark as already configured (legacy) - if (savedIdeConfigs[ide]) { - preConfiguredIdes[ide] = savedIdeConfigs[ide]; - } else { - preConfiguredIdes[ide] = { _alreadyConfigured: true }; - } - } - toolSelection = { - ides: config.ides || [], - skipIde: !config.ides || config.ides.length === 0, - configurations: preConfiguredIdes, - }; - } else { - // Pass pre-selected IDEs from early prompt (if available) - // This allows IDE selection to happen before file copying, improving UX - // Use config.ides if it's an array (even if empty), null means prompt - const preSelectedIdes = Array.isArray(config.ides) ? config.ides : null; - toolSelection = await this.collectToolConfigurations( - path.resolve(config.directory), - config.modules, - config._isFullReinstall || false, - config._previouslyConfiguredIdes || [], - preSelectedIdes, - config.skipPrompts || false, - ); - } - - // Merge tool selection into config (for both quick update and regular flow) - // Normalize IDE keys to lowercase so they match handler map keys consistently - config.ides = (toolSelection.ides || []).map((ide) => ide.toLowerCase()); - config.skipIde = toolSelection.skipIde; - const ideConfigurations = toolSelection.configurations; - - // Early check: fail fast if ALL selected IDEs are suspended - if (config.ides && config.ides.length > 0) { - await this.ideManager.ensureInitialized(); - const suspendedIdes = config.ides.filter((ide) => { - const handler = this.ideManager.handlers.get(ide); - return handler?.platformConfig?.suspended; - }); - - if (suspendedIdes.length > 0 && suspendedIdes.length === config.ides.length) { - for (const ide of suspendedIdes) { - const handler = this.ideManager.handlers.get(ide); - await prompts.log.error(`${handler.displayName || ide}: ${handler.platformConfig.suspended}`); - } - throw new Error( - `All selected tool(s) are suspended: ${suspendedIdes.join(', ')}. Installation aborted to prevent upgrading _bmad/ without a working IDE configuration.`, - ); - } - } - - // Detect IDEs that were previously installed but are NOT in the new selection (to be removed) - if (config._isUpdate && config._existingInstall) { - const previouslyInstalledIdes = new Set(config._existingInstall.ides || []); - const newlySelectedIdes = new Set(config.ides || []); - - const idesToRemove = [...previouslyInstalledIdes].filter((ide) => !newlySelectedIdes.has(ide)); - - if (idesToRemove.length > 0) { - if (config.skipPrompts) { - // Non-interactive mode: silently preserve existing IDE configs - if (!config.ides) config.ides = []; - const savedIdeConfigs = await this.ideConfigManager.loadAllIdeConfigs(bmadDir); - for (const ide of idesToRemove) { - config.ides.push(ide); - if (savedIdeConfigs[ide] && !ideConfigurations[ide]) { - ideConfigurations[ide] = savedIdeConfigs[ide]; - } - } - } else { - if (spinner.isSpinning) { - spinner.stop('IDE changes reviewed'); - } - - await prompts.log.warn('IDEs to be removed:'); - for (const ide of idesToRemove) { - await prompts.log.error(` - ${ide}`); - } - - const confirmRemoval = await prompts.confirm({ - message: `Remove BMAD configuration for ${idesToRemove.length} IDE(s)?`, - default: false, - }); - - if (confirmRemoval) { - await this.ideManager.ensureInitialized(); - for (const ide of idesToRemove) { - try { - const handler = this.ideManager.handlers.get(ide); - if (handler) { - await handler.cleanup(projectDir); - } - await this.ideConfigManager.deleteIdeConfig(bmadDir, ide); - await prompts.log.message(` Removed: ${ide}`); - } catch (error) { - await prompts.log.warn(` Warning: Failed to remove ${ide}: ${error.message}`); - } - } - await prompts.log.success(` Removed ${idesToRemove.length} IDE(s)`); - } else { - await prompts.log.message(' IDE removal cancelled'); - // Add IDEs back to selection and restore their saved configurations - if (!config.ides) config.ides = []; - const savedIdeConfigs = await this.ideConfigManager.loadAllIdeConfigs(bmadDir); - for (const ide of idesToRemove) { - config.ides.push(ide); - if (savedIdeConfigs[ide] && !ideConfigurations[ide]) { - ideConfigurations[ide] = savedIdeConfigs[ide]; - } - } - } - - spinner.start('Preparing installation...'); - } - } - } - - // Results collector for consolidated summary - const results = []; - const addResult = (step, status, detail = '') => results.push({ step, status, detail }); - - if (spinner.isSpinning) { - spinner.message('Preparing installation...'); - } else { - spinner.start('Preparing installation...'); - } - - // Create bmad directory structure - spinner.message('Creating directory structure...'); - await this.createDirectoryStructure(bmadDir); - - // Cache custom modules if any - if (customModulePaths && customModulePaths.size > 0) { - spinner.message('Caching custom modules...'); - const { CustomModuleCache } = require('./custom-module-cache'); - const customCache = new CustomModuleCache(bmadDir); - - for (const [moduleId, sourcePath] of customModulePaths) { - const cachedInfo = await customCache.cacheModule(moduleId, sourcePath, { - sourcePath: sourcePath, // Store original path for updates - }); - - // Update the customModulePaths to use the cached location - customModulePaths.set(moduleId, cachedInfo.cachePath); - } - - // Update module manager with the cached paths - this.moduleManager.setCustomModulePaths(customModulePaths); - addResult('Custom modules cached', 'ok'); - } - - const projectRoot = getProjectRoot(); - - // Custom content is already handled in UI before module selection - const finalCustomContent = config.customContent; - - // Prepare modules list including cached custom modules - let allModules = [...(config.modules || [])]; - - // During quick update, we might have custom module sources from the manifest - if (config._customModuleSources) { - // Add custom modules from stored sources - for (const [moduleId, customInfo] of config._customModuleSources) { - if (!allModules.includes(moduleId) && (await fs.pathExists(customInfo.sourcePath))) { - allModules.push(moduleId); - } - } - } - - // Add cached custom modules - if (finalCustomContent && finalCustomContent.cachedModules) { - for (const cachedModule of finalCustomContent.cachedModules) { - if (!allModules.includes(cachedModule.id)) { - allModules.push(cachedModule.id); - } - } - } - - // Regular custom content from user input (non-cached) - if (finalCustomContent && finalCustomContent.selected && finalCustomContent.selectedFiles) { - // Add custom modules to the installation list - const customHandler = new CustomHandler(); - for (const customFile of finalCustomContent.selectedFiles) { - const customInfo = await customHandler.getCustomInfo(customFile, projectDir); - if (customInfo && customInfo.id) { - allModules.push(customInfo.id); - } - } - } - - // Don't include core again if already installed - if (config.installCore) { - allModules = allModules.filter((m) => m !== 'core'); - } - - // For dependency resolution, we only need regular modules (not custom modules) - // Custom modules are already installed in _bmad and don't need dependency resolution from source - const regularModulesForResolution = allModules.filter((module) => { - // Check if this is a custom module - const isCustom = - customModulePaths.has(module) || - (finalCustomContent && finalCustomContent.cachedModules && finalCustomContent.cachedModules.some((cm) => cm.id === module)) || - (finalCustomContent && - finalCustomContent.selected && - finalCustomContent.selectedFiles && - finalCustomContent.selectedFiles.some((f) => f.includes(module))); - return !isCustom; - }); - - // Stop spinner before tasks() takes over progress display - spinner.stop('Preparation complete'); - - // ───────────────────────────────────────────────────────────────────────── - // FIRST TASKS BLOCK: Core installation through manifests (non-interactive) - // ───────────────────────────────────────────────────────────────────────── - const isQuickUpdate = config._quickUpdate || false; - - // Shared resolution result across task callbacks (closure-scoped, not on `this`) - let taskResolution; - - // Collect directory creation results for output after tasks() completes - const dirResults = { createdDirs: [], movedDirs: [], createdWdsFolders: [] }; - - // Build task list conditionally - const installTasks = []; - - // Core installation task - if (config.installCore) { - installTasks.push({ - title: isQuickUpdate ? 'Updating BMAD core' : 'Installing BMAD core', - task: async (message) => { - await this.installCoreWithDependencies(bmadDir, { core: {} }); - addResult('Core', 'ok', isQuickUpdate ? 'updated' : 'installed'); - await this.generateModuleConfigs(bmadDir, { core: config.coreConfig || {} }); - return isQuickUpdate ? 'Core updated' : 'Core installed'; - }, - }); - } - - // Dependency resolution task - installTasks.push({ - title: 'Resolving dependencies', - task: async (message) => { - // Create a temporary module manager that knows about custom content locations - const tempModuleManager = new ModuleManager({ - bmadDir: bmadDir, - }); - - taskResolution = await this.dependencyResolver.resolve(projectRoot, regularModulesForResolution, { - verbose: config.verbose, - moduleManager: tempModuleManager, - }); - return 'Dependencies resolved'; - }, - }); - - // Module installation task - if (allModules && allModules.length > 0) { - installTasks.push({ - title: isQuickUpdate ? `Updating ${allModules.length} module(s)` : `Installing ${allModules.length} module(s)`, - task: async (message) => { - const resolution = taskResolution; - const installedModuleNames = new Set(); - - for (const moduleName of allModules) { - if (installedModuleNames.has(moduleName)) continue; - installedModuleNames.add(moduleName); - - message(`${isQuickUpdate ? 'Updating' : 'Installing'} ${moduleName}...`); - - // Check if this is a custom module - let isCustomModule = false; - let customInfo = null; - - // First check if we have a cached version - if (finalCustomContent && finalCustomContent.cachedModules) { - const cachedModule = finalCustomContent.cachedModules.find((m) => m.id === moduleName); - if (cachedModule) { - isCustomModule = true; - customInfo = { id: moduleName, path: cachedModule.cachePath, config: {} }; - } - } - - // Then check custom module sources from manifest (for quick update) - if (!isCustomModule && config._customModuleSources && config._customModuleSources.has(moduleName)) { - customInfo = config._customModuleSources.get(moduleName); - isCustomModule = true; - if (customInfo.sourcePath && !customInfo.path) { - customInfo.path = path.isAbsolute(customInfo.sourcePath) - ? customInfo.sourcePath - : path.join(bmadDir, customInfo.sourcePath); - } - } - - // Finally check regular custom content - if (!isCustomModule && finalCustomContent && finalCustomContent.selected && finalCustomContent.selectedFiles) { - const customHandler = new CustomHandler(); - for (const customFile of finalCustomContent.selectedFiles) { - const info = await customHandler.getCustomInfo(customFile, projectDir); - if (info && info.id === moduleName) { - isCustomModule = true; - customInfo = info; - break; - } - } - } - - if (isCustomModule && customInfo) { - if (!customModulePaths.has(moduleName) && customInfo.path) { - customModulePaths.set(moduleName, customInfo.path); - this.moduleManager.setCustomModulePaths(customModulePaths); - } - - const collectedModuleConfig = moduleConfigs[moduleName] || {}; - await this.moduleManager.install( - moduleName, - bmadDir, - (filePath) => { - this.installedFiles.add(filePath); - }, - { - isCustom: true, - moduleConfig: collectedModuleConfig, - isQuickUpdate: isQuickUpdate, - installer: this, - silent: true, - }, - ); - await this.generateModuleConfigs(bmadDir, { - [moduleName]: { ...config.coreConfig, ...customInfo.config, ...collectedModuleConfig }, - }); - } else { - if (!resolution || !resolution.byModule) { - addResult(`Module: ${moduleName}`, 'warn', 'skipped (no resolution data)'); - continue; - } - if (moduleName === 'core') { - await this.installCoreWithDependencies(bmadDir, resolution.byModule[moduleName]); - } else { - await this.installModuleWithDependencies(moduleName, bmadDir, resolution.byModule[moduleName]); - } - } - - addResult(`Module: ${moduleName}`, 'ok', isQuickUpdate ? 'updated' : 'installed'); - } - - // Install partial modules (only dependencies) - if (!resolution || !resolution.byModule) { - return `${allModules.length} module(s) ${isQuickUpdate ? 'updated' : 'installed'}`; - } - for (const [module, files] of Object.entries(resolution.byModule)) { - if (!allModules.includes(module) && module !== 'core') { - const totalFiles = - files.agents.length + - files.tasks.length + - files.tools.length + - files.templates.length + - files.data.length + - files.other.length; - if (totalFiles > 0) { - message(`Installing ${module} dependencies...`); - await this.installPartialModule(module, bmadDir, files); - } - } - } - - return `${allModules.length} module(s) ${isQuickUpdate ? 'updated' : 'installed'}`; - }, - }); - } - - // Module directory creation task - installTasks.push({ - title: 'Creating module directories', - task: async (message) => { - const resolution = taskResolution; - if (!resolution || !resolution.byModule) { - addResult('Module directories', 'warn', 'no resolution data'); - return 'Module directories skipped (no resolution data)'; - } - const verboseMode = process.env.BMAD_VERBOSE_INSTALL === 'true' || config.verbose; - const moduleLogger = { - log: async (msg) => (verboseMode ? await prompts.log.message(msg) : undefined), - error: async (msg) => await prompts.log.error(msg), - warn: async (msg) => await prompts.log.warn(msg), - }; - - // Core module directories - if (config.installCore || resolution.byModule.core) { - const result = await this.moduleManager.createModuleDirectories('core', bmadDir, { - installedIDEs: config.ides || [], - moduleConfig: moduleConfigs.core || {}, - existingModuleConfig: this.configCollector.existingConfig?.core || {}, - coreConfig: moduleConfigs.core || {}, - logger: moduleLogger, - silent: true, - }); - if (result) { - dirResults.createdDirs.push(...result.createdDirs); - dirResults.movedDirs.push(...(result.movedDirs || [])); - dirResults.createdWdsFolders.push(...result.createdWdsFolders); - } - } - - // User-selected module directories - if (config.modules && config.modules.length > 0) { - for (const moduleName of config.modules) { - message(`Setting up ${moduleName}...`); - const result = await this.moduleManager.createModuleDirectories(moduleName, bmadDir, { - installedIDEs: config.ides || [], - moduleConfig: moduleConfigs[moduleName] || {}, - existingModuleConfig: this.configCollector.existingConfig?.[moduleName] || {}, - coreConfig: moduleConfigs.core || {}, - logger: moduleLogger, - silent: true, - }); - if (result) { - dirResults.createdDirs.push(...result.createdDirs); - dirResults.movedDirs.push(...(result.movedDirs || [])); - dirResults.createdWdsFolders.push(...result.createdWdsFolders); - } - } - } - - addResult('Module directories', 'ok'); - return 'Module directories created'; - }, - }); - - // Configuration generation task (stored as named reference for deferred execution) - const configTask = { - title: 'Generating configurations', - task: async (message) => { - // Generate clean config.yaml files for each installed module - await this.generateModuleConfigs(bmadDir, moduleConfigs); - addResult('Configurations', 'ok', 'generated'); - - // Pre-register manifest files - const cfgDir = path.join(bmadDir, '_config'); - this.installedFiles.add(path.join(cfgDir, 'manifest.yaml')); - this.installedFiles.add(path.join(cfgDir, 'agent-manifest.csv')); - - // Generate CSV manifests for agents, skills AND ALL FILES with hashes - // This must happen BEFORE mergeModuleHelpCatalogs because it depends on agent-manifest.csv - message('Generating manifests...'); - const manifestGen = new ManifestGenerator(); - - const allModulesForManifest = config._quickUpdate - ? config._existingModules || allModules || [] - : config._preserveModules - ? [...allModules, ...config._preserveModules] - : allModules || []; - - let modulesForCsvPreserve; - if (config._quickUpdate) { - modulesForCsvPreserve = config._existingModules || allModules || []; - } else { - modulesForCsvPreserve = config._preserveModules ? [...allModules, ...config._preserveModules] : allModules; - } - - const manifestStats = await manifestGen.generateManifests(bmadDir, allModulesForManifest, [...this.installedFiles], { - ides: config.ides || [], - preservedModules: modulesForCsvPreserve, - }); - - // Merge help catalogs - message('Generating help catalog...'); - await this.mergeModuleHelpCatalogs(bmadDir); - addResult('Help catalog', 'ok'); - - return 'Configurations generated'; - }, - }; - installTasks.push(configTask); - - // Run all tasks except config (which runs after directory output) - const mainTasks = installTasks.filter((t) => t !== configTask); - await prompts.tasks(mainTasks); - - // Render directory creation output right after directory task - const color = await prompts.getColor(); - if (dirResults.movedDirs.length > 0) { - const lines = dirResults.movedDirs.map((d) => ` ${d}`).join('\n'); - await prompts.log.message(color.cyan(`Moved directories:\n${lines}`)); - } - if (dirResults.createdDirs.length > 0) { - const lines = dirResults.createdDirs.map((d) => ` ${d}`).join('\n'); - await prompts.log.message(color.yellow(`Created directories:\n${lines}`)); - } - if (dirResults.createdWdsFolders.length > 0) { - const lines = dirResults.createdWdsFolders.map((f) => color.dim(` \u2713 ${f}/`)).join('\n'); - await prompts.log.message(color.cyan(`Created WDS folder structure:\n${lines}`)); - } - - // Now run configuration generation - await prompts.tasks([configTask]); - - // Resolution is now available via closure-scoped taskResolution - const resolution = taskResolution; - - // ───────────────────────────────────────────────────────────────────────── - // IDE SETUP: Keep as spinner since it may prompt for user input - // ───────────────────────────────────────────────────────────────────────── - if (!config.skipIde && config.ides && config.ides.length > 0) { - await this.ideManager.ensureInitialized(); - const validIdes = config.ides.filter((ide) => ide && typeof ide === 'string'); - - if (validIdes.length === 0) { - addResult('IDE configuration', 'warn', 'no valid IDEs selected'); - } else { - const needsPrompting = validIdes.some((ide) => !ideConfigurations[ide]); - const ideSpinner = await prompts.spinner(); - ideSpinner.start('Configuring tools...'); - - try { - for (const ide of validIdes) { - if (!needsPrompting || ideConfigurations[ide]) { - ideSpinner.message(`Configuring ${ide}...`); - } else { - if (ideSpinner.isSpinning) { - ideSpinner.stop('Ready for IDE configuration'); - } - } - - // Suppress stray console output for pre-configured IDEs (no user interaction) - const ideHasConfig = Boolean(ideConfigurations[ide]); - const originalLog = console.log; - if (!config.verbose && ideHasConfig) { - console.log = () => {}; - } - try { - const setupResult = await this.ideManager.setup(ide, projectDir, bmadDir, { - selectedModules: allModules || [], - preCollectedConfig: ideConfigurations[ide] || null, - verbose: config.verbose, - silent: ideHasConfig, - }); - - if (ideConfigurations[ide] && !ideConfigurations[ide]._alreadyConfigured) { - await this.ideConfigManager.saveIdeConfig(bmadDir, ide, ideConfigurations[ide]); - } - - if (setupResult.success) { - addResult(ide, 'ok', setupResult.detail || ''); - } else { - addResult(ide, 'error', setupResult.error || 'failed'); - } - } finally { - console.log = originalLog; - } - - if (needsPrompting && !ideSpinner.isSpinning) { - ideSpinner.start('Configuring tools...'); - } - } - } finally { - if (ideSpinner.isSpinning) { - ideSpinner.stop('Tool configuration complete'); - } - } - } - } - - // ───────────────────────────────────────────────────────────────────────── - // SECOND TASKS BLOCK: Post-IDE operations (non-interactive) - // ───────────────────────────────────────────────────────────────────────── - const postIdeTasks = []; - - // File restoration task (only for updates) - if ( - config._isUpdate && - ((config._customFiles && config._customFiles.length > 0) || (config._modifiedFiles && config._modifiedFiles.length > 0)) - ) { - postIdeTasks.push({ - title: 'Finalizing installation', - task: async (message) => { - let customFiles = []; - let modifiedFiles = []; - - if (config._customFiles && config._customFiles.length > 0) { - message(`Restoring ${config._customFiles.length} custom files...`); - - for (const originalPath of config._customFiles) { - const relativePath = path.relative(bmadDir, originalPath); - const backupPath = path.join(config._tempBackupDir, relativePath); - - if (await fs.pathExists(backupPath)) { - await fs.ensureDir(path.dirname(originalPath)); - await fs.copy(backupPath, originalPath, { overwrite: true }); - } - } - - if (config._tempBackupDir && (await fs.pathExists(config._tempBackupDir))) { - await fs.remove(config._tempBackupDir); - } - - customFiles = config._customFiles; - } - - if (config._modifiedFiles && config._modifiedFiles.length > 0) { - modifiedFiles = config._modifiedFiles; - - if (config._tempModifiedBackupDir && (await fs.pathExists(config._tempModifiedBackupDir))) { - message(`Restoring ${modifiedFiles.length} modified files as .bak...`); - - for (const modifiedFile of modifiedFiles) { - const relativePath = path.relative(bmadDir, modifiedFile.path); - const tempBackupPath = path.join(config._tempModifiedBackupDir, relativePath); - const bakPath = modifiedFile.path + '.bak'; - - if (await fs.pathExists(tempBackupPath)) { - await fs.ensureDir(path.dirname(bakPath)); - await fs.copy(tempBackupPath, bakPath, { overwrite: true }); - } - } - - await fs.remove(config._tempModifiedBackupDir); - } - } - - // Store for summary access - config._restoredCustomFiles = customFiles; - config._restoredModifiedFiles = modifiedFiles; - - return 'Installation finalized'; - }, - }); - } - - await prompts.tasks(postIdeTasks); - - // Retrieve restored file info for summary - const customFiles = config._restoredCustomFiles || []; - const modifiedFiles = config._restoredModifiedFiles || []; - - // Render consolidated summary - await this.renderInstallSummary(results, { - bmadDir, - modules: config.modules, - ides: config.ides, - customFiles: customFiles.length > 0 ? customFiles : undefined, - modifiedFiles: modifiedFiles.length > 0 ? modifiedFiles : undefined, - }); - - return { - success: true, - path: bmadDir, - modules: config.modules, - ides: config.ides, - projectDir: projectDir, - }; - } catch (error) { - try { - if (spinner.isSpinning) { - spinner.error('Installation failed'); - } else { - await prompts.log.error('Installation failed'); - } - } catch { - // Ensure the original error is never swallowed by a logging failure - } - - // Clean up any temp backup directories that were created before the failure - try { - if (config._tempBackupDir && (await fs.pathExists(config._tempBackupDir))) { - await fs.remove(config._tempBackupDir); - } - if (config._tempModifiedBackupDir && (await fs.pathExists(config._tempModifiedBackupDir))) { - await fs.remove(config._tempModifiedBackupDir); - } - } catch { - // Best-effort cleanup — don't mask the original error - } - - throw error; - } - } - - /** - * Render a consolidated install summary using prompts.note() - * @param {Array} results - Array of {step, status: 'ok'|'error'|'warn', detail} - * @param {Object} context - {bmadDir, modules, ides, customFiles, modifiedFiles} - */ - async renderInstallSummary(results, context = {}) { - const color = await prompts.getColor(); - const selectedIdes = new Set((context.ides || []).map((ide) => String(ide).toLowerCase())); - - // Build step lines with status indicators - const lines = []; - for (const r of results) { - let stepLabel = null; - - if (r.status !== 'ok') { - stepLabel = r.step; - } else if (r.step === 'Core') { - stepLabel = 'BMAD'; - } else if (r.step.startsWith('Module: ')) { - stepLabel = r.step; - } else if (selectedIdes.has(String(r.step).toLowerCase())) { - stepLabel = r.step; - } - - if (!stepLabel) { - continue; - } - - let icon; - if (r.status === 'ok') { - icon = color.green('\u2713'); - } else if (r.status === 'warn') { - icon = color.yellow('!'); - } else { - icon = color.red('\u2717'); - } - const detail = r.detail ? color.dim(` (${r.detail})`) : ''; - lines.push(` ${icon} ${stepLabel}${detail}`); - } - - if ((context.ides || []).length === 0) { - lines.push(` ${color.green('\u2713')} No IDE selected ${color.dim('(installed in _bmad only)')}`); - } - - // Context and warnings - lines.push(''); - if (context.bmadDir) { - lines.push(` Installed to: ${color.dim(context.bmadDir)}`); - } - if (context.customFiles && context.customFiles.length > 0) { - lines.push(` ${color.cyan(`Custom files preserved: ${context.customFiles.length}`)}`); - } - if (context.modifiedFiles && context.modifiedFiles.length > 0) { - lines.push(` ${color.yellow(`Modified files backed up (.bak): ${context.modifiedFiles.length}`)}`); - } - - // Next steps - lines.push( - '', - ' Next steps:', - ` Read our new Docs Site: ${color.dim('https://docs.bmad-method.org/')}`, - ` Join our Discord: ${color.dim('https://discord.gg/gk8jAdXWmj')}`, - ` Star us on GitHub: ${color.dim('https://github.com/bmad-code-org/BMAD-METHOD/')}`, - ` Subscribe on YouTube: ${color.dim('https://www.youtube.com/@BMadCode')}`, - ); - if (context.ides && context.ides.length > 0) { - lines.push(` Invoke the ${color.cyan('bmad-help')} skill in your IDE Agent to get started`); - } - - await prompts.note(lines.join('\n'), 'BMAD is ready to use!'); - } - - /** - * Update existing installation - */ - async update(config) { - const spinner = await prompts.spinner(); - spinner.start('Checking installation...'); - - try { - const projectDir = path.resolve(config.directory); - const { bmadDir } = await this.findBmadDir(projectDir); - const existingInstall = await this.detector.detect(bmadDir); - - if (!existingInstall.installed) { - spinner.stop('No BMAD installation found'); - throw new Error(`No BMAD installation found at ${bmadDir}`); - } - - spinner.message('Analyzing update requirements...'); - - // Compare versions and determine what needs updating - const currentVersion = existingInstall.version; - const newVersion = require(path.join(getProjectRoot(), 'package.json')).version; - - // Check for custom modules with missing sources before update - const customModuleSources = new Map(); - - // Check manifest for backward compatibility - if (existingInstall.customModules) { - for (const customModule of existingInstall.customModules) { - customModuleSources.set(customModule.id, customModule); - } - } - - // Also check cache directory - const cacheDir = path.join(bmadDir, '_config', 'custom'); - if (await fs.pathExists(cacheDir)) { - const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true }); - - for (const cachedModule of cachedModules) { - if (cachedModule.isDirectory()) { - const moduleId = cachedModule.name; - - // Skip if we already have this module - if (customModuleSources.has(moduleId)) { - continue; - } - - // Check if this is an external official module - skip cache for those - const isExternal = await this.moduleManager.isExternalModule(moduleId); - if (isExternal) { - // External modules are handled via cloneExternalModule, not from cache - continue; - } - - const cachedPath = path.join(cacheDir, moduleId); - - // Check if this is actually a custom module (has module.yaml) - const moduleYamlPath = path.join(cachedPath, 'module.yaml'); - if (await fs.pathExists(moduleYamlPath)) { - customModuleSources.set(moduleId, { - id: moduleId, - name: moduleId, - sourcePath: path.join('_config', 'custom', moduleId), // Relative path - cached: true, - }); - } - } - } - } - - if (customModuleSources.size > 0) { - spinner.stop('Update analysis complete'); - await prompts.log.warn('Checking custom module sources before update...'); - - const projectRoot = getProjectRoot(); - await this.handleMissingCustomSources( - customModuleSources, - bmadDir, - projectRoot, - 'update', - existingInstall.modules.map((m) => m.id), - config.skipPrompts || false, - ); - - spinner.start('Preparing update...'); - } - - if (config.dryRun) { - spinner.stop('Dry run analysis complete'); - let dryRunContent = `Current version: ${currentVersion}\n`; - dryRunContent += `New version: ${newVersion}\n`; - dryRunContent += `Core: ${existingInstall.hasCore ? 'Will be updated' : 'Not installed'}`; - - if (existingInstall.modules.length > 0) { - dryRunContent += '\n\nModules to update:'; - for (const mod of existingInstall.modules) { - dryRunContent += `\n - ${mod.id}`; - } - } - await prompts.note(dryRunContent, 'Update Preview (Dry Run)'); - return; - } - - // Perform actual update - if (existingInstall.hasCore) { - spinner.message('Updating core...'); - await this.updateCore(bmadDir, config.force); - } - - for (const module of existingInstall.modules) { - spinner.message(`Updating module: ${module.id}...`); - await this.moduleManager.update(module.id, bmadDir, config.force, { installer: this }); - } - - // Update manifest - spinner.message('Updating manifest...'); - await this.manifest.update(bmadDir, { - version: newVersion, - updateDate: new Date().toISOString(), - }); - - spinner.stop('Update complete'); - return { success: true }; - } catch (error) { - spinner.error('Update failed'); - throw error; - } - } - - /** - * Get installation status - */ - async getStatus(directory) { - const projectDir = path.resolve(directory); - const { bmadDir } = await this.findBmadDir(projectDir); - return await this.detector.detect(bmadDir); - } - - /** - * Get available modules - */ - async getAvailableModules() { - return await this.moduleManager.listAvailable(); - } - - /** - * Uninstall BMAD with selective removal options - * @param {string} directory - Project directory - * @param {Object} options - Uninstall options - * @param {boolean} [options.removeModules=true] - Remove _bmad/ directory - * @param {boolean} [options.removeIdeConfigs=true] - Remove IDE configurations - * @param {boolean} [options.removeOutputFolder=false] - Remove user artifacts output folder - * @returns {Object} Result with success status and removed components - */ - async uninstall(directory, options = {}) { - const projectDir = path.resolve(directory); - const { bmadDir } = await this.findBmadDir(projectDir); - - if (!(await fs.pathExists(bmadDir))) { - return { success: false, reason: 'not-installed' }; - } - - // 1. DETECT: Read state BEFORE deleting anything - const existingInstall = await this.detector.detect(bmadDir); - const outputFolder = await this._readOutputFolder(bmadDir); - - const removed = { modules: false, ideConfigs: false, outputFolder: false }; - - // 2. IDE CLEANUP (before _bmad/ deletion so configs are accessible) - if (options.removeIdeConfigs !== false) { - await this.uninstallIdeConfigs(projectDir, existingInstall, { silent: options.silent }); - removed.ideConfigs = true; - } - - // 3. OUTPUT FOLDER (only if explicitly requested) - if (options.removeOutputFolder === true && outputFolder) { - removed.outputFolder = await this.uninstallOutputFolder(projectDir, outputFolder); - } - - // 4. BMAD DIRECTORY (last, after everything that needs it) - if (options.removeModules !== false) { - removed.modules = await this.uninstallModules(projectDir); - } - - return { success: true, removed, version: existingInstall.version }; - } - - /** - * Uninstall IDE configurations only - * @param {string} projectDir - Project directory - * @param {Object} existingInstall - Detection result from detector.detect() - * @param {Object} [options] - Options (e.g. { silent: true }) - * @returns {Promise} Results from IDE cleanup - */ - async uninstallIdeConfigs(projectDir, existingInstall, options = {}) { - await this.ideManager.ensureInitialized(); - const cleanupOptions = { isUninstall: true, silent: options.silent }; - const ideList = existingInstall.ides || []; - if (ideList.length > 0) { - return this.ideManager.cleanupByList(projectDir, ideList, cleanupOptions); - } - return this.ideManager.cleanup(projectDir, cleanupOptions); - } - - /** - * Remove user artifacts output folder - * @param {string} projectDir - Project directory - * @param {string} outputFolder - Output folder name (relative) - * @returns {Promise} Whether the folder was removed - */ - async uninstallOutputFolder(projectDir, outputFolder) { - if (!outputFolder) return false; - const resolvedProject = path.resolve(projectDir); - const outputPath = path.resolve(resolvedProject, outputFolder); - if (!outputPath.startsWith(resolvedProject + path.sep)) { - return false; - } - if (await fs.pathExists(outputPath)) { - await fs.remove(outputPath); - return true; - } - return false; - } - - /** - * Remove the _bmad/ directory - * @param {string} projectDir - Project directory - * @returns {Promise} Whether the directory was removed - */ - async uninstallModules(projectDir) { - const { bmadDir } = await this.findBmadDir(projectDir); - if (await fs.pathExists(bmadDir)) { - await fs.remove(bmadDir); - return true; - } - return false; - } - - /** - * Get the configured output folder name for a project - * Resolves bmadDir internally from projectDir - * @param {string} projectDir - Project directory - * @returns {string} Output folder name (relative, default: '_bmad-output') - */ - async getOutputFolder(projectDir) { - const { bmadDir } = await this.findBmadDir(projectDir); - return this._readOutputFolder(bmadDir); - } - - /** - * Read the output_folder setting from module config files - * Checks bmm/config.yaml first, then other module configs - * @param {string} bmadDir - BMAD installation directory - * @returns {string} Output folder path or default - */ - async _readOutputFolder(bmadDir) { - const yaml = require('yaml'); - - // Check bmm/config.yaml first (most common) - const bmmConfigPath = path.join(bmadDir, 'bmm', 'config.yaml'); - if (await fs.pathExists(bmmConfigPath)) { - try { - const content = await fs.readFile(bmmConfigPath, 'utf8'); - const config = yaml.parse(content); - if (config && config.output_folder) { - // Strip {project-root}/ prefix if present - return config.output_folder.replace(/^\{project-root\}[/\\]/, ''); - } - } catch { - // Fall through to other modules - } - } - - // Scan other module config.yaml files - try { - const entries = await fs.readdir(bmadDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory() || entry.name === 'bmm' || entry.name.startsWith('_')) continue; - const configPath = path.join(bmadDir, entry.name, 'config.yaml'); - if (await fs.pathExists(configPath)) { - try { - const content = await fs.readFile(configPath, 'utf8'); - const config = yaml.parse(content); - if (config && config.output_folder) { - return config.output_folder.replace(/^\{project-root\}[/\\]/, ''); - } - } catch { - // Continue scanning - } - } - } - } catch { - // Directory scan failed - } - - // Default fallback - return '_bmad-output'; - } - - /** - * Private: Create directory structure - */ - /** - * Merge all module-help.csv files into a single bmad-help.csv - * Scans all installed modules for module-help.csv and merges them - * Enriches agent info from agent-manifest.csv - * Output is written to _bmad/_config/bmad-help.csv - * @param {string} bmadDir - BMAD installation directory - */ - async mergeModuleHelpCatalogs(bmadDir) { - const allRows = []; - const headerRow = - 'module,phase,name,code,sequence,workflow-file,command,required,agent-name,agent-command,agent-display-name,agent-title,options,description,output-location,outputs'; - - // Load agent manifest for agent info lookup - const agentManifestPath = path.join(bmadDir, '_config', 'agent-manifest.csv'); - const agentInfo = new Map(); // agent-name -> {command, displayName, title+icon} - - if (await fs.pathExists(agentManifestPath)) { - const manifestContent = await fs.readFile(agentManifestPath, 'utf8'); - const lines = manifestContent.split('\n').filter((line) => line.trim()); - - for (const line of lines) { - if (line.startsWith('name,')) continue; // Skip header - - const cols = line.split(','); - if (cols.length >= 4) { - const agentName = cols[0].replaceAll('"', '').trim(); - const displayName = cols[1].replaceAll('"', '').trim(); - const title = cols[2].replaceAll('"', '').trim(); - const icon = cols[3].replaceAll('"', '').trim(); - const module = cols[10] ? cols[10].replaceAll('"', '').trim() : ''; - - // Build agent command: bmad:module:agent:name - const agentCommand = module ? `bmad:${module}:agent:${agentName}` : `bmad:agent:${agentName}`; - - agentInfo.set(agentName, { - command: agentCommand, - displayName: displayName || agentName, - title: icon && title ? `${icon} ${title}` : title || agentName, - }); - } - } - } - - // Get all installed module directories - const entries = await fs.readdir(bmadDir, { withFileTypes: true }); - const installedModules = entries - .filter((entry) => entry.isDirectory() && entry.name !== '_config' && entry.name !== 'docs' && entry.name !== '_memory') - .map((entry) => entry.name); - - // Add core module to scan (it's installed at root level as _config, but we check src/core-skills) - const coreModulePath = getSourcePath('core-skills'); - const modulePaths = new Map(); - - // Map all module source paths - if (await fs.pathExists(coreModulePath)) { - modulePaths.set('core', coreModulePath); - } - - // Map installed module paths - for (const moduleName of installedModules) { - const modulePath = path.join(bmadDir, moduleName); - modulePaths.set(moduleName, modulePath); - } - - // Scan each module for module-help.csv - for (const [moduleName, modulePath] of modulePaths) { - const helpFilePath = path.join(modulePath, 'module-help.csv'); - - if (await fs.pathExists(helpFilePath)) { - try { - const content = await fs.readFile(helpFilePath, 'utf8'); - const lines = content.split('\n').filter((line) => line.trim() && !line.startsWith('#')); - - for (const line of lines) { - // Skip header row - if (line.startsWith('module,')) { - continue; - } - - // Parse the line - handle quoted fields with commas - const columns = this.parseCSVLine(line); - if (columns.length >= 12) { - // Map old schema to new schema - // Old: module,phase,name,code,sequence,workflow-file,command,required,agent,options,description,output-location,outputs - // New: module,phase,name,code,sequence,workflow-file,command,required,agent-name,agent-command,agent-display-name,agent-title,options,description,output-location,outputs - - const [ - module, - phase, - name, - code, - sequence, - workflowFile, - command, - required, - agentName, - options, - description, - outputLocation, - outputs, - ] = columns; - - // If module column is empty, set it to this module's name (except for core which stays empty for universal tools) - const finalModule = (!module || module.trim() === '') && moduleName !== 'core' ? moduleName : module || ''; - - // Lookup agent info - const cleanAgentName = agentName ? agentName.trim() : ''; - const agentData = agentInfo.get(cleanAgentName) || { command: '', displayName: '', title: '' }; - - // Build new row with agent info - const newRow = [ - finalModule, - phase || '', - name || '', - code || '', - sequence || '', - workflowFile || '', - command || '', - required || 'false', - cleanAgentName, - agentData.command, - agentData.displayName, - agentData.title, - options || '', - description || '', - outputLocation || '', - outputs || '', - ]; - - allRows.push(newRow.map((c) => this.escapeCSVField(c)).join(',')); - } - } - - if (process.env.BMAD_VERBOSE_INSTALL === 'true') { - await prompts.log.message(` Merged module-help from: ${moduleName}`); - } - } catch (error) { - await prompts.log.warn(` Warning: Failed to read module-help.csv from ${moduleName}: ${error.message}`); - } - } - } - - // Sort by module, then phase, then sequence - allRows.sort((a, b) => { - const colsA = this.parseCSVLine(a); - const colsB = this.parseCSVLine(b); - - // Module comparison (empty module/universal tools come first) - const moduleA = (colsA[0] || '').toLowerCase(); - const moduleB = (colsB[0] || '').toLowerCase(); - if (moduleA !== moduleB) { - return moduleA.localeCompare(moduleB); - } - - // Phase comparison - const phaseA = colsA[1] || ''; - const phaseB = colsB[1] || ''; - if (phaseA !== phaseB) { - return phaseA.localeCompare(phaseB); - } - - // Sequence comparison - const seqA = parseInt(colsA[4] || '0', 10); - const seqB = parseInt(colsB[4] || '0', 10); - return seqA - seqB; - }); - - // Write merged catalog - const outputDir = path.join(bmadDir, '_config'); - await fs.ensureDir(outputDir); - const outputPath = path.join(outputDir, 'bmad-help.csv'); - - const mergedContent = [headerRow, ...allRows].join('\n'); - await fs.writeFile(outputPath, mergedContent, 'utf8'); - - // Track the installed file - this.installedFiles.add(outputPath); - - if (process.env.BMAD_VERBOSE_INSTALL === 'true') { - await prompts.log.message(` Generated bmad-help.csv: ${allRows.length} workflows`); - } - } - - /** - * Parse a CSV line, handling quoted fields - * @param {string} line - CSV line to parse - * @returns {Array} Array of field values - */ - parseCSVLine(line) { - const result = []; - let current = ''; - let inQuotes = false; - - for (let i = 0; i < line.length; i++) { - const char = line[i]; - const nextChar = line[i + 1]; - - if (char === '"') { - if (inQuotes && nextChar === '"') { - // Escaped quote - current += '"'; - i++; // Skip next quote - } else { - // Toggle quote mode - inQuotes = !inQuotes; - } - } else if (char === ',' && !inQuotes) { - result.push(current); - current = ''; - } else { - current += char; - } - } - result.push(current); - return result; - } - - /** - * Escape a CSV field if it contains special characters - * @param {string} field - Field value to escape - * @returns {string} Escaped field - */ - escapeCSVField(field) { - if (field === null || field === undefined) { - return ''; - } - const str = String(field); - // If field contains comma, quote, or newline, wrap in quotes and escape inner quotes - if (str.includes(',') || str.includes('"') || str.includes('\n')) { - return `"${str.replaceAll('"', '""')}"`; - } - return str; - } - - async createDirectoryStructure(bmadDir) { - await fs.ensureDir(bmadDir); - await fs.ensureDir(path.join(bmadDir, '_config')); - await fs.ensureDir(path.join(bmadDir, '_config', 'agents')); - await fs.ensureDir(path.join(bmadDir, '_config', 'custom')); - } - - /** - * Generate clean config.yaml files for each installed module - * @param {string} bmadDir - BMAD installation directory - * @param {Object} moduleConfigs - Collected configuration values - */ - async generateModuleConfigs(bmadDir, moduleConfigs) { - const yaml = require('yaml'); - - // Extract core config values to share with other modules - const coreConfig = moduleConfigs.core || {}; - - // Get all installed module directories - const entries = await fs.readdir(bmadDir, { withFileTypes: true }); - const installedModules = entries - .filter((entry) => entry.isDirectory() && entry.name !== '_config' && entry.name !== 'docs') - .map((entry) => entry.name); - - // Generate config.yaml for each installed module - for (const moduleName of installedModules) { - const modulePath = path.join(bmadDir, moduleName); - - // Get module-specific config or use empty object if none - const config = moduleConfigs[moduleName] || {}; - - if (await fs.pathExists(modulePath)) { - const configPath = path.join(modulePath, 'config.yaml'); - - // Create header - const packageJson = require(path.join(getProjectRoot(), 'package.json')); - const header = `# ${moduleName.toUpperCase()} Module Configuration -# Generated by BMAD installer -# Version: ${packageJson.version} -# Date: ${new Date().toISOString()} - -`; - - // For non-core modules, add core config values directly - let finalConfig = { ...config }; - let coreSection = ''; - - if (moduleName !== 'core' && coreConfig && Object.keys(coreConfig).length > 0) { - // Add core values directly to the module config - // These will be available for reference in the module - finalConfig = { - ...config, - ...coreConfig, // Spread core config values directly into the module config - }; - - // Create a comment section to identify core values - coreSection = '\n# Core Configuration Values\n'; - } - - // Clean the config to remove any non-serializable values (like functions) - const cleanConfig = structuredClone(finalConfig); - - // Convert config to YAML - let yamlContent = yaml.stringify(cleanConfig, { - indent: 2, - lineWidth: 0, - minContentWidth: 0, - }); - - // If we have core values, reorganize the YAML to group them with their comment - if (coreSection && moduleName !== 'core') { - // Split the YAML into lines - const lines = yamlContent.split('\n'); - const moduleConfigLines = []; - const coreConfigLines = []; - - // Separate module-specific and core config lines - for (const line of lines) { - const key = line.split(':')[0].trim(); - if (Object.prototype.hasOwnProperty.call(coreConfig, key)) { - coreConfigLines.push(line); - } else { - moduleConfigLines.push(line); - } - } - - // Rebuild YAML with module config first, then core config with comment - yamlContent = moduleConfigLines.join('\n'); - if (coreConfigLines.length > 0) { - yamlContent += coreSection + coreConfigLines.join('\n'); - } - } - - // Write the clean config file with POSIX-compliant final newline - const content = header + yamlContent; - await fs.writeFile(configPath, content.endsWith('\n') ? content : content + '\n', 'utf8'); - - // Track the config file in installedFiles - this.installedFiles.add(configPath); - } - } - } - - /** - * Install core with resolved dependencies - * @param {string} bmadDir - BMAD installation directory - * @param {Object} coreFiles - Core files to install - */ - async installCoreWithDependencies(bmadDir, coreFiles) { - const sourcePath = getModulePath('core'); - const targetPath = path.join(bmadDir, 'core'); - await this.installCore(bmadDir); - } - - /** - * Install module with resolved dependencies - * @param {string} moduleName - Module name - * @param {string} bmadDir - BMAD installation directory - * @param {Object} moduleFiles - Module files to install - */ - async installModuleWithDependencies(moduleName, bmadDir, moduleFiles) { - // Get module configuration for conditional installation - const moduleConfig = this.configCollector.collectedConfig[moduleName] || {}; - - // Use existing module manager for full installation with file tracking - // Note: Module-specific installers are called separately after IDE setup - await this.moduleManager.install( - moduleName, - bmadDir, - (filePath) => { - this.installedFiles.add(filePath); - }, - { - skipModuleInstaller: true, // We'll run it later after IDE setup - moduleConfig: moduleConfig, // Pass module config for conditional filtering - installer: this, - silent: true, - }, - ); - - // Dependencies are already included in full module install - } - - /** - * Install partial module (only dependencies needed by other modules) - */ - async installPartialModule(moduleName, bmadDir, files) { - const sourceBase = getModulePath(moduleName); - const targetBase = path.join(bmadDir, moduleName); - - // Create module directory - await fs.ensureDir(targetBase); - - // Copy only the required dependency files - if (files.agents && files.agents.length > 0) { - const agentsDir = path.join(targetBase, 'agents'); - await fs.ensureDir(agentsDir); - - for (const agentPath of files.agents) { - const fileName = path.basename(agentPath); - const sourcePath = path.join(sourceBase, 'agents', fileName); - const targetPath = path.join(agentsDir, fileName); - - if (await fs.pathExists(sourcePath)) { - await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath); - this.installedFiles.add(targetPath); - } - } - } - - if (files.tasks && files.tasks.length > 0) { - const tasksDir = path.join(targetBase, 'tasks'); - await fs.ensureDir(tasksDir); - - for (const taskPath of files.tasks) { - const fileName = path.basename(taskPath); - const sourcePath = path.join(sourceBase, 'tasks', fileName); - const targetPath = path.join(tasksDir, fileName); - - if (await fs.pathExists(sourcePath)) { - await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath); - this.installedFiles.add(targetPath); - } - } - } - - if (files.tools && files.tools.length > 0) { - const toolsDir = path.join(targetBase, 'tools'); - await fs.ensureDir(toolsDir); - - for (const toolPath of files.tools) { - const fileName = path.basename(toolPath); - const sourcePath = path.join(sourceBase, 'tools', fileName); - const targetPath = path.join(toolsDir, fileName); - - if (await fs.pathExists(sourcePath)) { - await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath); - this.installedFiles.add(targetPath); - } - } - } - - if (files.templates && files.templates.length > 0) { - const templatesDir = path.join(targetBase, 'templates'); - await fs.ensureDir(templatesDir); - - for (const templatePath of files.templates) { - const fileName = path.basename(templatePath); - const sourcePath = path.join(sourceBase, 'templates', fileName); - const targetPath = path.join(templatesDir, fileName); - - if (await fs.pathExists(sourcePath)) { - await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath); - this.installedFiles.add(targetPath); - } - } - } - - if (files.data && files.data.length > 0) { - for (const dataPath of files.data) { - // Preserve directory structure for data files - const relative = path.relative(sourceBase, dataPath); - const targetPath = path.join(targetBase, relative); - - await fs.ensureDir(path.dirname(targetPath)); - - if (await fs.pathExists(dataPath)) { - await this.copyFileWithPlaceholderReplacement(dataPath, targetPath); - this.installedFiles.add(targetPath); - } - } - } - - // Create a marker file to indicate this is a partial installation - const markerPath = path.join(targetBase, '.partial'); - await fs.writeFile( - markerPath, - `This module contains only dependencies required by other modules.\nInstalled: ${new Date().toISOString()}\n`, - ); - } - - /** - * Private: Install core - * @param {string} bmadDir - BMAD installation directory - */ - async installCore(bmadDir) { - const sourcePath = getModulePath('core'); - const targetPath = path.join(bmadDir, 'core'); - - // Copy core files - await this.copyCoreFiles(sourcePath, targetPath); - } - - /** - * Copy core files (similar to copyModuleWithFiltering but for core) - * @param {string} sourcePath - Source path - * @param {string} targetPath - Target path - */ - async copyCoreFiles(sourcePath, targetPath) { - // Get all files in source - const files = await this.getFileList(sourcePath); - - for (const file of files) { - // Skip sub-modules directory - these are IDE-specific and handled separately - if (file.startsWith('sub-modules/')) { - continue; - } - - // Skip module.yaml at root - it's only needed at install time - if (file === 'module.yaml') { - continue; - } - - // Skip config.yaml templates - we'll generate clean ones with actual values - if (file === 'config.yaml' || file.endsWith('/config.yaml') || file === 'custom.yaml' || file.endsWith('/custom.yaml')) { - continue; - } - - const sourceFile = path.join(sourcePath, file); - const targetFile = path.join(targetPath, file); - - // Copy the file with placeholder replacement - await fs.ensureDir(path.dirname(targetFile)); - await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile); - - // Track the installed file - this.installedFiles.add(targetFile); - } - } - - /** - * Get list of all files in a directory recursively - * @param {string} dir - Directory path - * @param {string} baseDir - Base directory for relative paths - * @returns {Array} List of relative file paths - */ - async getFileList(dir, baseDir = dir) { - const files = []; - const entries = await fs.readdir(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - - if (entry.isDirectory()) { - const subFiles = await this.getFileList(fullPath, baseDir); - files.push(...subFiles); - } else { - files.push(path.relative(baseDir, fullPath)); - } - } - - return files; - } - - /** - * Private: Update core - */ - async updateCore(bmadDir, force = false) { - const sourcePath = getModulePath('core'); - const targetPath = path.join(bmadDir, 'core'); - - if (force) { - await fs.remove(targetPath); - await this.installCore(bmadDir); - } else { - // Selective update - preserve user modifications - await this.fileOps.syncDirectory(sourcePath, targetPath); - } - } - - /** - * Quick update method - preserves all settings and only prompts for new config fields - * @param {Object} config - Configuration with directory - * @returns {Object} Update result - */ - async quickUpdate(config) { - const spinner = await prompts.spinner(); - spinner.start('Starting quick update...'); - - try { - const projectDir = path.resolve(config.directory); - const { bmadDir } = await this.findBmadDir(projectDir); - - // Check if bmad directory exists - if (!(await fs.pathExists(bmadDir))) { - spinner.stop('No BMAD installation found'); - throw new Error(`BMAD not installed at ${bmadDir}. Use regular install for first-time setup.`); - } - - spinner.message('Detecting installed modules and configuration...'); - - // Detect existing installation - const existingInstall = await this.detector.detect(bmadDir); - const installedModules = existingInstall.modules.map((m) => m.id); - const configuredIdes = existingInstall.ides || []; - const projectRoot = path.dirname(bmadDir); - - // Get custom module sources: first from --custom-content (re-cache from source), then from cache - const customModuleSources = new Map(); - if (config.customContent?.sources?.length > 0) { - for (const source of config.customContent.sources) { - if (source.id && source.path && (await fs.pathExists(source.path))) { - customModuleSources.set(source.id, { - id: source.id, - name: source.name || source.id, - sourcePath: source.path, - cached: false, // From CLI, will be re-cached - }); - } - } - } - const cacheDir = path.join(bmadDir, '_config', 'custom'); - if (await fs.pathExists(cacheDir)) { - const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true }); - - for (const cachedModule of cachedModules) { - const moduleId = cachedModule.name; - const cachedPath = path.join(cacheDir, moduleId); - - // Skip if path doesn't exist (broken symlink, deleted dir) - avoids lstat ENOENT - if (!(await fs.pathExists(cachedPath))) { - continue; - } - if (!cachedModule.isDirectory()) { - continue; - } - - // Skip if we already have this module from manifest - if (customModuleSources.has(moduleId)) { - continue; - } - - // Check if this is an external official module - skip cache for those - const isExternal = await this.moduleManager.isExternalModule(moduleId); - if (isExternal) { - // External modules are handled via cloneExternalModule, not from cache - continue; - } - - // Check if this is actually a custom module (has module.yaml) - const moduleYamlPath = path.join(cachedPath, 'module.yaml'); - if (await fs.pathExists(moduleYamlPath)) { - // For quick update, we always rebuild from cache - customModuleSources.set(moduleId, { - id: moduleId, - name: moduleId, // We'll read the actual name if needed - sourcePath: cachedPath, - cached: true, // Flag to indicate this is from cache - }); - } - } - } - - // Load saved IDE configurations - const savedIdeConfigs = await this.ideConfigManager.loadAllIdeConfigs(bmadDir); - - // Get available modules (what we have source for) - const availableModulesData = await this.moduleManager.listAvailable(); - const availableModules = [...availableModulesData.modules, ...availableModulesData.customModules]; - - // Add external official modules to available modules - // These can always be obtained by cloning from their remote URLs - const { ExternalModuleManager } = require('../modules/external-manager'); - const externalManager = new ExternalModuleManager(); - const externalModules = await externalManager.listAvailable(); - for (const externalModule of externalModules) { - // Only add if not already in the list and is installed - if (installedModules.includes(externalModule.code) && !availableModules.some((m) => m.id === externalModule.code)) { - availableModules.push({ - id: externalModule.code, - name: externalModule.name, - isExternal: true, - fromExternal: true, - }); - } - } - - // Add custom modules from manifest if their sources exist - for (const [moduleId, customModule] of customModuleSources) { - // Use the absolute sourcePath - const sourcePath = customModule.sourcePath; - - // Check if source exists at the recorded path - if ( - sourcePath && - (await fs.pathExists(sourcePath)) && // Add to available modules if not already there - !availableModules.some((m) => m.id === moduleId) - ) { - availableModules.push({ - id: moduleId, - name: customModule.name || moduleId, - path: sourcePath, - isCustom: true, - fromManifest: true, - }); - } - } - - // Handle missing custom module sources using shared method - const customModuleResult = await this.handleMissingCustomSources( - customModuleSources, - bmadDir, - projectRoot, - 'update', - installedModules, - config.skipPrompts || false, - ); - - const { validCustomModules, keptModulesWithoutSources } = customModuleResult; - - const customModulesFromManifest = validCustomModules.map((m) => ({ - ...m, - isCustom: true, - hasUpdate: true, - })); - - const allAvailableModules = [...availableModules, ...customModulesFromManifest]; - const availableModuleIds = new Set(allAvailableModules.map((m) => m.id)); - - // Core module is special - never include it in update flow - const nonCoreInstalledModules = installedModules.filter((id) => id !== 'core'); - - // Only update modules that are BOTH installed AND available (we have source for) - const modulesToUpdate = nonCoreInstalledModules.filter((id) => availableModuleIds.has(id)); - const skippedModules = nonCoreInstalledModules.filter((id) => !availableModuleIds.has(id)); - - // Add custom modules that were kept without sources to the skipped modules - // This ensures their agents are preserved in the manifest - for (const keptModule of keptModulesWithoutSources) { - if (!skippedModules.includes(keptModule)) { - skippedModules.push(keptModule); - } - } - - spinner.stop(`Found ${modulesToUpdate.length} module(s) to update and ${configuredIdes.length} configured tool(s)`); - - if (skippedModules.length > 0) { - await prompts.log.warn(`Skipping ${skippedModules.length} module(s) - no source available: ${skippedModules.join(', ')}`); - } - - // Load existing configs and collect new fields (if any) - await prompts.log.info('Checking for new configuration options...'); - await this.configCollector.loadExistingConfig(projectDir); - - let promptedForNewFields = false; - - // Check core config for new fields - const corePrompted = await this.configCollector.collectModuleConfigQuick('core', projectDir, true); - if (corePrompted) { - promptedForNewFields = true; - } - - // Check each module we're updating for new fields (NOT skipped modules) - for (const moduleName of modulesToUpdate) { - const modulePrompted = await this.configCollector.collectModuleConfigQuick(moduleName, projectDir, true); - if (modulePrompted) { - promptedForNewFields = true; - } - } - - if (!promptedForNewFields) { - await prompts.log.success('All configuration is up to date, no new options to configure'); - } - - // Add metadata - this.configCollector.collectedConfig._meta = { - version: require(path.join(getProjectRoot(), 'package.json')).version, - installDate: new Date().toISOString(), - lastModified: new Date().toISOString(), - }; - - // Build the config object for the installer - const installConfig = { - directory: projectDir, - installCore: true, - modules: modulesToUpdate, // Only update modules we have source for - ides: configuredIdes, - skipIde: configuredIdes.length === 0, - coreConfig: this.configCollector.collectedConfig.core, - actionType: 'install', // Use regular install flow - _quickUpdate: true, // Flag to skip certain prompts - _preserveModules: skippedModules, // Preserve these in manifest even though we didn't update them - _savedIdeConfigs: savedIdeConfigs, // Pass saved IDE configs to installer - _customModuleSources: customModuleSources, // Pass custom module sources for updates - _existingModules: installedModules, // Pass all installed modules for manifest generation - customContent: config.customContent, // Pass through for re-caching from source - }; - - // Call the standard install method - const result = await this.install(installConfig); - - // Only succeed the spinner if it's still spinning - // (install method might have stopped it if folder name changed) - if (spinner.isSpinning) { - spinner.stop('Quick update complete!'); - } - - return { - success: true, - moduleCount: modulesToUpdate.length + 1, // +1 for core - hadNewFields: promptedForNewFields, - modules: ['core', ...modulesToUpdate], - skippedModules: skippedModules, - ides: configuredIdes, - }; - } catch (error) { - spinner.error('Quick update failed'); - throw error; - } - } - - /** - * Private: Prompt for update action - */ - async promptUpdateAction() { - const action = await prompts.select({ - message: 'What would you like to do?', - choices: [{ name: 'Update existing installation', value: 'update' }], - }); - return { action }; - } - - /** - * Handle legacy BMAD v4 detection with simple warning - * @param {string} _projectDir - Project directory (unused in simplified version) - * @param {Object} _legacyV4 - Legacy V4 detection result (unused in simplified version) - */ - async handleLegacyV4Migration(_projectDir, _legacyV4) { - await prompts.note( - 'Found .bmad-method folder from BMAD v4 installation.\n\n' + - 'Before continuing with installation, we recommend:\n' + - ' 1. Remove the .bmad-method folder, OR\n' + - ' 2. Back it up by renaming it to another name (e.g., bmad-method-backup)\n\n' + - 'If your v4 installation set up rules or commands, you should remove those as well.', - 'Legacy BMAD v4 detected', - ); - - const proceed = await prompts.select({ - message: 'What would you like to do?', - choices: [ - { - name: 'Exit and clean up manually (recommended)', - value: 'exit', - hint: 'Exit installation', - }, - { - name: 'Continue with installation anyway', - value: 'continue', - hint: 'Continue', - }, - ], - default: 'exit', - }); - - if (proceed === 'exit') { - await prompts.log.info('Please remove the .bmad-method folder and any v4 rules/commands, then run the installer again.'); - // Allow event loop to flush pending I/O before exit - setImmediate(() => process.exit(0)); - return; - } - - await prompts.log.warn('Proceeding with installation despite legacy v4 folder'); - } - - /** - * Read files-manifest.csv - * @param {string} bmadDir - BMAD installation directory - * @returns {Array} Array of file entries from files-manifest.csv - */ - async readFilesManifest(bmadDir) { - const filesManifestPath = path.join(bmadDir, '_config', 'files-manifest.csv'); - if (!(await fs.pathExists(filesManifestPath))) { - return []; - } - - try { - const content = await fs.readFile(filesManifestPath, 'utf8'); - const lines = content.split('\n'); - const files = []; - - for (let i = 1; i < lines.length; i++) { - // Skip header - const line = lines[i].trim(); - if (!line) continue; - - // Parse CSV line properly handling quoted values - const parts = []; - let current = ''; - let inQuotes = false; - - for (const char of line) { - if (char === '"') { - inQuotes = !inQuotes; - } else if (char === ',' && !inQuotes) { - parts.push(current); - current = ''; - } else { - current += char; - } - } - parts.push(current); // Add last part - - if (parts.length >= 4) { - files.push({ - type: parts[0], - name: parts[1], - module: parts[2], - path: parts[3], - hash: parts[4] || null, // Hash may not exist in old manifests - }); - } - } - - return files; - } catch (error) { - await prompts.log.warn('Could not read files-manifest.csv: ' + error.message); - return []; - } - } - - /** - * Detect custom and modified files - * @param {string} bmadDir - BMAD installation directory - * @param {Array} existingFilesManifest - Previous files from files-manifest.csv - * @returns {Object} Object with customFiles and modifiedFiles arrays - */ - async detectCustomFiles(bmadDir, existingFilesManifest) { - const customFiles = []; - const modifiedFiles = []; - - // Memory is always in _bmad/_memory - const bmadMemoryPath = '_memory'; - - // Check if the manifest has hashes - if not, we can't detect modifications - let manifestHasHashes = false; - if (existingFilesManifest && existingFilesManifest.length > 0) { - manifestHasHashes = existingFilesManifest.some((f) => f.hash); - } - - // Build map of previously installed files from files-manifest.csv with their hashes - const installedFilesMap = new Map(); - for (const fileEntry of existingFilesManifest) { - if (fileEntry.path) { - const absolutePath = path.join(bmadDir, fileEntry.path); - installedFilesMap.set(path.normalize(absolutePath), { - hash: fileEntry.hash, - relativePath: fileEntry.path, - }); - } - } - - // Recursively scan bmadDir for all files - const scanDirectory = async (dir) => { - try { - const entries = await fs.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - - if (entry.isDirectory()) { - // Skip certain directories - if (entry.name === 'node_modules' || entry.name === '.git') { - continue; - } - await scanDirectory(fullPath); - } else if (entry.isFile()) { - const normalizedPath = path.normalize(fullPath); - const fileInfo = installedFilesMap.get(normalizedPath); - - // Skip certain system files that are auto-generated - const relativePath = path.relative(bmadDir, fullPath); - const fileName = path.basename(fullPath); - - // Skip _config directory EXCEPT for modified agent customizations - if (relativePath.startsWith('_config/') || relativePath.startsWith('_config\\')) { - // Special handling for .customize.yaml files - only preserve if modified - if (relativePath.includes('/agents/') && fileName.endsWith('.customize.yaml')) { - // Check if the customization file has been modified from manifest - const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml'); - if (await fs.pathExists(manifestPath)) { - const crypto = require('node:crypto'); - const currentContent = await fs.readFile(fullPath, 'utf8'); - const currentHash = crypto.createHash('sha256').update(currentContent).digest('hex'); - - const yaml = require('yaml'); - const manifestContent = await fs.readFile(manifestPath, 'utf8'); - const manifestData = yaml.parse(manifestContent); - const originalHash = manifestData.agentCustomizations?.[relativePath]; - - // Only add to customFiles if hash differs (user modified) - if (originalHash && currentHash !== originalHash) { - customFiles.push(fullPath); - } - } - } - continue; - } - - if (relativePath.startsWith(bmadMemoryPath + '/') && path.dirname(relativePath).includes('-sidecar')) { - continue; - } - - // Skip config.yaml files - these are regenerated on each install/update - if (fileName === 'config.yaml') { - continue; - } - - if (!fileInfo) { - // File not in manifest = custom file - // EXCEPT: Agent .md files in module folders are generated files, not custom - // Only treat .md files under _config/agents/ as custom - if (!(fileName.endsWith('.md') && relativePath.includes('/agents/') && !relativePath.startsWith('_config/'))) { - customFiles.push(fullPath); - } - } else if (manifestHasHashes && fileInfo.hash) { - // File in manifest with hash - check if it was modified - const currentHash = await this.manifest.calculateFileHash(fullPath); - if (currentHash && currentHash !== fileInfo.hash) { - // Hash changed = file was modified - modifiedFiles.push({ - path: fullPath, - relativePath: fileInfo.relativePath, - }); - } - } - } - } - } catch { - // Ignore errors scanning directories - } - }; - - await scanDirectory(bmadDir); - return { customFiles, modifiedFiles }; - } - - /** - * Handle missing custom module sources interactively - * @param {Map} customModuleSources - Map of custom module ID to info - * @param {string} bmadDir - BMAD directory - * @param {string} projectRoot - Project root directory - * @param {string} operation - Current operation ('update', 'compile', etc.) - * @param {Array} installedModules - Array of installed module IDs (will be modified) - * @param {boolean} [skipPrompts=false] - Skip interactive prompts and keep all modules with missing sources - * @returns {Object} Object with validCustomModules array and keptModulesWithoutSources array - */ - async handleMissingCustomSources(customModuleSources, bmadDir, projectRoot, operation, installedModules, skipPrompts = false) { - const validCustomModules = []; - const keptModulesWithoutSources = []; // Track modules kept without sources - const customModulesWithMissingSources = []; - - // Check which sources exist - for (const [moduleId, customInfo] of customModuleSources) { - if (await fs.pathExists(customInfo.sourcePath)) { - validCustomModules.push({ - id: moduleId, - name: customInfo.name, - path: customInfo.sourcePath, - info: customInfo, - }); - } else { - // For cached modules that are missing, we just skip them without prompting - if (customInfo.cached) { - // Skip cached modules without prompting - keptModulesWithoutSources.push({ - id: moduleId, - name: customInfo.name, - cached: true, - }); - } else { - customModulesWithMissingSources.push({ - id: moduleId, - name: customInfo.name, - sourcePath: customInfo.sourcePath, - relativePath: customInfo.relativePath, - info: customInfo, - }); - } - } - } - - // If no missing sources, return immediately - if (customModulesWithMissingSources.length === 0) { - return { - validCustomModules, - keptModulesWithoutSources: [], - }; - } - - // Non-interactive mode: keep all modules with missing sources - if (skipPrompts) { - for (const missing of customModulesWithMissingSources) { - keptModulesWithoutSources.push(missing.id); - } - return { validCustomModules, keptModulesWithoutSources }; - } - - await prompts.log.warn(`Found ${customModulesWithMissingSources.length} custom module(s) with missing sources:`); - - let keptCount = 0; - let updatedCount = 0; - let removedCount = 0; - - for (const missing of customModulesWithMissingSources) { - await prompts.log.message( - `${missing.name} (${missing.id})\n Original source: ${missing.relativePath}\n Full path: ${missing.sourcePath}`, - ); - - const choices = [ - { - name: 'Keep installed (will not be processed)', - value: 'keep', - hint: 'Keep', - }, - { - name: 'Specify new source location', - value: 'update', - hint: 'Update', - }, - ]; - - // Only add remove option if not just compiling agents - if (operation !== 'compile-agents') { - choices.push({ - name: '⚠️ REMOVE module completely (destructive!)', - value: 'remove', - hint: 'Remove', - }); - } - - const action = await prompts.select({ - message: `How would you like to handle "${missing.name}"?`, - choices, - }); - - switch (action) { - case 'update': { - // Use sync validation because @clack/prompts doesn't support async validate - const newSourcePath = await prompts.text({ - message: 'Enter the new path to the custom module:', - default: missing.sourcePath, - validate: (input) => { - if (!input || input.trim() === '') { - return 'Please enter a path'; - } - const expandedPath = path.resolve(input.trim()); - if (!fs.pathExistsSync(expandedPath)) { - return 'Path does not exist'; - } - // Check if it looks like a valid module - const moduleYamlPath = path.join(expandedPath, 'module.yaml'); - const agentsPath = path.join(expandedPath, 'agents'); - const workflowsPath = path.join(expandedPath, 'workflows'); - - if (!fs.pathExistsSync(moduleYamlPath) && !fs.pathExistsSync(agentsPath) && !fs.pathExistsSync(workflowsPath)) { - return 'Path does not appear to contain a valid custom module'; - } - return; // clack expects undefined for valid input - }, - }); - - // Defensive: handleCancel should have exited, but guard against symbol propagation - if (typeof newSourcePath !== 'string') { - keptCount++; - keptModulesWithoutSources.push(missing.id); - continue; - } - - // Update the source in manifest - const resolvedPath = path.resolve(newSourcePath.trim()); - missing.info.sourcePath = resolvedPath; - // Remove relativePath - we only store absolute sourcePath now - delete missing.info.relativePath; - await this.manifest.addCustomModule(bmadDir, missing.info); - - validCustomModules.push({ - id: missing.id, - name: missing.name, - path: resolvedPath, - info: missing.info, - }); - - updatedCount++; - await prompts.log.success('Updated source location'); - - break; - } - case 'remove': { - // Extra confirmation for destructive remove - await prompts.log.error( - `WARNING: This will PERMANENTLY DELETE "${missing.name}" and all its files!\n Module location: ${path.join(bmadDir, missing.id)}`, - ); - - const confirmDelete = await prompts.confirm({ - message: 'Are you absolutely sure you want to delete this module?', - default: false, - }); - - if (confirmDelete) { - const typedConfirm = await prompts.text({ - message: 'Type "DELETE" to confirm permanent deletion:', - validate: (input) => { - if (input !== 'DELETE') { - return 'You must type "DELETE" exactly to proceed'; - } - return; // clack expects undefined for valid input - }, - }); - - if (typedConfirm === 'DELETE') { - // Remove the module from filesystem and manifest - const modulePath = path.join(bmadDir, missing.id); - if (await fs.pathExists(modulePath)) { - const fsExtra = require('fs-extra'); - await fsExtra.remove(modulePath); - await prompts.log.warn(`Deleted module directory: ${path.relative(projectRoot, modulePath)}`); - } - - await this.manifest.removeModule(bmadDir, missing.id); - await this.manifest.removeCustomModule(bmadDir, missing.id); - await prompts.log.warn('Removed from manifest'); - - // Also remove from installedModules list - if (installedModules && installedModules.includes(missing.id)) { - const index = installedModules.indexOf(missing.id); - if (index !== -1) { - installedModules.splice(index, 1); - } - } - - removedCount++; - await prompts.log.error(`"${missing.name}" has been permanently removed`); - } else { - await prompts.log.message('Removal cancelled - module will be kept'); - keptCount++; - } - } else { - await prompts.log.message('Removal cancelled - module will be kept'); - keptCount++; - } - - break; - } - case 'keep': { - keptCount++; - keptModulesWithoutSources.push(missing.id); - await prompts.log.message('Module will be kept as-is'); - - break; - } - // No default - } - } - - // Show summary - if (keptCount > 0 || updatedCount > 0 || removedCount > 0) { - let summary = 'Summary for custom modules with missing sources:'; - if (keptCount > 0) summary += `\n • ${keptCount} module(s) kept as-is`; - if (updatedCount > 0) summary += `\n • ${updatedCount} module(s) updated with new sources`; - if (removedCount > 0) summary += `\n • ${removedCount} module(s) permanently deleted`; - await prompts.log.message(summary); - } - - return { - validCustomModules, - keptModulesWithoutSources, - }; - } -} - -module.exports = { Installer }; diff --git a/tools/cli/installers/lib/ide/_base-ide.js b/tools/cli/installers/lib/ide/_base-ide.js deleted file mode 100644 index 8c970d130..000000000 --- a/tools/cli/installers/lib/ide/_base-ide.js +++ /dev/null @@ -1,657 +0,0 @@ -const path = require('node:path'); -const fs = require('fs-extra'); -const prompts = require('../../../lib/prompts'); -const { getSourcePath } = require('../../../lib/project-root'); -const { BMAD_FOLDER_NAME } = require('./shared/path-utils'); - -/** - * Base class for IDE-specific setup - * All IDE handlers should extend this class - */ -class BaseIdeSetup { - constructor(name, displayName = null, preferred = false) { - this.name = name; - this.displayName = displayName || name; // Human-readable name for UI - this.preferred = preferred; // Whether this IDE should be shown in preferred list - this.configDir = null; // Override in subclasses - this.rulesDir = null; // Override in subclasses - this.configFile = null; // Override in subclasses when detection is file-based - this.detectionPaths = []; // Additional paths that indicate the IDE is configured - this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden - } - - /** - * Set the bmad folder name for placeholder replacement - * @param {string} bmadFolderName - The bmad folder name - */ - setBmadFolderName(bmadFolderName) { - this.bmadFolderName = bmadFolderName; - } - - /** - * Main setup method - must be implemented by subclasses - * @param {string} projectDir - Project directory - * @param {string} bmadDir - BMAD installation directory - * @param {Object} options - Setup options - */ - async setup(projectDir, bmadDir, options = {}) { - throw new Error(`setup() must be implemented by ${this.name} handler`); - } - - /** - * Cleanup IDE configuration - * @param {string} projectDir - Project directory - */ - async cleanup(projectDir, options = {}) { - // Default implementation - can be overridden - if (this.configDir) { - const configPath = path.join(projectDir, this.configDir); - if (await fs.pathExists(configPath)) { - const bmadRulesPath = path.join(configPath, BMAD_FOLDER_NAME); - if (await fs.pathExists(bmadRulesPath)) { - await fs.remove(bmadRulesPath); - if (!options.silent) await prompts.log.message(`Removed ${this.name} BMAD configuration`); - } - } - } - } - - /** - * Install a custom agent launcher - subclasses should override - * @param {string} projectDir - Project directory - * @param {string} agentName - Agent name (e.g., "fred-commit-poet") - * @param {string} agentPath - Path to compiled agent (relative to project root) - * @param {Object} metadata - Agent metadata - * @returns {Object|null} Info about created command, or null if not supported - */ - async installCustomAgentLauncher(projectDir, agentName, agentPath, metadata) { - // Default implementation - subclasses can override - return null; - } - - /** - * Detect whether this IDE already has configuration in the project - * Subclasses can override for custom logic - * @param {string} projectDir - Project directory - * @returns {boolean} - */ - async detect(projectDir) { - const pathsToCheck = []; - - if (this.configDir) { - pathsToCheck.push(path.join(projectDir, this.configDir)); - } - - if (this.configFile) { - pathsToCheck.push(path.join(projectDir, this.configFile)); - } - - if (Array.isArray(this.detectionPaths)) { - for (const candidate of this.detectionPaths) { - if (!candidate) continue; - const resolved = path.isAbsolute(candidate) ? candidate : path.join(projectDir, candidate); - pathsToCheck.push(resolved); - } - } - - for (const candidate of pathsToCheck) { - if (await fs.pathExists(candidate)) { - return true; - } - } - - return false; - } - - /** - * Get list of agents from BMAD installation - * @param {string} bmadDir - BMAD installation directory - * @returns {Array} List of agent files - */ - async getAgents(bmadDir) { - const agents = []; - - // Get core agents - const coreAgentsPath = path.join(bmadDir, 'core', 'agents'); - if (await fs.pathExists(coreAgentsPath)) { - const coreAgents = await this.scanDirectory(coreAgentsPath, '.md'); - agents.push( - ...coreAgents.map((a) => ({ - ...a, - module: 'core', - })), - ); - } - - // Get module agents - const entries = await fs.readdir(bmadDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_config' && entry.name !== 'agents') { - const moduleAgentsPath = path.join(bmadDir, entry.name, 'agents'); - if (await fs.pathExists(moduleAgentsPath)) { - const moduleAgents = await this.scanDirectory(moduleAgentsPath, '.md'); - agents.push( - ...moduleAgents.map((a) => ({ - ...a, - module: entry.name, - })), - ); - } - } - } - - // Get standalone agents from bmad/agents/ directory - const standaloneAgentsDir = path.join(bmadDir, 'agents'); - if (await fs.pathExists(standaloneAgentsDir)) { - const agentDirs = await fs.readdir(standaloneAgentsDir, { withFileTypes: true }); - - for (const agentDir of agentDirs) { - if (!agentDir.isDirectory()) continue; - - const agentDirPath = path.join(standaloneAgentsDir, agentDir.name); - const agentFiles = await fs.readdir(agentDirPath); - - for (const file of agentFiles) { - if (!file.endsWith('.md')) continue; - if (file.includes('.customize.')) continue; - - const filePath = path.join(agentDirPath, file); - const content = await fs.readFile(filePath, 'utf8'); - - if (content.includes('localskip="true"')) continue; - - agents.push({ - name: file.replace('.md', ''), - path: filePath, - relativePath: path.relative(standaloneAgentsDir, filePath), - filename: file, - module: 'standalone', // Mark as standalone agent - }); - } - } - } - - return agents; - } - - /** - * Get list of tasks from BMAD installation - * @param {string} bmadDir - BMAD installation directory - * @param {boolean} standaloneOnly - If true, only return standalone tasks - * @returns {Array} List of task files - */ - async getTasks(bmadDir, standaloneOnly = false) { - const tasks = []; - - // Get core tasks (scan for both .md and .xml) - const coreTasksPath = path.join(bmadDir, 'core', 'tasks'); - if (await fs.pathExists(coreTasksPath)) { - const coreTasks = await this.scanDirectoryWithStandalone(coreTasksPath, ['.md', '.xml']); - tasks.push( - ...coreTasks.map((t) => ({ - ...t, - module: 'core', - })), - ); - } - - // Get module tasks - const entries = await fs.readdir(bmadDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_config' && entry.name !== 'agents') { - const moduleTasksPath = path.join(bmadDir, entry.name, 'tasks'); - if (await fs.pathExists(moduleTasksPath)) { - const moduleTasks = await this.scanDirectoryWithStandalone(moduleTasksPath, ['.md', '.xml']); - tasks.push( - ...moduleTasks.map((t) => ({ - ...t, - module: entry.name, - })), - ); - } - } - } - - // Filter by standalone if requested - if (standaloneOnly) { - return tasks.filter((t) => t.standalone === true); - } - - return tasks; - } - - /** - * Get list of tools from BMAD installation - * @param {string} bmadDir - BMAD installation directory - * @param {boolean} standaloneOnly - If true, only return standalone tools - * @returns {Array} List of tool files - */ - async getTools(bmadDir, standaloneOnly = false) { - const tools = []; - - // Get core tools (scan for both .md and .xml) - const coreToolsPath = path.join(bmadDir, 'core', 'tools'); - if (await fs.pathExists(coreToolsPath)) { - const coreTools = await this.scanDirectoryWithStandalone(coreToolsPath, ['.md', '.xml']); - tools.push( - ...coreTools.map((t) => ({ - ...t, - module: 'core', - })), - ); - } - - // Get module tools - const entries = await fs.readdir(bmadDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_config' && entry.name !== 'agents') { - const moduleToolsPath = path.join(bmadDir, entry.name, 'tools'); - if (await fs.pathExists(moduleToolsPath)) { - const moduleTools = await this.scanDirectoryWithStandalone(moduleToolsPath, ['.md', '.xml']); - tools.push( - ...moduleTools.map((t) => ({ - ...t, - module: entry.name, - })), - ); - } - } - } - - // Filter by standalone if requested - if (standaloneOnly) { - return tools.filter((t) => t.standalone === true); - } - - return tools; - } - - /** - * Get list of workflows from BMAD installation - * @param {string} bmadDir - BMAD installation directory - * @param {boolean} standaloneOnly - If true, only return standalone workflows - * @returns {Array} List of workflow files - */ - async getWorkflows(bmadDir, standaloneOnly = false) { - const workflows = []; - - // Get core workflows - const coreWorkflowsPath = path.join(bmadDir, 'core', 'workflows'); - if (await fs.pathExists(coreWorkflowsPath)) { - const coreWorkflows = await this.findWorkflowFiles(coreWorkflowsPath); - workflows.push( - ...coreWorkflows.map((w) => ({ - ...w, - module: 'core', - })), - ); - } - - // Get module workflows - const entries = await fs.readdir(bmadDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_config' && entry.name !== 'agents') { - const moduleWorkflowsPath = path.join(bmadDir, entry.name, 'workflows'); - if (await fs.pathExists(moduleWorkflowsPath)) { - const moduleWorkflows = await this.findWorkflowFiles(moduleWorkflowsPath); - workflows.push( - ...moduleWorkflows.map((w) => ({ - ...w, - module: entry.name, - })), - ); - } - } - } - - // Filter by standalone if requested - if (standaloneOnly) { - return workflows.filter((w) => w.standalone === true); - } - - return workflows; - } - - /** - * Recursively find workflow.md files - * @param {string} dir - Directory to search - * @param {string} [rootDir] - Original root directory (used internally for recursion) - * @returns {Array} List of workflow file info objects - */ - async findWorkflowFiles(dir, rootDir = null) { - rootDir = rootDir || dir; - const workflows = []; - - if (!(await fs.pathExists(dir))) { - return workflows; - } - - const entries = await fs.readdir(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - - if (entry.isDirectory()) { - // Recursively search subdirectories - const subWorkflows = await this.findWorkflowFiles(fullPath, rootDir); - workflows.push(...subWorkflows); - } else if (entry.isFile() && entry.name === 'workflow.md') { - // Read workflow.md frontmatter to get name and standalone property - try { - const content = await fs.readFile(fullPath, 'utf8'); - const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); - if (!frontmatterMatch) continue; - - const workflowData = yaml.parse(frontmatterMatch[1]); - - if (workflowData && workflowData.name) { - // Workflows are standalone by default unless explicitly false - const standalone = workflowData.standalone !== false && workflowData.standalone !== 'false'; - workflows.push({ - name: workflowData.name, - path: fullPath, - relativePath: path.relative(rootDir, fullPath), - filename: entry.name, - description: workflowData.description || '', - standalone: standalone, - }); - } - } catch { - // Skip invalid workflow files - } - } - } - - return workflows; - } - - /** - * Scan a directory for files with specific extension(s) - * @param {string} dir - Directory to scan - * @param {string|Array} ext - File extension(s) to match (e.g., '.md' or ['.md', '.xml']) - * @param {string} [rootDir] - Original root directory (used internally for recursion) - * @returns {Array} List of file info objects - */ - async scanDirectory(dir, ext, rootDir = null) { - rootDir = rootDir || dir; - const files = []; - - if (!(await fs.pathExists(dir))) { - return files; - } - - // Normalize ext to array - const extensions = Array.isArray(ext) ? ext : [ext]; - - const entries = await fs.readdir(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - - if (entry.isDirectory()) { - // Recursively scan subdirectories - const subFiles = await this.scanDirectory(fullPath, ext, rootDir); - files.push(...subFiles); - } else if (entry.isFile()) { - // Check if file matches any of the extensions - const matchedExt = extensions.find((e) => entry.name.endsWith(e)); - if (matchedExt) { - files.push({ - name: path.basename(entry.name, matchedExt), - path: fullPath, - relativePath: path.relative(rootDir, fullPath), - filename: entry.name, - }); - } - } - } - - return files; - } - - /** - * Scan a directory for files with specific extension(s) and check standalone attribute - * @param {string} dir - Directory to scan - * @param {string|Array} ext - File extension(s) to match (e.g., '.md' or ['.md', '.xml']) - * @param {string} [rootDir] - Original root directory (used internally for recursion) - * @returns {Array} List of file info objects with standalone property - */ - async scanDirectoryWithStandalone(dir, ext, rootDir = null) { - rootDir = rootDir || dir; - const files = []; - - if (!(await fs.pathExists(dir))) { - return files; - } - - // Normalize ext to array - const extensions = Array.isArray(ext) ? ext : [ext]; - - const entries = await fs.readdir(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - - if (entry.isDirectory()) { - // Recursively scan subdirectories - const subFiles = await this.scanDirectoryWithStandalone(fullPath, ext, rootDir); - files.push(...subFiles); - } else if (entry.isFile()) { - // Check if file matches any of the extensions - const matchedExt = extensions.find((e) => entry.name.endsWith(e)); - if (matchedExt) { - // Read file content to check for standalone attribute - // All non-internal files are considered standalone by default - let standalone = true; - try { - const content = await fs.readFile(fullPath, 'utf8'); - - // Skip internal/engine files (not user-facing) - if (content.includes('internal="true"')) { - continue; - } - - // Check for explicit standalone: false - if (entry.name.endsWith('.xml')) { - // For XML files, check for standalone="false" attribute - const tagMatch = content.match(/<(task|tool)[^>]*standalone="false"/); - standalone = !tagMatch; - } else if (entry.name.endsWith('.md')) { - // For MD files, parse YAML frontmatter - const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); - if (frontmatterMatch) { - try { - const yaml = require('yaml'); - const frontmatter = yaml.parse(frontmatterMatch[1]); - standalone = frontmatter.standalone !== false && frontmatter.standalone !== 'false'; - } catch { - // If YAML parsing fails, default to standalone - } - } - // No frontmatter means standalone (default) - } - } catch { - // If we can't read the file, default to standalone - standalone = true; - } - - files.push({ - name: path.basename(entry.name, matchedExt), - path: fullPath, - relativePath: path.relative(rootDir, fullPath), - filename: entry.name, - standalone: standalone, - }); - } - } - } - - return files; - } - - /** - * Create IDE command/rule file from agent or task - * @param {string} content - File content - * @param {Object} metadata - File metadata - * @param {string} projectDir - The actual project directory path - * @returns {string} Processed content - */ - processContent(content, metadata = {}, projectDir = null) { - // Replace placeholders - let processed = content; - - // Only replace {project-root} if a specific projectDir is provided - // Otherwise leave the placeholder intact - // Note: Don't add trailing slash - paths in source include leading slash - if (projectDir) { - processed = processed.replaceAll('{project-root}', projectDir); - } - processed = processed.replaceAll('{module}', metadata.module || 'core'); - processed = processed.replaceAll('{agent}', metadata.name || ''); - processed = processed.replaceAll('{task}', metadata.name || ''); - - return processed; - } - - /** - * Ensure directory exists - * @param {string} dirPath - Directory path - */ - async ensureDir(dirPath) { - await fs.ensureDir(dirPath); - } - - /** - * Write file with content (replaces _bmad placeholder) - * @param {string} filePath - File path - * @param {string} content - File content - */ - async writeFile(filePath, content) { - // Replace _bmad placeholder if present - if (typeof content === 'string' && content.includes('_bmad')) { - content = content.replaceAll('_bmad', this.bmadFolderName); - } - - // Replace escape sequence _bmad with literal _bmad - if (typeof content === 'string' && content.includes('_bmad')) { - content = content.replaceAll('_bmad', '_bmad'); - } - await this.ensureDir(path.dirname(filePath)); - await fs.writeFile(filePath, content, 'utf8'); - } - - /** - * Copy file from source to destination (replaces _bmad placeholder in text files) - * @param {string} source - Source file path - * @param {string} dest - Destination file path - */ - async copyFile(source, dest) { - // List of text file extensions that should have placeholder replacement - const textExtensions = ['.md', '.yaml', '.yml', '.txt', '.json', '.js', '.ts', '.html', '.css', '.sh', '.bat', '.csv']; - const ext = path.extname(source).toLowerCase(); - - await this.ensureDir(path.dirname(dest)); - - // Check if this is a text file that might contain placeholders - if (textExtensions.includes(ext)) { - try { - // Read the file content - let content = await fs.readFile(source, 'utf8'); - - // Replace _bmad placeholder with actual folder name - if (content.includes('_bmad')) { - content = content.replaceAll('_bmad', this.bmadFolderName); - } - - // Replace escape sequence _bmad with literal _bmad - if (content.includes('_bmad')) { - content = content.replaceAll('_bmad', '_bmad'); - } - - // Write to dest with replaced content - await fs.writeFile(dest, content, 'utf8'); - } catch { - // If reading as text fails, fall back to regular copy - await fs.copy(source, dest, { overwrite: true }); - } - } else { - // Binary file or other file type - just copy directly - await fs.copy(source, dest, { overwrite: true }); - } - } - - /** - * Check if path exists - * @param {string} pathToCheck - Path to check - * @returns {boolean} True if path exists - */ - async exists(pathToCheck) { - return await fs.pathExists(pathToCheck); - } - - /** - * Alias for exists method - * @param {string} pathToCheck - Path to check - * @returns {boolean} True if path exists - */ - async pathExists(pathToCheck) { - return await fs.pathExists(pathToCheck); - } - - /** - * Read file content - * @param {string} filePath - File path - * @returns {string} File content - */ - async readFile(filePath) { - return await fs.readFile(filePath, 'utf8'); - } - - /** - * Format name as title - * @param {string} name - Name to format - * @returns {string} Formatted title - */ - formatTitle(name) { - return name - .split('-') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); - } - - /** - * Flatten a relative path to a single filename for flat slash command naming - * @deprecated Use toColonPath() or toDashPath() from shared/path-utils.js instead - * Example: 'module/agents/name.md' -> 'bmad-module-agents-name.md' - * Used by IDEs that ignore directory structure for slash commands (e.g., Antigravity, Codex) - * @param {string} relativePath - Relative path to flatten - * @returns {string} Flattened filename with 'bmad-' prefix - */ - flattenFilename(relativePath) { - const sanitized = relativePath.replaceAll(/[/\\]/g, '-'); - return `bmad-${sanitized}`; - } - - /** - * Create agent configuration file - * @param {string} bmadDir - BMAD installation directory - * @param {Object} agent - Agent information - */ - async createAgentConfig(bmadDir, agent) { - const agentConfigDir = path.join(bmadDir, '_config', 'agents'); - await this.ensureDir(agentConfigDir); - - // Load agent config template - const templatePath = getSourcePath('utility', 'models', 'agent-config-template.md'); - const templateContent = await this.readFile(templatePath); - - const configContent = `# Agent Config: ${agent.name} - -${templateContent}`; - - const configPath = path.join(agentConfigDir, `${agent.module}-${agent.name}.md`); - await this.writeFile(configPath, configContent); - } -} - -module.exports = { BaseIdeSetup }; diff --git a/tools/cli/installers/lib/ide/platform-codes.js b/tools/cli/installers/lib/ide/platform-codes.js deleted file mode 100644 index d5d8e0a47..000000000 --- a/tools/cli/installers/lib/ide/platform-codes.js +++ /dev/null @@ -1,100 +0,0 @@ -const fs = require('fs-extra'); -const path = require('node:path'); -const yaml = require('yaml'); - -const PLATFORM_CODES_PATH = path.join(__dirname, 'platform-codes.yaml'); - -let _cachedPlatformCodes = null; - -/** - * Load the platform codes configuration from YAML - * @returns {Object} Platform codes configuration - */ -async function loadPlatformCodes() { - if (_cachedPlatformCodes) { - return _cachedPlatformCodes; - } - - if (!(await fs.pathExists(PLATFORM_CODES_PATH))) { - throw new Error(`Platform codes configuration not found at: ${PLATFORM_CODES_PATH}`); - } - - const content = await fs.readFile(PLATFORM_CODES_PATH, 'utf8'); - _cachedPlatformCodes = yaml.parse(content); - return _cachedPlatformCodes; -} - -/** - * Get platform information by code - * @param {string} platformCode - Platform code (e.g., 'claude-code', 'cursor') - * @returns {Object|null} Platform info or null if not found - */ -function getPlatformInfo(platformCode) { - if (!_cachedPlatformCodes) { - throw new Error('Platform codes not loaded. Call loadPlatformCodes() first.'); - } - - return _cachedPlatformCodes.platforms[platformCode] || null; -} - -/** - * Get all preferred platforms - * @returns {Promise} Array of preferred platform codes - */ -async function getPreferredPlatforms() { - const config = await loadPlatformCodes(); - return Object.entries(config.platforms) - .filter(([_, info]) => info.preferred) - .map(([code, _]) => code); -} - -/** - * Get all platform codes by category - * @param {string} category - Category to filter by (ide, cli, tool, etc.) - * @returns {Promise} Array of platform codes in the category - */ -async function getPlatformsByCategory(category) { - const config = await loadPlatformCodes(); - return Object.entries(config.platforms) - .filter(([_, info]) => info.category === category) - .map(([code, _]) => code); -} - -/** - * Get all platforms with installer config - * @returns {Promise} Array of platform codes that have installer config - */ -async function getConfigDrivenPlatforms() { - const config = await loadPlatformCodes(); - return Object.entries(config.platforms) - .filter(([_, info]) => info.installer) - .map(([code, _]) => code); -} - -/** - * Get platforms that use custom installers (no installer config) - * @returns {Promise} Array of platform codes with custom installers - */ -async function getCustomInstallerPlatforms() { - const config = await loadPlatformCodes(); - return Object.entries(config.platforms) - .filter(([_, info]) => !info.installer) - .map(([code, _]) => code); -} - -/** - * Clear the cached platform codes (useful for testing) - */ -function clearCache() { - _cachedPlatformCodes = null; -} - -module.exports = { - loadPlatformCodes, - getPlatformInfo, - getPreferredPlatforms, - getPlatformsByCategory, - getConfigDrivenPlatforms, - getCustomInstallerPlatforms, - clearCache, -}; diff --git a/tools/cli/installers/lib/ide/platform-codes.yaml b/tools/cli/installers/lib/ide/platform-codes.yaml deleted file mode 100644 index 2c4d2e920..000000000 --- a/tools/cli/installers/lib/ide/platform-codes.yaml +++ /dev/null @@ -1,341 +0,0 @@ -# BMAD Platform Codes Configuration -# Central configuration for all platform/IDE codes used in the BMAD system -# -# This file defines: -# 1. Platform metadata (name, preferred status, category, description) -# 2. Installer configuration (target directories, templates, artifact types) -# -# Format: -# code: Platform identifier used internally -# name: Display name shown to users -# preferred: Whether this platform is shown as a recommended option on install -# category: Type of platform (ide, cli, tool, service) -# description: Brief description of the platform -# installer: Installation configuration (optional - omit for custom installers) - -platforms: - antigravity: - name: "Google Antigravity" - preferred: false - category: ide - description: "Google's AI development environment" - installer: - legacy_targets: - - .agent/workflows - target_dir: .agent/skills - template_type: antigravity - skill_format: true - - auggie: - name: "Auggie" - preferred: false - category: cli - description: "AI development tool" - installer: - legacy_targets: - - .augment/commands - target_dir: .augment/skills - template_type: default - skill_format: true - - claude-code: - name: "Claude Code" - preferred: true - category: cli - description: "Anthropic's official CLI for Claude" - installer: - legacy_targets: - - .claude/commands - target_dir: .claude/skills - template_type: default - skill_format: true - ancestor_conflict_check: true - - cline: - name: "Cline" - preferred: false - category: ide - description: "AI coding assistant" - installer: - legacy_targets: - - .clinerules/workflows - target_dir: .cline/skills - template_type: default - skill_format: true - - codex: - name: "Codex" - preferred: false - category: cli - description: "OpenAI Codex integration" - installer: - legacy_targets: - - .codex/prompts - - ~/.codex/prompts - target_dir: .agents/skills - template_type: default - skill_format: true - ancestor_conflict_check: true - artifact_types: [agents, workflows, tasks] - - codebuddy: - name: "CodeBuddy" - preferred: false - category: ide - description: "Tencent Cloud Code Assistant - AI-powered coding companion" - installer: - legacy_targets: - - .codebuddy/commands - target_dir: .codebuddy/skills - template_type: default - skill_format: true - - crush: - name: "Crush" - preferred: false - category: ide - description: "AI development assistant" - installer: - legacy_targets: - - .crush/commands - target_dir: .crush/skills - template_type: default - skill_format: true - - cursor: - name: "Cursor" - preferred: true - category: ide - description: "AI-first code editor" - installer: - legacy_targets: - - .cursor/commands - target_dir: .cursor/skills - template_type: default - skill_format: true - - gemini: - name: "Gemini CLI" - preferred: false - category: cli - description: "Google's CLI for Gemini" - installer: - legacy_targets: - - .gemini/commands - target_dir: .gemini/skills - template_type: default - skill_format: true - - github-copilot: - name: "GitHub Copilot" - preferred: false - category: ide - description: "GitHub's AI pair programmer" - installer: - legacy_targets: - - .github/agents - - .github/prompts - target_dir: .github/skills - template_type: default - skill_format: true - - iflow: - name: "iFlow" - preferred: false - category: ide - description: "AI workflow automation" - installer: - legacy_targets: - - .iflow/commands - target_dir: .iflow/skills - template_type: default - skill_format: true - - kilo: - name: "KiloCoder" - preferred: false - category: ide - description: "AI coding platform" - suspended: "Kilo Code does not yet support the Agent Skills standard. Support is paused until they implement it. See https://github.com/kilocode/kilo-code/issues for updates." - installer: - legacy_targets: - - .kilocode/workflows - target_dir: .kilocode/skills - template_type: default - skill_format: true - - kiro: - name: "Kiro" - preferred: false - category: ide - description: "Amazon's AI-powered IDE" - installer: - legacy_targets: - - .kiro/steering - target_dir: .kiro/skills - template_type: kiro - skill_format: true - - ona: - name: "Ona" - preferred: false - category: ide - description: "Ona AI development environment" - installer: - target_dir: .ona/skills - template_type: default - skill_format: true - - opencode: - name: "OpenCode" - preferred: false - category: ide - description: "OpenCode terminal coding assistant" - installer: - legacy_targets: - - .opencode/agents - - .opencode/commands - - .opencode/agent - - .opencode/command - target_dir: .opencode/skills - template_type: opencode - skill_format: true - ancestor_conflict_check: true - - pi: - name: "Pi" - preferred: false - category: cli - description: "Provider-agnostic terminal-native AI coding agent" - installer: - target_dir: .pi/skills - template_type: default - skill_format: true - - qoder: - name: "Qoder" - preferred: false - category: ide - description: "Qoder AI coding assistant" - installer: - target_dir: .qoder/skills - template_type: default - skill_format: true - - qwen: - name: "QwenCoder" - preferred: false - category: ide - description: "Qwen AI coding assistant" - installer: - legacy_targets: - - .qwen/commands - target_dir: .qwen/skills - template_type: default - skill_format: true - - roo: - name: "Roo Code" - preferred: false - category: ide - description: "Enhanced Cline fork" - installer: - legacy_targets: - - .roo/commands - target_dir: .roo/skills - template_type: default - skill_format: true - - rovo-dev: - name: "Rovo Dev" - preferred: false - category: ide - description: "Atlassian's Rovo development environment" - installer: - legacy_targets: - - .rovodev/workflows - target_dir: .rovodev/skills - template_type: default - skill_format: true - - trae: - name: "Trae" - preferred: false - category: ide - description: "AI coding tool" - installer: - legacy_targets: - - .trae/rules - target_dir: .trae/skills - template_type: default - skill_format: true - - windsurf: - name: "Windsurf" - preferred: false - category: ide - description: "AI-powered IDE with cascade flows" - installer: - legacy_targets: - - .windsurf/workflows - target_dir: .windsurf/skills - template_type: windsurf - skill_format: true - -# ============================================================================ -# Installer Config Schema -# ============================================================================ -# -# installer: -# target_dir: string # Directory where artifacts are installed -# template_type: string # Default template type to use -# header_template: string (optional) # Override for header/frontmatter template -# body_template: string (optional) # Override for body/content template -# legacy_targets: array (optional) # Old target dirs to clean up on reinstall (migration) -# - string # Relative path, e.g. .opencode/agent -# targets: array (optional) # For multi-target installations -# - target_dir: string -# template_type: string -# artifact_types: [agents, workflows, tasks, tools] -# artifact_types: array (optional) # Filter which artifacts to install (default: all) -# skip_existing: boolean (optional) # Skip files that already exist (default: false) -# skill_format: boolean (optional) # Use directory-per-skill output: /SKILL.md -# # with clean frontmatter (name + description, unquoted) -# ancestor_conflict_check: boolean (optional) # Refuse install when ancestor dir has BMAD files -# # in the same target_dir (for IDEs that inherit -# # skills from parent directories) - -# ============================================================================ -# Platform Categories -# ============================================================================ - -categories: - ide: - name: "Integrated Development Environment" - description: "Full-featured code editors with AI assistance" - - cli: - name: "Command Line Interface" - description: "Terminal-based tools" - - tool: - name: "Development Tool" - description: "Standalone development utilities" - - service: - name: "Cloud Service" - description: "Cloud-based development platforms" - - extension: - name: "Editor Extension" - description: "Plugins for existing editors" - -# ============================================================================ -# Naming Conventions and Rules -# ============================================================================ - -conventions: - code_format: "lowercase-kebab-case" - name_format: "Title Case" - max_code_length: 20 - allowed_characters: "a-z0-9-" diff --git a/tools/cli/installers/lib/ide/templates/combined/claude-workflow-yaml.md b/tools/cli/installers/lib/ide/templates/combined/claude-workflow-yaml.md deleted file mode 120000 index 11f78e1d4..000000000 --- a/tools/cli/installers/lib/ide/templates/combined/claude-workflow-yaml.md +++ /dev/null @@ -1 +0,0 @@ -default-workflow-yaml.md \ No newline at end of file diff --git a/tools/cli/installers/lib/modules/external-manager.js b/tools/cli/installers/lib/modules/external-manager.js deleted file mode 100644 index f1ea2206e..000000000 --- a/tools/cli/installers/lib/modules/external-manager.js +++ /dev/null @@ -1,136 +0,0 @@ -const fs = require('fs-extra'); -const path = require('node:path'); -const yaml = require('yaml'); -const prompts = require('../../../lib/prompts'); - -/** - * Manages external official modules defined in external-official-modules.yaml - * These are modules hosted in external repositories that can be installed - * - * @class ExternalModuleManager - */ -class ExternalModuleManager { - constructor() { - this.externalModulesConfigPath = path.join(__dirname, '../../../external-official-modules.yaml'); - this.cachedModules = null; - } - - /** - * Load and parse the external-official-modules.yaml file - * @returns {Object} Parsed YAML content with modules object - */ - async loadExternalModulesConfig() { - if (this.cachedModules) { - return this.cachedModules; - } - - try { - const content = await fs.readFile(this.externalModulesConfigPath, 'utf8'); - const config = yaml.parse(content); - this.cachedModules = config; - return config; - } catch (error) { - await prompts.log.warn(`Failed to load external modules config: ${error.message}`); - return { modules: {} }; - } - } - - /** - * Get list of available external modules - * @returns {Array} Array of module info objects - */ - async listAvailable() { - const config = await this.loadExternalModulesConfig(); - const modules = []; - - for (const [key, moduleConfig] of Object.entries(config.modules || {})) { - modules.push({ - key, - url: moduleConfig.url, - moduleDefinition: moduleConfig['module-definition'], - code: moduleConfig.code, - name: moduleConfig.name, - header: moduleConfig.header, - subheader: moduleConfig.subheader, - description: moduleConfig.description || '', - defaultSelected: moduleConfig.defaultSelected === true, - type: moduleConfig.type || 'community', // bmad-org or community - npmPackage: moduleConfig.npmPackage || null, // Include npm package name - isExternal: true, - }); - } - - return modules; - } - - /** - * Get module info by code - * @param {string} code - The module code (e.g., 'cis') - * @returns {Object|null} Module info or null if not found - */ - async getModuleByCode(code) { - const modules = await this.listAvailable(); - return modules.find((m) => m.code === code) || null; - } - - /** - * Get module info by key - * @param {string} key - The module key (e.g., 'bmad-creative-intelligence-suite') - * @returns {Object|null} Module info or null if not found - */ - async getModuleByKey(key) { - const config = await this.loadExternalModulesConfig(); - const moduleConfig = config.modules?.[key]; - - if (!moduleConfig) { - return null; - } - - return { - key, - url: moduleConfig.url, - moduleDefinition: moduleConfig['module-definition'], - code: moduleConfig.code, - name: moduleConfig.name, - header: moduleConfig.header, - subheader: moduleConfig.subheader, - description: moduleConfig.description || '', - defaultSelected: moduleConfig.defaultSelected === true, - type: moduleConfig.type || 'community', // bmad-org or community - npmPackage: moduleConfig.npmPackage || null, // Include npm package name - isExternal: true, - }; - } - - /** - * Check if a module code exists in external modules - * @param {string} code - The module code to check - * @returns {boolean} True if the module exists - */ - async hasModule(code) { - const module = await this.getModuleByCode(code); - return module !== null; - } - - /** - * Get the URL for a module by code - * @param {string} code - The module code - * @returns {string|null} The URL or null if not found - */ - async getModuleUrl(code) { - const module = await this.getModuleByCode(code); - return module ? module.url : null; - } - - /** - * Get the module definition path for a module by code - * @param {string} code - The module code - * @returns {string|null} The module definition path or null if not found - */ - async getModuleDefinition(code) { - const module = await this.getModuleByCode(code); - return module ? module.moduleDefinition : null; - } -} - -module.exports = { ExternalModuleManager }; diff --git a/tools/cli/installers/lib/modules/manager.js b/tools/cli/installers/lib/modules/manager.js deleted file mode 100644 index 17a320c44..000000000 --- a/tools/cli/installers/lib/modules/manager.js +++ /dev/null @@ -1,928 +0,0 @@ -const path = require('node:path'); -const fs = require('fs-extra'); -const yaml = require('yaml'); -const prompts = require('../../../lib/prompts'); -const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root'); -const { ExternalModuleManager } = require('./external-manager'); -const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils'); - -/** - * Manages the installation, updating, and removal of BMAD modules. - * Handles module discovery, dependency resolution, and configuration processing. - * - * @class ModuleManager - * @requires fs-extra - * @requires yaml - * @requires prompts - * - * @example - * const manager = new ModuleManager(); - * const modules = await manager.listAvailable(); - * await manager.install('core-module', '/path/to/bmad'); - */ -class ModuleManager { - constructor(options = {}) { - this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden - this.customModulePaths = new Map(); // Initialize custom module paths - this.externalModuleManager = new ExternalModuleManager(); // For external official modules - } - - /** - * Set the bmad folder name for placeholder replacement - * @param {string} bmadFolderName - The bmad folder name - */ - setBmadFolderName(bmadFolderName) { - this.bmadFolderName = bmadFolderName; - } - - /** - * Set the core configuration for access during module installation - * @param {Object} coreConfig - Core configuration object - */ - setCoreConfig(coreConfig) { - this.coreConfig = coreConfig; - } - - /** - * Set custom module paths for priority lookup - * @param {Map} customModulePaths - Map of module ID to source path - */ - setCustomModulePaths(customModulePaths) { - this.customModulePaths = customModulePaths; - } - - /** - * Copy a file to the target location - * @param {string} sourcePath - Source file path - * @param {string} targetPath - Target file path - * @param {boolean} overwrite - Whether to overwrite existing files (default: true) - */ - async copyFileWithPlaceholderReplacement(sourcePath, targetPath, overwrite = true) { - await fs.copy(sourcePath, targetPath, { overwrite }); - } - - /** - * Copy a directory recursively - * @param {string} sourceDir - Source directory path - * @param {string} targetDir - Target directory path - * @param {boolean} overwrite - Whether to overwrite existing files (default: true) - */ - async copyDirectoryWithPlaceholderReplacement(sourceDir, targetDir, overwrite = true) { - await fs.ensureDir(targetDir); - const entries = await fs.readdir(sourceDir, { withFileTypes: true }); - - for (const entry of entries) { - const sourcePath = path.join(sourceDir, entry.name); - const targetPath = path.join(targetDir, entry.name); - - if (entry.isDirectory()) { - await this.copyDirectoryWithPlaceholderReplacement(sourcePath, targetPath, overwrite); - } else { - await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath, overwrite); - } - } - } - - /** - * List all available modules (excluding core which is always installed) - * bmm is the only built-in module, directly under src/bmm-skills - * All other modules come from external-official-modules.yaml - * @returns {Object} Object with modules array and customModules array - */ - async listAvailable() { - const modules = []; - const customModules = []; - - // Add built-in bmm module (directly under src/bmm-skills) - const bmmPath = getSourcePath('bmm-skills'); - if (await fs.pathExists(bmmPath)) { - const bmmInfo = await this.getModuleInfo(bmmPath, 'bmm', 'src/bmm-skills'); - if (bmmInfo) { - modules.push(bmmInfo); - } - } - - // Check for cached custom modules in _config/custom/ - if (this.bmadDir) { - const customCacheDir = path.join(this.bmadDir, '_config', 'custom'); - if (await fs.pathExists(customCacheDir)) { - const cacheEntries = await fs.readdir(customCacheDir, { withFileTypes: true }); - for (const entry of cacheEntries) { - if (entry.isDirectory()) { - const cachePath = path.join(customCacheDir, entry.name); - const moduleInfo = await this.getModuleInfo(cachePath, entry.name, '_config/custom'); - if (moduleInfo && !modules.some((m) => m.id === moduleInfo.id) && !customModules.some((m) => m.id === moduleInfo.id)) { - moduleInfo.isCustom = true; - moduleInfo.fromCache = true; - customModules.push(moduleInfo); - } - } - } - } - } - - return { modules, customModules }; - } - - /** - * Get module information from a module path - * @param {string} modulePath - Path to the module directory - * @param {string} defaultName - Default name for the module - * @param {string} sourceDescription - Description of where the module was found - * @returns {Object|null} Module info or null if not a valid module - */ - async getModuleInfo(modulePath, defaultName, sourceDescription) { - // Check for module structure (module.yaml OR custom.yaml) - const moduleConfigPath = path.join(modulePath, 'module.yaml'); - const rootCustomConfigPath = path.join(modulePath, 'custom.yaml'); - let configPath = null; - - if (await fs.pathExists(moduleConfigPath)) { - configPath = moduleConfigPath; - } else if (await fs.pathExists(rootCustomConfigPath)) { - configPath = rootCustomConfigPath; - } - - // Skip if this doesn't look like a module - if (!configPath) { - return null; - } - - // Mark as custom if it's using custom.yaml OR if it's outside src/bmm or src/core - const isCustomSource = - sourceDescription !== 'src/bmm-skills' && sourceDescription !== 'src/core-skills' && sourceDescription !== 'src/modules'; - const moduleInfo = { - id: defaultName, - path: modulePath, - name: defaultName - .split('-') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '), - description: 'BMAD Module', - version: '5.0.0', - source: sourceDescription, - isCustom: configPath === rootCustomConfigPath || isCustomSource, - }; - - // Read module config for metadata - try { - const configContent = await fs.readFile(configPath, 'utf8'); - const config = yaml.parse(configContent); - - // Use the code property as the id if available - if (config.code) { - moduleInfo.id = config.code; - } - - moduleInfo.name = config.name || moduleInfo.name; - moduleInfo.description = config.description || moduleInfo.description; - moduleInfo.version = config.version || moduleInfo.version; - moduleInfo.dependencies = config.dependencies || []; - moduleInfo.defaultSelected = config.default_selected === undefined ? false : config.default_selected; - } catch (error) { - await prompts.log.warn(`Failed to read config for ${defaultName}: ${error.message}`); - } - - return moduleInfo; - } - - /** - * Find the source path for a module by searching all possible locations - * @param {string} moduleCode - Code of the module to find (from module.yaml) - * @returns {string|null} Path to the module source or null if not found - */ - async findModuleSource(moduleCode, options = {}) { - const projectRoot = getProjectRoot(); - - // First check custom module paths if they exist - if (this.customModulePaths && this.customModulePaths.has(moduleCode)) { - return this.customModulePaths.get(moduleCode); - } - - // Check for built-in bmm module (directly under src/bmm-skills) - if (moduleCode === 'bmm') { - const bmmPath = getSourcePath('bmm-skills'); - if (await fs.pathExists(bmmPath)) { - return bmmPath; - } - } - - // Check external official modules - const externalSource = await this.findExternalModuleSource(moduleCode, options); - if (externalSource) { - return externalSource; - } - - return null; - } - - /** - * Check if a module is an external official module - * @param {string} moduleCode - Code of the module to check - * @returns {boolean} True if the module is external - */ - async isExternalModule(moduleCode) { - return await this.externalModuleManager.hasModule(moduleCode); - } - - /** - * Get the cache directory for external modules - * @returns {string} Path to the external modules cache directory - */ - getExternalCacheDir() { - const os = require('node:os'); - const cacheDir = path.join(os.homedir(), '.bmad', 'cache', 'external-modules'); - return cacheDir; - } - - /** - * Clone an external module repository to cache - * @param {string} moduleCode - Code of the external module - * @returns {string} Path to the cloned repository - */ - async cloneExternalModule(moduleCode, options = {}) { - const { execSync } = require('node:child_process'); - const moduleInfo = await this.externalModuleManager.getModuleByCode(moduleCode); - - if (!moduleInfo) { - throw new Error(`External module '${moduleCode}' not found in external-official-modules.yaml`); - } - - const cacheDir = this.getExternalCacheDir(); - const moduleCacheDir = path.join(cacheDir, moduleCode); - const silent = options.silent || false; - - // Create cache directory if it doesn't exist - await fs.ensureDir(cacheDir); - - // Helper to create a spinner or a no-op when silent - const createSpinner = async () => { - if (silent) { - return { - start() {}, - stop() {}, - error() {}, - message() {}, - cancel() {}, - clear() {}, - get isSpinning() { - return false; - }, - get isCancelled() { - return false; - }, - }; - } - return await prompts.spinner(); - }; - - // Track if we need to install dependencies - let needsDependencyInstall = false; - let wasNewClone = false; - - // Check if already cloned - if (await fs.pathExists(moduleCacheDir)) { - // Try to update if it's a git repo - const fetchSpinner = await createSpinner(); - fetchSpinner.start(`Fetching ${moduleInfo.name}...`); - try { - const currentRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim(); - // Fetch and reset to remote - works better with shallow clones than pull - execSync('git fetch origin --depth 1', { - cwd: moduleCacheDir, - stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, - }); - execSync('git reset --hard origin/HEAD', { - cwd: moduleCacheDir, - stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, - }); - const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim(); - - fetchSpinner.stop(`Fetched ${moduleInfo.name}`); - // Force dependency install if we got new code - if (currentRef !== newRef) { - needsDependencyInstall = true; - } - } catch { - fetchSpinner.error(`Fetch failed, re-downloading ${moduleInfo.name}`); - // If update fails, remove and re-clone - await fs.remove(moduleCacheDir); - wasNewClone = true; - } - } else { - wasNewClone = true; - } - - // Clone if not exists or was removed - if (wasNewClone) { - const fetchSpinner = await createSpinner(); - fetchSpinner.start(`Fetching ${moduleInfo.name}...`); - try { - execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, { - stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, - }); - fetchSpinner.stop(`Fetched ${moduleInfo.name}`); - } catch (error) { - fetchSpinner.error(`Failed to fetch ${moduleInfo.name}`); - throw new Error(`Failed to clone external module '${moduleCode}': ${error.message}`); - } - } - - // Install dependencies if package.json exists - const packageJsonPath = path.join(moduleCacheDir, 'package.json'); - const nodeModulesPath = path.join(moduleCacheDir, 'node_modules'); - if (await fs.pathExists(packageJsonPath)) { - // Install if node_modules doesn't exist, or if package.json is newer (dependencies changed) - const nodeModulesMissing = !(await fs.pathExists(nodeModulesPath)); - - // Force install if we updated or cloned new - if (needsDependencyInstall || wasNewClone || nodeModulesMissing) { - const installSpinner = await createSpinner(); - installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`); - try { - execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', { - cwd: moduleCacheDir, - stdio: ['ignore', 'pipe', 'pipe'], - timeout: 120_000, // 2 minute timeout - }); - installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`); - } catch (error) { - installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`); - if (!silent) await prompts.log.warn(` ${error.message}`); - } - } else { - // Check if package.json is newer than node_modules - let packageJsonNewer = false; - try { - const packageStats = await fs.stat(packageJsonPath); - const nodeModulesStats = await fs.stat(nodeModulesPath); - packageJsonNewer = packageStats.mtime > nodeModulesStats.mtime; - } catch { - // If stat fails, assume we need to install - packageJsonNewer = true; - } - - if (packageJsonNewer) { - const installSpinner = await createSpinner(); - installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`); - try { - execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', { - cwd: moduleCacheDir, - stdio: ['ignore', 'pipe', 'pipe'], - timeout: 120_000, // 2 minute timeout - }); - installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`); - } catch (error) { - installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`); - if (!silent) await prompts.log.warn(` ${error.message}`); - } - } - } - } - - return moduleCacheDir; - } - - /** - * Find the source path for an external module - * @param {string} moduleCode - Code of the external module - * @returns {string|null} Path to the module source or null if not found - */ - async findExternalModuleSource(moduleCode, options = {}) { - const moduleInfo = await this.externalModuleManager.getModuleByCode(moduleCode); - - if (!moduleInfo) { - return null; - } - - // Clone the external module repo - const cloneDir = await this.cloneExternalModule(moduleCode, options); - - // The module-definition specifies the path to module.yaml relative to repo root - // We need to return the directory containing module.yaml - const moduleDefinitionPath = moduleInfo.moduleDefinition; // e.g., 'src/module.yaml' - const moduleDir = path.dirname(path.join(cloneDir, moduleDefinitionPath)); - - return moduleDir; - } - - /** - * Install a module - * @param {string} moduleName - Code of the module to install (from module.yaml) - * @param {string} bmadDir - Target bmad directory - * @param {Function} fileTrackingCallback - Optional callback to track installed files - * @param {Object} options - Additional installation options - * @param {Array} options.installedIDEs - Array of IDE codes that were installed - * @param {Object} options.moduleConfig - Module configuration from config collector - * @param {Object} options.logger - Logger instance for output - */ - async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) { - const sourcePath = await this.findModuleSource(moduleName, { silent: options.silent }); - const targetPath = path.join(bmadDir, moduleName); - - // Check if source module exists - if (!sourcePath) { - // Provide a more user-friendly error message - throw new Error( - `Source for module '${moduleName}' is not available. It will be retained but cannot be updated without its source files.`, - ); - } - - // Check if this is a custom module and read its custom.yaml values - let customConfig = null; - const rootCustomConfigPath = path.join(sourcePath, 'custom.yaml'); - - if (await fs.pathExists(rootCustomConfigPath)) { - try { - const customContent = await fs.readFile(rootCustomConfigPath, 'utf8'); - customConfig = yaml.parse(customContent); - } catch (error) { - await prompts.log.warn(`Failed to read custom.yaml for ${moduleName}: ${error.message}`); - } - } - - // If this is a custom module, merge its values into the module config - if (customConfig) { - options.moduleConfig = { ...options.moduleConfig, ...customConfig }; - if (options.logger) { - await options.logger.log(` Merged custom configuration for ${moduleName}`); - } - } - - // Check if already installed - if (await fs.pathExists(targetPath)) { - await fs.remove(targetPath); - } - - // Copy module files with filtering - await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig); - - // Create directories declared in module.yaml (unless explicitly skipped) - if (!options.skipModuleInstaller) { - await this.createModuleDirectories(moduleName, bmadDir, options); - } - - // Capture version info for manifest - const { Manifest } = require('../core/manifest'); - const manifestObj = new Manifest(); - const versionInfo = await manifestObj.getModuleVersionInfo(moduleName, bmadDir, sourcePath); - - await manifestObj.addModule(bmadDir, moduleName, { - version: versionInfo.version, - source: versionInfo.source, - npmPackage: versionInfo.npmPackage, - repoUrl: versionInfo.repoUrl, - }); - - return { - success: true, - module: moduleName, - path: targetPath, - versionInfo, - }; - } - - /** - * Update an existing module - * @param {string} moduleName - Name of the module to update - * @param {string} bmadDir - Target bmad directory - * @param {boolean} force - Force update (overwrite modifications) - */ - async update(moduleName, bmadDir, force = false, options = {}) { - const sourcePath = await this.findModuleSource(moduleName); - const targetPath = path.join(bmadDir, moduleName); - - // Check if source module exists - if (!sourcePath) { - throw new Error(`Module '${moduleName}' not found in any source location`); - } - - // Check if module is installed - if (!(await fs.pathExists(targetPath))) { - throw new Error(`Module '${moduleName}' is not installed`); - } - - if (force) { - // Force update - remove and reinstall - await fs.remove(targetPath); - return await this.install(moduleName, bmadDir, null, { installer: options.installer }); - } else { - // Selective update - preserve user modifications - await this.syncModule(sourcePath, targetPath); - } - - return { - success: true, - module: moduleName, - path: targetPath, - }; - } - - /** - * Remove a module - * @param {string} moduleName - Name of the module to remove - * @param {string} bmadDir - Target bmad directory - */ - async remove(moduleName, bmadDir) { - const targetPath = path.join(bmadDir, moduleName); - - if (!(await fs.pathExists(targetPath))) { - throw new Error(`Module '${moduleName}' is not installed`); - } - - await fs.remove(targetPath); - - return { - success: true, - module: moduleName, - }; - } - - /** - * Check if a module is installed - * @param {string} moduleName - Name of the module - * @param {string} bmadDir - Target bmad directory - * @returns {boolean} True if module is installed - */ - async isInstalled(moduleName, bmadDir) { - const targetPath = path.join(bmadDir, moduleName); - return await fs.pathExists(targetPath); - } - - /** - * Get installed module info - * @param {string} moduleName - Name of the module - * @param {string} bmadDir - Target bmad directory - * @returns {Object|null} Module info or null if not installed - */ - async getInstalledInfo(moduleName, bmadDir) { - const targetPath = path.join(bmadDir, moduleName); - - if (!(await fs.pathExists(targetPath))) { - return null; - } - - const configPath = path.join(targetPath, 'config.yaml'); - const moduleInfo = { - id: moduleName, - path: targetPath, - installed: true, - }; - - if (await fs.pathExists(configPath)) { - try { - const configContent = await fs.readFile(configPath, 'utf8'); - const config = yaml.parse(configContent); - Object.assign(moduleInfo, config); - } catch (error) { - await prompts.log.warn(`Failed to read installed module config: ${error.message}`); - } - } - - return moduleInfo; - } - - /** - * Copy module with filtering for localskip agents and conditional content - * @param {string} sourcePath - Source module path - * @param {string} targetPath - Target module path - * @param {Function} fileTrackingCallback - Optional callback to track installed files - * @param {Object} moduleConfig - Module configuration with conditional flags - */ - async copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback = null, moduleConfig = {}) { - // Get all files in source - const sourceFiles = await this.getFileList(sourcePath); - - for (const file of sourceFiles) { - // Skip sub-modules directory - these are IDE-specific and handled separately - if (file.startsWith('sub-modules/')) { - continue; - } - - // Skip sidecar directories - these contain agent-specific assets not needed at install time - const isInSidecarDirectory = path - .dirname(file) - .split('/') - .some((dir) => dir.toLowerCase().endsWith('-sidecar')); - - if (isInSidecarDirectory) { - continue; - } - - // Skip module.yaml at root - it's only needed at install time - if (file === 'module.yaml') { - continue; - } - - // Skip module root config.yaml only - generated by config collector with actual values - // Workflow-level config.yaml (e.g. workflows/orchestrate-story/config.yaml) must be copied - // for custom modules that use workflow-specific configuration - if (file === 'config.yaml') { - continue; - } - - const sourceFile = path.join(sourcePath, file); - const targetFile = path.join(targetPath, file); - - // Check if this is an agent file - if (file.startsWith('agents/') && file.endsWith('.md')) { - // Read the file to check for localskip - const content = await fs.readFile(sourceFile, 'utf8'); - - // Check for localskip="true" in the agent tag - const agentMatch = content.match(/]*\slocalskip="true"[^>]*>/); - if (agentMatch) { - await prompts.log.message(` Skipping web-only agent: ${path.basename(file)}`); - continue; // Skip this agent - } - } - - // Copy the file with placeholder replacement - await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile); - - // Track the file if callback provided - if (fileTrackingCallback) { - fileTrackingCallback(targetFile); - } - } - } - - /** - * Find all .md agent files recursively in a directory - * @param {string} dir - Directory to search - * @returns {Array} List of .md agent file paths - */ - async findAgentMdFiles(dir) { - const agentFiles = []; - - async function searchDirectory(searchDir) { - const entries = await fs.readdir(searchDir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(searchDir, entry.name); - - if (entry.isFile() && entry.name.endsWith('.md')) { - agentFiles.push(fullPath); - } else if (entry.isDirectory()) { - await searchDirectory(fullPath); - } - } - } - - await searchDirectory(dir); - return agentFiles; - } - - /** - * Create directories declared in module.yaml's `directories` key - * This replaces the security-risky module installer pattern with declarative config - * During updates, if a directory path changed, moves the old directory to the new path - * @param {string} moduleName - Name of the module - * @param {string} bmadDir - Target bmad directory - * @param {Object} options - Installation options - * @param {Object} options.moduleConfig - Module configuration from config collector - * @param {Object} options.existingModuleConfig - Previous module config (for detecting path changes during updates) - * @param {Object} options.coreConfig - Core configuration - * @returns {Promise<{createdDirs: string[], movedDirs: string[], createdWdsFolders: string[]}>} Created directories info - */ - async createModuleDirectories(moduleName, bmadDir, options = {}) { - const moduleConfig = options.moduleConfig || {}; - const existingModuleConfig = options.existingModuleConfig || {}; - const projectRoot = path.dirname(bmadDir); - const emptyResult = { createdDirs: [], movedDirs: [], createdWdsFolders: [] }; - - // Special handling for core module - it's in src/core-skills not src/modules - let sourcePath; - if (moduleName === 'core') { - sourcePath = getSourcePath('core-skills'); - } else { - sourcePath = await this.findModuleSource(moduleName, { silent: true }); - if (!sourcePath) { - return emptyResult; // No source found, skip - } - } - - // Read module.yaml to find the `directories` key - const moduleYamlPath = path.join(sourcePath, 'module.yaml'); - if (!(await fs.pathExists(moduleYamlPath))) { - return emptyResult; // No module.yaml, skip - } - - let moduleYaml; - try { - const yamlContent = await fs.readFile(moduleYamlPath, 'utf8'); - moduleYaml = yaml.parse(yamlContent); - } catch { - return emptyResult; // Invalid YAML, skip - } - - if (!moduleYaml || !moduleYaml.directories) { - return emptyResult; // No directories declared, skip - } - - const directories = moduleYaml.directories; - const wdsFolders = moduleYaml.wds_folders || []; - const createdDirs = []; - const movedDirs = []; - const createdWdsFolders = []; - - for (const dirRef of directories) { - // Parse variable reference like "{design_artifacts}" - const varMatch = dirRef.match(/^\{([^}]+)\}$/); - if (!varMatch) { - // Not a variable reference, skip - continue; - } - - const configKey = varMatch[1]; - const dirValue = moduleConfig[configKey]; - if (!dirValue || typeof dirValue !== 'string') { - continue; // No value or not a string, skip - } - - // Strip {project-root}/ prefix if present - let dirPath = dirValue.replace(/^\{project-root\}\/?/, ''); - - // Handle remaining {project-root} anywhere in the path - dirPath = dirPath.replaceAll('{project-root}', ''); - - // Resolve to absolute path - const fullPath = path.join(projectRoot, dirPath); - - // Validate path is within project root (prevent directory traversal) - const normalizedPath = path.normalize(fullPath); - const normalizedRoot = path.normalize(projectRoot); - if (!normalizedPath.startsWith(normalizedRoot + path.sep) && normalizedPath !== normalizedRoot) { - const color = await prompts.getColor(); - await prompts.log.warn(color.yellow(`${configKey} path escapes project root, skipping: ${dirPath}`)); - continue; - } - - // Check if directory path changed from previous config (update/modify scenario) - const oldDirValue = existingModuleConfig[configKey]; - let oldFullPath = null; - let oldDirPath = null; - if (oldDirValue && typeof oldDirValue === 'string') { - // F3: Normalize both values before comparing to avoid false negatives - // from trailing slashes, separator differences, or prefix format variations - let normalizedOld = oldDirValue.replace(/^\{project-root\}\/?/, ''); - normalizedOld = path.normalize(normalizedOld.replaceAll('{project-root}', '')); - const normalizedNew = path.normalize(dirPath); - - if (normalizedOld !== normalizedNew) { - oldDirPath = normalizedOld; - oldFullPath = path.join(projectRoot, oldDirPath); - const normalizedOldAbsolute = path.normalize(oldFullPath); - if (!normalizedOldAbsolute.startsWith(normalizedRoot + path.sep) && normalizedOldAbsolute !== normalizedRoot) { - oldFullPath = null; // Old path escapes project root, ignore it - } - - // F13: Prevent parent/child move (e.g. docs/planning → docs/planning/v2) - if (oldFullPath) { - const normalizedNewAbsolute = path.normalize(fullPath); - if ( - normalizedOldAbsolute.startsWith(normalizedNewAbsolute + path.sep) || - normalizedNewAbsolute.startsWith(normalizedOldAbsolute + path.sep) - ) { - const color = await prompts.getColor(); - await prompts.log.warn( - color.yellow( - `${configKey}: cannot move between parent/child paths (${oldDirPath} / ${dirPath}), creating new directory instead`, - ), - ); - oldFullPath = null; - } - } - } - } - - const dirName = configKey.replaceAll('_', ' '); - - if (oldFullPath && (await fs.pathExists(oldFullPath)) && !(await fs.pathExists(fullPath))) { - // Path changed and old dir exists → move old to new location - // F1: Use fs.move() instead of fs.rename() for cross-device/volume support - // F2: Wrap in try/catch — fallback to creating new dir on failure - try { - await fs.ensureDir(path.dirname(fullPath)); - await fs.move(oldFullPath, fullPath); - movedDirs.push(`${dirName}: ${oldDirPath} → ${dirPath}`); - } catch (moveError) { - const color = await prompts.getColor(); - await prompts.log.warn( - color.yellow( - `Failed to move ${oldDirPath} → ${dirPath}: ${moveError.message}\n Creating new directory instead. Please move contents from the old directory manually.`, - ), - ); - await fs.ensureDir(fullPath); - createdDirs.push(`${dirName}: ${dirPath}`); - } - } else if (oldFullPath && (await fs.pathExists(oldFullPath)) && (await fs.pathExists(fullPath))) { - // F5: Both old and new directories exist — warn user about potential orphaned documents - const color = await prompts.getColor(); - await prompts.log.warn( - color.yellow( - `${dirName}: path changed but both directories exist:\n Old: ${oldDirPath}\n New: ${dirPath}\n Old directory may contain orphaned documents — please review and merge manually.`, - ), - ); - } else if (!(await fs.pathExists(fullPath))) { - // New directory doesn't exist yet → create it - createdDirs.push(`${dirName}: ${dirPath}`); - await fs.ensureDir(fullPath); - } - - // Create WDS subfolders if this is the design_artifacts directory - if (configKey === 'design_artifacts' && wdsFolders.length > 0) { - for (const subfolder of wdsFolders) { - const subPath = path.join(fullPath, subfolder); - if (!(await fs.pathExists(subPath))) { - await fs.ensureDir(subPath); - createdWdsFolders.push(subfolder); - } - } - } - } - - return { createdDirs, movedDirs, createdWdsFolders }; - } - - /** - * Private: Process module configuration - * @param {string} modulePath - Path to installed module - * @param {string} moduleName - Module name - */ - async processModuleConfig(modulePath, moduleName) { - const configPath = path.join(modulePath, 'config.yaml'); - - if (await fs.pathExists(configPath)) { - try { - let configContent = await fs.readFile(configPath, 'utf8'); - - // Replace path placeholders - configContent = configContent.replaceAll('{project-root}', `bmad/${moduleName}`); - configContent = configContent.replaceAll('{module}', moduleName); - - await fs.writeFile(configPath, configContent, 'utf8'); - } catch (error) { - await prompts.log.warn(`Failed to process module config: ${error.message}`); - } - } - } - - /** - * Private: Sync module files (preserving user modifications) - * @param {string} sourcePath - Source module path - * @param {string} targetPath - Target module path - */ - async syncModule(sourcePath, targetPath) { - // Get list of all source files - const sourceFiles = await this.getFileList(sourcePath); - - for (const file of sourceFiles) { - const sourceFile = path.join(sourcePath, file); - const targetFile = path.join(targetPath, file); - - // Check if target file exists and has been modified - if (await fs.pathExists(targetFile)) { - const sourceStats = await fs.stat(sourceFile); - const targetStats = await fs.stat(targetFile); - - // Skip if target is newer (user modified) - if (targetStats.mtime > sourceStats.mtime) { - continue; - } - } - - // Copy file with placeholder replacement - await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile); - } - } - - /** - * Private: Get list of all files in a directory - * @param {string} dir - Directory path - * @param {string} baseDir - Base directory for relative paths - * @returns {Array} List of relative file paths - */ - async getFileList(dir, baseDir = dir) { - const files = []; - const entries = await fs.readdir(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - - if (entry.isDirectory()) { - const subFiles = await this.getFileList(fullPath, baseDir); - files.push(...subFiles); - } else { - files.push(path.relative(baseDir, fullPath)); - } - } - - return files; - } -} - -module.exports = { ModuleManager }; diff --git a/tools/cli/lib/config.js b/tools/cli/lib/config.js deleted file mode 100644 index a78250305..000000000 --- a/tools/cli/lib/config.js +++ /dev/null @@ -1,213 +0,0 @@ -const fs = require('fs-extra'); -const yaml = require('yaml'); -const path = require('node:path'); -const packageJson = require('../../../package.json'); - -/** - * Configuration utility class - */ -class Config { - /** - * Load a YAML configuration file - * @param {string} configPath - Path to config file - * @returns {Object} Parsed configuration - */ - async loadYaml(configPath) { - if (!(await fs.pathExists(configPath))) { - throw new Error(`Configuration file not found: ${configPath}`); - } - - const content = await fs.readFile(configPath, 'utf8'); - return yaml.parse(content); - } - - /** - * Save configuration to YAML file - * @param {string} configPath - Path to config file - * @param {Object} config - Configuration object - */ - async saveYaml(configPath, config) { - const yamlContent = yaml.dump(config, { - indent: 2, - lineWidth: 120, - noRefs: true, - }); - - await fs.ensureDir(path.dirname(configPath)); - // Ensure POSIX-compliant final newline - const content = yamlContent.endsWith('\n') ? yamlContent : yamlContent + '\n'; - await fs.writeFile(configPath, content, 'utf8'); - } - - /** - * Process configuration file (replace placeholders) - * @param {string} configPath - Path to config file - * @param {Object} replacements - Replacement values - */ - async processConfig(configPath, replacements = {}) { - let content = await fs.readFile(configPath, 'utf8'); - - // Standard replacements - const standardReplacements = { - '{project-root}': replacements.root || '', - '{module}': replacements.module || '', - '{version}': replacements.version || packageJson.version, - '{date}': new Date().toISOString().split('T')[0], - }; - - // Apply all replacements - const allReplacements = { ...standardReplacements, ...replacements }; - - for (const [placeholder, value] of Object.entries(allReplacements)) { - if (typeof placeholder === 'string' && typeof value === 'string') { - const regex = new RegExp(placeholder.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`), 'g'); - content = content.replace(regex, value); - } - } - - await fs.writeFile(configPath, content, 'utf8'); - } - - /** - * Merge configurations - * @param {Object} base - Base configuration - * @param {Object} override - Override configuration - * @returns {Object} Merged configuration - */ - mergeConfigs(base, override) { - return this.deepMerge(base, override); - } - - /** - * Deep merge two objects - * @param {Object} target - Target object - * @param {Object} source - Source object - * @returns {Object} Merged object - */ - deepMerge(target, source) { - const output = { ...target }; - - if (this.isObject(target) && this.isObject(source)) { - for (const key of Object.keys(source)) { - if (this.isObject(source[key])) { - if (key in target) { - output[key] = this.deepMerge(target[key], source[key]); - } else { - output[key] = source[key]; - } - } else { - output[key] = source[key]; - } - } - } - - return output; - } - - /** - * Check if value is an object - * @param {*} item - Item to check - * @returns {boolean} True if object - */ - isObject(item) { - return item && typeof item === 'object' && !Array.isArray(item); - } - - /** - * Validate configuration against schema - * @param {Object} config - Configuration to validate - * @param {Object} schema - Validation schema - * @returns {Object} Validation result - */ - validateConfig(config, schema) { - const errors = []; - const warnings = []; - - // Check required fields - if (schema.required) { - for (const field of schema.required) { - if (!(field in config)) { - errors.push(`Missing required field: ${field}`); - } - } - } - - // Check field types - if (schema.properties) { - for (const [field, spec] of Object.entries(schema.properties)) { - if (field in config) { - const value = config[field]; - const expectedType = spec.type; - - if (expectedType === 'array' && !Array.isArray(value)) { - errors.push(`Field '${field}' should be an array`); - } else if (expectedType === 'object' && !this.isObject(value)) { - errors.push(`Field '${field}' should be an object`); - } else if (expectedType === 'string' && typeof value !== 'string') { - errors.push(`Field '${field}' should be a string`); - } else if (expectedType === 'number' && typeof value !== 'number') { - errors.push(`Field '${field}' should be a number`); - } else if (expectedType === 'boolean' && typeof value !== 'boolean') { - errors.push(`Field '${field}' should be a boolean`); - } - - // Check enum values - if (spec.enum && !spec.enum.includes(value)) { - errors.push(`Field '${field}' must be one of: ${spec.enum.join(', ')}`); - } - } - } - } - - return { - valid: errors.length === 0, - errors, - warnings, - }; - } - - /** - * Get configuration value with fallback - * @param {Object} config - Configuration object - * @param {string} path - Dot-notation path to value - * @param {*} defaultValue - Default value if not found - * @returns {*} Configuration value - */ - getValue(config, path, defaultValue = null) { - const keys = path.split('.'); - let current = config; - - for (const key of keys) { - if (current && typeof current === 'object' && key in current) { - current = current[key]; - } else { - return defaultValue; - } - } - - return current; - } - - /** - * Set configuration value - * @param {Object} config - Configuration object - * @param {string} path - Dot-notation path to value - * @param {*} value - Value to set - */ - setValue(config, path, value) { - const keys = path.split('.'); - const lastKey = keys.pop(); - let current = config; - - for (const key of keys) { - if (!(key in current) || typeof current[key] !== 'object') { - current[key] = {}; - } - current = current[key]; - } - - current[lastKey] = value; - } -} - -module.exports = { Config }; diff --git a/tools/cli/lib/platform-codes.js b/tools/cli/lib/platform-codes.js deleted file mode 100644 index bdf0e48c9..000000000 --- a/tools/cli/lib/platform-codes.js +++ /dev/null @@ -1,116 +0,0 @@ -const fs = require('fs-extra'); -const path = require('node:path'); -const yaml = require('yaml'); -const { getProjectRoot } = require('./project-root'); - -/** - * Platform Codes Manager - * Loads and provides access to the centralized platform codes configuration - */ -class PlatformCodes { - constructor() { - this.configPath = path.join(getProjectRoot(), 'tools', 'platform-codes.yaml'); - this.loadConfig(); - } - - /** - * Load the platform codes configuration - */ - loadConfig() { - try { - if (fs.existsSync(this.configPath)) { - const content = fs.readFileSync(this.configPath, 'utf8'); - this.config = yaml.parse(content); - } else { - console.warn(`Platform codes config not found at ${this.configPath}`); - this.config = { platforms: {} }; - } - } catch (error) { - console.error(`Error loading platform codes: ${error.message}`); - this.config = { platforms: {} }; - } - } - - /** - * Get all platform codes - * @returns {Object} All platform configurations - */ - getAllPlatforms() { - return this.config.platforms || {}; - } - - /** - * Get a specific platform configuration - * @param {string} code - Platform code - * @returns {Object|null} Platform configuration or null if not found - */ - getPlatform(code) { - return this.config.platforms[code] || null; - } - - /** - * Check if a platform code is valid - * @param {string} code - Platform code to validate - * @returns {boolean} True if valid - */ - isValidPlatform(code) { - return code in this.config.platforms; - } - - /** - * Get all preferred platforms - * @returns {Array} Array of preferred platform codes - */ - getPreferredPlatforms() { - return Object.entries(this.config.platforms) - .filter(([, config]) => config.preferred) - .map(([code]) => code); - } - - /** - * Get platforms by category - * @param {string} category - Category to filter by - * @returns {Array} Array of platform codes in the category - */ - getPlatformsByCategory(category) { - return Object.entries(this.config.platforms) - .filter(([, config]) => config.category === category) - .map(([code]) => code); - } - - /** - * Get platform display name - * @param {string} code - Platform code - * @returns {string} Display name or code if not found - */ - getDisplayName(code) { - const platform = this.getPlatform(code); - return platform ? platform.name : code; - } - - /** - * Validate platform code format - * @param {string} code - Platform code to validate - * @returns {boolean} True if format is valid - */ - isValidFormat(code) { - const conventions = this.config.conventions || {}; - const pattern = conventions.allowed_characters || 'a-z0-9-'; - const maxLength = conventions.max_code_length || 20; - - const regex = new RegExp(`^[${pattern}]+$`); - return regex.test(code) && code.length <= maxLength; - } - - /** - * Get all platform codes as array - * @returns {Array} Array of platform codes - */ - getCodes() { - return Object.keys(this.config.platforms); - } - config = null; -} - -// Export singleton instance -module.exports = new PlatformCodes(); diff --git a/tools/docs/_prompt-external-modules-page.md b/tools/docs/_prompt-external-modules-page.md index f5e124373..414f977a8 100644 --- a/tools/docs/_prompt-external-modules-page.md +++ b/tools/docs/_prompt-external-modules-page.md @@ -6,7 +6,7 @@ Create a reference documentation page at `docs/reference/modules.md` that lists ## Source of Truth -Read `tools/cli/external-official-modules.yaml` — this is the authoritative registry of official external modules. Use the module names, codes, npm package names, and repository URLs from this file. +Read `tools/installer/external-official-modules.yaml` — this is the authoritative registry of official external modules. Use the module names, codes, npm package names, and repository URLs from this file. ## Research Step diff --git a/tools/cli/README.md b/tools/installer/README.md similarity index 100% rename from tools/cli/README.md rename to tools/installer/README.md diff --git a/tools/cli/bmad-cli.js b/tools/installer/bmad-cli.js similarity index 98% rename from tools/cli/bmad-cli.js rename to tools/installer/bmad-cli.js index 31db41fbf..042714e45 100755 --- a/tools/cli/bmad-cli.js +++ b/tools/installer/bmad-cli.js @@ -1,9 +1,11 @@ +#!/usr/bin/env node + const { program } = require('commander'); const path = require('node:path'); const fs = require('node:fs'); const { execSync } = require('node:child_process'); const semver = require('semver'); -const prompts = require('./lib/prompts'); +const prompts = require('./prompts'); // The installer flow uses many sequential @clack/prompts, each adding keypress // listeners to stdin. Raise the limit to avoid spurious EventEmitter warnings. diff --git a/tools/cli/lib/cli-utils.js b/tools/installer/cli-utils.js similarity index 96% rename from tools/cli/lib/cli-utils.js rename to tools/installer/cli-utils.js index 569f1c44c..6ca615534 100644 --- a/tools/cli/lib/cli-utils.js +++ b/tools/installer/cli-utils.js @@ -8,7 +8,7 @@ const CLIUtils = { */ getVersion() { try { - const packageJson = require(path.join(__dirname, '..', '..', '..', 'package.json')); + const packageJson = require(path.join(__dirname, '..', '..', 'package.json')); return packageJson.version || 'Unknown'; } catch { return 'Unknown'; @@ -16,10 +16,9 @@ const CLIUtils = { }, /** - * Display BMAD logo using @clack intro + box - * @param {boolean} _clearScreen - Deprecated, ignored (no longer clears screen) + * Display BMAD logo and version using @clack intro + box */ - async displayLogo(_clearScreen = true) { + async displayLogo() { const version = this.getVersion(); const color = await prompts.getColor(); diff --git a/tools/cli/commands/install.js b/tools/installer/commands/install.js similarity index 95% rename from tools/cli/commands/install.js rename to tools/installer/commands/install.js index 3577116d7..96f536ef4 100644 --- a/tools/cli/commands/install.js +++ b/tools/installer/commands/install.js @@ -1,7 +1,7 @@ const path = require('node:path'); -const prompts = require('../lib/prompts'); -const { Installer } = require('../installers/lib/core/installer'); -const { UI } = require('../lib/ui'); +const prompts = require('../prompts'); +const { Installer } = require('../core/installer'); +const { UI } = require('../ui'); const installer = new Installer(); const ui = new UI(); diff --git a/tools/cli/commands/status.js b/tools/installer/commands/status.js similarity index 89% rename from tools/cli/commands/status.js rename to tools/installer/commands/status.js index ec931fe46..49c0afd73 100644 --- a/tools/cli/commands/status.js +++ b/tools/installer/commands/status.js @@ -1,8 +1,8 @@ const path = require('node:path'); -const prompts = require('../lib/prompts'); -const { Installer } = require('../installers/lib/core/installer'); -const { Manifest } = require('../installers/lib/core/manifest'); -const { UI } = require('../lib/ui'); +const prompts = require('../prompts'); +const { Installer } = require('../core/installer'); +const { Manifest } = require('../core/manifest'); +const { UI } = require('../ui'); const installer = new Installer(); const manifest = new Manifest(); diff --git a/tools/cli/commands/uninstall.js b/tools/installer/commands/uninstall.js similarity index 94% rename from tools/cli/commands/uninstall.js rename to tools/installer/commands/uninstall.js index 99734791e..d0e168a15 100644 --- a/tools/cli/commands/uninstall.js +++ b/tools/installer/commands/uninstall.js @@ -1,7 +1,7 @@ const path = require('node:path'); const fs = require('fs-extra'); -const prompts = require('../lib/prompts'); -const { Installer } = require('../installers/lib/core/installer'); +const prompts = require('../prompts'); +const { Installer } = require('../core/installer'); const installer = new Installer(); @@ -62,9 +62,9 @@ module.exports = { } const existingInstall = await installer.getStatus(projectDir); - const version = existingInstall.version || 'unknown'; - const modules = (existingInstall.modules || []).map((m) => m.id || m.name).join(', '); - const ides = (existingInstall.ides || []).join(', '); + const version = existingInstall.installed ? existingInstall.version : 'unknown'; + const modules = existingInstall.moduleIds.join(', '); + const ides = existingInstall.ides.join(', '); const outputFolder = await installer.getOutputFolder(projectDir); diff --git a/tools/installer/core/config.js b/tools/installer/core/config.js new file mode 100644 index 000000000..c844e2d00 --- /dev/null +++ b/tools/installer/core/config.js @@ -0,0 +1,52 @@ +/** + * Clean install configuration built from user input. + * User input comes from either UI answers or headless CLI flags. + */ +class Config { + constructor({ directory, modules, ides, skipPrompts, verbose, actionType, coreConfig, moduleConfigs, quickUpdate }) { + this.directory = directory; + this.modules = Object.freeze([...modules]); + this.ides = Object.freeze([...ides]); + this.skipPrompts = skipPrompts; + this.verbose = verbose; + this.actionType = actionType; + this.coreConfig = coreConfig; + this.moduleConfigs = moduleConfigs; + this._quickUpdate = quickUpdate; + Object.freeze(this); + } + + /** + * Build a clean install config from raw user input. + * @param {Object} userInput - UI answers or CLI flags + * @returns {Config} + */ + static build(userInput) { + const modules = [...(userInput.modules || [])]; + if (userInput.installCore && !modules.includes('core')) { + modules.unshift('core'); + } + + return new Config({ + directory: userInput.directory, + modules, + ides: userInput.skipIde ? [] : [...(userInput.ides || [])], + skipPrompts: userInput.skipPrompts || false, + verbose: userInput.verbose || false, + actionType: userInput.actionType, + coreConfig: userInput.coreConfig || {}, + moduleConfigs: userInput.moduleConfigs || null, + quickUpdate: userInput._quickUpdate || false, + }); + } + + hasCoreConfig() { + return this.coreConfig && Object.keys(this.coreConfig).length > 0; + } + + isQuickUpdate() { + return this._quickUpdate; + } +} + +module.exports = { Config }; diff --git a/tools/cli/installers/lib/core/custom-module-cache.js b/tools/installer/core/custom-module-cache.js similarity index 99% rename from tools/cli/installers/lib/core/custom-module-cache.js rename to tools/installer/core/custom-module-cache.js index b1cc3d0f7..4afe77884 100644 --- a/tools/cli/installers/lib/core/custom-module-cache.js +++ b/tools/installer/core/custom-module-cache.js @@ -7,7 +7,7 @@ const fs = require('fs-extra'); const path = require('node:path'); const crypto = require('node:crypto'); -const prompts = require('../../../lib/prompts'); +const prompts = require('../prompts'); class CustomModuleCache { constructor(bmadDir) { diff --git a/tools/installer/core/existing-install.js b/tools/installer/core/existing-install.js new file mode 100644 index 000000000..8e86f4b03 --- /dev/null +++ b/tools/installer/core/existing-install.js @@ -0,0 +1,127 @@ +const path = require('node:path'); +const fs = require('fs-extra'); +const yaml = require('yaml'); +const { Manifest } = require('./manifest'); + +/** + * Immutable snapshot of an existing BMAD installation. + * Pure query object — no filesystem operations after construction. + */ +class ExistingInstall { + #version; + + constructor({ installed, version, hasCore, modules, ides, customModules }) { + this.installed = installed; + this.#version = version; + this.hasCore = hasCore; + this.modules = Object.freeze(modules.map((m) => Object.freeze({ ...m }))); + this.moduleIds = Object.freeze(this.modules.map((m) => m.id)); + this.ides = Object.freeze([...ides]); + this.customModules = Object.freeze([...customModules]); + Object.freeze(this); + } + + get version() { + if (!this.installed) { + throw new Error('version is not available when nothing is installed'); + } + return this.#version; + } + + static empty() { + return new ExistingInstall({ + installed: false, + version: null, + hasCore: false, + modules: [], + ides: [], + customModules: [], + }); + } + + /** + * Scan a bmad directory and return an immutable snapshot of what's installed. + * @param {string} bmadDir - Path to bmad directory + * @returns {Promise} + */ + static async detect(bmadDir) { + if (!(await fs.pathExists(bmadDir))) { + return ExistingInstall.empty(); + } + + let version = null; + let hasCore = false; + const modules = []; + let ides = []; + let customModules = []; + + const manifest = new Manifest(); + const manifestData = await manifest.read(bmadDir); + if (manifestData) { + version = manifestData.version; + if (manifestData.customModules) { + customModules = manifestData.customModules; + } + if (manifestData.ides) { + ides = manifestData.ides.filter((ide) => ide && typeof ide === 'string'); + } + } + + const corePath = path.join(bmadDir, 'core'); + if (await fs.pathExists(corePath)) { + hasCore = true; + + if (!version) { + const coreConfigPath = path.join(corePath, 'config.yaml'); + if (await fs.pathExists(coreConfigPath)) { + try { + const configContent = await fs.readFile(coreConfigPath, 'utf8'); + const config = yaml.parse(configContent); + if (config.version) { + version = config.version; + } + } catch { + // Ignore config read errors + } + } + } + } + + if (manifestData && manifestData.modules && manifestData.modules.length > 0) { + for (const moduleId of manifestData.modules) { + const modulePath = path.join(bmadDir, moduleId); + const moduleConfigPath = path.join(modulePath, 'config.yaml'); + + const moduleInfo = { + id: moduleId, + path: modulePath, + version: 'unknown', + }; + + if (await fs.pathExists(moduleConfigPath)) { + try { + const configContent = await fs.readFile(moduleConfigPath, 'utf8'); + const config = yaml.parse(configContent); + moduleInfo.version = config.version || 'unknown'; + moduleInfo.name = config.name || moduleId; + moduleInfo.description = config.description; + } catch { + // Ignore config read errors + } + } + + modules.push(moduleInfo); + } + } + + const installed = hasCore || modules.length > 0 || !!manifestData; + + if (!installed) { + return ExistingInstall.empty(); + } + + return new ExistingInstall({ installed, version, hasCore, modules, ides, customModules }); + } +} + +module.exports = { ExistingInstall }; diff --git a/tools/installer/core/install-paths.js b/tools/installer/core/install-paths.js new file mode 100644 index 000000000..7383f9bfd --- /dev/null +++ b/tools/installer/core/install-paths.js @@ -0,0 +1,129 @@ +const path = require('node:path'); +const fs = require('fs-extra'); +const { getProjectRoot } = require('../project-root'); +const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils'); + +class InstallPaths { + static async create(config) { + const srcDir = getProjectRoot(); + await assertReadableDir(srcDir, 'BMAD source root'); + + const pkgPath = path.join(srcDir, 'package.json'); + await assertReadableFile(pkgPath, 'package.json'); + const version = require(pkgPath).version; + + const projectRoot = path.resolve(config.directory); + await ensureWritableDir(projectRoot, 'project root'); + + const bmadDir = path.join(projectRoot, BMAD_FOLDER_NAME); + const isUpdate = await fs.pathExists(bmadDir); + + const configDir = path.join(bmadDir, '_config'); + const agentsDir = path.join(configDir, 'agents'); + const customCacheDir = path.join(configDir, 'custom'); + const coreDir = path.join(bmadDir, 'core'); + + for (const [dir, label] of [ + [bmadDir, 'bmad directory'], + [configDir, 'config directory'], + [agentsDir, 'agents config directory'], + [customCacheDir, 'custom modules cache'], + [coreDir, 'core module directory'], + ]) { + await ensureWritableDir(dir, label); + } + + return new InstallPaths({ + srcDir, + version, + projectRoot, + bmadDir, + configDir, + agentsDir, + customCacheDir, + coreDir, + isUpdate, + }); + } + + constructor(props) { + Object.assign(this, props); + Object.freeze(this); + } + + manifestFile() { + return path.join(this.configDir, 'manifest.yaml'); + } + agentManifest() { + return path.join(this.configDir, 'agent-manifest.csv'); + } + filesManifest() { + return path.join(this.configDir, 'files-manifest.csv'); + } + helpCatalog() { + return path.join(this.configDir, 'bmad-help.csv'); + } + moduleDir(name) { + return path.join(this.bmadDir, name); + } + moduleConfig(name) { + return path.join(this.bmadDir, name, 'config.yaml'); + } +} + +async function assertReadableDir(dirPath, label) { + const stat = await fs.stat(dirPath).catch(() => null); + if (!stat) { + throw new Error(`${label} does not exist: ${dirPath}`); + } + if (!stat.isDirectory()) { + throw new Error(`${label} is not a directory: ${dirPath}`); + } + try { + await fs.access(dirPath, fs.constants.R_OK); + } catch { + throw new Error(`${label} is not readable: ${dirPath}`); + } +} + +async function assertReadableFile(filePath, label) { + const stat = await fs.stat(filePath).catch(() => null); + if (!stat) { + throw new Error(`${label} does not exist: ${filePath}`); + } + if (!stat.isFile()) { + throw new Error(`${label} is not a file: ${filePath}`); + } + try { + await fs.access(filePath, fs.constants.R_OK); + } catch { + throw new Error(`${label} is not readable: ${filePath}`); + } +} + +async function ensureWritableDir(dirPath, label) { + const stat = await fs.stat(dirPath).catch(() => null); + if (stat && !stat.isDirectory()) { + throw new Error(`${label} exists but is not a directory: ${dirPath}`); + } + + try { + await fs.ensureDir(dirPath); + } catch (error) { + if (error.code === 'EACCES') { + throw new Error(`${label}: permission denied creating directory: ${dirPath}`); + } + if (error.code === 'ENOSPC') { + throw new Error(`${label}: no space left on device: ${dirPath}`); + } + throw new Error(`${label}: cannot create directory: ${dirPath} (${error.message})`); + } + + try { + await fs.access(dirPath, fs.constants.R_OK | fs.constants.W_OK); + } catch { + throw new Error(`${label} is not writable: ${dirPath}`); + } +} + +module.exports = { InstallPaths }; diff --git a/tools/installer/core/installer.js b/tools/installer/core/installer.js new file mode 100644 index 000000000..111c88b54 --- /dev/null +++ b/tools/installer/core/installer.js @@ -0,0 +1,1790 @@ +const path = require('node:path'); +const fs = require('fs-extra'); +const { Manifest } = require('./manifest'); +const { OfficialModules } = require('../modules/official-modules'); +const { CustomModules } = require('../modules/custom-modules'); +const { IdeManager } = require('../ide/manager'); +const { FileOps } = require('../file-ops'); +const { Config } = require('./config'); +const { getProjectRoot, getSourcePath } = require('../project-root'); +const { ManifestGenerator } = require('./manifest-generator'); +const prompts = require('../prompts'); +const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils'); +const { InstallPaths } = require('./install-paths'); +const { ExternalModuleManager } = require('../modules/external-manager'); + +const { ExistingInstall } = require('./existing-install'); + +class Installer { + constructor() { + this.externalModuleManager = new ExternalModuleManager(); + this.manifest = new Manifest(); + this.customModules = new CustomModules(); + this.ideManager = new IdeManager(); + this.fileOps = new FileOps(); + this.installedFiles = new Set(); // Track all installed files + this.bmadFolderName = BMAD_FOLDER_NAME; + } + + /** + * Main installation method + * @param {Object} config - Installation configuration + * @param {string} config.directory - Target directory + * @param {string[]} config.modules - Modules to install (including 'core') + * @param {string[]} config.ides - IDEs to configure + */ + async install(originalConfig) { + let updateState = null; + + try { + const config = Config.build(originalConfig); + const paths = await InstallPaths.create(config); + const officialModules = await OfficialModules.build(config, paths); + const existingInstall = await ExistingInstall.detect(paths.bmadDir); + + await this.customModules.discoverPaths(originalConfig, paths); + + if (existingInstall.installed) { + await this._removeDeselectedModules(existingInstall, config, paths); + updateState = await this._prepareUpdateState(paths, config, existingInstall, officialModules); + await this._removeDeselectedIdes(existingInstall, config, paths); + } + + await this._validateIdeSelection(config); + + // Results collector for consolidated summary + const results = []; + const addResult = (step, status, detail = '') => results.push({ step, status, detail }); + + await this._cacheCustomModules(paths, addResult); + + // Compute module lists: official = selected minus custom, all = both + const customModuleIds = new Set(this.customModules.paths.keys()); + const officialModuleIds = (config.modules || []).filter((m) => !customModuleIds.has(m)); + const allModules = [...officialModuleIds, ...[...customModuleIds].filter((id) => !officialModuleIds.includes(id))]; + + await this._installAndConfigure(config, originalConfig, paths, officialModuleIds, allModules, addResult, officialModules); + + await this._setupIdes(config, allModules, paths, addResult); + + const restoreResult = await this._restoreUserFiles(paths, updateState); + + // Render consolidated summary + await this.renderInstallSummary(results, { + bmadDir: paths.bmadDir, + modules: config.modules, + ides: config.ides, + customFiles: restoreResult.customFiles.length > 0 ? restoreResult.customFiles : undefined, + modifiedFiles: restoreResult.modifiedFiles.length > 0 ? restoreResult.modifiedFiles : undefined, + }); + + return { + success: true, + path: paths.bmadDir, + modules: config.modules, + ides: config.ides, + projectDir: paths.projectRoot, + }; + } catch (error) { + await prompts.log.error('Installation failed'); + + // Clean up any temp backup directories that were created before the failure + try { + if (updateState?.tempBackupDir && (await fs.pathExists(updateState.tempBackupDir))) { + await fs.remove(updateState.tempBackupDir); + } + if (updateState?.tempModifiedBackupDir && (await fs.pathExists(updateState.tempModifiedBackupDir))) { + await fs.remove(updateState.tempModifiedBackupDir); + } + } catch { + // Best-effort cleanup — don't mask the original error + } + + throw error; + } + } + + /** + * Remove modules that were previously installed but are no longer selected. + * No confirmation — the user's module selection is the decision. + */ + async _removeDeselectedModules(existingInstall, config, paths) { + const previouslyInstalled = new Set(existingInstall.moduleIds); + const newlySelected = new Set(config.modules || []); + const toRemove = [...previouslyInstalled].filter((m) => !newlySelected.has(m) && m !== 'core'); + + for (const moduleId of toRemove) { + const modulePath = paths.moduleDir(moduleId); + try { + if (await fs.pathExists(modulePath)) { + await fs.remove(modulePath); + } + } catch (error) { + await prompts.log.warn(`Warning: Failed to remove ${moduleId}: ${error.message}`); + } + } + } + + /** + * Fail fast if all selected IDEs are suspended. + */ + async _validateIdeSelection(config) { + if (!config.ides || config.ides.length === 0) return; + + await this.ideManager.ensureInitialized(); + const suspendedIdes = config.ides.filter((ide) => { + const handler = this.ideManager.handlers.get(ide); + return handler?.platformConfig?.suspended; + }); + + if (suspendedIdes.length > 0 && suspendedIdes.length === config.ides.length) { + for (const ide of suspendedIdes) { + const handler = this.ideManager.handlers.get(ide); + await prompts.log.error(`${handler.displayName || ide}: ${handler.platformConfig.suspended}`); + } + throw new Error( + `All selected tool(s) are suspended: ${suspendedIdes.join(', ')}. Installation aborted to prevent upgrading _bmad/ without a working IDE configuration.`, + ); + } + } + + /** + * Remove IDEs that were previously installed but are no longer selected. + * No confirmation — the user's IDE selection is the decision. + */ + async _removeDeselectedIdes(existingInstall, config, paths) { + const previouslyInstalled = new Set(existingInstall.ides); + const newlySelected = new Set(config.ides || []); + const toRemove = [...previouslyInstalled].filter((ide) => !newlySelected.has(ide)); + + if (toRemove.length === 0) return; + + await this.ideManager.ensureInitialized(); + for (const ide of toRemove) { + try { + const handler = this.ideManager.handlers.get(ide); + if (handler) { + await handler.cleanup(paths.projectRoot); + } + } catch (error) { + await prompts.log.warn(`Warning: Failed to remove ${ide}: ${error.message}`); + } + } + } + + /** + * Cache custom modules into the local cache directory. + * Updates this.customModules.paths in place with cached locations. + */ + async _cacheCustomModules(paths, addResult) { + if (!this.customModules.paths || this.customModules.paths.size === 0) return; + + const { CustomModuleCache } = require('./custom-module-cache'); + const customCache = new CustomModuleCache(paths.bmadDir); + + for (const [moduleId, sourcePath] of this.customModules.paths) { + const cachedInfo = await customCache.cacheModule(moduleId, sourcePath, { + sourcePath: sourcePath, + }); + this.customModules.paths.set(moduleId, cachedInfo.cachePath); + } + + addResult('Custom modules cached', 'ok'); + } + + /** + * Install modules, create directories, generate configs and manifests. + */ + async _installAndConfigure(config, originalConfig, paths, officialModuleIds, allModules, addResult, officialModules) { + const isQuickUpdate = config.isQuickUpdate(); + const moduleConfigs = officialModules.moduleConfigs; + + const dirResults = { createdDirs: [], movedDirs: [], createdWdsFolders: [] }; + + const installTasks = []; + + if (allModules.length > 0) { + installTasks.push({ + title: isQuickUpdate ? `Updating ${allModules.length} module(s)` : `Installing ${allModules.length} module(s)`, + task: async (message) => { + const installedModuleNames = new Set(); + + await this._installOfficialModules(config, paths, officialModuleIds, addResult, isQuickUpdate, officialModules, { + message, + installedModuleNames, + }); + + await this._installCustomModules(config, paths, addResult, officialModules, { + message, + installedModuleNames, + }); + + return `${allModules.length} module(s) ${isQuickUpdate ? 'updated' : 'installed'}`; + }, + }); + } + + installTasks.push({ + title: 'Creating module directories', + task: async (message) => { + const verboseMode = process.env.BMAD_VERBOSE_INSTALL === 'true' || config.verbose; + const moduleLogger = { + log: async (msg) => (verboseMode ? await prompts.log.message(msg) : undefined), + error: async (msg) => await prompts.log.error(msg), + warn: async (msg) => await prompts.log.warn(msg), + }; + + if (config.modules && config.modules.length > 0) { + for (const moduleName of config.modules) { + message(`Setting up ${moduleName}...`); + const result = await officialModules.createModuleDirectories(moduleName, paths.bmadDir, { + installedIDEs: config.ides || [], + moduleConfig: moduleConfigs[moduleName] || {}, + existingModuleConfig: officialModules.existingConfig?.[moduleName] || {}, + coreConfig: moduleConfigs.core || {}, + logger: moduleLogger, + silent: true, + }); + if (result) { + dirResults.createdDirs.push(...result.createdDirs); + dirResults.movedDirs.push(...(result.movedDirs || [])); + dirResults.createdWdsFolders.push(...result.createdWdsFolders); + } + } + } + + addResult('Module directories', 'ok'); + return 'Module directories created'; + }, + }); + + const configTask = { + title: 'Generating configurations', + task: async (message) => { + await this.generateModuleConfigs(paths.bmadDir, moduleConfigs); + addResult('Configurations', 'ok', 'generated'); + + this.installedFiles.add(paths.manifestFile()); + this.installedFiles.add(paths.agentManifest()); + + message('Generating manifests...'); + const manifestGen = new ManifestGenerator(); + + const allModulesForManifest = config.isQuickUpdate() + ? originalConfig._existingModules || allModules || [] + : originalConfig._preserveModules + ? [...allModules, ...originalConfig._preserveModules] + : allModules || []; + + let modulesForCsvPreserve; + if (config.isQuickUpdate()) { + modulesForCsvPreserve = originalConfig._existingModules || allModules || []; + } else { + modulesForCsvPreserve = originalConfig._preserveModules ? [...allModules, ...originalConfig._preserveModules] : allModules; + } + + await manifestGen.generateManifests(paths.bmadDir, allModulesForManifest, [...this.installedFiles], { + ides: config.ides || [], + preservedModules: modulesForCsvPreserve, + }); + + message('Generating help catalog...'); + await this.mergeModuleHelpCatalogs(paths.bmadDir); + addResult('Help catalog', 'ok'); + + return 'Configurations generated'; + }, + }; + installTasks.push(configTask); + + // Run install + dirs first, then render dir output, then run config generation + const mainTasks = installTasks.filter((t) => t !== configTask); + await prompts.tasks(mainTasks); + + const color = await prompts.getColor(); + if (dirResults.movedDirs.length > 0) { + const lines = dirResults.movedDirs.map((d) => ` ${d}`).join('\n'); + await prompts.log.message(color.cyan(`Moved directories:\n${lines}`)); + } + if (dirResults.createdDirs.length > 0) { + const lines = dirResults.createdDirs.map((d) => ` ${d}`).join('\n'); + await prompts.log.message(color.yellow(`Created directories:\n${lines}`)); + } + if (dirResults.createdWdsFolders.length > 0) { + const lines = dirResults.createdWdsFolders.map((f) => color.dim(` \u2713 ${f}/`)).join('\n'); + await prompts.log.message(color.cyan(`Created WDS folder structure:\n${lines}`)); + } + + await prompts.tasks([configTask]); + } + + /** + * Set up IDE integrations for each selected IDE. + */ + async _setupIdes(config, allModules, paths, addResult) { + if (config.skipIde || !config.ides || config.ides.length === 0) return; + + await this.ideManager.ensureInitialized(); + const validIdes = config.ides.filter((ide) => ide && typeof ide === 'string'); + + if (validIdes.length === 0) { + addResult('IDE configuration', 'warn', 'no valid IDEs selected'); + return; + } + + for (const ide of validIdes) { + const setupResult = await this.ideManager.setup(ide, paths.projectRoot, paths.bmadDir, { + selectedModules: allModules || [], + verbose: config.verbose, + }); + + if (setupResult.success) { + addResult(ide, 'ok', setupResult.detail || ''); + } else { + addResult(ide, 'error', setupResult.error || 'failed'); + } + } + } + + /** + * Restore custom and modified files that were backed up before the update. + * No-op for fresh installs (updateState is null). + * @param {Object} paths - InstallPaths instance + * @param {Object|null} updateState - From _prepareUpdateState, or null for fresh installs + * @returns {Object} { customFiles, modifiedFiles } — lists of restored files + */ + async _restoreUserFiles(paths, updateState) { + const noFiles = { customFiles: [], modifiedFiles: [] }; + + if (!updateState || (updateState.customFiles.length === 0 && updateState.modifiedFiles.length === 0)) { + return noFiles; + } + + let restoredCustomFiles = []; + let restoredModifiedFiles = []; + + await prompts.tasks([ + { + title: 'Finalizing installation', + task: async (message) => { + if (updateState.customFiles.length > 0) { + message(`Restoring ${updateState.customFiles.length} custom files...`); + + for (const originalPath of updateState.customFiles) { + const relativePath = path.relative(paths.bmadDir, originalPath); + const backupPath = path.join(updateState.tempBackupDir, relativePath); + + if (await fs.pathExists(backupPath)) { + await fs.ensureDir(path.dirname(originalPath)); + await fs.copy(backupPath, originalPath, { overwrite: true }); + } + } + + if (updateState.tempBackupDir && (await fs.pathExists(updateState.tempBackupDir))) { + await fs.remove(updateState.tempBackupDir); + } + + restoredCustomFiles = updateState.customFiles; + } + + if (updateState.modifiedFiles.length > 0) { + restoredModifiedFiles = updateState.modifiedFiles; + + if (updateState.tempModifiedBackupDir && (await fs.pathExists(updateState.tempModifiedBackupDir))) { + message(`Restoring ${restoredModifiedFiles.length} modified files as .bak...`); + + for (const modifiedFile of restoredModifiedFiles) { + const relativePath = path.relative(paths.bmadDir, modifiedFile.path); + const tempBackupPath = path.join(updateState.tempModifiedBackupDir, relativePath); + const bakPath = modifiedFile.path + '.bak'; + + if (await fs.pathExists(tempBackupPath)) { + await fs.ensureDir(path.dirname(bakPath)); + await fs.copy(tempBackupPath, bakPath, { overwrite: true }); + } + } + + await fs.remove(updateState.tempModifiedBackupDir); + } + } + + return 'Installation finalized'; + }, + }, + ]); + + return { customFiles: restoredCustomFiles, modifiedFiles: restoredModifiedFiles }; + } + + /** + * Scan the custom module cache directory and register any cached custom modules + * that aren't already known from the manifest or external module list. + * @param {Object} paths - InstallPaths instance + */ + async _scanCachedCustomModules(paths) { + const cacheDir = paths.customCacheDir; + if (!(await fs.pathExists(cacheDir))) { + return; + } + + const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true }); + + for (const cachedModule of cachedModules) { + const moduleId = cachedModule.name; + const cachedPath = path.join(cacheDir, moduleId); + + // Skip if path doesn't exist (broken symlink, deleted dir) - avoids lstat ENOENT + if (!(await fs.pathExists(cachedPath)) || !cachedModule.isDirectory()) { + continue; + } + + // Skip if we already have this module from manifest + if (this.customModules.paths.has(moduleId)) { + continue; + } + + // Check if this is an external official module - skip cache for those + const isExternal = await this.externalModuleManager.hasModule(moduleId); + if (isExternal) { + continue; + } + + // Check if this is actually a custom module (has module.yaml) + const moduleYamlPath = path.join(cachedPath, 'module.yaml'); + if (await fs.pathExists(moduleYamlPath)) { + this.customModules.paths.set(moduleId, cachedPath); + } + } + } + + /** + * Common update preparation: detect files, preserve core config, scan cache, back up. + * @param {Object} paths - InstallPaths instance + * @param {Object} config - Clean config (may have coreConfig updated) + * @param {Object} existingInstall - Detection result + * @param {Object} officialModules - OfficialModules instance + * @returns {Object} Update state: { customFiles, modifiedFiles, tempBackupDir, tempModifiedBackupDir } + */ + async _prepareUpdateState(paths, config, existingInstall, officialModules) { + // Detect custom and modified files BEFORE updating (compare current files vs files-manifest.csv) + const existingFilesManifest = await this.readFilesManifest(paths.bmadDir); + const { customFiles, modifiedFiles } = await this.detectCustomFiles(paths.bmadDir, existingFilesManifest); + + // Preserve existing core configuration during updates + // (no-op for quick-update which already has core config from collectModuleConfigQuick) + const coreConfigPath = paths.moduleConfig('core'); + if ((await fs.pathExists(coreConfigPath)) && (!config.coreConfig || Object.keys(config.coreConfig).length === 0)) { + try { + const yaml = require('yaml'); + const coreConfigContent = await fs.readFile(coreConfigPath, 'utf8'); + const existingCoreConfig = yaml.parse(coreConfigContent); + + config.coreConfig = existingCoreConfig; + officialModules.moduleConfigs.core = existingCoreConfig; + } catch (error) { + await prompts.log.warn(`Warning: Could not read existing core config: ${error.message}`); + } + } + + await this._scanCachedCustomModules(paths); + + const backupDirs = await this._backupUserFiles(paths, customFiles, modifiedFiles); + + return { + customFiles, + modifiedFiles, + tempBackupDir: backupDirs.tempBackupDir, + tempModifiedBackupDir: backupDirs.tempModifiedBackupDir, + }; + } + + /** + * Back up custom and modified files to temp directories before overwriting. + * Returns the temp directory paths (or undefined if no files to back up). + * @param {Object} paths - InstallPaths instance + * @param {string[]} customFiles - Absolute paths of custom (user-added) files + * @param {Object[]} modifiedFiles - Array of { path, relativePath } for modified files + * @returns {Object} { tempBackupDir, tempModifiedBackupDir } — undefined if no files + */ + async _backupUserFiles(paths, customFiles, modifiedFiles) { + let tempBackupDir; + let tempModifiedBackupDir; + + if (customFiles.length > 0) { + tempBackupDir = path.join(paths.projectRoot, '_bmad-custom-backup-temp'); + await fs.ensureDir(tempBackupDir); + + for (const customFile of customFiles) { + const relativePath = path.relative(paths.bmadDir, customFile); + const backupPath = path.join(tempBackupDir, relativePath); + await fs.ensureDir(path.dirname(backupPath)); + await fs.copy(customFile, backupPath); + } + } + + if (modifiedFiles.length > 0) { + tempModifiedBackupDir = path.join(paths.projectRoot, '_bmad-modified-backup-temp'); + await fs.ensureDir(tempModifiedBackupDir); + + for (const modifiedFile of modifiedFiles) { + const relativePath = path.relative(paths.bmadDir, modifiedFile.path); + const tempBackupPath = path.join(tempModifiedBackupDir, relativePath); + await fs.ensureDir(path.dirname(tempBackupPath)); + await fs.copy(modifiedFile.path, tempBackupPath, { overwrite: true }); + } + } + + return { tempBackupDir, tempModifiedBackupDir }; + } + + /** + * Install official (non-custom) modules. + * @param {Object} config - Installation configuration + * @param {Object} paths - InstallPaths instance + * @param {string[]} officialModuleIds - Official module IDs to install + * @param {Function} addResult - Callback to record installation results + * @param {boolean} isQuickUpdate - Whether this is a quick update + * @param {Object} ctx - Shared context: { message, installedModuleNames } + */ + async _installOfficialModules(config, paths, officialModuleIds, addResult, isQuickUpdate, officialModules, ctx) { + const { message, installedModuleNames } = ctx; + + for (const moduleName of officialModuleIds) { + if (installedModuleNames.has(moduleName)) continue; + installedModuleNames.add(moduleName); + + message(`${isQuickUpdate ? 'Updating' : 'Installing'} ${moduleName}...`); + + const moduleConfig = officialModules.moduleConfigs[moduleName] || {}; + await officialModules.install( + moduleName, + paths.bmadDir, + (filePath) => { + this.installedFiles.add(filePath); + }, + { + skipModuleInstaller: true, + moduleConfig: moduleConfig, + installer: this, + silent: true, + }, + ); + + addResult(`Module: ${moduleName}`, 'ok', isQuickUpdate ? 'updated' : 'installed'); + } + } + + /** + * Install custom modules using CustomModules.install(). + * Source paths come from this.customModules.paths (populated by discoverPaths). + */ + async _installCustomModules(config, paths, addResult, officialModules, ctx) { + const { message, installedModuleNames } = ctx; + const isQuickUpdate = config.isQuickUpdate(); + + for (const [moduleName, sourcePath] of this.customModules.paths) { + if (installedModuleNames.has(moduleName)) continue; + installedModuleNames.add(moduleName); + + message(`${isQuickUpdate ? 'Updating' : 'Installing'} ${moduleName}...`); + + const collectedModuleConfig = officialModules.moduleConfigs[moduleName] || {}; + const result = await this.customModules.install(moduleName, paths.bmadDir, (filePath) => this.installedFiles.add(filePath), { + moduleConfig: collectedModuleConfig, + }); + + // Generate runtime config.yaml with merged values + await this.generateModuleConfigs(paths.bmadDir, { + [moduleName]: { ...config.coreConfig, ...result.moduleConfig, ...collectedModuleConfig }, + }); + + addResult(`Module: ${moduleName}`, 'ok', isQuickUpdate ? 'updated' : 'installed'); + } + } + + /** + * Read files-manifest.csv + * @param {string} bmadDir - BMAD installation directory + * @returns {Array} Array of file entries from files-manifest.csv + */ + async readFilesManifest(bmadDir) { + const filesManifestPath = path.join(bmadDir, '_config', 'files-manifest.csv'); + if (!(await fs.pathExists(filesManifestPath))) { + return []; + } + + try { + const content = await fs.readFile(filesManifestPath, 'utf8'); + const lines = content.split('\n'); + const files = []; + + for (let i = 1; i < lines.length; i++) { + // Skip header + const line = lines[i].trim(); + if (!line) continue; + + // Parse CSV line properly handling quoted values + const parts = []; + let current = ''; + let inQuotes = false; + + for (const char of line) { + if (char === '"') { + inQuotes = !inQuotes; + } else if (char === ',' && !inQuotes) { + parts.push(current); + current = ''; + } else { + current += char; + } + } + parts.push(current); // Add last part + + if (parts.length >= 4) { + files.push({ + type: parts[0], + name: parts[1], + module: parts[2], + path: parts[3], + hash: parts[4] || null, // Hash may not exist in old manifests + }); + } + } + + return files; + } catch (error) { + await prompts.log.warn('Could not read files-manifest.csv: ' + error.message); + return []; + } + } + + /** + * Detect custom and modified files + * @param {string} bmadDir - BMAD installation directory + * @param {Array} existingFilesManifest - Previous files from files-manifest.csv + * @returns {Object} Object with customFiles and modifiedFiles arrays + */ + async detectCustomFiles(bmadDir, existingFilesManifest) { + const customFiles = []; + const modifiedFiles = []; + + // Memory is always in _bmad/_memory + const bmadMemoryPath = '_memory'; + + // Check if the manifest has hashes - if not, we can't detect modifications + let manifestHasHashes = false; + if (existingFilesManifest && existingFilesManifest.length > 0) { + manifestHasHashes = existingFilesManifest.some((f) => f.hash); + } + + // Build map of previously installed files from files-manifest.csv with their hashes + const installedFilesMap = new Map(); + for (const fileEntry of existingFilesManifest) { + if (fileEntry.path) { + const absolutePath = path.join(bmadDir, fileEntry.path); + installedFilesMap.set(path.normalize(absolutePath), { + hash: fileEntry.hash, + relativePath: fileEntry.path, + }); + } + } + + // Recursively scan bmadDir for all files + const scanDirectory = async (dir) => { + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + // Skip certain directories + if (entry.name === 'node_modules' || entry.name === '.git') { + continue; + } + await scanDirectory(fullPath); + } else if (entry.isFile()) { + const normalizedPath = path.normalize(fullPath); + const fileInfo = installedFilesMap.get(normalizedPath); + + // Skip certain system files that are auto-generated + const relativePath = path.relative(bmadDir, fullPath); + const fileName = path.basename(fullPath); + + // Skip _config directory EXCEPT for modified agent customizations + if (relativePath.startsWith('_config/') || relativePath.startsWith('_config\\')) { + // Special handling for .customize.yaml files - only preserve if modified + if (relativePath.includes('/agents/') && fileName.endsWith('.customize.yaml')) { + // Check if the customization file has been modified from manifest + const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml'); + if (await fs.pathExists(manifestPath)) { + const crypto = require('node:crypto'); + const currentContent = await fs.readFile(fullPath, 'utf8'); + const currentHash = crypto.createHash('sha256').update(currentContent).digest('hex'); + + const yaml = require('yaml'); + const manifestContent = await fs.readFile(manifestPath, 'utf8'); + const manifestData = yaml.parse(manifestContent); + const originalHash = manifestData.agentCustomizations?.[relativePath]; + + // Only add to customFiles if hash differs (user modified) + if (originalHash && currentHash !== originalHash) { + customFiles.push(fullPath); + } + } + } + continue; + } + + if (relativePath.startsWith(bmadMemoryPath + '/') && path.dirname(relativePath).includes('-sidecar')) { + continue; + } + + // Skip config.yaml files - these are regenerated on each install/update + if (fileName === 'config.yaml') { + continue; + } + + if (!fileInfo) { + // File not in manifest = custom file + // EXCEPT: Agent .md files in module folders are generated files, not custom + // Only treat .md files under _config/agents/ as custom + if (!(fileName.endsWith('.md') && relativePath.includes('/agents/') && !relativePath.startsWith('_config/'))) { + customFiles.push(fullPath); + } + } else if (manifestHasHashes && fileInfo.hash) { + // File in manifest with hash - check if it was modified + const currentHash = await this.manifest.calculateFileHash(fullPath); + if (currentHash && currentHash !== fileInfo.hash) { + // Hash changed = file was modified + modifiedFiles.push({ + path: fullPath, + relativePath: fileInfo.relativePath, + }); + } + } + } + } + } catch { + // Ignore errors scanning directories + } + }; + + await scanDirectory(bmadDir); + return { customFiles, modifiedFiles }; + } + + /** + * Generate clean config.yaml files for each installed module + * @param {string} bmadDir - BMAD installation directory + * @param {Object} moduleConfigs - Collected configuration values + */ + async generateModuleConfigs(bmadDir, moduleConfigs) { + const yaml = require('yaml'); + + // Extract core config values to share with other modules + const coreConfig = moduleConfigs.core || {}; + + // Get all installed module directories + const entries = await fs.readdir(bmadDir, { withFileTypes: true }); + const installedModules = entries + .filter((entry) => entry.isDirectory() && entry.name !== '_config' && entry.name !== 'docs') + .map((entry) => entry.name); + + // Generate config.yaml for each installed module + for (const moduleName of installedModules) { + const modulePath = path.join(bmadDir, moduleName); + + // Get module-specific config or use empty object if none + const config = moduleConfigs[moduleName] || {}; + + if (await fs.pathExists(modulePath)) { + const configPath = path.join(modulePath, 'config.yaml'); + + // Create header + const packageJson = require(path.join(getProjectRoot(), 'package.json')); + const header = `# ${moduleName.toUpperCase()} Module Configuration +# Generated by BMAD installer +# Version: ${packageJson.version} +# Date: ${new Date().toISOString()} + +`; + + // For non-core modules, add core config values directly + let finalConfig = { ...config }; + let coreSection = ''; + + if (moduleName !== 'core' && coreConfig && Object.keys(coreConfig).length > 0) { + // Add core values directly to the module config + // These will be available for reference in the module + finalConfig = { + ...config, + ...coreConfig, // Spread core config values directly into the module config + }; + + // Create a comment section to identify core values + coreSection = '\n# Core Configuration Values\n'; + } + + // Clean the config to remove any non-serializable values (like functions) + const cleanConfig = structuredClone(finalConfig); + + // Convert config to YAML + let yamlContent = yaml.stringify(cleanConfig, { + indent: 2, + lineWidth: 0, + minContentWidth: 0, + }); + + // If we have core values, reorganize the YAML to group them with their comment + if (coreSection && moduleName !== 'core') { + // Split the YAML into lines + const lines = yamlContent.split('\n'); + const moduleConfigLines = []; + const coreConfigLines = []; + + // Separate module-specific and core config lines + for (const line of lines) { + const key = line.split(':')[0].trim(); + if (Object.prototype.hasOwnProperty.call(coreConfig, key)) { + coreConfigLines.push(line); + } else { + moduleConfigLines.push(line); + } + } + + // Rebuild YAML with module config first, then core config with comment + yamlContent = moduleConfigLines.join('\n'); + if (coreConfigLines.length > 0) { + yamlContent += coreSection + coreConfigLines.join('\n'); + } + } + + // Write the clean config file with POSIX-compliant final newline + const content = header + yamlContent; + await fs.writeFile(configPath, content.endsWith('\n') ? content : content + '\n', 'utf8'); + + // Track the config file in installedFiles + this.installedFiles.add(configPath); + } + } + } + + /** + * Merge all module-help.csv files into a single bmad-help.csv + * Scans all installed modules for module-help.csv and merges them + * Enriches agent info from agent-manifest.csv + * Output is written to _bmad/_config/bmad-help.csv + * @param {string} bmadDir - BMAD installation directory + */ + async mergeModuleHelpCatalogs(bmadDir) { + const allRows = []; + const headerRow = + 'module,phase,name,code,sequence,workflow-file,command,required,agent-name,agent-command,agent-display-name,agent-title,options,description,output-location,outputs'; + + // Load agent manifest for agent info lookup + const agentManifestPath = path.join(bmadDir, '_config', 'agent-manifest.csv'); + const agentInfo = new Map(); // agent-name -> {command, displayName, title+icon} + + if (await fs.pathExists(agentManifestPath)) { + const manifestContent = await fs.readFile(agentManifestPath, 'utf8'); + const lines = manifestContent.split('\n').filter((line) => line.trim()); + + for (const line of lines) { + if (line.startsWith('name,')) continue; // Skip header + + const cols = line.split(','); + if (cols.length >= 4) { + const agentName = cols[0].replaceAll('"', '').trim(); + const displayName = cols[1].replaceAll('"', '').trim(); + const title = cols[2].replaceAll('"', '').trim(); + const icon = cols[3].replaceAll('"', '').trim(); + const module = cols[10] ? cols[10].replaceAll('"', '').trim() : ''; + + // Build agent command: bmad:module:agent:name + const agentCommand = module ? `bmad:${module}:agent:${agentName}` : `bmad:agent:${agentName}`; + + agentInfo.set(agentName, { + command: agentCommand, + displayName: displayName || agentName, + title: icon && title ? `${icon} ${title}` : title || agentName, + }); + } + } + } + + // Get all installed module directories + const entries = await fs.readdir(bmadDir, { withFileTypes: true }); + const installedModules = entries + .filter((entry) => entry.isDirectory() && entry.name !== '_config' && entry.name !== 'docs' && entry.name !== '_memory') + .map((entry) => entry.name); + + // Add core module to scan (it's installed at root level as _config, but we check src/core-skills) + const coreModulePath = getSourcePath('core-skills'); + const modulePaths = new Map(); + + // Map all module source paths + if (await fs.pathExists(coreModulePath)) { + modulePaths.set('core', coreModulePath); + } + + // Map installed module paths + for (const moduleName of installedModules) { + const modulePath = path.join(bmadDir, moduleName); + modulePaths.set(moduleName, modulePath); + } + + // Scan each module for module-help.csv + for (const [moduleName, modulePath] of modulePaths) { + const helpFilePath = path.join(modulePath, 'module-help.csv'); + + if (await fs.pathExists(helpFilePath)) { + try { + const content = await fs.readFile(helpFilePath, 'utf8'); + const lines = content.split('\n').filter((line) => line.trim() && !line.startsWith('#')); + + for (const line of lines) { + // Skip header row + if (line.startsWith('module,')) { + continue; + } + + // Parse the line - handle quoted fields with commas + const columns = this.parseCSVLine(line); + if (columns.length >= 12) { + // Map old schema to new schema + // Old: module,phase,name,code,sequence,workflow-file,command,required,agent,options,description,output-location,outputs + // New: module,phase,name,code,sequence,workflow-file,command,required,agent-name,agent-command,agent-display-name,agent-title,options,description,output-location,outputs + + const [ + module, + phase, + name, + code, + sequence, + workflowFile, + command, + required, + agentName, + options, + description, + outputLocation, + outputs, + ] = columns; + + // If module column is empty, set it to this module's name (except for core which stays empty for universal tools) + const finalModule = (!module || module.trim() === '') && moduleName !== 'core' ? moduleName : module || ''; + + // Lookup agent info + const cleanAgentName = agentName ? agentName.trim() : ''; + const agentData = agentInfo.get(cleanAgentName) || { command: '', displayName: '', title: '' }; + + // Build new row with agent info + const newRow = [ + finalModule, + phase || '', + name || '', + code || '', + sequence || '', + workflowFile || '', + command || '', + required || 'false', + cleanAgentName, + agentData.command, + agentData.displayName, + agentData.title, + options || '', + description || '', + outputLocation || '', + outputs || '', + ]; + + allRows.push(newRow.map((c) => this.escapeCSVField(c)).join(',')); + } + } + + if (process.env.BMAD_VERBOSE_INSTALL === 'true') { + await prompts.log.message(` Merged module-help from: ${moduleName}`); + } + } catch (error) { + await prompts.log.warn(` Warning: Failed to read module-help.csv from ${moduleName}: ${error.message}`); + } + } + } + + // Sort by module, then phase, then sequence + allRows.sort((a, b) => { + const colsA = this.parseCSVLine(a); + const colsB = this.parseCSVLine(b); + + // Module comparison (empty module/universal tools come first) + const moduleA = (colsA[0] || '').toLowerCase(); + const moduleB = (colsB[0] || '').toLowerCase(); + if (moduleA !== moduleB) { + return moduleA.localeCompare(moduleB); + } + + // Phase comparison + const phaseA = colsA[1] || ''; + const phaseB = colsB[1] || ''; + if (phaseA !== phaseB) { + return phaseA.localeCompare(phaseB); + } + + // Sequence comparison + const seqA = parseInt(colsA[4] || '0', 10); + const seqB = parseInt(colsB[4] || '0', 10); + return seqA - seqB; + }); + + // Write merged catalog + const outputDir = path.join(bmadDir, '_config'); + await fs.ensureDir(outputDir); + const outputPath = path.join(outputDir, 'bmad-help.csv'); + + const mergedContent = [headerRow, ...allRows].join('\n'); + await fs.writeFile(outputPath, mergedContent, 'utf8'); + + // Track the installed file + this.installedFiles.add(outputPath); + + if (process.env.BMAD_VERBOSE_INSTALL === 'true') { + await prompts.log.message(` Generated bmad-help.csv: ${allRows.length} workflows`); + } + } + + /** + * Render a consolidated install summary using prompts.note() + * @param {Array} results - Array of {step, status: 'ok'|'error'|'warn', detail} + * @param {Object} context - {bmadDir, modules, ides, customFiles, modifiedFiles} + */ + async renderInstallSummary(results, context = {}) { + const color = await prompts.getColor(); + const selectedIdes = new Set((context.ides || []).map((ide) => String(ide).toLowerCase())); + + // Build step lines with status indicators + const lines = []; + for (const r of results) { + let stepLabel = null; + + if (r.status !== 'ok') { + stepLabel = r.step; + } else if (r.step === 'Core') { + stepLabel = 'BMAD'; + } else if (r.step.startsWith('Module: ')) { + stepLabel = r.step; + } else if (selectedIdes.has(String(r.step).toLowerCase())) { + stepLabel = r.step; + } + + if (!stepLabel) { + continue; + } + + let icon; + if (r.status === 'ok') { + icon = color.green('\u2713'); + } else if (r.status === 'warn') { + icon = color.yellow('!'); + } else { + icon = color.red('\u2717'); + } + const detail = r.detail ? color.dim(` (${r.detail})`) : ''; + lines.push(` ${icon} ${stepLabel}${detail}`); + } + + if ((context.ides || []).length === 0) { + lines.push(` ${color.green('\u2713')} No IDE selected ${color.dim('(installed in _bmad only)')}`); + } + + // Context and warnings + lines.push(''); + if (context.bmadDir) { + lines.push(` Installed to: ${color.dim(context.bmadDir)}`); + } + if (context.customFiles && context.customFiles.length > 0) { + lines.push(` ${color.cyan(`Custom files preserved: ${context.customFiles.length}`)}`); + } + if (context.modifiedFiles && context.modifiedFiles.length > 0) { + lines.push(` ${color.yellow(`Modified files backed up (.bak): ${context.modifiedFiles.length}`)}`); + } + + // Next steps + lines.push( + '', + ' Next steps:', + ` Read our new Docs Site: ${color.dim('https://docs.bmad-method.org/')}`, + ` Join our Discord: ${color.dim('https://discord.gg/gk8jAdXWmj')}`, + ` Star us on GitHub: ${color.dim('https://github.com/bmad-code-org/BMAD-METHOD/')}`, + ` Subscribe on YouTube: ${color.dim('https://www.youtube.com/@BMadCode')}`, + ); + if (context.ides && context.ides.length > 0) { + lines.push(` Invoke the ${color.cyan('bmad-help')} skill in your IDE Agent to get started`); + } + + await prompts.note(lines.join('\n'), 'BMAD is ready to use!'); + } + + /** + * Quick update method - preserves all settings and only prompts for new config fields + * @param {Object} config - Configuration with directory + * @returns {Object} Update result + */ + async quickUpdate(config) { + const projectDir = path.resolve(config.directory); + const { bmadDir } = await this.findBmadDir(projectDir); + + // Check if bmad directory exists + if (!(await fs.pathExists(bmadDir))) { + throw new Error(`BMAD not installed at ${bmadDir}. Use regular install for first-time setup.`); + } + + // Detect existing installation + const existingInstall = await ExistingInstall.detect(bmadDir); + const installedModules = existingInstall.moduleIds; + const configuredIdes = existingInstall.ides; + const projectRoot = path.dirname(bmadDir); + + // Get custom module sources: first from --custom-content (re-cache from source), then from cache + const customModuleSources = new Map(); + if (config.customContent?.sources?.length > 0) { + for (const source of config.customContent.sources) { + if (source.id && source.path && (await fs.pathExists(source.path))) { + customModuleSources.set(source.id, { + id: source.id, + name: source.name || source.id, + sourcePath: source.path, + cached: false, // From CLI, will be re-cached + }); + } + } + } + const cacheDir = path.join(bmadDir, '_config', 'custom'); + if (await fs.pathExists(cacheDir)) { + const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true }); + + for (const cachedModule of cachedModules) { + const moduleId = cachedModule.name; + const cachedPath = path.join(cacheDir, moduleId); + + // Skip if path doesn't exist (broken symlink, deleted dir) - avoids lstat ENOENT + if (!(await fs.pathExists(cachedPath))) { + continue; + } + if (!cachedModule.isDirectory()) { + continue; + } + + // Skip if we already have this module from manifest + if (customModuleSources.has(moduleId)) { + continue; + } + + // Check if this is an external official module - skip cache for those + const isExternal = await this.externalModuleManager.hasModule(moduleId); + if (isExternal) { + continue; + } + + // Check if this is actually a custom module (has module.yaml) + const moduleYamlPath = path.join(cachedPath, 'module.yaml'); + if (await fs.pathExists(moduleYamlPath)) { + customModuleSources.set(moduleId, { + id: moduleId, + name: moduleId, + sourcePath: cachedPath, + cached: true, + }); + } + } + } + + // Get available modules (what we have source for) + const availableModulesData = await new OfficialModules().listAvailable(); + const availableModules = [...availableModulesData.modules, ...availableModulesData.customModules]; + + // Add external official modules to available modules + const externalModules = await this.externalModuleManager.listAvailable(); + for (const externalModule of externalModules) { + if (installedModules.includes(externalModule.code) && !availableModules.some((m) => m.id === externalModule.code)) { + availableModules.push({ + id: externalModule.code, + name: externalModule.name, + isExternal: true, + fromExternal: true, + }); + } + } + + // Add custom modules from manifest if their sources exist + for (const [moduleId, customModule] of customModuleSources) { + const sourcePath = customModule.sourcePath; + if (sourcePath && (await fs.pathExists(sourcePath)) && !availableModules.some((m) => m.id === moduleId)) { + availableModules.push({ + id: moduleId, + name: customModule.name || moduleId, + path: sourcePath, + isCustom: true, + fromManifest: true, + }); + } + } + + // Handle missing custom module sources + const customModuleResult = await this.handleMissingCustomSources( + customModuleSources, + bmadDir, + projectRoot, + 'update', + installedModules, + config.skipPrompts || false, + ); + + const { validCustomModules, keptModulesWithoutSources } = customModuleResult; + + const customModulesFromManifest = validCustomModules.map((m) => ({ + ...m, + isCustom: true, + hasUpdate: true, + })); + + const allAvailableModules = [...availableModules, ...customModulesFromManifest]; + const availableModuleIds = new Set(allAvailableModules.map((m) => m.id)); + + // Only update modules that are BOTH installed AND available (we have source for) + const modulesToUpdate = installedModules.filter((id) => availableModuleIds.has(id)); + const skippedModules = installedModules.filter((id) => !availableModuleIds.has(id)); + + // Add custom modules that were kept without sources to the skipped modules + for (const keptModule of keptModulesWithoutSources) { + if (!skippedModules.includes(keptModule)) { + skippedModules.push(keptModule); + } + } + + if (skippedModules.length > 0) { + await prompts.log.warn(`Skipping ${skippedModules.length} module(s) - no source available: ${skippedModules.join(', ')}`); + } + + // Load existing configs and collect new fields (if any) + await prompts.log.info('Checking for new configuration options...'); + const quickModules = new OfficialModules(); + await quickModules.loadExistingConfig(projectDir); + + let promptedForNewFields = false; + + const corePrompted = await quickModules.collectModuleConfigQuick('core', projectDir, true); + if (corePrompted) { + promptedForNewFields = true; + } + + for (const moduleName of modulesToUpdate) { + const modulePrompted = await quickModules.collectModuleConfigQuick(moduleName, projectDir, true); + if (modulePrompted) { + promptedForNewFields = true; + } + } + + if (!promptedForNewFields) { + await prompts.log.success('All configuration is up to date, no new options to configure'); + } + + quickModules.collectedConfig._meta = { + version: require(path.join(getProjectRoot(), 'package.json')).version, + installDate: new Date().toISOString(), + lastModified: new Date().toISOString(), + }; + + // Build config and delegate to install() + const installConfig = { + directory: projectDir, + modules: modulesToUpdate, + ides: configuredIdes, + coreConfig: quickModules.collectedConfig.core, + moduleConfigs: quickModules.collectedConfig, + actionType: 'install', + _quickUpdate: true, + _preserveModules: skippedModules, + _customModuleSources: customModuleSources, + _existingModules: installedModules, + customContent: config.customContent, + }; + + await this.install(installConfig); + + return { + success: true, + moduleCount: modulesToUpdate.length, + hadNewFields: promptedForNewFields, + modules: modulesToUpdate, + skippedModules: skippedModules, + ides: configuredIdes, + }; + } + + /** + * Uninstall BMAD with selective removal options + * @param {string} directory - Project directory + * @param {Object} options - Uninstall options + * @param {boolean} [options.removeModules=true] - Remove _bmad/ directory + * @param {boolean} [options.removeIdeConfigs=true] - Remove IDE configurations + * @param {boolean} [options.removeOutputFolder=false] - Remove user artifacts output folder + * @returns {Object} Result with success status and removed components + */ + async uninstall(directory, options = {}) { + const projectDir = path.resolve(directory); + const { bmadDir } = await this.findBmadDir(projectDir); + + if (!(await fs.pathExists(bmadDir))) { + return { success: false, reason: 'not-installed' }; + } + + // 1. DETECT: Read state BEFORE deleting anything + const existingInstall = await ExistingInstall.detect(bmadDir); + const outputFolder = await this._readOutputFolder(bmadDir); + + const removed = { modules: false, ideConfigs: false, outputFolder: false }; + + // 2. IDE CLEANUP (before _bmad/ deletion so configs are accessible) + if (options.removeIdeConfigs !== false) { + await this.uninstallIdeConfigs(projectDir, existingInstall, { silent: options.silent }); + removed.ideConfigs = true; + } + + // 3. OUTPUT FOLDER (only if explicitly requested) + if (options.removeOutputFolder === true && outputFolder) { + removed.outputFolder = await this.uninstallOutputFolder(projectDir, outputFolder); + } + + // 4. BMAD DIRECTORY (last, after everything that needs it) + if (options.removeModules !== false) { + removed.modules = await this.uninstallModules(projectDir); + } + + return { success: true, removed, version: existingInstall.installed ? existingInstall.version : null }; + } + + /** + * Uninstall IDE configurations only + * @param {string} projectDir - Project directory + * @param {Object} existingInstall - Detection result from detector.detect() + * @param {Object} [options] - Options (e.g. { silent: true }) + * @returns {Promise} Results from IDE cleanup + */ + async uninstallIdeConfigs(projectDir, existingInstall, options = {}) { + await this.ideManager.ensureInitialized(); + const cleanupOptions = { isUninstall: true, silent: options.silent }; + const ideList = existingInstall.ides; + if (ideList.length > 0) { + return this.ideManager.cleanupByList(projectDir, ideList, cleanupOptions); + } + return this.ideManager.cleanup(projectDir, cleanupOptions); + } + + /** + * Remove user artifacts output folder + * @param {string} projectDir - Project directory + * @param {string} outputFolder - Output folder name (relative) + * @returns {Promise} Whether the folder was removed + */ + async uninstallOutputFolder(projectDir, outputFolder) { + if (!outputFolder) return false; + const resolvedProject = path.resolve(projectDir); + const outputPath = path.resolve(resolvedProject, outputFolder); + if (!outputPath.startsWith(resolvedProject + path.sep)) { + return false; + } + if (await fs.pathExists(outputPath)) { + await fs.remove(outputPath); + return true; + } + return false; + } + + /** + * Remove the _bmad/ directory + * @param {string} projectDir - Project directory + * @returns {Promise} Whether the directory was removed + */ + async uninstallModules(projectDir) { + const { bmadDir } = await this.findBmadDir(projectDir); + if (await fs.pathExists(bmadDir)) { + await fs.remove(bmadDir); + return true; + } + return false; + } + + /** + * Get installation status + */ + async getStatus(directory) { + const projectDir = path.resolve(directory); + const { bmadDir } = await this.findBmadDir(projectDir); + return await ExistingInstall.detect(bmadDir); + } + + /** + * Get available modules + */ + async getAvailableModules() { + return await new OfficialModules().listAvailable(); + } + + /** + * Get the configured output folder name for a project + * Resolves bmadDir internally from projectDir + * @param {string} projectDir - Project directory + * @returns {string} Output folder name (relative, default: '_bmad-output') + */ + async getOutputFolder(projectDir) { + const { bmadDir } = await this.findBmadDir(projectDir); + return this._readOutputFolder(bmadDir); + } + + /** + * Handle missing custom module sources interactively + * @param {Map} customModuleSources - Map of custom module ID to info + * @param {string} bmadDir - BMAD directory + * @param {string} projectRoot - Project root directory + * @param {string} operation - Current operation ('update', 'compile', etc.) + * @param {Array} installedModules - Array of installed module IDs (will be modified) + * @param {boolean} [skipPrompts=false] - Skip interactive prompts and keep all modules with missing sources + * @returns {Object} Object with validCustomModules array and keptModulesWithoutSources array + */ + async handleMissingCustomSources(customModuleSources, bmadDir, projectRoot, operation, installedModules, skipPrompts = false) { + const validCustomModules = []; + const keptModulesWithoutSources = []; // Track modules kept without sources + const customModulesWithMissingSources = []; + + // Check which sources exist + for (const [moduleId, customInfo] of customModuleSources) { + if (await fs.pathExists(customInfo.sourcePath)) { + validCustomModules.push({ + id: moduleId, + name: customInfo.name, + path: customInfo.sourcePath, + info: customInfo, + }); + } else { + // For cached modules that are missing, we just skip them without prompting + if (customInfo.cached) { + // Skip cached modules without prompting + keptModulesWithoutSources.push({ + id: moduleId, + name: customInfo.name, + cached: true, + }); + } else { + customModulesWithMissingSources.push({ + id: moduleId, + name: customInfo.name, + sourcePath: customInfo.sourcePath, + relativePath: customInfo.relativePath, + info: customInfo, + }); + } + } + } + + // If no missing sources, return immediately + if (customModulesWithMissingSources.length === 0) { + return { + validCustomModules, + keptModulesWithoutSources: [], + }; + } + + // Non-interactive mode: keep all modules with missing sources + if (skipPrompts) { + for (const missing of customModulesWithMissingSources) { + keptModulesWithoutSources.push(missing.id); + } + return { validCustomModules, keptModulesWithoutSources }; + } + + await prompts.log.warn(`Found ${customModulesWithMissingSources.length} custom module(s) with missing sources:`); + + let keptCount = 0; + let updatedCount = 0; + let removedCount = 0; + + for (const missing of customModulesWithMissingSources) { + await prompts.log.message( + `${missing.name} (${missing.id})\n Original source: ${missing.relativePath}\n Full path: ${missing.sourcePath}`, + ); + + const choices = [ + { + name: 'Keep installed (will not be processed)', + value: 'keep', + hint: 'Keep', + }, + { + name: 'Specify new source location', + value: 'update', + hint: 'Update', + }, + ]; + + // Only add remove option if not just compiling agents + if (operation !== 'compile-agents') { + choices.push({ + name: '⚠️ REMOVE module completely (destructive!)', + value: 'remove', + hint: 'Remove', + }); + } + + const action = await prompts.select({ + message: `How would you like to handle "${missing.name}"?`, + choices, + }); + + switch (action) { + case 'update': { + // Use sync validation because @clack/prompts doesn't support async validate + const newSourcePath = await prompts.text({ + message: 'Enter the new path to the custom module:', + default: missing.sourcePath, + validate: (input) => { + if (!input || input.trim() === '') { + return 'Please enter a path'; + } + const expandedPath = path.resolve(input.trim()); + if (!fs.pathExistsSync(expandedPath)) { + return 'Path does not exist'; + } + // Check if it looks like a valid module + const moduleYamlPath = path.join(expandedPath, 'module.yaml'); + const agentsPath = path.join(expandedPath, 'agents'); + const workflowsPath = path.join(expandedPath, 'workflows'); + + if (!fs.pathExistsSync(moduleYamlPath) && !fs.pathExistsSync(agentsPath) && !fs.pathExistsSync(workflowsPath)) { + return 'Path does not appear to contain a valid custom module'; + } + return; // clack expects undefined for valid input + }, + }); + + // Defensive: handleCancel should have exited, but guard against symbol propagation + if (typeof newSourcePath !== 'string') { + keptCount++; + keptModulesWithoutSources.push(missing.id); + continue; + } + + // Update the source in manifest + const resolvedPath = path.resolve(newSourcePath.trim()); + missing.info.sourcePath = resolvedPath; + // Remove relativePath - we only store absolute sourcePath now + delete missing.info.relativePath; + await this.manifest.addCustomModule(bmadDir, missing.info); + + validCustomModules.push({ + id: missing.id, + name: missing.name, + path: resolvedPath, + info: missing.info, + }); + + updatedCount++; + await prompts.log.success('Updated source location'); + + break; + } + case 'remove': { + // Extra confirmation for destructive remove + await prompts.log.error( + `WARNING: This will PERMANENTLY DELETE "${missing.name}" and all its files!\n Module location: ${path.join(bmadDir, missing.id)}`, + ); + + const confirmDelete = await prompts.confirm({ + message: 'Are you absolutely sure you want to delete this module?', + default: false, + }); + + if (confirmDelete) { + const typedConfirm = await prompts.text({ + message: 'Type "DELETE" to confirm permanent deletion:', + validate: (input) => { + if (input !== 'DELETE') { + return 'You must type "DELETE" exactly to proceed'; + } + return; // clack expects undefined for valid input + }, + }); + + if (typedConfirm === 'DELETE') { + // Remove the module from filesystem and manifest + const modulePath = path.join(bmadDir, missing.id); + if (await fs.pathExists(modulePath)) { + const fsExtra = require('fs-extra'); + await fsExtra.remove(modulePath); + await prompts.log.warn(`Deleted module directory: ${path.relative(projectRoot, modulePath)}`); + } + + await this.manifest.removeModule(bmadDir, missing.id); + await this.manifest.removeCustomModule(bmadDir, missing.id); + await prompts.log.warn('Removed from manifest'); + + // Also remove from installedModules list + if (installedModules && installedModules.includes(missing.id)) { + const index = installedModules.indexOf(missing.id); + if (index !== -1) { + installedModules.splice(index, 1); + } + } + + removedCount++; + await prompts.log.error(`"${missing.name}" has been permanently removed`); + } else { + await prompts.log.message('Removal cancelled - module will be kept'); + keptCount++; + } + } else { + await prompts.log.message('Removal cancelled - module will be kept'); + keptCount++; + } + + break; + } + case 'keep': { + keptCount++; + keptModulesWithoutSources.push(missing.id); + await prompts.log.message('Module will be kept as-is'); + + break; + } + // No default + } + } + + // Show summary + if (keptCount > 0 || updatedCount > 0 || removedCount > 0) { + let summary = 'Summary for custom modules with missing sources:'; + if (keptCount > 0) summary += `\n • ${keptCount} module(s) kept as-is`; + if (updatedCount > 0) summary += `\n • ${updatedCount} module(s) updated with new sources`; + if (removedCount > 0) summary += `\n • ${removedCount} module(s) permanently deleted`; + await prompts.log.message(summary); + } + + return { + validCustomModules, + keptModulesWithoutSources, + }; + } + + /** + * Find the bmad installation directory in a project + * Always uses the standard _bmad folder name + * @param {string} projectDir - Project directory + * @returns {Promise} { bmadDir: string } + */ + async findBmadDir(projectDir) { + const bmadDir = path.join(projectDir, BMAD_FOLDER_NAME); + return { bmadDir }; + } + + /** + * Read the output_folder setting from module config files + * Checks bmm/config.yaml first, then other module configs + * @param {string} bmadDir - BMAD installation directory + * @returns {string} Output folder path or default + */ + async _readOutputFolder(bmadDir) { + const yaml = require('yaml'); + + // Check bmm/config.yaml first (most common) + const bmmConfigPath = path.join(bmadDir, 'bmm', 'config.yaml'); + if (await fs.pathExists(bmmConfigPath)) { + try { + const content = await fs.readFile(bmmConfigPath, 'utf8'); + const config = yaml.parse(content); + if (config && config.output_folder) { + // Strip {project-root}/ prefix if present + return config.output_folder.replace(/^\{project-root\}[/\\]/, ''); + } + } catch { + // Fall through to other modules + } + } + + // Scan other module config.yaml files + try { + const entries = await fs.readdir(bmadDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === 'bmm' || entry.name.startsWith('_')) continue; + const configPath = path.join(bmadDir, entry.name, 'config.yaml'); + if (await fs.pathExists(configPath)) { + try { + const content = await fs.readFile(configPath, 'utf8'); + const config = yaml.parse(content); + if (config && config.output_folder) { + return config.output_folder.replace(/^\{project-root\}[/\\]/, ''); + } + } catch { + // Continue scanning + } + } + } + } catch { + // Directory scan failed + } + + // Default fallback + return '_bmad-output'; + } + + /** + * Parse a CSV line, handling quoted fields + * @param {string} line - CSV line to parse + * @returns {Array} Array of field values + */ + parseCSVLine(line) { + const result = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + const nextChar = line[i + 1]; + + if (char === '"') { + if (inQuotes && nextChar === '"') { + // Escaped quote + current += '"'; + i++; // Skip next quote + } else { + // Toggle quote mode + inQuotes = !inQuotes; + } + } else if (char === ',' && !inQuotes) { + result.push(current); + current = ''; + } else { + current += char; + } + } + result.push(current); + return result; + } + + /** + * Escape a CSV field if it contains special characters + * @param {string} field - Field value to escape + * @returns {string} Escaped field + */ + escapeCSVField(field) { + if (field === null || field === undefined) { + return ''; + } + const str = String(field); + // If field contains comma, quote, or newline, wrap in quotes and escape inner quotes + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + return `"${str.replaceAll('"', '""')}"`; + } + return str; + } +} + +module.exports = { Installer }; diff --git a/tools/cli/installers/lib/core/manifest-generator.js b/tools/installer/core/manifest-generator.js similarity index 99% rename from tools/cli/installers/lib/core/manifest-generator.js rename to tools/installer/core/manifest-generator.js index 14fd8887e..65e0f4ed3 100644 --- a/tools/cli/installers/lib/core/manifest-generator.js +++ b/tools/installer/core/manifest-generator.js @@ -3,8 +3,8 @@ const fs = require('fs-extra'); const yaml = require('yaml'); const crypto = require('node:crypto'); const csv = require('csv-parse/sync'); -const { getSourcePath, getModulePath } = require('../../../lib/project-root'); -const prompts = require('../../../lib/prompts'); +const { getSourcePath, getModulePath } = require('../project-root'); +const prompts = require('../prompts'); const { loadSkillManifest: loadSkillManifestShared, getCanonicalId: getCanonicalIdShared, @@ -13,7 +13,7 @@ const { } = require('../ide/shared/skill-manifest'); // Load package.json for version info -const packageJson = require('../../../../../package.json'); +const packageJson = require('../../../package.json'); /** * Generates manifest files for installed skills and agents diff --git a/tools/cli/installers/lib/core/manifest.js b/tools/installer/core/manifest.js similarity index 99% rename from tools/cli/installers/lib/core/manifest.js rename to tools/installer/core/manifest.js index 0b5fc447b..d6eade648 100644 --- a/tools/cli/installers/lib/core/manifest.js +++ b/tools/installer/core/manifest.js @@ -1,8 +1,8 @@ const path = require('node:path'); const fs = require('fs-extra'); const crypto = require('node:crypto'); -const { getProjectRoot } = require('../../../lib/project-root'); -const prompts = require('../../../lib/prompts'); +const { getProjectRoot } = require('../project-root'); +const prompts = require('../prompts'); class Manifest { /** diff --git a/tools/cli/installers/lib/custom/handler.js b/tools/installer/custom-handler.js similarity index 98% rename from tools/cli/installers/lib/custom/handler.js rename to tools/installer/custom-handler.js index fbd6c728f..a1966b7e7 100644 --- a/tools/cli/installers/lib/custom/handler.js +++ b/tools/installer/custom-handler.js @@ -1,7 +1,7 @@ const path = require('node:path'); const fs = require('fs-extra'); const yaml = require('yaml'); -const prompts = require('../../../lib/prompts'); +const prompts = require('./prompts'); /** * Handler for custom content (custom.yaml) * Discovers custom agents and workflows in the project diff --git a/tools/cli/external-official-modules.yaml b/tools/installer/external-official-modules.yaml similarity index 100% rename from tools/cli/external-official-modules.yaml rename to tools/installer/external-official-modules.yaml diff --git a/tools/cli/lib/file-ops.js b/tools/installer/file-ops.js similarity index 100% rename from tools/cli/lib/file-ops.js rename to tools/installer/file-ops.js diff --git a/tools/cli/installers/lib/ide/_config-driven.js b/tools/installer/ide/_config-driven.js similarity index 54% rename from tools/cli/installers/lib/ide/_config-driven.js rename to tools/installer/ide/_config-driven.js index 5fb4c595a..603ffc7a4 100644 --- a/tools/cli/installers/lib/ide/_config-driven.js +++ b/tools/installer/ide/_config-driven.js @@ -2,9 +2,9 @@ const os = require('node:os'); const path = require('node:path'); const fs = require('fs-extra'); const yaml = require('yaml'); -const { BaseIdeSetup } = require('./_base-ide'); -const prompts = require('../../../lib/prompts'); +const prompts = require('../prompts'); const csv = require('csv-parse/sync'); +const { BMAD_FOLDER_NAME } = require('./shared/path-utils'); /** * Config-driven IDE setup handler @@ -15,43 +15,45 @@ const csv = require('csv-parse/sync'); * * Features: * - Config-driven from platform-codes.yaml - * - Template-based content generation - * - Multi-target installation support (e.g., GitHub Copilot) - * - Artifact type filtering (agents, workflows, tasks, tools) + * - Verbatim skill installation from skill-manifest.csv + * - Legacy directory cleanup and IDE-specific marker removal */ -class ConfigDrivenIdeSetup extends BaseIdeSetup { +class ConfigDrivenIdeSetup { constructor(platformCode, platformConfig) { - super(platformCode, platformConfig.name, platformConfig.preferred); + this.name = platformCode; + this.displayName = platformConfig.name || platformCode; + this.preferred = platformConfig.preferred || false; this.platformConfig = platformConfig; this.installerConfig = platformConfig.installer || null; + this.bmadFolderName = BMAD_FOLDER_NAME; - // Set configDir from target_dir so base-class detect() works - if (this.installerConfig?.target_dir) { - this.configDir = this.installerConfig.target_dir; - } + // Set configDir from target_dir so detect() works + this.configDir = this.installerConfig?.target_dir || null; + } + + setBmadFolderName(bmadFolderName) { + this.bmadFolderName = bmadFolderName; } /** * Detect whether this IDE already has configuration in the project. - * For skill_format platforms, checks for bmad-prefixed entries in target_dir - * (matching old codex.js behavior) instead of just checking directory existence. + * Checks for bmad-prefixed entries in target_dir. * @param {string} projectDir - Project directory * @returns {Promise} */ async detect(projectDir) { - if (this.installerConfig?.skill_format && this.configDir) { - const dir = path.join(projectDir || process.cwd(), this.configDir); - if (await fs.pathExists(dir)) { - try { - const entries = await fs.readdir(dir); - return entries.some((e) => typeof e === 'string' && e.startsWith('bmad')); - } catch { - return false; - } + if (!this.configDir) return false; + + const dir = path.join(projectDir || process.cwd(), this.configDir); + if (await fs.pathExists(dir)) { + try { + const entries = await fs.readdir(dir); + return entries.some((e) => typeof e === 'string' && e.startsWith('bmad')); + } catch { + return false; } - return false; } - return super.detect(projectDir); + return false; } /** @@ -90,12 +92,6 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { return { success: false, reason: 'no-config' }; } - // Handle multi-target installations (e.g., GitHub Copilot) - if (this.installerConfig.targets) { - return this.installToMultipleTargets(projectDir, bmadDir, this.installerConfig.targets, options); - } - - // Handle single-target installations if (this.installerConfig.target_dir) { return this.installToTarget(projectDir, bmadDir, this.installerConfig, options); } @@ -113,13 +109,8 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { */ async installToTarget(projectDir, bmadDir, config, options) { const { target_dir } = config; - - if (!config.skill_format) { - return { success: false, reason: 'missing-skill-format', error: 'Installer config missing skill_format — cannot install skills' }; - } - const targetPath = path.join(projectDir, target_dir); - await this.ensureDir(targetPath); + await fs.ensureDir(targetPath); this.skillWriteTracker = new Set(); const results = { skills: 0 }; @@ -132,351 +123,6 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { return { success: true, results }; } - /** - * Install to multiple target directories - * @param {string} projectDir - Project directory - * @param {string} bmadDir - BMAD installation directory - * @param {Array} targets - Array of target configurations - * @param {Object} options - Setup options - * @returns {Promise} Installation result - */ - async installToMultipleTargets(projectDir, bmadDir, targets, options) { - const allResults = { skills: 0 }; - - for (const target of targets) { - const result = await this.installToTarget(projectDir, bmadDir, target, options); - if (result.success) { - allResults.skills += result.results.skills || 0; - } - } - - return { success: true, results: allResults }; - } - - /** - * Load template based on type and configuration - * @param {string} templateType - Template type (claude, windsurf, etc.) - * @param {string} artifactType - Artifact type (agent, workflow, task, tool) - * @param {Object} config - Installation configuration - * @param {string} fallbackTemplateType - Fallback template type if requested template not found - * @returns {Promise<{content: string, extension: string}>} Template content and extension - */ - async loadTemplate(templateType, artifactType, config = {}, fallbackTemplateType = null) { - const { header_template, body_template } = config; - - // Check for separate header/body templates - if (header_template || body_template) { - const content = await this.loadSplitTemplates(templateType, artifactType, header_template, body_template); - // Allow config to override extension, default to .md - const ext = config.extension || '.md'; - const normalizedExt = ext.startsWith('.') ? ext : `.${ext}`; - return { content, extension: normalizedExt }; - } - - // Load combined template - try multiple extensions - // If artifactType is empty, templateType already contains full name (e.g., 'gemini-workflow-yaml') - const templateBaseName = artifactType ? `${templateType}-${artifactType}` : templateType; - const templateDir = path.join(__dirname, 'templates', 'combined'); - const extensions = ['.md', '.toml', '.yaml', '.yml']; - - for (const ext of extensions) { - const templatePath = path.join(templateDir, templateBaseName + ext); - if (await fs.pathExists(templatePath)) { - const content = await fs.readFile(templatePath, 'utf8'); - return { content, extension: ext }; - } - } - - // Fall back to default template (if provided) - if (fallbackTemplateType) { - for (const ext of extensions) { - const fallbackPath = path.join(templateDir, `${fallbackTemplateType}${ext}`); - if (await fs.pathExists(fallbackPath)) { - const content = await fs.readFile(fallbackPath, 'utf8'); - return { content, extension: ext }; - } - } - } - - // Ultimate fallback - minimal template - return { content: this.getDefaultTemplate(artifactType), extension: '.md' }; - } - - /** - * Load split templates (header + body) - * @param {string} templateType - Template type - * @param {string} artifactType - Artifact type - * @param {string} headerTpl - Header template name - * @param {string} bodyTpl - Body template name - * @returns {Promise} Combined template content - */ - async loadSplitTemplates(templateType, artifactType, headerTpl, bodyTpl) { - let header = ''; - let body = ''; - - // Load header template - if (headerTpl) { - const headerPath = path.join(__dirname, 'templates', 'split', headerTpl); - if (await fs.pathExists(headerPath)) { - header = await fs.readFile(headerPath, 'utf8'); - } - } else { - // Use default header for template type - const defaultHeaderPath = path.join(__dirname, 'templates', 'split', templateType, 'header.md'); - if (await fs.pathExists(defaultHeaderPath)) { - header = await fs.readFile(defaultHeaderPath, 'utf8'); - } - } - - // Load body template - if (bodyTpl) { - const bodyPath = path.join(__dirname, 'templates', 'split', bodyTpl); - if (await fs.pathExists(bodyPath)) { - body = await fs.readFile(bodyPath, 'utf8'); - } - } else { - // Use default body for template type - const defaultBodyPath = path.join(__dirname, 'templates', 'split', templateType, 'body.md'); - if (await fs.pathExists(defaultBodyPath)) { - body = await fs.readFile(defaultBodyPath, 'utf8'); - } - } - - // Combine header and body - return `${header}\n${body}`; - } - - /** - * Get default minimal template - * @param {string} artifactType - Artifact type - * @returns {string} Default template - */ - getDefaultTemplate(artifactType) { - if (artifactType === 'agent') { - return `--- -name: '{{name}}' -description: '{{description}}' -disable-model-invocation: true ---- - -You must fully embody this agent's persona and follow all activation instructions exactly as specified. - - -1. LOAD the FULL agent file from {project-root}/{{bmadFolderName}}/{{path}} -2. READ its entire contents - this contains the complete agent persona, menu, and instructions -3. FOLLOW every step in the section precisely - -`; - } - return `--- -name: '{{name}}' -description: '{{description}}' ---- - -# {{name}} - -LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} -`; - } - - /** - * Render template with artifact data - * @param {string} template - Template content - * @param {Object} artifact - Artifact data - * @returns {string} Rendered content - */ - renderTemplate(template, artifact) { - // Use the appropriate path property based on artifact type - let pathToUse = artifact.relativePath || ''; - switch (artifact.type) { - case 'agent-launcher': { - pathToUse = artifact.agentPath || artifact.relativePath || ''; - - break; - } - case 'workflow-command': { - pathToUse = artifact.workflowPath || artifact.relativePath || ''; - - break; - } - case 'task': - case 'tool': { - pathToUse = artifact.path || artifact.relativePath || ''; - - break; - } - // No default - } - - // Replace _bmad placeholder with actual folder name BEFORE inserting paths, - // so that paths containing '_bmad' are not corrupted by the blanket replacement. - let rendered = template.replaceAll('_bmad', this.bmadFolderName); - - // Replace {{bmadFolderName}} placeholder if present - rendered = rendered.replaceAll('{{bmadFolderName}}', this.bmadFolderName); - - rendered = rendered - .replaceAll('{{name}}', artifact.name || '') - .replaceAll('{{module}}', artifact.module || 'core') - .replaceAll('{{path}}', pathToUse) - .replaceAll('{{description}}', artifact.description || `${artifact.name} ${artifact.type || ''}`) - .replaceAll('{{workflow_path}}', pathToUse); - - return rendered; - } - - /** - * Write artifact as a skill directory with SKILL.md inside. - * Writes artifact as a skill directory with SKILL.md inside. - * @param {string} targetPath - Base skills directory - * @param {Object} artifact - Artifact data - * @param {string} content - Rendered template content - */ - async writeSkillFile(targetPath, artifact, content) { - const { resolveSkillName } = require('./shared/path-utils'); - - // Get the skill name (prefers canonicalId, falls back to path-derived) and remove .md - const flatName = resolveSkillName(artifact); - const skillName = path.basename(flatName.replace(/\.md$/, '')); - - if (!skillName) { - throw new Error(`Cannot derive skill name for artifact: ${artifact.relativePath || JSON.stringify(artifact)}`); - } - - // Create skill directory - const skillDir = path.join(targetPath, skillName); - await this.ensureDir(skillDir); - this.skillWriteTracker?.add(skillName); - - // Transform content: rewrite frontmatter for skills format - const skillContent = this.transformToSkillFormat(content, skillName); - - await this.writeFile(path.join(skillDir, 'SKILL.md'), skillContent); - } - - /** - * Transform artifact content to Agent Skills format. - * Rewrites frontmatter to contain only unquoted name and description. - * @param {string} content - Original content with YAML frontmatter - * @param {string} skillName - Skill name (must match directory name) - * @returns {string} Transformed content - */ - transformToSkillFormat(content, skillName) { - // Normalize line endings - content = content.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); - - // Parse frontmatter - const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); - if (!fmMatch) { - // No frontmatter -- wrap with minimal frontmatter - const fm = yaml.stringify({ name: skillName, description: skillName }).trimEnd(); - return `---\n${fm}\n---\n\n${content}`; - } - - const frontmatter = fmMatch[1]; - const body = fmMatch[2]; - - // Parse frontmatter with yaml library to extract description - let description; - try { - const parsed = yaml.parse(frontmatter); - const rawDesc = parsed?.description; - description = typeof rawDesc === 'string' && rawDesc ? rawDesc : `${skillName} skill`; - } catch { - description = `${skillName} skill`; - } - - // Build new frontmatter with only name and description, unquoted - const newFrontmatter = yaml.stringify({ name: skillName, description: String(description) }, { lineWidth: 0 }).trimEnd(); - return `---\n${newFrontmatter}\n---\n${body}`; - } - - /** - * Install a custom agent launcher. - * For skill_format platforms, produces /SKILL.md. - * For flat platforms, produces a single file in target_dir. - * @param {string} projectDir - Project directory - * @param {string} agentName - Agent name (e.g., "fred-commit-poet") - * @param {string} agentPath - Path to compiled agent (relative to project root) - * @param {Object} metadata - Agent metadata - * @returns {Object|null} Info about created file/skill - */ - async installCustomAgentLauncher(projectDir, agentName, agentPath, metadata) { - if (!this.installerConfig?.target_dir) return null; - - const { customAgentDashName } = require('./shared/path-utils'); - const targetPath = path.join(projectDir, this.installerConfig.target_dir); - await this.ensureDir(targetPath); - - // Build artifact to reuse existing template rendering. - // The default-agent template already includes the _bmad/ prefix before {{path}}, - // but agentPath is relative to project root (e.g. "_bmad/custom/agents/fred.md"). - // Strip the bmadFolderName prefix so the template doesn't produce a double path. - const bmadPrefix = this.bmadFolderName + '/'; - const normalizedPath = agentPath.startsWith(bmadPrefix) ? agentPath.slice(bmadPrefix.length) : agentPath; - - const artifact = { - type: 'agent-launcher', - name: agentName, - description: metadata?.description || `${agentName} agent`, - agentPath: normalizedPath, - relativePath: normalizedPath, - module: 'custom', - }; - - const { content: template } = await this.loadTemplate( - this.installerConfig.template_type || 'default', - 'agent', - this.installerConfig, - 'default-agent', - ); - const content = this.renderTemplate(template, artifact); - - if (this.installerConfig.skill_format) { - const skillName = customAgentDashName(agentName).replace(/\.md$/, ''); - const skillDir = path.join(targetPath, skillName); - await this.ensureDir(skillDir); - const skillContent = this.transformToSkillFormat(content, skillName); - const skillPath = path.join(skillDir, 'SKILL.md'); - await this.writeFile(skillPath, skillContent); - return { path: path.relative(projectDir, skillPath), command: `$${skillName}` }; - } - - // Flat file output - const filename = customAgentDashName(agentName); - const filePath = path.join(targetPath, filename); - await this.writeFile(filePath, content); - return { path: path.relative(projectDir, filePath), command: agentName }; - } - - /** - * Generate filename for artifact - * @param {Object} artifact - Artifact data - * @param {string} artifactType - Artifact type (agent, workflow, task, tool) - * @param {string} extension - File extension to use (e.g., '.md', '.toml') - * @returns {string} Generated filename - */ - generateFilename(artifact, artifactType, extension = '.md') { - const { resolveSkillName } = require('./shared/path-utils'); - - // Reuse central logic to ensure consistent naming conventions - // Prefers canonicalId from manifest when available, falls back to path-derived name - const standardName = resolveSkillName(artifact); - - // Clean up potential double extensions from source files (e.g. .yaml.md, .xml.md -> .md) - // This handles any extensions that might slip through toDashPath() - const baseName = standardName.replace(/\.(md|yaml|yml|json|xml|toml)\.md$/i, '.md'); - - // If using default markdown, preserve the bmad-agent- prefix for agents - if (extension === '.md') { - return baseName; - } - - // For other extensions (e.g., .toml), replace .md extension - // Note: agent prefix is preserved even with non-markdown extensions - return baseName.replace(/\.md$/, extension); - } - /** * Install verbatim native SKILL.md directories from skill-manifest.csv. * Copies the entire source directory as-is into the IDE skill directory. @@ -598,22 +244,8 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} await this.cleanupRovoDevPrompts(projectDir, options); } - // Clean all target directories - if (this.installerConfig?.targets) { - const parentDirs = new Set(); - for (const target of this.installerConfig.targets) { - await this.cleanupTarget(projectDir, target.target_dir, options); - // Track parent directories for empty-dir cleanup - const parentDir = path.dirname(target.target_dir); - if (parentDir && parentDir !== '.') { - parentDirs.add(parentDir); - } - } - // After all targets cleaned, remove empty parent directories (recursive up to projectDir) - for (const parentDir of parentDirs) { - await this.removeEmptyParents(projectDir, parentDir); - } - } else if (this.installerConfig?.target_dir) { + // Clean target directory + if (this.installerConfig?.target_dir) { await this.cleanupTarget(projectDir, this.installerConfig.target_dir, options); } } @@ -711,6 +343,7 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} } } } + /** * Strip BMAD-owned content from .github/copilot-instructions.md. * The old custom installer injected content between and markers. diff --git a/tools/cli/installers/lib/ide/manager.js b/tools/installer/ide/manager.js similarity index 82% rename from tools/cli/installers/lib/ide/manager.js rename to tools/installer/ide/manager.js index 0d7f91209..ac49a8773 100644 --- a/tools/cli/installers/lib/ide/manager.js +++ b/tools/installer/ide/manager.js @@ -1,5 +1,5 @@ const { BMAD_FOLDER_NAME } = require('./shared/path-utils'); -const prompts = require('../../../lib/prompts'); +const prompts = require('../prompts'); /** * IDE Manager - handles IDE-specific setup @@ -226,23 +226,6 @@ class IdeManager { return results; } - /** - * Get list of supported IDEs - * @returns {Array} List of supported IDE names - */ - getSupportedIdes() { - return [...this.handlers.keys()]; - } - - /** - * Check if an IDE is supported - * @param {string} ideName - Name of the IDE - * @returns {boolean} True if IDE is supported - */ - isSupported(ideName) { - return this.handlers.has(ideName.toLowerCase()); - } - /** * Detect installed IDEs * @param {string} projectDir - Project directory @@ -259,41 +242,6 @@ class IdeManager { return detected; } - - /** - * Install custom agent launchers for specified IDEs - * @param {Array} ides - List of IDE names to install for - * @param {string} projectDir - Project directory - * @param {string} agentName - Agent name (e.g., "fred-commit-poet") - * @param {string} agentPath - Path to compiled agent (relative to project root) - * @param {Object} metadata - Agent metadata - * @returns {Object} Results for each IDE - */ - async installCustomAgentLaunchers(ides, projectDir, agentName, agentPath, metadata) { - const results = {}; - - for (const ideName of ides) { - const handler = this.handlers.get(ideName.toLowerCase()); - - if (!handler) { - await prompts.log.warn(`IDE '${ideName}' is not yet supported for custom agent installation`); - continue; - } - - try { - if (typeof handler.installCustomAgentLauncher === 'function') { - const result = await handler.installCustomAgentLauncher(projectDir, agentName, agentPath, metadata); - if (result) { - results[ideName] = result; - } - } - } catch (error) { - await prompts.log.warn(`Failed to install ${ideName} launcher: ${error.message}`); - } - } - - return results; - } } module.exports = { IdeManager }; diff --git a/tools/installer/ide/platform-codes.js b/tools/installer/ide/platform-codes.js new file mode 100644 index 000000000..32d82e9cc --- /dev/null +++ b/tools/installer/ide/platform-codes.js @@ -0,0 +1,37 @@ +const fs = require('fs-extra'); +const path = require('node:path'); +const yaml = require('yaml'); + +const PLATFORM_CODES_PATH = path.join(__dirname, 'platform-codes.yaml'); + +let _cachedPlatformCodes = null; + +/** + * Load the platform codes configuration from YAML + * @returns {Object} Platform codes configuration + */ +async function loadPlatformCodes() { + if (_cachedPlatformCodes) { + return _cachedPlatformCodes; + } + + if (!(await fs.pathExists(PLATFORM_CODES_PATH))) { + throw new Error(`Platform codes configuration not found at: ${PLATFORM_CODES_PATH}`); + } + + const content = await fs.readFile(PLATFORM_CODES_PATH, 'utf8'); + _cachedPlatformCodes = yaml.parse(content); + return _cachedPlatformCodes; +} + +/** + * Clear the cached platform codes (useful for testing) + */ +function clearCache() { + _cachedPlatformCodes = null; +} + +module.exports = { + loadPlatformCodes, + clearCache, +}; diff --git a/tools/installer/ide/platform-codes.yaml b/tools/installer/ide/platform-codes.yaml new file mode 100644 index 000000000..3f3e068be --- /dev/null +++ b/tools/installer/ide/platform-codes.yaml @@ -0,0 +1,190 @@ +# BMAD Platform Codes Configuration +# +# Each platform entry has: +# name: Display name shown to users +# preferred: Whether shown as a recommended option on install +# suspended: (optional) Message explaining why install is blocked +# installer: +# target_dir: Directory where skill directories are installed +# legacy_targets: (optional) Old target dirs to clean up on reinstall +# ancestor_conflict_check: (optional) Refuse install when ancestor dir has BMAD files + +platforms: + antigravity: + name: "Google Antigravity" + preferred: false + installer: + legacy_targets: + - .agent/workflows + target_dir: .agent/skills + + auggie: + name: "Auggie" + preferred: false + installer: + legacy_targets: + - .augment/commands + target_dir: .augment/skills + + claude-code: + name: "Claude Code" + preferred: true + installer: + legacy_targets: + - .claude/commands + target_dir: .claude/skills + ancestor_conflict_check: true + + cline: + name: "Cline" + preferred: false + installer: + legacy_targets: + - .clinerules/workflows + target_dir: .cline/skills + + codex: + name: "Codex" + preferred: false + installer: + legacy_targets: + - .codex/prompts + - ~/.codex/prompts + target_dir: .agents/skills + ancestor_conflict_check: true + + codebuddy: + name: "CodeBuddy" + preferred: false + installer: + legacy_targets: + - .codebuddy/commands + target_dir: .codebuddy/skills + + crush: + name: "Crush" + preferred: false + installer: + legacy_targets: + - .crush/commands + target_dir: .crush/skills + + cursor: + name: "Cursor" + preferred: true + installer: + legacy_targets: + - .cursor/commands + target_dir: .cursor/skills + + gemini: + name: "Gemini CLI" + preferred: false + installer: + legacy_targets: + - .gemini/commands + target_dir: .gemini/skills + + github-copilot: + name: "GitHub Copilot" + preferred: false + installer: + legacy_targets: + - .github/agents + - .github/prompts + target_dir: .github/skills + + iflow: + name: "iFlow" + preferred: false + installer: + legacy_targets: + - .iflow/commands + target_dir: .iflow/skills + + kilo: + name: "KiloCoder" + preferred: false + suspended: "Kilo Code does not yet support the Agent Skills standard. Support is paused until they implement it. See https://github.com/kilocode/kilo-code/issues for updates." + installer: + legacy_targets: + - .kilocode/workflows + target_dir: .kilocode/skills + + kiro: + name: "Kiro" + preferred: false + installer: + legacy_targets: + - .kiro/steering + target_dir: .kiro/skills + + ona: + name: "Ona" + preferred: false + installer: + target_dir: .ona/skills + + opencode: + name: "OpenCode" + preferred: false + installer: + legacy_targets: + - .opencode/agents + - .opencode/commands + - .opencode/agent + - .opencode/command + target_dir: .opencode/skills + ancestor_conflict_check: true + + pi: + name: "Pi" + preferred: false + installer: + target_dir: .pi/skills + + qoder: + name: "Qoder" + preferred: false + installer: + target_dir: .qoder/skills + + qwen: + name: "QwenCoder" + preferred: false + installer: + legacy_targets: + - .qwen/commands + target_dir: .qwen/skills + + roo: + name: "Roo Code" + preferred: false + installer: + legacy_targets: + - .roo/commands + target_dir: .roo/skills + + rovo-dev: + name: "Rovo Dev" + preferred: false + installer: + legacy_targets: + - .rovodev/workflows + target_dir: .rovodev/skills + + trae: + name: "Trae" + preferred: false + installer: + legacy_targets: + - .trae/rules + target_dir: .trae/skills + + windsurf: + name: "Windsurf" + preferred: false + installer: + legacy_targets: + - .windsurf/workflows + target_dir: .windsurf/skills diff --git a/tools/cli/installers/lib/ide/shared/agent-command-generator.js b/tools/installer/ide/shared/agent-command-generator.js similarity index 100% rename from tools/cli/installers/lib/ide/shared/agent-command-generator.js rename to tools/installer/ide/shared/agent-command-generator.js diff --git a/tools/cli/installers/lib/ide/shared/bmad-artifacts.js b/tools/installer/ide/shared/bmad-artifacts.js similarity index 100% rename from tools/cli/installers/lib/ide/shared/bmad-artifacts.js rename to tools/installer/ide/shared/bmad-artifacts.js diff --git a/tools/cli/installers/lib/ide/shared/module-injections.js b/tools/installer/ide/shared/module-injections.js similarity index 98% rename from tools/cli/installers/lib/ide/shared/module-injections.js rename to tools/installer/ide/shared/module-injections.js index fe3f999d8..3090c5da4 100644 --- a/tools/cli/installers/lib/ide/shared/module-injections.js +++ b/tools/installer/ide/shared/module-injections.js @@ -2,7 +2,7 @@ const path = require('node:path'); const fs = require('fs-extra'); const yaml = require('yaml'); const { glob } = require('glob'); -const { getSourcePath } = require('../../../../lib/project-root'); +const { getSourcePath } = require('../../project-root'); async function loadModuleInjectionConfig(handler, moduleName) { const sourceModulesPath = getSourcePath('modules'); diff --git a/tools/cli/installers/lib/ide/shared/path-utils.js b/tools/installer/ide/shared/path-utils.js similarity index 100% rename from tools/cli/installers/lib/ide/shared/path-utils.js rename to tools/installer/ide/shared/path-utils.js diff --git a/tools/cli/installers/lib/ide/shared/skill-manifest.js b/tools/installer/ide/shared/skill-manifest.js similarity index 100% rename from tools/cli/installers/lib/ide/shared/skill-manifest.js rename to tools/installer/ide/shared/skill-manifest.js diff --git a/tools/cli/installers/lib/ide/templates/agent-command-template.md b/tools/installer/ide/templates/agent-command-template.md similarity index 100% rename from tools/cli/installers/lib/ide/templates/agent-command-template.md rename to tools/installer/ide/templates/agent-command-template.md diff --git a/tools/cli/installers/lib/ide/templates/combined/antigravity.md b/tools/installer/ide/templates/combined/antigravity.md similarity index 100% rename from tools/cli/installers/lib/ide/templates/combined/antigravity.md rename to tools/installer/ide/templates/combined/antigravity.md diff --git a/tools/cli/installers/lib/ide/templates/combined/claude-agent.md b/tools/installer/ide/templates/combined/claude-agent.md similarity index 100% rename from tools/cli/installers/lib/ide/templates/combined/claude-agent.md rename to tools/installer/ide/templates/combined/claude-agent.md diff --git a/tools/cli/installers/lib/ide/templates/combined/claude-workflow.md b/tools/installer/ide/templates/combined/claude-workflow.md similarity index 100% rename from tools/cli/installers/lib/ide/templates/combined/claude-workflow.md rename to tools/installer/ide/templates/combined/claude-workflow.md diff --git a/tools/cli/installers/lib/ide/templates/combined/default-agent.md b/tools/installer/ide/templates/combined/default-agent.md similarity index 100% rename from tools/cli/installers/lib/ide/templates/combined/default-agent.md rename to tools/installer/ide/templates/combined/default-agent.md diff --git a/tools/cli/installers/lib/ide/templates/combined/default-task.md b/tools/installer/ide/templates/combined/default-task.md similarity index 100% rename from tools/cli/installers/lib/ide/templates/combined/default-task.md rename to tools/installer/ide/templates/combined/default-task.md diff --git a/tools/cli/installers/lib/ide/templates/combined/default-tool.md b/tools/installer/ide/templates/combined/default-tool.md similarity index 100% rename from tools/cli/installers/lib/ide/templates/combined/default-tool.md rename to tools/installer/ide/templates/combined/default-tool.md diff --git a/tools/cli/installers/lib/ide/templates/combined/default-workflow.md b/tools/installer/ide/templates/combined/default-workflow.md similarity index 100% rename from tools/cli/installers/lib/ide/templates/combined/default-workflow.md rename to tools/installer/ide/templates/combined/default-workflow.md diff --git a/tools/cli/installers/lib/ide/templates/combined/gemini-agent.toml b/tools/installer/ide/templates/combined/gemini-agent.toml similarity index 100% rename from tools/cli/installers/lib/ide/templates/combined/gemini-agent.toml rename to tools/installer/ide/templates/combined/gemini-agent.toml diff --git a/tools/cli/installers/lib/ide/templates/combined/gemini-task.toml b/tools/installer/ide/templates/combined/gemini-task.toml similarity index 100% rename from tools/cli/installers/lib/ide/templates/combined/gemini-task.toml rename to tools/installer/ide/templates/combined/gemini-task.toml diff --git a/tools/cli/installers/lib/ide/templates/combined/gemini-tool.toml b/tools/installer/ide/templates/combined/gemini-tool.toml similarity index 100% rename from tools/cli/installers/lib/ide/templates/combined/gemini-tool.toml rename to tools/installer/ide/templates/combined/gemini-tool.toml diff --git a/tools/cli/installers/lib/ide/templates/combined/gemini-workflow-yaml.toml b/tools/installer/ide/templates/combined/gemini-workflow-yaml.toml similarity index 100% rename from tools/cli/installers/lib/ide/templates/combined/gemini-workflow-yaml.toml rename to tools/installer/ide/templates/combined/gemini-workflow-yaml.toml diff --git a/tools/cli/installers/lib/ide/templates/combined/gemini-workflow.toml b/tools/installer/ide/templates/combined/gemini-workflow.toml similarity index 100% rename from tools/cli/installers/lib/ide/templates/combined/gemini-workflow.toml rename to tools/installer/ide/templates/combined/gemini-workflow.toml diff --git a/tools/cli/installers/lib/ide/templates/combined/kiro-agent.md b/tools/installer/ide/templates/combined/kiro-agent.md similarity index 100% rename from tools/cli/installers/lib/ide/templates/combined/kiro-agent.md rename to tools/installer/ide/templates/combined/kiro-agent.md diff --git a/tools/cli/installers/lib/ide/templates/combined/kiro-task.md b/tools/installer/ide/templates/combined/kiro-task.md similarity index 100% rename from tools/cli/installers/lib/ide/templates/combined/kiro-task.md rename to tools/installer/ide/templates/combined/kiro-task.md diff --git a/tools/cli/installers/lib/ide/templates/combined/kiro-tool.md b/tools/installer/ide/templates/combined/kiro-tool.md similarity index 100% rename from tools/cli/installers/lib/ide/templates/combined/kiro-tool.md rename to tools/installer/ide/templates/combined/kiro-tool.md diff --git a/tools/cli/installers/lib/ide/templates/combined/kiro-workflow.md b/tools/installer/ide/templates/combined/kiro-workflow.md similarity index 100% rename from tools/cli/installers/lib/ide/templates/combined/kiro-workflow.md rename to tools/installer/ide/templates/combined/kiro-workflow.md diff --git a/tools/cli/installers/lib/ide/templates/combined/opencode-agent.md b/tools/installer/ide/templates/combined/opencode-agent.md similarity index 100% rename from tools/cli/installers/lib/ide/templates/combined/opencode-agent.md rename to tools/installer/ide/templates/combined/opencode-agent.md diff --git a/tools/cli/installers/lib/ide/templates/combined/opencode-task.md b/tools/installer/ide/templates/combined/opencode-task.md similarity index 100% rename from tools/cli/installers/lib/ide/templates/combined/opencode-task.md rename to tools/installer/ide/templates/combined/opencode-task.md diff --git a/tools/cli/installers/lib/ide/templates/combined/opencode-tool.md b/tools/installer/ide/templates/combined/opencode-tool.md similarity index 100% rename from tools/cli/installers/lib/ide/templates/combined/opencode-tool.md rename to tools/installer/ide/templates/combined/opencode-tool.md diff --git a/tools/cli/installers/lib/ide/templates/combined/opencode-workflow-yaml.md b/tools/installer/ide/templates/combined/opencode-workflow-yaml.md similarity index 100% rename from tools/cli/installers/lib/ide/templates/combined/opencode-workflow-yaml.md rename to tools/installer/ide/templates/combined/opencode-workflow-yaml.md diff --git a/tools/cli/installers/lib/ide/templates/combined/opencode-workflow.md b/tools/installer/ide/templates/combined/opencode-workflow.md similarity index 100% rename from tools/cli/installers/lib/ide/templates/combined/opencode-workflow.md rename to tools/installer/ide/templates/combined/opencode-workflow.md diff --git a/tools/cli/installers/lib/ide/templates/combined/rovodev.md b/tools/installer/ide/templates/combined/rovodev.md similarity index 100% rename from tools/cli/installers/lib/ide/templates/combined/rovodev.md rename to tools/installer/ide/templates/combined/rovodev.md diff --git a/tools/cli/installers/lib/ide/templates/combined/trae.md b/tools/installer/ide/templates/combined/trae.md similarity index 100% rename from tools/cli/installers/lib/ide/templates/combined/trae.md rename to tools/installer/ide/templates/combined/trae.md diff --git a/tools/cli/installers/lib/ide/templates/combined/windsurf-workflow.md b/tools/installer/ide/templates/combined/windsurf-workflow.md similarity index 100% rename from tools/cli/installers/lib/ide/templates/combined/windsurf-workflow.md rename to tools/installer/ide/templates/combined/windsurf-workflow.md diff --git a/tools/cli/installers/lib/ide/templates/split/.gitkeep b/tools/installer/ide/templates/split/.gitkeep similarity index 100% rename from tools/cli/installers/lib/ide/templates/split/.gitkeep rename to tools/installer/ide/templates/split/.gitkeep diff --git a/tools/cli/installers/install-messages.yaml b/tools/installer/install-messages.yaml similarity index 100% rename from tools/cli/installers/install-messages.yaml rename to tools/installer/install-messages.yaml diff --git a/tools/cli/installers/lib/message-loader.js b/tools/installer/message-loader.js similarity index 93% rename from tools/cli/installers/lib/message-loader.js rename to tools/installer/message-loader.js index 7198f0328..03ba7eca1 100644 --- a/tools/cli/installers/lib/message-loader.js +++ b/tools/installer/message-loader.js @@ -1,7 +1,7 @@ const fs = require('fs-extra'); const path = require('node:path'); const yaml = require('yaml'); -const prompts = require('../../lib/prompts'); +const prompts = require('./prompts'); /** * Load and display installer messages from messages.yaml @@ -18,7 +18,7 @@ class MessageLoader { return this.messages; } - const messagesPath = path.join(__dirname, '..', 'install-messages.yaml'); + const messagesPath = path.join(__dirname, 'install-messages.yaml'); try { const content = fs.readFileSync(messagesPath, 'utf8'); diff --git a/tools/installer/modules/custom-modules.js b/tools/installer/modules/custom-modules.js new file mode 100644 index 000000000..b41bf47b1 --- /dev/null +++ b/tools/installer/modules/custom-modules.js @@ -0,0 +1,197 @@ +const path = require('node:path'); +const fs = require('fs-extra'); +const yaml = require('yaml'); +const { CustomHandler } = require('../custom-handler'); +const { Manifest } = require('../core/manifest'); +const prompts = require('../prompts'); + +class CustomModules { + constructor() { + this.paths = new Map(); + } + + has(moduleCode) { + return this.paths.has(moduleCode); + } + + get(moduleCode) { + return this.paths.get(moduleCode); + } + + set(moduleId, sourcePath) { + this.paths.set(moduleId, sourcePath); + } + + /** + * Install a custom module from its source path. + * @param {string} moduleName - Module identifier + * @param {string} bmadDir - Target bmad directory + * @param {Function} fileTrackingCallback - Optional callback to track installed files + * @param {Object} options - Install options + * @param {Object} options.moduleConfig - Pre-collected module configuration + * @returns {Object} Install result + */ + async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) { + const sourcePath = this.paths.get(moduleName); + if (!sourcePath) { + throw new Error(`No source path for custom module '${moduleName}'`); + } + + if (!(await fs.pathExists(sourcePath))) { + throw new Error(`Source for custom module '${moduleName}' not found at: ${sourcePath}`); + } + + const targetPath = path.join(bmadDir, moduleName); + + // Read custom.yaml and merge into module config + let moduleConfig = options.moduleConfig ? { ...options.moduleConfig } : {}; + const customConfigPath = path.join(sourcePath, 'custom.yaml'); + if (await fs.pathExists(customConfigPath)) { + try { + const content = await fs.readFile(customConfigPath, 'utf8'); + const customConfig = yaml.parse(content); + if (customConfig) { + moduleConfig = { ...moduleConfig, ...customConfig }; + } + } catch (error) { + await prompts.log.warn(`Failed to read custom.yaml for ${moduleName}: ${error.message}`); + } + } + + // Remove existing installation + if (await fs.pathExists(targetPath)) { + await fs.remove(targetPath); + } + + // Copy files with filtering + await this._copyWithFiltering(sourcePath, targetPath, fileTrackingCallback); + + // Add to manifest + const manifest = new Manifest(); + const versionInfo = await manifest.getModuleVersionInfo(moduleName, bmadDir, sourcePath); + await manifest.addModule(bmadDir, moduleName, { + version: versionInfo.version, + source: versionInfo.source, + npmPackage: versionInfo.npmPackage, + repoUrl: versionInfo.repoUrl, + }); + + return { success: true, module: moduleName, path: targetPath, moduleConfig }; + } + + /** + * Copy module files, filtering out install-time-only artifacts. + * @param {string} sourcePath - Source module directory + * @param {string} targetPath - Target module directory + * @param {Function} fileTrackingCallback - Optional callback to track installed files + */ + async _copyWithFiltering(sourcePath, targetPath, fileTrackingCallback = null) { + const files = await this._getFileList(sourcePath); + + for (const file of files) { + if (file.startsWith('sub-modules/')) continue; + + const isInSidecar = path + .dirname(file) + .split('/') + .some((dir) => dir.toLowerCase().endsWith('-sidecar')); + if (isInSidecar) continue; + + if (file === 'module.yaml') continue; + if (file === 'config.yaml') continue; + + const sourceFile = path.join(sourcePath, file); + const targetFile = path.join(targetPath, file); + + // Skip web-only agents + if (file.startsWith('agents/') && file.endsWith('.md')) { + const content = await fs.readFile(sourceFile, 'utf8'); + if (/]*\slocalskip="true"[^>]*>/.test(content)) { + continue; + } + } + + await fs.ensureDir(path.dirname(targetFile)); + await fs.copy(sourceFile, targetFile, { overwrite: true }); + + if (fileTrackingCallback) { + fileTrackingCallback(targetFile); + } + } + } + + /** + * Recursively list all files in a directory. + * @param {string} dir - Directory to scan + * @param {string} baseDir - Base directory for relative paths + * @returns {string[]} Relative file paths + */ + async _getFileList(dir, baseDir = dir) { + const files = []; + const entries = await fs.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await this._getFileList(fullPath, baseDir))); + } else { + files.push(path.relative(baseDir, fullPath)); + } + } + + return files; + } + + /** + * Discover custom module source paths from all available sources. + * @param {Object} config - Installation configuration + * @param {Object} paths - InstallPaths instance + * @returns {Map} Map of module ID to source path + */ + async discoverPaths(config, paths) { + this.paths = new Map(); + + if (config._quickUpdate) { + if (config._customModuleSources) { + for (const [moduleId, customInfo] of config._customModuleSources) { + this.paths.set(moduleId, customInfo.sourcePath); + } + } + return this.paths; + } + + // From UI: selectedFiles + if (config.customContent && config.customContent.selected && config.customContent.selectedFiles) { + const customHandler = new CustomHandler(); + for (const customFile of config.customContent.selectedFiles) { + const customInfo = await customHandler.getCustomInfo(customFile, paths.projectRoot); + if (customInfo && customInfo.id) { + this.paths.set(customInfo.id, customInfo.path); + } + } + } + + // From UI: sources + if (config.customContent && config.customContent.sources) { + for (const source of config.customContent.sources) { + this.paths.set(source.id, source.path); + } + } + + // From UI: cachedModules + if (config.customContent && config.customContent.cachedModules) { + const selectedCachedIds = config.customContent.selectedCachedModules || []; + const shouldIncludeAll = selectedCachedIds.length === 0 && config.customContent.selected; + + for (const cachedModule of config.customContent.cachedModules) { + if (cachedModule.id && cachedModule.cachePath && (shouldIncludeAll || selectedCachedIds.includes(cachedModule.id))) { + this.paths.set(cachedModule.id, cachedModule.cachePath); + } + } + } + + return this.paths; + } +} + +module.exports = { CustomModules }; diff --git a/tools/installer/modules/external-manager.js b/tools/installer/modules/external-manager.js new file mode 100644 index 000000000..467520163 --- /dev/null +++ b/tools/installer/modules/external-manager.js @@ -0,0 +1,323 @@ +const fs = require('fs-extra'); +const os = require('node:os'); +const path = require('node:path'); +const { execSync } = require('node:child_process'); +const yaml = require('yaml'); +const prompts = require('../prompts'); + +/** + * Manages external official modules defined in external-official-modules.yaml + * These are modules hosted in external repositories that can be installed + * + * @class ExternalModuleManager + */ +class ExternalModuleManager { + constructor() { + this.externalModulesConfigPath = path.join(__dirname, '../external-official-modules.yaml'); + this.cachedModules = null; + } + + /** + * Load and parse the external-official-modules.yaml file + * @returns {Object} Parsed YAML content with modules object + */ + async loadExternalModulesConfig() { + if (this.cachedModules) { + return this.cachedModules; + } + + try { + const content = await fs.readFile(this.externalModulesConfigPath, 'utf8'); + const config = yaml.parse(content); + this.cachedModules = config; + return config; + } catch (error) { + await prompts.log.warn(`Failed to load external modules config: ${error.message}`); + return { modules: {} }; + } + } + + /** + * Get list of available external modules + * @returns {Array} Array of module info objects + */ + async listAvailable() { + const config = await this.loadExternalModulesConfig(); + const modules = []; + + for (const [key, moduleConfig] of Object.entries(config.modules || {})) { + modules.push({ + key, + url: moduleConfig.url, + moduleDefinition: moduleConfig['module-definition'], + code: moduleConfig.code, + name: moduleConfig.name, + header: moduleConfig.header, + subheader: moduleConfig.subheader, + description: moduleConfig.description || '', + defaultSelected: moduleConfig.defaultSelected === true, + type: moduleConfig.type || 'community', // bmad-org or community + npmPackage: moduleConfig.npmPackage || null, // Include npm package name + isExternal: true, + }); + } + + return modules; + } + + /** + * Get module info by code + * @param {string} code - The module code (e.g., 'cis') + * @returns {Object|null} Module info or null if not found + */ + async getModuleByCode(code) { + const modules = await this.listAvailable(); + return modules.find((m) => m.code === code) || null; + } + + /** + * Get module info by key + * @param {string} key - The module key (e.g., 'bmad-creative-intelligence-suite') + * @returns {Object|null} Module info or null if not found + */ + async getModuleByKey(key) { + const config = await this.loadExternalModulesConfig(); + const moduleConfig = config.modules?.[key]; + + if (!moduleConfig) { + return null; + } + + return { + key, + url: moduleConfig.url, + moduleDefinition: moduleConfig['module-definition'], + code: moduleConfig.code, + name: moduleConfig.name, + header: moduleConfig.header, + subheader: moduleConfig.subheader, + description: moduleConfig.description || '', + defaultSelected: moduleConfig.defaultSelected === true, + type: moduleConfig.type || 'community', // bmad-org or community + npmPackage: moduleConfig.npmPackage || null, // Include npm package name + isExternal: true, + }; + } + + /** + * Check if a module code exists in external modules + * @param {string} code - The module code to check + * @returns {boolean} True if the module exists + */ + async hasModule(code) { + const module = await this.getModuleByCode(code); + return module !== null; + } + + /** + * Get the URL for a module by code + * @param {string} code - The module code + * @returns {string|null} The URL or null if not found + */ + async getModuleUrl(code) { + const module = await this.getModuleByCode(code); + return module ? module.url : null; + } + + /** + * Get the module definition path for a module by code + * @param {string} code - The module code + * @returns {string|null} The module definition path or null if not found + */ + async getModuleDefinition(code) { + const module = await this.getModuleByCode(code); + return module ? module.moduleDefinition : null; + } + + /** + * Get the cache directory for external modules + * @returns {string} Path to the external modules cache directory + */ + getExternalCacheDir() { + const cacheDir = path.join(os.homedir(), '.bmad', 'cache', 'external-modules'); + return cacheDir; + } + + /** + * Clone an external module repository to cache + * @param {string} moduleCode - Code of the external module + * @param {Object} options - Clone options + * @param {boolean} options.silent - Suppress spinner output + * @returns {string} Path to the cloned repository + */ + async cloneExternalModule(moduleCode, options = {}) { + const moduleInfo = await this.getModuleByCode(moduleCode); + + if (!moduleInfo) { + throw new Error(`External module '${moduleCode}' not found in external-official-modules.yaml`); + } + + const cacheDir = this.getExternalCacheDir(); + const moduleCacheDir = path.join(cacheDir, moduleCode); + const silent = options.silent || false; + + // Create cache directory if it doesn't exist + await fs.ensureDir(cacheDir); + + // Helper to create a spinner or a no-op when silent + const createSpinner = async () => { + if (silent) { + return { + start() {}, + stop() {}, + error() {}, + message() {}, + cancel() {}, + clear() {}, + get isSpinning() { + return false; + }, + get isCancelled() { + return false; + }, + }; + } + return await prompts.spinner(); + }; + + // Track if we need to install dependencies + let needsDependencyInstall = false; + let wasNewClone = false; + + // Check if already cloned + if (await fs.pathExists(moduleCacheDir)) { + // Try to update if it's a git repo + const fetchSpinner = await createSpinner(); + fetchSpinner.start(`Fetching ${moduleInfo.name}...`); + try { + const currentRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim(); + // Fetch and reset to remote - works better with shallow clones than pull + execSync('git fetch origin --depth 1', { + cwd: moduleCacheDir, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }); + execSync('git reset --hard origin/HEAD', { + cwd: moduleCacheDir, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }); + const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim(); + + fetchSpinner.stop(`Fetched ${moduleInfo.name}`); + // Force dependency install if we got new code + if (currentRef !== newRef) { + needsDependencyInstall = true; + } + } catch { + fetchSpinner.error(`Fetch failed, re-downloading ${moduleInfo.name}`); + // If update fails, remove and re-clone + await fs.remove(moduleCacheDir); + wasNewClone = true; + } + } else { + wasNewClone = true; + } + + // Clone if not exists or was removed + if (wasNewClone) { + const fetchSpinner = await createSpinner(); + fetchSpinner.start(`Fetching ${moduleInfo.name}...`); + try { + execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }); + fetchSpinner.stop(`Fetched ${moduleInfo.name}`); + } catch (error) { + fetchSpinner.error(`Failed to fetch ${moduleInfo.name}`); + throw new Error(`Failed to clone external module '${moduleCode}': ${error.message}`); + } + } + + // Install dependencies if package.json exists + const packageJsonPath = path.join(moduleCacheDir, 'package.json'); + const nodeModulesPath = path.join(moduleCacheDir, 'node_modules'); + if (await fs.pathExists(packageJsonPath)) { + // Install if node_modules doesn't exist, or if package.json is newer (dependencies changed) + const nodeModulesMissing = !(await fs.pathExists(nodeModulesPath)); + + // Force install if we updated or cloned new + if (needsDependencyInstall || wasNewClone || nodeModulesMissing) { + const installSpinner = await createSpinner(); + installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`); + try { + execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', { + cwd: moduleCacheDir, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 120_000, // 2 minute timeout + }); + installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`); + } catch (error) { + installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`); + if (!silent) await prompts.log.warn(` ${error.message}`); + } + } else { + // Check if package.json is newer than node_modules + let packageJsonNewer = false; + try { + const packageStats = await fs.stat(packageJsonPath); + const nodeModulesStats = await fs.stat(nodeModulesPath); + packageJsonNewer = packageStats.mtime > nodeModulesStats.mtime; + } catch { + // If stat fails, assume we need to install + packageJsonNewer = true; + } + + if (packageJsonNewer) { + const installSpinner = await createSpinner(); + installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`); + try { + execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', { + cwd: moduleCacheDir, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 120_000, // 2 minute timeout + }); + installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`); + } catch (error) { + installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`); + if (!silent) await prompts.log.warn(` ${error.message}`); + } + } + } + } + + return moduleCacheDir; + } + + /** + * Find the source path for an external module + * @param {string} moduleCode - Code of the external module + * @param {Object} options - Options passed to cloneExternalModule + * @returns {string|null} Path to the module source or null if not found + */ + async findExternalModuleSource(moduleCode, options = {}) { + const moduleInfo = await this.getModuleByCode(moduleCode); + + if (!moduleInfo) { + return null; + } + + // Clone the external module repo + const cloneDir = await this.cloneExternalModule(moduleCode, options); + + // The module-definition specifies the path to module.yaml relative to repo root + // We need to return the directory containing module.yaml + const moduleDefinitionPath = moduleInfo.moduleDefinition; // e.g., 'src/module.yaml' + const moduleDir = path.dirname(path.join(cloneDir, moduleDefinitionPath)); + + return moduleDir; + } +} + +module.exports = { ExternalModuleManager }; diff --git a/tools/cli/installers/lib/core/config-collector.js b/tools/installer/modules/official-modules.js similarity index 64% rename from tools/cli/installers/lib/core/config-collector.js rename to tools/installer/modules/official-modules.js index 665c7957a..5b67fc4dd 100644 --- a/tools/cli/installers/lib/core/config-collector.js +++ b/tools/installer/modules/official-modules.js @@ -1,30 +1,701 @@ const path = require('node:path'); const fs = require('fs-extra'); const yaml = require('yaml'); -const { getProjectRoot, getModulePath } = require('../../../lib/project-root'); -const { CLIUtils } = require('../../../lib/cli-utils'); -const prompts = require('../../../lib/prompts'); +const prompts = require('../prompts'); +const { getProjectRoot, getSourcePath, getModulePath } = require('../project-root'); +const { CLIUtils } = require('../cli-utils'); +const { ExternalModuleManager } = require('./external-manager'); -class ConfigCollector { - constructor() { +class OfficialModules { + constructor(options = {}) { + this.externalModuleManager = new ExternalModuleManager(); + // Config collection state (merged from ConfigCollector) this.collectedConfig = {}; - this.existingConfig = null; + this._existingConfig = null; this.currentProjectDir = null; - this._moduleManagerInstance = null; } /** - * Get or create a cached ModuleManager instance (lazy initialization) - * @returns {Object} ModuleManager instance + * Module configurations collected during install. */ - _getModuleManager() { - if (!this._moduleManagerInstance) { - const { ModuleManager } = require('../modules/manager'); - this._moduleManagerInstance = new ModuleManager(); - } - return this._moduleManagerInstance; + get moduleConfigs() { + return this.collectedConfig; } + /** + * Existing module configurations read from a previous installation. + */ + get existingConfig() { + return this._existingConfig; + } + + /** + * Build a configured OfficialModules instance from install config. + * @param {Object} config - Clean install config (from Config.build) + * @param {Object} paths - InstallPaths instance + * @returns {OfficialModules} + */ + static async build(config, paths) { + const instance = new OfficialModules(); + + // Pre-collected by UI or quickUpdate — store and load existing for path-change detection + if (config.moduleConfigs) { + instance.collectedConfig = config.moduleConfigs; + await instance.loadExistingConfig(paths.projectRoot); + return instance; + } + + // Headless collection (--yes flag from CLI without UI, tests) + if (config.hasCoreConfig()) { + instance.collectedConfig.core = config.coreConfig; + instance.allAnswers = {}; + for (const [key, value] of Object.entries(config.coreConfig)) { + instance.allAnswers[`core_${key}`] = value; + } + } + + const toCollect = config.hasCoreConfig() ? config.modules.filter((m) => m !== 'core') : [...config.modules]; + + await instance.collectAllConfigurations(toCollect, paths.projectRoot, { + skipPrompts: config.skipPrompts, + }); + + return instance; + } + + /** + * Copy a file to the target location + * @param {string} sourcePath - Source file path + * @param {string} targetPath - Target file path + * @param {boolean} overwrite - Whether to overwrite existing files (default: true) + */ + async copyFile(sourcePath, targetPath, overwrite = true) { + await fs.copy(sourcePath, targetPath, { overwrite }); + } + + /** + * Copy a directory recursively + * @param {string} sourceDir - Source directory path + * @param {string} targetDir - Target directory path + * @param {boolean} overwrite - Whether to overwrite existing files (default: true) + */ + async copyDirectory(sourceDir, targetDir, overwrite = true) { + await fs.ensureDir(targetDir); + const entries = await fs.readdir(sourceDir, { withFileTypes: true }); + + for (const entry of entries) { + const sourcePath = path.join(sourceDir, entry.name); + const targetPath = path.join(targetDir, entry.name); + + if (entry.isDirectory()) { + await this.copyDirectory(sourcePath, targetPath, overwrite); + } else { + await this.copyFile(sourcePath, targetPath, overwrite); + } + } + } + + /** + * List all available built-in modules (core and bmm). + * All other modules come from external-official-modules.yaml + * @returns {Object} Object with modules array and customModules array + */ + async listAvailable() { + const modules = []; + const customModules = []; + + // Add built-in core module (directly under src/core-skills) + const corePath = getSourcePath('core-skills'); + if (await fs.pathExists(corePath)) { + const coreInfo = await this.getModuleInfo(corePath, 'core', 'src/core-skills'); + if (coreInfo) { + modules.push(coreInfo); + } + } + + // Add built-in bmm module (directly under src/bmm-skills) + const bmmPath = getSourcePath('bmm-skills'); + if (await fs.pathExists(bmmPath)) { + const bmmInfo = await this.getModuleInfo(bmmPath, 'bmm', 'src/bmm-skills'); + if (bmmInfo) { + modules.push(bmmInfo); + } + } + + return { modules, customModules }; + } + + /** + * Get module information from a module path + * @param {string} modulePath - Path to the module directory + * @param {string} defaultName - Default name for the module + * @param {string} sourceDescription - Description of where the module was found + * @returns {Object|null} Module info or null if not a valid module + */ + async getModuleInfo(modulePath, defaultName, sourceDescription) { + // Check for module structure (module.yaml OR custom.yaml) + const moduleConfigPath = path.join(modulePath, 'module.yaml'); + const rootCustomConfigPath = path.join(modulePath, 'custom.yaml'); + let configPath = null; + + if (await fs.pathExists(moduleConfigPath)) { + configPath = moduleConfigPath; + } else if (await fs.pathExists(rootCustomConfigPath)) { + configPath = rootCustomConfigPath; + } + + // Skip if this doesn't look like a module + if (!configPath) { + return null; + } + + // Mark as custom if it's using custom.yaml OR if it's outside src/bmm or src/core + const isCustomSource = + sourceDescription !== 'src/bmm-skills' && sourceDescription !== 'src/core-skills' && sourceDescription !== 'src/modules'; + const moduleInfo = { + id: defaultName, + path: modulePath, + name: defaultName + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '), + description: 'BMAD Module', + version: '5.0.0', + source: sourceDescription, + isCustom: configPath === rootCustomConfigPath || isCustomSource, + }; + + // Read module config for metadata + try { + const configContent = await fs.readFile(configPath, 'utf8'); + const config = yaml.parse(configContent); + + // Use the code property as the id if available + if (config.code) { + moduleInfo.id = config.code; + } + + moduleInfo.name = config.name || moduleInfo.name; + moduleInfo.description = config.description || moduleInfo.description; + moduleInfo.version = config.version || moduleInfo.version; + moduleInfo.dependencies = config.dependencies || []; + moduleInfo.defaultSelected = config.default_selected === undefined ? false : config.default_selected; + } catch (error) { + await prompts.log.warn(`Failed to read config for ${defaultName}: ${error.message}`); + } + + return moduleInfo; + } + + /** + * Find the source path for a module by searching all possible locations + * @param {string} moduleCode - Code of the module to find (from module.yaml) + * @returns {string|null} Path to the module source or null if not found + */ + async findModuleSource(moduleCode, options = {}) { + const projectRoot = getProjectRoot(); + + // Check for core module (directly under src/core-skills) + if (moduleCode === 'core') { + const corePath = getSourcePath('core-skills'); + if (await fs.pathExists(corePath)) { + return corePath; + } + } + + // Check for built-in bmm module (directly under src/bmm-skills) + if (moduleCode === 'bmm') { + const bmmPath = getSourcePath('bmm-skills'); + if (await fs.pathExists(bmmPath)) { + return bmmPath; + } + } + + // Check external official modules + const externalSource = await this.externalModuleManager.findExternalModuleSource(moduleCode, options); + if (externalSource) { + return externalSource; + } + + return null; + } + + /** + * Install a module + * @param {string} moduleName - Code of the module to install (from module.yaml) + * @param {string} bmadDir - Target bmad directory + * @param {Function} fileTrackingCallback - Optional callback to track installed files + * @param {Object} options - Additional installation options + * @param {Array} options.installedIDEs - Array of IDE codes that were installed + * @param {Object} options.moduleConfig - Module configuration from config collector + * @param {Object} options.logger - Logger instance for output + */ + async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) { + const sourcePath = await this.findModuleSource(moduleName, { silent: options.silent }); + const targetPath = path.join(bmadDir, moduleName); + + if (!sourcePath) { + throw new Error( + `Source for module '${moduleName}' is not available. It will be retained but cannot be updated without its source files.`, + ); + } + + if (await fs.pathExists(targetPath)) { + await fs.remove(targetPath); + } + + await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig); + + if (!options.skipModuleInstaller) { + await this.createModuleDirectories(moduleName, bmadDir, options); + } + + const { Manifest } = require('../core/manifest'); + const manifestObj = new Manifest(); + const versionInfo = await manifestObj.getModuleVersionInfo(moduleName, bmadDir, sourcePath); + + await manifestObj.addModule(bmadDir, moduleName, { + version: versionInfo.version, + source: versionInfo.source, + npmPackage: versionInfo.npmPackage, + repoUrl: versionInfo.repoUrl, + }); + + return { success: true, module: moduleName, path: targetPath, versionInfo }; + } + + /** + * Update an existing module + * @param {string} moduleName - Name of the module to update + * @param {string} bmadDir - Target bmad directory + */ + async update(moduleName, bmadDir) { + const sourcePath = await this.findModuleSource(moduleName); + const targetPath = path.join(bmadDir, moduleName); + + if (!sourcePath) { + throw new Error(`Module '${moduleName}' not found in any source location`); + } + + if (!(await fs.pathExists(targetPath))) { + throw new Error(`Module '${moduleName}' is not installed`); + } + + await this.syncModule(sourcePath, targetPath); + + return { + success: true, + module: moduleName, + path: targetPath, + }; + } + + /** + * Remove a module + * @param {string} moduleName - Name of the module to remove + * @param {string} bmadDir - Target bmad directory + */ + async remove(moduleName, bmadDir) { + const targetPath = path.join(bmadDir, moduleName); + + if (!(await fs.pathExists(targetPath))) { + throw new Error(`Module '${moduleName}' is not installed`); + } + + await fs.remove(targetPath); + + return { + success: true, + module: moduleName, + }; + } + + /** + * Check if a module is installed + * @param {string} moduleName - Name of the module + * @param {string} bmadDir - Target bmad directory + * @returns {boolean} True if module is installed + */ + async isInstalled(moduleName, bmadDir) { + const targetPath = path.join(bmadDir, moduleName); + return await fs.pathExists(targetPath); + } + + /** + * Get installed module info + * @param {string} moduleName - Name of the module + * @param {string} bmadDir - Target bmad directory + * @returns {Object|null} Module info or null if not installed + */ + async getInstalledInfo(moduleName, bmadDir) { + const targetPath = path.join(bmadDir, moduleName); + + if (!(await fs.pathExists(targetPath))) { + return null; + } + + const configPath = path.join(targetPath, 'config.yaml'); + const moduleInfo = { + id: moduleName, + path: targetPath, + installed: true, + }; + + if (await fs.pathExists(configPath)) { + try { + const configContent = await fs.readFile(configPath, 'utf8'); + const config = yaml.parse(configContent); + Object.assign(moduleInfo, config); + } catch (error) { + await prompts.log.warn(`Failed to read installed module config: ${error.message}`); + } + } + + return moduleInfo; + } + + /** + * Copy module with filtering for localskip agents and conditional content + * @param {string} sourcePath - Source module path + * @param {string} targetPath - Target module path + * @param {Function} fileTrackingCallback - Optional callback to track installed files + * @param {Object} moduleConfig - Module configuration with conditional flags + */ + async copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback = null, moduleConfig = {}) { + // Get all files in source + const sourceFiles = await this.getFileList(sourcePath); + + for (const file of sourceFiles) { + // Skip sub-modules directory - these are IDE-specific and handled separately + if (file.startsWith('sub-modules/')) { + continue; + } + + // Skip sidecar directories - these contain agent-specific assets not needed at install time + const isInSidecarDirectory = path + .dirname(file) + .split('/') + .some((dir) => dir.toLowerCase().endsWith('-sidecar')); + + if (isInSidecarDirectory) { + continue; + } + + // Skip module.yaml at root - it's only needed at install time + if (file === 'module.yaml') { + continue; + } + + // Skip module root config.yaml only - generated by config collector with actual values + // Workflow-level config.yaml (e.g. workflows/orchestrate-story/config.yaml) must be copied + // for custom modules that use workflow-specific configuration + if (file === 'config.yaml') { + continue; + } + + const sourceFile = path.join(sourcePath, file); + const targetFile = path.join(targetPath, file); + + // Check if this is an agent file + if (file.startsWith('agents/') && file.endsWith('.md')) { + // Read the file to check for localskip + const content = await fs.readFile(sourceFile, 'utf8'); + + // Check for localskip="true" in the agent tag + const agentMatch = content.match(/]*\slocalskip="true"[^>]*>/); + if (agentMatch) { + await prompts.log.message(` Skipping web-only agent: ${path.basename(file)}`); + continue; // Skip this agent + } + } + + // Copy the file with placeholder replacement + await this.copyFile(sourceFile, targetFile); + + // Track the file if callback provided + if (fileTrackingCallback) { + fileTrackingCallback(targetFile); + } + } + } + + /** + * Find all .md agent files recursively in a directory + * @param {string} dir - Directory to search + * @returns {Array} List of .md agent file paths + */ + async findAgentMdFiles(dir) { + const agentFiles = []; + + async function searchDirectory(searchDir) { + const entries = await fs.readdir(searchDir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(searchDir, entry.name); + + if (entry.isFile() && entry.name.endsWith('.md')) { + agentFiles.push(fullPath); + } else if (entry.isDirectory()) { + await searchDirectory(fullPath); + } + } + } + + await searchDirectory(dir); + return agentFiles; + } + + /** + * Create directories declared in module.yaml's `directories` key + * This replaces the security-risky module installer pattern with declarative config + * During updates, if a directory path changed, moves the old directory to the new path + * @param {string} moduleName - Name of the module + * @param {string} bmadDir - Target bmad directory + * @param {Object} options - Installation options + * @param {Object} options.moduleConfig - Module configuration from config collector + * @param {Object} options.existingModuleConfig - Previous module config (for detecting path changes during updates) + * @param {Object} options.coreConfig - Core configuration + * @returns {Promise<{createdDirs: string[], movedDirs: string[], createdWdsFolders: string[]}>} Created directories info + */ + async createModuleDirectories(moduleName, bmadDir, options = {}) { + const moduleConfig = options.moduleConfig || {}; + const existingModuleConfig = options.existingModuleConfig || {}; + const projectRoot = path.dirname(bmadDir); + const emptyResult = { createdDirs: [], movedDirs: [], createdWdsFolders: [] }; + + // Special handling for core module - it's in src/core-skills not src/modules + let sourcePath; + if (moduleName === 'core') { + sourcePath = getSourcePath('core-skills'); + } else { + sourcePath = await this.findModuleSource(moduleName, { silent: true }); + if (!sourcePath) { + return emptyResult; // No source found, skip + } + } + + // Read module.yaml to find the `directories` key + const moduleYamlPath = path.join(sourcePath, 'module.yaml'); + if (!(await fs.pathExists(moduleYamlPath))) { + return emptyResult; // No module.yaml, skip + } + + let moduleYaml; + try { + const yamlContent = await fs.readFile(moduleYamlPath, 'utf8'); + moduleYaml = yaml.parse(yamlContent); + } catch (error) { + await prompts.log.warn(`Invalid module.yaml for ${moduleName}: ${error.message}`); + return emptyResult; + } + + if (!moduleYaml || !moduleYaml.directories) { + return emptyResult; // No directories declared, skip + } + + const directories = moduleYaml.directories; + const wdsFolders = moduleYaml.wds_folders || []; + const createdDirs = []; + const movedDirs = []; + const createdWdsFolders = []; + + for (const dirRef of directories) { + // Parse variable reference like "{design_artifacts}" + const varMatch = dirRef.match(/^\{([^}]+)\}$/); + if (!varMatch) { + // Not a variable reference, skip + continue; + } + + const configKey = varMatch[1]; + const dirValue = moduleConfig[configKey]; + if (!dirValue || typeof dirValue !== 'string') { + continue; // No value or not a string, skip + } + + // Strip {project-root}/ prefix if present + let dirPath = dirValue.replace(/^\{project-root\}\/?/, ''); + + // Handle remaining {project-root} anywhere in the path + dirPath = dirPath.replaceAll('{project-root}', ''); + + // Resolve to absolute path + const fullPath = path.join(projectRoot, dirPath); + + // Validate path is within project root (prevent directory traversal) + const normalizedPath = path.normalize(fullPath); + const normalizedRoot = path.normalize(projectRoot); + if (!normalizedPath.startsWith(normalizedRoot + path.sep) && normalizedPath !== normalizedRoot) { + const color = await prompts.getColor(); + await prompts.log.warn(color.yellow(`${configKey} path escapes project root, skipping: ${dirPath}`)); + continue; + } + + // Check if directory path changed from previous config (update/modify scenario) + const oldDirValue = existingModuleConfig[configKey]; + let oldFullPath = null; + let oldDirPath = null; + if (oldDirValue && typeof oldDirValue === 'string') { + // F3: Normalize both values before comparing to avoid false negatives + // from trailing slashes, separator differences, or prefix format variations + let normalizedOld = oldDirValue.replace(/^\{project-root\}\/?/, ''); + normalizedOld = path.normalize(normalizedOld.replaceAll('{project-root}', '')); + const normalizedNew = path.normalize(dirPath); + + if (normalizedOld !== normalizedNew) { + oldDirPath = normalizedOld; + oldFullPath = path.join(projectRoot, oldDirPath); + const normalizedOldAbsolute = path.normalize(oldFullPath); + if (!normalizedOldAbsolute.startsWith(normalizedRoot + path.sep) && normalizedOldAbsolute !== normalizedRoot) { + oldFullPath = null; // Old path escapes project root, ignore it + } + + // F13: Prevent parent/child move (e.g. docs/planning → docs/planning/v2) + if (oldFullPath) { + const normalizedNewAbsolute = path.normalize(fullPath); + if ( + normalizedOldAbsolute.startsWith(normalizedNewAbsolute + path.sep) || + normalizedNewAbsolute.startsWith(normalizedOldAbsolute + path.sep) + ) { + const color = await prompts.getColor(); + await prompts.log.warn( + color.yellow( + `${configKey}: cannot move between parent/child paths (${oldDirPath} / ${dirPath}), creating new directory instead`, + ), + ); + oldFullPath = null; + } + } + } + } + + const dirName = configKey.replaceAll('_', ' '); + + if (oldFullPath && (await fs.pathExists(oldFullPath)) && !(await fs.pathExists(fullPath))) { + // Path changed and old dir exists → move old to new location + // F1: Use fs.move() instead of fs.rename() for cross-device/volume support + // F2: Wrap in try/catch — fallback to creating new dir on failure + try { + await fs.ensureDir(path.dirname(fullPath)); + await fs.move(oldFullPath, fullPath); + movedDirs.push(`${dirName}: ${oldDirPath} → ${dirPath}`); + } catch (moveError) { + const color = await prompts.getColor(); + await prompts.log.warn( + color.yellow( + `Failed to move ${oldDirPath} → ${dirPath}: ${moveError.message}\n Creating new directory instead. Please move contents from the old directory manually.`, + ), + ); + await fs.ensureDir(fullPath); + createdDirs.push(`${dirName}: ${dirPath}`); + } + } else if (oldFullPath && (await fs.pathExists(oldFullPath)) && (await fs.pathExists(fullPath))) { + // F5: Both old and new directories exist — warn user about potential orphaned documents + const color = await prompts.getColor(); + await prompts.log.warn( + color.yellow( + `${dirName}: path changed but both directories exist:\n Old: ${oldDirPath}\n New: ${dirPath}\n Old directory may contain orphaned documents — please review and merge manually.`, + ), + ); + } else if (!(await fs.pathExists(fullPath))) { + // New directory doesn't exist yet → create it + createdDirs.push(`${dirName}: ${dirPath}`); + await fs.ensureDir(fullPath); + } + + // Create WDS subfolders if this is the design_artifacts directory + if (configKey === 'design_artifacts' && wdsFolders.length > 0) { + for (const subfolder of wdsFolders) { + const subPath = path.join(fullPath, subfolder); + if (!(await fs.pathExists(subPath))) { + await fs.ensureDir(subPath); + createdWdsFolders.push(subfolder); + } + } + } + } + + return { createdDirs, movedDirs, createdWdsFolders }; + } + + /** + * Private: Process module configuration + * @param {string} modulePath - Path to installed module + * @param {string} moduleName - Module name + */ + async processModuleConfig(modulePath, moduleName) { + const configPath = path.join(modulePath, 'config.yaml'); + + if (await fs.pathExists(configPath)) { + try { + let configContent = await fs.readFile(configPath, 'utf8'); + + // Replace path placeholders + configContent = configContent.replaceAll('{project-root}', `bmad/${moduleName}`); + configContent = configContent.replaceAll('{module}', moduleName); + + await fs.writeFile(configPath, configContent, 'utf8'); + } catch (error) { + await prompts.log.warn(`Failed to process module config: ${error.message}`); + } + } + } + + /** + * Private: Sync module files (preserving user modifications) + * @param {string} sourcePath - Source module path + * @param {string} targetPath - Target module path + */ + async syncModule(sourcePath, targetPath) { + // Get list of all source files + const sourceFiles = await this.getFileList(sourcePath); + + for (const file of sourceFiles) { + const sourceFile = path.join(sourcePath, file); + const targetFile = path.join(targetPath, file); + + // Check if target file exists and has been modified + if (await fs.pathExists(targetFile)) { + const sourceStats = await fs.stat(sourceFile); + const targetStats = await fs.stat(targetFile); + + // Skip if target is newer (user modified) + if (targetStats.mtime > sourceStats.mtime) { + continue; + } + } + + // Copy file with placeholder replacement + await this.copyFile(sourceFile, targetFile); + } + } + + /** + * Private: Get list of all files in a directory + * @param {string} dir - Directory path + * @param {string} baseDir - Base directory for relative paths + * @returns {Array} List of relative file paths + */ + async getFileList(dir, baseDir = dir) { + const files = []; + const entries = await fs.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + const subFiles = await this.getFileList(fullPath, baseDir); + files.push(...subFiles); + } else { + files.push(path.relative(baseDir, fullPath)); + } + } + + return files; + } + + // ─── Config collection methods (merged from ConfigCollector) ─── + /** * Find the bmad installation directory in a project * V6+ installations can use ANY folder name but ALWAYS have _config/manifest.yaml @@ -95,7 +766,7 @@ class ConfigCollector { * @param {string} projectDir - Target project directory */ async loadExistingConfig(projectDir) { - this.existingConfig = {}; + this._existingConfig = {}; // Check if project directory exists first if (!(await fs.pathExists(projectDir))) { @@ -129,7 +800,7 @@ class ConfigCollector { const content = await fs.readFile(moduleConfigPath, 'utf8'); const moduleConfig = yaml.parse(content); if (moduleConfig) { - this.existingConfig[entry.name] = moduleConfig; + this._existingConfig[entry.name] = moduleConfig; foundAny = true; } } catch { @@ -153,7 +824,7 @@ class ConfigCollector { const results = []; for (const moduleName of modules) { - // Resolve module.yaml path - custom paths first, then standard location, then ModuleManager search + // Resolve module.yaml path - custom paths first, then standard location, then OfficialModules search let moduleConfigPath = null; const customPath = this.customModulePaths?.get(moduleName); if (customPath) { @@ -163,7 +834,7 @@ class ConfigCollector { if (await fs.pathExists(standardPath)) { moduleConfigPath = standardPath; } else { - const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true }); + const moduleSourcePath = await this.findModuleSource(moduleName, { silent: true }); if (moduleSourcePath) { moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); } @@ -349,7 +1020,7 @@ class ConfigCollector { this.currentProjectDir = projectDir; // Load existing config if not already loaded - if (!this.existingConfig) { + if (!this._existingConfig) { await this.loadExistingConfig(projectDir); } @@ -364,7 +1035,7 @@ class ConfigCollector { // If not found in src/modules, we need to find it by searching the project if (!(await fs.pathExists(moduleConfigPath))) { - const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true }); + const moduleSourcePath = await this.findModuleSource(moduleName, { silent: true }); if (moduleSourcePath) { moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); @@ -378,7 +1049,7 @@ class ConfigCollector { configPath = moduleConfigPath; } else { // Check if this is a custom module with custom.yaml - const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true }); + const moduleSourcePath = await this.findModuleSource(moduleName, { silent: true }); if (moduleSourcePath) { const rootCustomConfigPath = path.join(moduleSourcePath, 'custom.yaml'); @@ -391,11 +1062,11 @@ class ConfigCollector { } // No config schema for this module - use existing values - if (this.existingConfig && this.existingConfig[moduleName]) { + if (this._existingConfig && this._existingConfig[moduleName]) { if (!this.collectedConfig[moduleName]) { this.collectedConfig[moduleName] = {}; } - this.collectedConfig[moduleName] = { ...this.existingConfig[moduleName] }; + this.collectedConfig[moduleName] = { ...this._existingConfig[moduleName] }; } return false; } @@ -409,7 +1080,7 @@ class ConfigCollector { // Compare schema with existing config to find new/missing fields const configKeys = Object.keys(moduleConfig).filter((key) => key !== 'prompt'); - const existingKeys = this.existingConfig && this.existingConfig[moduleName] ? Object.keys(this.existingConfig[moduleName]) : []; + const existingKeys = this._existingConfig && this._existingConfig[moduleName] ? Object.keys(this._existingConfig[moduleName]) : []; // Check if this module has no configuration keys at all (like CIS) // Filter out metadata fields and only count actual config objects @@ -440,11 +1111,11 @@ class ConfigCollector { // If in silent mode and no new keys (neither interactive nor static), use existing config and skip prompts if (silentMode && newKeys.length === 0 && newStaticKeys.length === 0) { - if (this.existingConfig && this.existingConfig[moduleName]) { + if (this._existingConfig && this._existingConfig[moduleName]) { if (!this.collectedConfig[moduleName]) { this.collectedConfig[moduleName] = {}; } - this.collectedConfig[moduleName] = { ...this.existingConfig[moduleName] }; + this.collectedConfig[moduleName] = { ...this._existingConfig[moduleName] }; // Special handling for user_name: ensure it has a value if ( @@ -455,7 +1126,7 @@ class ConfigCollector { } // Also populate allAnswers for cross-referencing - for (const [key, value] of Object.entries(this.existingConfig[moduleName])) { + for (const [key, value] of Object.entries(this._existingConfig[moduleName])) { // Ensure user_name is properly set in allAnswers too let finalValue = value; if (moduleName === 'core' && key === 'user_name' && (!value || value === '[USER_NAME]')) { @@ -519,8 +1190,8 @@ class ConfigCollector { // Process all answers (both static and prompted) // First, copy existing config to preserve values that aren't being updated - if (this.existingConfig && this.existingConfig[moduleName]) { - this.collectedConfig[moduleName] = { ...this.existingConfig[moduleName] }; + if (this._existingConfig && this._existingConfig[moduleName]) { + this.collectedConfig[moduleName] = { ...this._existingConfig[moduleName] }; } else { this.collectedConfig[moduleName] = {}; } @@ -545,11 +1216,11 @@ class ConfigCollector { } // Copy over existing values for fields that weren't prompted - if (this.existingConfig && this.existingConfig[moduleName]) { + if (this._existingConfig && this._existingConfig[moduleName]) { if (!this.collectedConfig[moduleName]) { this.collectedConfig[moduleName] = {}; } - for (const [key, value] of Object.entries(this.existingConfig[moduleName])) { + for (const [key, value] of Object.entries(this._existingConfig[moduleName])) { if (!this.collectedConfig[moduleName][key]) { this.collectedConfig[moduleName][key] = value; this.allAnswers[`${moduleName}_${key}`] = value; @@ -652,7 +1323,7 @@ class ConfigCollector { async collectModuleConfig(moduleName, projectDir, skipLoadExisting = false, skipCompletion = false) { this.currentProjectDir = projectDir; // Load existing config if needed and not already loaded - if (!skipLoadExisting && !this.existingConfig) { + if (!skipLoadExisting && !this._existingConfig) { await this.loadExistingConfig(projectDir); } @@ -674,7 +1345,7 @@ class ConfigCollector { // If not found in src/modules or custom paths, search the project if (!(await fs.pathExists(moduleConfigPath))) { - const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true }); + const moduleSourcePath = await this.findModuleSource(moduleName, { silent: true }); if (moduleSourcePath) { moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); @@ -994,8 +1665,8 @@ class ConfigCollector { } // Prefer the current module's persisted value when re-prompting an existing install - if (!configValue && currentModule && this.existingConfig?.[currentModule]?.[configKey] !== undefined) { - configValue = this.existingConfig[currentModule][configKey]; + if (!configValue && currentModule && this._existingConfig?.[currentModule]?.[configKey] !== undefined) { + configValue = this._existingConfig[currentModule][configKey]; } // Check in already collected config @@ -1009,10 +1680,10 @@ class ConfigCollector { } // Fall back to other existing module config values - if (!configValue && this.existingConfig) { - for (const mod of Object.keys(this.existingConfig)) { - if (mod !== '_meta' && this.existingConfig[mod] && this.existingConfig[mod][configKey]) { - configValue = this.existingConfig[mod][configKey]; + if (!configValue && this._existingConfig) { + for (const mod of Object.keys(this._existingConfig)) { + if (mod !== '_meta' && this._existingConfig[mod] && this._existingConfig[mod][configKey]) { + configValue = this._existingConfig[mod][configKey]; break; } } @@ -1083,8 +1754,8 @@ class ConfigCollector { // Check for existing value let existingValue = null; - if (this.existingConfig && this.existingConfig[moduleName]) { - existingValue = this.existingConfig[moduleName][key]; + if (this._existingConfig && this._existingConfig[moduleName]) { + existingValue = this._existingConfig[moduleName][key]; existingValue = this.normalizeExistingValueForPrompt(existingValue, moduleName, item, moduleConfig); } @@ -1369,4 +2040,4 @@ class ConfigCollector { } } -module.exports = { ConfigCollector }; +module.exports = { OfficialModules }; diff --git a/tools/cli/lib/project-root.js b/tools/installer/project-root.js similarity index 100% rename from tools/cli/lib/project-root.js rename to tools/installer/project-root.js diff --git a/tools/cli/lib/prompts.js b/tools/installer/prompts.js similarity index 100% rename from tools/cli/lib/prompts.js rename to tools/installer/prompts.js diff --git a/tools/cli/lib/ui.js b/tools/installer/ui.js similarity index 81% rename from tools/cli/lib/ui.js rename to tools/installer/ui.js index 3f25dae03..03d38e4da 100644 --- a/tools/cli/lib/ui.js +++ b/tools/installer/ui.js @@ -2,8 +2,8 @@ const path = require('node:path'); const os = require('node:os'); const fs = require('fs-extra'); const { CLIUtils } = require('./cli-utils'); -const { CustomHandler } = require('../installers/lib/custom/handler'); -const { ExternalModuleManager } = require('../installers/lib/modules/external-manager'); +const { CustomHandler } = require('./custom-handler'); +const { ExternalModuleManager } = require('./modules/external-manager'); const prompts = require('./prompts'); // Separator class for visual grouping in select/multiselect prompts @@ -32,7 +32,7 @@ class UI { await CLIUtils.displayLogo(); // Display version-specific start message from install-messages.yaml - const { MessageLoader } = require('../installers/lib/message-loader'); + const { MessageLoader } = require('./message-loader'); const messageLoader = new MessageLoader(); await messageLoader.displayStartMessage(); @@ -51,125 +51,11 @@ class UI { confirmedDirectory = await this.getConfirmedDirectory(); } - // Preflight: Check for legacy BMAD v4 footprints immediately after getting directory - const { Detector } = require('../installers/lib/core/detector'); - const { Installer } = require('../installers/lib/core/installer'); - const detector = new Detector(); + const { Installer } = require('./core/installer'); const installer = new Installer(); - const legacyV4 = await detector.detectLegacyV4(confirmedDirectory); - if (legacyV4.hasLegacyV4) { - await installer.handleLegacyV4Migration(confirmedDirectory, legacyV4); - } + const { bmadDir } = await installer.findBmadDir(confirmedDirectory); - // Check for legacy folders and prompt for rename before showing any menus - let hasLegacyCfg = false; - let hasLegacyBmadFolder = false; - let bmadDir = null; - let legacyBmadPath = null; - - // First check for legacy .bmad folder (instead of _bmad) - // Only check if directory exists - if (await fs.pathExists(confirmedDirectory)) { - const entries = await fs.readdir(confirmedDirectory, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory() && (entry.name === '.bmad' || entry.name === 'bmad')) { - hasLegacyBmadFolder = true; - legacyBmadPath = path.join(confirmedDirectory, entry.name); - bmadDir = legacyBmadPath; - - // Check if it has _cfg folder - const cfgPath = path.join(legacyBmadPath, '_cfg'); - if (await fs.pathExists(cfgPath)) { - hasLegacyCfg = true; - } - break; - } - } - } - - // If no .bmad or bmad found, check for current installations _bmad - if (!hasLegacyBmadFolder) { - const bmadResult = await installer.findBmadDir(confirmedDirectory); - bmadDir = bmadResult.bmadDir; - hasLegacyCfg = bmadResult.hasLegacyCfg; - } - - // Handle legacy .bmad or _cfg folder - these are very old (v4 or alpha) - // Show version warning instead of offering conversion - if (hasLegacyBmadFolder || hasLegacyCfg) { - await prompts.log.warn('LEGACY INSTALLATION DETECTED'); - await prompts.note( - 'Found a ".bmad"/"bmad" folder, or a legacy "_cfg" folder under the bmad folder -\n' + - 'this is from an old BMAD version that is out of date for automatic upgrade,\n' + - 'manual intervention required.\n\n' + - 'You have a legacy version installed (v4 or alpha).\n' + - 'Legacy installations may have compatibility issues.\n\n' + - 'For the best experience, we strongly recommend:\n' + - ' 1. Delete your current BMAD installation folder (.bmad or bmad)\n' + - ' 2. Run a fresh installation\n\n' + - 'If you do not want to start fresh, you can attempt to proceed beyond this\n' + - 'point IF you have ensured the bmad folder is named _bmad, and under it there\n' + - 'is a _config folder. If you have a folder under your bmad folder named _cfg,\n' + - 'you would need to rename it _config, and then restart the installer.\n\n' + - 'Benefits of a fresh install:\n' + - ' \u2022 Cleaner configuration without legacy artifacts\n' + - ' \u2022 All new features properly configured\n' + - ' \u2022 Fewer potential conflicts\n\n' + - 'If you have already produced output from an earlier alpha version, you can\n' + - 'still retain those artifacts. After installation, ensure you configured during\n' + - 'install the proper file locations for artifacts depending on the module you\n' + - 'are using, or move the files to the proper locations.', - 'Legacy Installation Detected', - ); - - const proceed = await prompts.select({ - message: 'How would you like to proceed?', - choices: [ - { - name: 'Cancel and do a fresh install (recommended)', - value: 'cancel', - }, - { - name: 'Proceed anyway (will attempt update, potentially may fail or have unstable behavior)', - value: 'proceed', - }, - ], - default: 'cancel', - }); - - if (proceed === 'cancel') { - await prompts.note('1. Delete the existing bmad folder in your project\n' + "2. Run 'bmad install' again", 'To do a fresh install'); - process.exit(0); - return; - } - - const s = await prompts.spinner(); - s.start('Updating folder structure...'); - try { - // Handle .bmad folder - if (hasLegacyBmadFolder) { - const newBmadPath = path.join(confirmedDirectory, '_bmad'); - await fs.move(legacyBmadPath, newBmadPath); - bmadDir = newBmadPath; - s.stop(`Renamed "${path.basename(legacyBmadPath)}" to "_bmad"`); - } - - // Handle _cfg folder (either from .bmad or standalone) - const cfgPath = path.join(bmadDir, '_cfg'); - if (await fs.pathExists(cfgPath)) { - s.start('Renaming configuration folder...'); - const newCfgPath = path.join(bmadDir, '_config'); - await fs.move(cfgPath, newCfgPath); - s.stop('Renamed "_cfg" to "_config"'); - } - } catch (error) { - s.stop('Failed to update folder structure'); - await prompts.log.error(`Error: ${error.message}`); - process.exit(1); - } - } - - // Check if there's an existing BMAD installation (after any folder renames) + // Check if there's an existing BMAD installation const hasExistingInstall = await fs.pathExists(bmadDir); let customContentConfig = { hasCustomContent: false }; @@ -184,18 +70,9 @@ class UI { if (hasExistingInstall) { // Get version information const { existingInstall, bmadDir } = await this.getExistingInstallation(confirmedDirectory); - const packageJsonPath = path.join(__dirname, '../../../package.json'); + const packageJsonPath = path.join(__dirname, '../../package.json'); const currentVersion = require(packageJsonPath).version; - const installedVersion = existingInstall.version || 'unknown'; - - // Check if version is pre beta - const shouldProceed = await this.showLegacyVersionWarning(installedVersion, currentVersion, path.basename(bmadDir), options); - - // If user chose to cancel, exit the installer - if (!shouldProceed) { - process.exit(0); - return; - } + const installedVersion = existingInstall.installed ? existingInstall.version || 'unknown' : 'unknown'; // Build menu choices dynamically const choices = []; @@ -402,7 +279,7 @@ class UI { customModuleResult = await this.handleCustomModulesInModifyFlow(confirmedDirectory, selectedModules); } else { // Preserve existing custom modules if user doesn't want to modify them - const { Installer } = require('../installers/lib/core/installer'); + const { Installer } = require('./core/installer'); const installer = new Installer(); const { bmadDir } = await installer.findBmadDir(confirmedDirectory); @@ -423,22 +300,24 @@ class UI { selectedModules.push(...customModuleResult.selectedCustomModules); } - // Filter out core - it's always installed via installCore flag - selectedModules = selectedModules.filter((m) => m !== 'core'); + // Ensure core is in the modules list + if (!selectedModules.includes('core')) { + selectedModules.unshift('core'); + } // Get tool selection const toolSelection = await this.promptToolSelection(confirmedDirectory, options); - const coreConfig = await this.collectCoreConfig(confirmedDirectory, options); + const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, options); return { actionType: 'update', directory: confirmedDirectory, - installCore: true, modules: selectedModules, ides: toolSelection.ides, skipIde: toolSelection.skipIde, - coreConfig: coreConfig, + coreConfig: moduleConfigs.core || {}, + moduleConfigs: moduleConfigs, customContent: customModuleResult.customContentConfig, skipPrompts: options.yes || false, }; @@ -543,18 +422,21 @@ class UI { selectedModules.push(...customContentConfig.selectedModuleIds); } - selectedModules = selectedModules.filter((m) => m !== 'core'); + // Ensure core is in the modules list + if (!selectedModules.includes('core')) { + selectedModules.unshift('core'); + } let toolSelection = await this.promptToolSelection(confirmedDirectory, options); - const coreConfig = await this.collectCoreConfig(confirmedDirectory, options); + const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, options); return { actionType: 'install', directory: confirmedDirectory, - installCore: true, modules: selectedModules, ides: toolSelection.ides, skipIde: toolSelection.skipIde, - coreConfig: coreConfig, + coreConfig: moduleConfigs.core || {}, + moduleConfigs: moduleConfigs, customContent: customContentConfig, skipPrompts: options.yes || false, }; @@ -570,18 +452,15 @@ class UI { * @returns {Object} Tool configuration */ async promptToolSelection(projectDir, options = {}) { - // Check for existing configured IDEs - use findBmadDir to detect custom folder names - const { Detector } = require('../installers/lib/core/detector'); - const { Installer } = require('../installers/lib/core/installer'); - const detector = new Detector(); + const { ExistingInstall } = require('./core/existing-install'); + const { Installer } = require('./core/installer'); const installer = new Installer(); - const bmadResult = await installer.findBmadDir(projectDir || process.cwd()); - const bmadDir = bmadResult.bmadDir; - const existingInstall = await detector.detect(bmadDir); - const configuredIdes = existingInstall.ides || []; + const { bmadDir } = await installer.findBmadDir(projectDir || process.cwd()); + const existingInstall = await ExistingInstall.detect(bmadDir); + const configuredIdes = existingInstall.ides; // Get IDE manager to fetch available IDEs dynamically - const { IdeManager } = require('../installers/lib/ide/manager'); + const { IdeManager } = require('./ide/manager'); const ideManager = new IdeManager(); await ideManager.ensureInitialized(); // IMPORTANT: Must initialize before getting IDEs @@ -811,29 +690,29 @@ class UI { * @returns {Object} Object with existingInstall, installedModuleIds, and bmadDir */ async getExistingInstallation(directory) { - const { Detector } = require('../installers/lib/core/detector'); - const { Installer } = require('../installers/lib/core/installer'); - const detector = new Detector(); + const { ExistingInstall } = require('./core/existing-install'); + const { Installer } = require('./core/installer'); const installer = new Installer(); - const bmadDirResult = await installer.findBmadDir(directory); - const bmadDir = bmadDirResult.bmadDir; - const existingInstall = await detector.detect(bmadDir); - const installedModuleIds = new Set(existingInstall.modules.map((mod) => mod.id)); + const { bmadDir } = await installer.findBmadDir(directory); + const existingInstall = await ExistingInstall.detect(bmadDir); + const installedModuleIds = new Set(existingInstall.moduleIds); return { existingInstall, installedModuleIds, bmadDir }; } /** - * Collect core configuration + * Collect all module configurations (core + selected modules). + * All interactive prompting happens here in the UI layer. * @param {string} directory - Installation directory + * @param {string[]} modules - Modules to configure (including 'core') * @param {Object} options - Command-line options - * @returns {Object} Core configuration + * @returns {Object} Collected module configurations keyed by module name */ - async collectCoreConfig(directory, options = {}) { - const { ConfigCollector } = require('../installers/lib/core/config-collector'); - const configCollector = new ConfigCollector(); + async collectModuleConfigs(directory, modules, options = {}) { + const { OfficialModules } = require('./modules/official-modules'); + const configCollector = new OfficialModules(); - // If options are provided, set them directly + // Seed core config from CLI options if provided if (options.userName || options.communicationLanguage || options.documentOutputLanguage || options.outputFolder) { const coreConfig = {}; if (options.userName) { @@ -855,8 +734,6 @@ class UI { // Load existing config to merge with provided options await configCollector.loadExistingConfig(directory); - - // Merge provided options with existing config (or defaults) const existingConfig = configCollector.collectedConfig.core || {}; configCollector.collectedConfig.core = { ...existingConfig, ...coreConfig }; @@ -872,7 +749,6 @@ class UI { await configCollector.loadExistingConfig(directory); const existingConfig = configCollector.collectedConfig.core || {}; - // If no existing config, use defaults if (Object.keys(existingConfig).length === 0) { let safeUsername; try { @@ -889,16 +765,14 @@ class UI { }; await prompts.log.info('Using default configuration (--yes flag)'); } - } else { - // Load existing configs first if they exist - await configCollector.loadExistingConfig(directory); - // Now collect with existing values as defaults (false = don't skip loading, true = skip completion message) - await configCollector.collectModuleConfig('core', directory, false, true); } - const coreConfig = configCollector.collectedConfig.core; - // Ensure we always have a core config object, even if empty - return coreConfig || {}; + // Collect all module configs — core is skipped if already seeded above + await configCollector.collectAllConfigurations(modules, directory, { + skipPrompts: options.yes || false, + }); + + return configCollector.collectedConfig; } /** @@ -935,9 +809,9 @@ class UI { } // Add official modules - const { ModuleManager } = require('../installers/lib/modules/manager'); - const moduleManager = new ModuleManager(); - const { modules: availableModules, customModules: customModulesFromCache } = await moduleManager.listAvailable(); + const { OfficialModules } = require('./modules/official-modules'); + const officialModules = new OfficialModules(); + const { modules: availableModules, customModules: customModulesFromCache } = await officialModules.listAvailable(); // First, add all items to appropriate sections const allCustomModules = []; @@ -992,9 +866,9 @@ class UI { * @returns {Array} Selected module codes (excluding core) */ async selectAllModules(installedModuleIds = new Set()) { - const { ModuleManager } = require('../installers/lib/modules/manager'); - const moduleManager = new ModuleManager(); - const { modules: localModules } = await moduleManager.listAvailable(); + const { OfficialModules } = require('./modules/official-modules'); + const officialModulesSource = new OfficialModules(); + const { modules: localModules } = await officialModulesSource.listAvailable(); // Get external modules const externalManager = new ExternalModuleManager(); @@ -1069,7 +943,7 @@ class UI { maxItems: allOptions.length, }); - const result = selected ? selected.filter((m) => m !== 'core') : []; + const result = selected ? [...selected] : []; // Display selected modules as bulleted list if (result.length > 0) { @@ -1089,9 +963,9 @@ class UI { * @returns {Array} Default module codes */ async getDefaultModules(installedModuleIds = new Set()) { - const { ModuleManager } = require('../installers/lib/modules/manager'); - const moduleManager = new ModuleManager(); - const { modules: localModules } = await moduleManager.listAvailable(); + const { OfficialModules } = require('./modules/official-modules'); + const officialModules = new OfficialModules(); + const { modules: localModules } = await officialModules.listAvailable(); const defaultModules = []; @@ -1149,7 +1023,7 @@ class UI { const files = await fs.readdir(directory); if (files.length > 0) { // Check for any bmad installation (any folder with _config/manifest.yaml) - const { Installer } = require('../installers/lib/core/installer'); + const { Installer } = require('./core/installer'); const installer = new Installer(); const bmadResult = await installer.findBmadDir(directory); const hasBmadInstall = @@ -1385,50 +1259,18 @@ class UI { return path.resolve(expanded); } - /** - * Load existing configurations to use as defaults - * @param {string} directory - Installation directory - * @returns {Object} Existing configurations - */ - async loadExistingConfigurations(directory) { - const configs = { - hasCustomContent: false, - coreConfig: {}, - ideConfig: { ides: [], skipIde: false }, - }; - - try { - // Load core config - configs.coreConfig = await this.collectCoreConfig(directory); - - // Load IDE configuration - const configuredIdes = await this.getConfiguredIdes(directory); - if (configuredIdes.length > 0) { - configs.ideConfig.ides = configuredIdes; - configs.ideConfig.skipIde = false; - } - - return configs; - } catch { - // If loading fails, return empty configs - await prompts.log.warn('Could not load existing configurations'); - return configs; - } - } - /** * Get configured IDEs from existing installation * @param {string} directory - Installation directory * @returns {Array} List of configured IDEs */ async getConfiguredIdes(directory) { - const { Detector } = require('../installers/lib/core/detector'); - const { Installer } = require('../installers/lib/core/installer'); - const detector = new Detector(); + const { ExistingInstall } = require('./core/existing-install'); + const { Installer } = require('./core/installer'); const installer = new Installer(); - const bmadResult = await installer.findBmadDir(directory); - const existingInstall = await detector.detect(bmadResult.bmadDir); - return existingInstall.ides || []; + const { bmadDir } = await installer.findBmadDir(directory); + const existingInstall = await ExistingInstall.detect(bmadDir); + return existingInstall.ides; } /** @@ -1573,7 +1415,7 @@ class UI { const { existingInstall } = await this.getExistingInstallation(directory); // Check if there are any custom modules in cache - const { Installer } = require('../installers/lib/core/installer'); + const { Installer } = require('./core/installer'); const installer = new Installer(); const { bmadDir } = await installer.findBmadDir(directory); @@ -1707,82 +1549,6 @@ class UI { return result; } - /** - * Check if installed version is a legacy version that needs fresh install - * @param {string} installedVersion - The installed version - * @returns {boolean} True if legacy (v4 or any alpha) - */ - isLegacyVersion(installedVersion) { - if (!installedVersion || installedVersion === 'unknown') { - return true; // Treat unknown as legacy for safety - } - // Check if version string contains -alpha or -Alpha (any v6 alpha) - return /-alpha\./i.test(installedVersion); - } - - /** - * Show warning for legacy version (v4 or alpha) and ask if user wants to proceed - * @param {string} installedVersion - The installed version - * @param {string} currentVersion - The current version - * @param {string} bmadFolderName - Name of the BMAD folder - * @returns {Promise} True if user wants to proceed, false if they cancel - */ - async showLegacyVersionWarning(installedVersion, currentVersion, bmadFolderName, options = {}) { - if (!this.isLegacyVersion(installedVersion)) { - return true; // Not legacy, proceed - } - - let warningContent; - if (installedVersion === 'unknown') { - warningContent = 'Unable to detect your installed BMAD version.\n' + 'This appears to be a legacy or unsupported installation.'; - } else { - warningContent = - `You are updating from ${installedVersion} to ${currentVersion}.\n` + 'You have a legacy version installed (v4 or alpha).'; - } - - warningContent += - '\n\nFor the best experience, we recommend:\n' + - ' 1. Delete your current BMAD installation folder\n' + - ` (the "${bmadFolderName}/" folder in your project)\n` + - ' 2. Run a fresh installation\n\n' + - 'Benefits of a fresh install:\n' + - ' \u2022 Cleaner configuration without legacy artifacts\n' + - ' \u2022 All new features properly configured\n' + - ' \u2022 Fewer potential conflicts'; - - await prompts.log.warn('VERSION WARNING'); - await prompts.note(warningContent, 'Version Warning'); - - if (options.yes) { - await prompts.log.warn('Non-interactive mode (--yes): auto-proceeding with legacy update'); - return true; - } - - const proceed = await prompts.select({ - message: 'How would you like to proceed?', - choices: [ - { - name: 'Proceed with update anyway (may have issues)', - value: 'proceed', - }, - { - name: 'Cancel (recommended - do a fresh install instead)', - value: 'cancel', - }, - ], - default: 'cancel', - }); - - if (proceed === 'cancel') { - await prompts.note( - `1. Delete the "${bmadFolderName}/" folder in your project\n` + "2. Run 'bmad install' again", - 'To do a fresh install', - ); - } - - return proceed === 'proceed'; - } - /** * Display module versions with update availability * @param {Array} modules - Array of module info objects with version info diff --git a/tools/cli/lib/yaml-format.js b/tools/installer/yaml-format.js similarity index 100% rename from tools/cli/lib/yaml-format.js rename to tools/installer/yaml-format.js diff --git a/tools/javascript-conventions.md b/tools/javascript-conventions.md new file mode 100644 index 000000000..99ea39520 --- /dev/null +++ b/tools/javascript-conventions.md @@ -0,0 +1,5 @@ +# JavaScript Conventions + +## Function ordering + +Define functions top-to-bottom in call order: callers above callees. If `install()` calls `_initPaths()`, then `install` appears first and `_initPaths` appears after it. diff --git a/tools/lib/xml-utils.js b/tools/lib/xml-utils.js deleted file mode 100644 index 482373151..000000000 --- a/tools/lib/xml-utils.js +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Escape XML special characters in a string - * @param {string} text - The text to escape - * @returns {string} The escaped text - */ -function escapeXml(text) { - if (!text) return ''; - return text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); -} - -module.exports = { - escapeXml, -};