refactor(installer): remove custom content installation feature

Remove the entire local filesystem custom content feature from the
installer to make way for marketplace-based plugin installation.

Deleted: custom-handler.js, custom-module-cache.js, custom-modules.js
Removed: --custom-content CLI flag, interactive custom content prompts,
custom module caching, manifest tracking, missing-source resolution,
and related test suites. Updated docs across all translations.
This commit is contained in:
Brian Madison 2026-04-07 21:03:20 -05:00
parent 9ca0316674
commit 537ff0cbf0
21 changed files with 34 additions and 2094 deletions

View File

@ -27,7 +27,6 @@ Vyžaduje [Node.js](https://nodejs.org) v20+ a `npx` (součástí npm).
| `--directory <cesta>` | Instalační adresář | `--directory ~/projects/myapp` | | `--directory <cesta>` | Instalační adresář | `--directory ~/projects/myapp` |
| `--modules <moduly>` | Čárkou oddělená ID modulů | `--modules bmm,bmb` | | `--modules <moduly>` | Čárkou oddělená ID modulů | `--modules bmm,bmb` |
| `--tools <nástroje>` | Čárkou oddělená ID nástrojů/IDE (použijte `none` pro přeskočení) | `--tools claude-code,cursor` nebo `--tools none` | | `--tools <nástroje>` | Čárkou oddělená ID nástrojů/IDE (použijte `none` pro přeskočení) | `--tools claude-code,cursor` nebo `--tools none` |
| `--custom-content <cesty>` | Čárkou oddělené cesty k vlastním modulům | `--custom-content ~/my-module,~/another-module` |
| `--action <typ>` | Akce pro existující instalace: `install` (výchozí), `update` nebo `quick-update` | `--action quick-update` | | `--action <typ>` | Akce pro existující instalace: `install` (výchozí), `update` nebo `quick-update` | `--action quick-update` |
### Základní konfigurace ### Základní konfigurace
@ -108,16 +107,6 @@ npx bmad-method install \
--action quick-update --action quick-update
``` ```
### Instalace s vlastním obsahem
```bash
npx bmad-method install \
--directory ~/projects/myapp \
--modules bmm \
--custom-content ~/my-custom-module,~/another-module \
--tools claude-code
```
## Co získáte ## Co získáte
- Plně nakonfigurovaný adresář `_bmad/` ve vašem projektu - Plně nakonfigurovaný adresář `_bmad/` ve vašem projektu
@ -159,13 +148,6 @@ Neplatné hodnoty buď:
- Ověřte, že ID modulu je správné - Ověřte, že ID modulu je správné
- Externí moduly musí být dostupné v registru - Externí moduly musí být dostupné v registru
### Neplatná cesta k vlastnímu obsahu
Ujistěte se, že každá cesta k vlastnímu obsahu:
- Ukazuje na adresář
- Obsahuje soubor `module.yaml` v kořeni
- Má pole `code` v `module.yaml`
::: note[Stále jste uvízli?] ::: note[Stále jste uvízli?]
Spusťte s `--debug` pro detailní výstup, zkuste interaktivní režim pro izolaci problému, nebo nahlaste na <https://github.com/bmad-code-org/BMAD-METHOD/issues>. Spusťte s `--debug` pro detailní výstup, zkuste interaktivní režim pro izolaci problému, nebo nahlaste na <https://github.com/bmad-code-org/BMAD-METHOD/issues>.
::: :::

View File

@ -72,7 +72,7 @@ L'installateur affiche les modules disponibles. Sélectionnez ceux dont vous ave
### 5. Suivre les instructions ### 5. Suivre les instructions
L'installateur vous guide pour le reste — contenu personnalisé, paramètres, etc. L'installateur vous guide pour le reste — paramètres, intégrations d'outils, etc.
## Ce que vous obtenez ## Ce que vous obtenez

View File

@ -27,7 +27,6 @@ Nécessite [Node.js](https://nodejs.org) v20+ et `npx` (inclus avec npm).
| `--directory <chemin>` | Répertoire d'installation | `--directory ~/projects/myapp` | | `--directory <chemin>` | Répertoire d'installation | `--directory ~/projects/myapp` |
| `--modules <modules>` | IDs de modules séparés par des virgules | `--modules bmm,bmb` | | `--modules <modules>` | IDs de modules séparés par des virgules | `--modules bmm,bmb` |
| `--tools <outils>` | IDs d'outils/IDE séparés par des virgules (utilisez `none` pour ignorer) | `--tools claude-code,cursor` ou `--tools none` | | `--tools <outils>` | IDs d'outils/IDE séparés par des virgules (utilisez `none` pour ignorer) | `--tools claude-code,cursor` ou `--tools none` |
| `--custom-content <chemins>` | Chemins vers des modules personnalisés séparés par des virgules | `--custom-content ~/my-module,~/another-module` |
| `--action <type>` | Action pour les installations existantes : `install` (par défaut), `update`, ou `quick-update` | `--action quick-update` | | `--action <type>` | Action pour les installations existantes : `install` (par défaut), `update`, ou `quick-update` | `--action quick-update` |
### Configuration principale ### Configuration principale
@ -120,16 +119,6 @@ npx bmad-method install \
--action quick-update --action quick-update
``` ```
### Installation avec du contenu personnalisé
```bash
npx bmad-method install \
--directory ~/projects/myapp \
--modules bmm \
--custom-content ~/my-custom-module,~/another-module \
--tools claude-code
```
## Ce que vous obtenez ## Ce que vous obtenez
- Un répertoire `_bmad/` entièrement configuré dans votre projet - Un répertoire `_bmad/` entièrement configuré dans votre projet
@ -143,12 +132,11 @@ BMad valide toutes les options fournis :
- **Directory** — Doit être un chemin valide avec des permissions d'écriture - **Directory** — Doit être un chemin valide avec des permissions d'écriture
- **Modules** — Avertit des IDs de modules invalides (mais n'échoue pas) - **Modules** — Avertit des IDs de modules invalides (mais n'échoue pas)
- **Tools** — Avertit des IDs d'outils invalides (mais n'échoue pas) - **Tools** — Avertit des IDs d'outils invalides (mais n'échoue pas)
- **Custom Content** — Chaque chemin doit contenir un fichier `module.yaml` valide
- **Action** — Doit être l'une des suivantes : `install`, `update`, `quick-update` - **Action** — Doit être l'une des suivantes : `install`, `update`, `quick-update`
Les valeurs invalides entraîneront soit : Les valeurs invalides entraîneront soit :
1. Laffichage dun message d'erreur suivi dun exit (pour les options critiques comme le répertoire) 1. Laffichage dun message d'erreur suivi dun exit (pour les options critiques comme le répertoire)
2. Un avertissement puis la continuation de linstallation (pour les éléments optionnels comme le contenu personnalisé) 2. Un avertissement puis la continuation de linstallation (pour les éléments optionnels)
3. Un retour aux invites interactives (pour les valeurs requises manquantes) 3. Un retour aux invites interactives (pour les valeurs requises manquantes)
:::tip[Bonnes pratiques] :::tip[Bonnes pratiques]
@ -172,13 +160,6 @@ Les valeurs invalides entraîneront soit :
- Vérifiez que l'ID du module est correct - Vérifiez que l'ID du module est correct
- Les modules externes doivent être disponibles dans le registre - Les modules externes doivent être disponibles dans le registre
### Chemin de contenu personnalisé invalide
Assurez-vous que chaque chemin de contenu personnalisé :
- Pointe vers un répertoire
- Contient un fichier `module.yaml` à la racine
- Possède un champ `code` dans `module.yaml`
::: note[Toujours bloqué ?] ::: note[Toujours bloqué ?]
Exécutez avec `--debug` pour une sortie détaillée, essayez le mode interactif pour isoler le problème, ou signalez-le à <https://github.com/bmad-code-org/BMAD-METHOD/issues>. Exécutez avec `--debug` pour une sortie détaillée, essayez le mode interactif pour isoler le problème, ou signalez-le à <https://github.com/bmad-code-org/BMAD-METHOD/issues>.
::: :::

View File

@ -72,7 +72,7 @@ The installer shows available modules. Select whichever ones you need — most u
### 5. Follow the Prompts ### 5. Follow the Prompts
The installer guides you through the rest — custom content, settings, etc. The installer guides you through the rest — settings, tool integrations, etc.
## What You Get ## What You Get

View File

@ -27,7 +27,6 @@ Requires [Node.js](https://nodejs.org) v20+ and `npx` (included with npm).
| `--directory <path>` | Installation directory | `--directory ~/projects/myapp` | | `--directory <path>` | Installation directory | `--directory ~/projects/myapp` |
| `--modules <modules>` | Comma-separated module IDs | `--modules bmm,bmb` | | `--modules <modules>` | Comma-separated module IDs | `--modules bmm,bmb` |
| `--tools <tools>` | Comma-separated tool/IDE IDs (use `none` to skip) | `--tools claude-code,cursor` or `--tools none` | | `--tools <tools>` | Comma-separated tool/IDE IDs (use `none` to skip) | `--tools claude-code,cursor` or `--tools none` |
| `--custom-content <paths>` | Comma-separated paths to custom modules | `--custom-content ~/my-module,~/another-module` |
| `--action <type>` | Action for existing installations: `install` (default), `update`, or `quick-update` | `--action quick-update` | | `--action <type>` | Action for existing installations: `install` (default), `update`, or `quick-update` | `--action quick-update` |
### Core Configuration ### Core Configuration
@ -120,16 +119,6 @@ npx bmad-method install \
--action quick-update --action quick-update
``` ```
### Installation with Custom Content
```bash
npx bmad-method install \
--directory ~/projects/myapp \
--modules bmm \
--custom-content ~/my-custom-module,~/another-module \
--tools claude-code
```
## What You Get ## What You Get
- A fully configured `_bmad/` directory in your project - A fully configured `_bmad/` directory in your project
@ -143,12 +132,11 @@ BMad validates all provided flags:
- **Directory** — Must be a valid path with write permissions - **Directory** — Must be a valid path with write permissions
- **Modules** — Warns about invalid module IDs (but won't fail) - **Modules** — Warns about invalid module IDs (but won't fail)
- **Tools** — Warns about invalid tool IDs (but won't fail) - **Tools** — Warns about invalid tool IDs (but won't fail)
- **Custom Content** — Each path must contain a valid `module.yaml` file
- **Action** — Must be one of: `install`, `update`, `quick-update` - **Action** — Must be one of: `install`, `update`, `quick-update`
Invalid values will either: Invalid values will either:
1. Show an error and exit (for critical options like directory) 1. Show an error and exit (for critical options like directory)
2. Show a warning and skip (for optional items like custom content) 2. Show a warning and skip (for optional items)
3. Fall back to interactive prompts (for missing required values) 3. Fall back to interactive prompts (for missing required values)
:::tip[Best Practices] :::tip[Best Practices]
@ -172,13 +160,6 @@ Invalid values will either:
- Verify the module ID is correct - Verify the module ID is correct
- External modules must be available in the registry - External modules must be available in the registry
### Custom content path invalid
Ensure each custom content path:
- Points to a directory
- Contains a `module.yaml` file in the root
- Has a `code` field in the `module.yaml`
::: note[Still stuck?] ::: note[Still stuck?]
Run with `--debug` for detailed output, try interactive mode to isolate the issue, or report at <https://github.com/bmad-code-org/BMAD-METHOD/issues>. Run with `--debug` for detailed output, try interactive mode to isolate the issue, or report at <https://github.com/bmad-code-org/BMAD-METHOD/issues>.
::: :::

View File

@ -72,7 +72,7 @@ Trình cài đặt sẽ hiện các module có sẵn. Chọn những module bạ
### 5. Làm theo các prompt ### 5. Làm theo các prompt
Trình cài đặt sẽ hướng dẫn các bước còn lại - nội dung tùy chỉnh, cài đặt, và các tùy chọn khác. Trình cài đặt sẽ hướng dẫn các bước còn lại - cài đặt, tích hợp công cụ, và các tùy chọn khác.
## Bạn nhận được gì ## Bạn nhận được gì

View File

@ -27,7 +27,6 @@ Yêu cầu [Node.js](https://nodejs.org) v20+ và `npx` (đi kèm với npm).
| `--directory <path>` | Thư mục cài đặt | `--directory ~/projects/myapp` | | `--directory <path>` | Thư mục cài đặt | `--directory ~/projects/myapp` |
| `--modules <modules>` | Danh sách ID module, cách nhau bởi dấu phẩy | `--modules bmm,bmb` | | `--modules <modules>` | Danh sách ID module, cách nhau bởi dấu phẩy | `--modules bmm,bmb` |
| `--tools <tools>` | Danh sách ID công cụ/IDE, cách nhau bởi dấu phẩy (dùng `none` để bỏ qua) | `--tools claude-code,cursor` hoặc `--tools none` | | `--tools <tools>` | Danh sách ID công cụ/IDE, cách nhau bởi dấu phẩy (dùng `none` để bỏ qua) | `--tools claude-code,cursor` hoặc `--tools none` |
| `--custom-content <paths>` | Danh sách đường dẫn đến module tùy chỉnh, cách nhau bởi dấu phẩy | `--custom-content ~/my-module,~/another-module` |
| `--action <type>` | Hành động cho bản cài đặt hiện có: `install` (mặc định), `update`, hoặc `quick-update` | `--action quick-update` | | `--action <type>` | Hành động cho bản cài đặt hiện có: `install` (mặc định), `update`, hoặc `quick-update` | `--action quick-update` |
### Cấu hình cốt lõi ### Cấu hình cốt lõi
@ -120,16 +119,6 @@ npx bmad-method install \
--action quick-update --action quick-update
``` ```
### Cài đặt với nội dung tùy chỉnh
```bash
npx bmad-method install \
--directory ~/projects/myapp \
--modules bmm \
--custom-content ~/my-custom-module,~/another-module \
--tools claude-code
```
## Bạn nhận được gì ## Bạn nhận được gì
- Thư mục `_bmad/` đã được cấu hình đầy đủ trong dự án của bạn - Thư mục `_bmad/` đã được cấu hình đầy đủ trong dự án của bạn
@ -143,12 +132,11 @@ BMad sẽ kiểm tra tất cả các cờ được cung cấp:
- **Directory** - Phải là đường dẫn hợp lệ và có quyền ghi - **Directory** - Phải là đường dẫn hợp lệ và có quyền ghi
- **Modules** - Cảnh báo nếu ID module không hợp lệ (nhưng không thất bại) - **Modules** - Cảnh báo nếu ID module không hợp lệ (nhưng không thất bại)
- **Tools** - Cảnh báo nếu ID công cụ không hợp lệ (nhưng không thất bại) - **Tools** - Cảnh báo nếu ID công cụ không hợp lệ (nhưng không thất bại)
- **Custom Content** - Mỗi đường dẫn phải chứa tệp `module.yaml` hợp lệ
- **Action** - Phải là một trong: `install`, `update`, `quick-update` - **Action** - Phải là một trong: `install`, `update`, `quick-update`
Giá trị không hợp lệ sẽ dẫn đến một trong các trường hợp sau: Giá trị không hợp lệ sẽ dẫn đến một trong các trường hợp sau:
1. Hiện lỗi và thoát (với các tùy chọn quan trọng như directory) 1. Hiện lỗi và thoát (với các tùy chọn quan trọng như directory)
2. Hiện cảnh báo và bỏ qua (với mục tùy chọn như custom content) 2. Hiện cảnh báo và bỏ qua (với mục tùy chọn)
3. Quay lại hỏi interactive (với giá trị bắt buộc bị thiếu) 3. Quay lại hỏi interactive (với giá trị bắt buộc bị thiếu)
:::tip[Thực hành tốt] :::tip[Thực hành tốt]
@ -172,13 +160,6 @@ Giá trị không hợp lệ sẽ dẫn đến một trong các trường hợp
- Xác minh ID module có đúng không - Xác minh ID module có đúng không
- Module bên ngoài phải có sẵn trong registry - Module bên ngoài phải có sẵn trong registry
### Đường dẫn custom content không hợp lệ
Đảm bảo mỗi đường dẫn custom content:
- Trỏ tới một thư mục
- Chứa tệp `module.yaml` ở cấp gốc
- Có trường `code` trong tệp `module.yaml`
:::note[Vẫn bị mắc?] :::note[Vẫn bị mắc?]
Chạy với `--debug` để xem output chi tiết, thử chế độ interactive để cô lập vấn đề, hoặc báo cáo tại <https://github.com/bmad-code-org/BMAD-METHOD/issues>. Chạy với `--debug` để xem output chi tiết, thử chế độ interactive để cô lập vấn đề, hoặc báo cáo tại <https://github.com/bmad-code-org/BMAD-METHOD/issues>.
::: :::

View File

@ -72,7 +72,7 @@ npx github:bmad-code-org/BMAD-METHOD install
### 5. 按照提示操作 ### 5. 按照提示操作
安装程序会引导你完成剩余步骤——自定义内容、设置等。 安装程序会引导你完成剩余步骤——设置、工具集成等。
## 你将获得 ## 你将获得

View File

@ -27,7 +27,6 @@ sidebar:
| `--directory <path>` | 安装目录 | `--directory ~/projects/myapp` | | `--directory <path>` | 安装目录 | `--directory ~/projects/myapp` |
| `--modules <modules>` | 逗号分隔的模块 ID | `--modules bmm,bmb` | | `--modules <modules>` | 逗号分隔的模块 ID | `--modules bmm,bmb` |
| `--tools <tools>` | 逗号分隔的工具/IDE ID使用 `none` 跳过) | `--tools claude-code,cursor``--tools none` | | `--tools <tools>` | 逗号分隔的工具/IDE ID使用 `none` 跳过) | `--tools claude-code,cursor``--tools none` |
| `--custom-content <paths>` | 逗号分隔的自定义模块路径 | `--custom-content ~/my-module,~/another-module` |
| `--action <type>` | 对现有安装的操作:`install`(默认)、`update` 或 `quick-update` | `--action quick-update` | | `--action <type>` | 对现有安装的操作:`install`(默认)、`update` 或 `quick-update` | `--action quick-update` |
### 核心配置 ### 核心配置
@ -108,16 +107,6 @@ npx bmad-method install \
--action quick-update --action quick-update
``` ```
### 使用自定义内容安装
```bash
npx bmad-method install \
--directory ~/projects/myapp \
--modules bmm \
--custom-content ~/my-custom-module,~/another-module \
--tools claude-code
```
## 安装结果 ## 安装结果
- 项目中完全配置的 `_bmad/` 目录 - 项目中完全配置的 `_bmad/` 目录
@ -131,12 +120,11 @@ BMad 会验证你提供的所有参数:
- **目录** — 必须是具有写入权限的有效路径 - **目录** — 必须是具有写入权限的有效路径
- **模块** — 对无效的模块 ID 发出警告(但不会失败) - **模块** — 对无效的模块 ID 发出警告(但不会失败)
- **工具** — 对无效的工具 ID 发出警告(但不会失败) - **工具** — 对无效的工具 ID 发出警告(但不会失败)
- **自定义内容** — 每个路径必须包含有效的 `module.yaml` 文件
- **操作** — 必须是以下之一:`install`、`update`、`quick-update` - **操作** — 必须是以下之一:`install`、`update`、`quick-update`
无效值将: 无效值将:
1. 显示错误并退出(对于目录等关键选项) 1. 显示错误并退出(对于目录等关键选项)
2. 显示警告并跳过(对于自定义内容等可选项目) 2. 显示警告并跳过(对于可选项目)
3. 回退到交互式提示(对于缺失的必需值) 3. 回退到交互式提示(对于缺失的必需值)
:::tip[最佳实践] :::tip[最佳实践]
@ -159,13 +147,6 @@ BMad 会验证你提供的所有参数:
- 验证模块 ID 是否正确 - 验证模块 ID 是否正确
- 外部模块必须在注册表中可用 - 外部模块必须在注册表中可用
### 自定义内容路径无效
确保每个自定义内容路径:
- 指向一个目录
- 在根目录中包含 `module.yaml` 文件
- 在 `module.yaml` 中有 `code` 字段
::: note[仍然卡住了?] ::: note[仍然卡住了?]
使用 `--debug` 获取详细输出,尝试交互模式定位问题,或在 <https://github.com/bmad-code-org/BMAD-METHOD/issues> 提交反馈。 使用 `--debug` 获取详细输出,尝试交互模式定位问题,或在 <https://github.com/bmad-code-org/BMAD-METHOD/issues> 提交反馈。
::: :::

View File

@ -128,56 +128,6 @@ async function createSkillCollisionFixture() {
return { root: fixtureRoot, bmadDir: fixtureDir }; return { root: fixtureRoot, bmadDir: fixtureDir };
} }
async function createCustomModuleManifestFixture() {
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-custom-manifest-'));
const bmadDir = path.join(fixtureRoot, '_bmad');
const configDir = path.join(bmadDir, '_config');
const moduleSourceDir = path.join(fixtureRoot, 'test-module-source');
await fs.ensureDir(configDir);
await fs.ensureDir(moduleSourceDir);
const minimalAgent = '<agent name="Test" title="T"><persona>p</persona></agent>';
await fs.ensureDir(path.join(bmadDir, 'core', 'agents'));
await fs.writeFile(path.join(bmadDir, 'core', 'agents', 'test.md'), minimalAgent);
await fs.ensureDir(path.join(bmadDir, 'test-module', 'agents'));
await fs.writeFile(path.join(bmadDir, 'test-module', 'agents', 'test.md'), minimalAgent);
await fs.writeFile(path.join(moduleSourceDir, 'module.yaml'), ['code: test-module', 'name: Test Module', ''].join('\n'));
await fs.writeFile(
path.join(configDir, 'manifest.yaml'),
[
'installation:',
' version: 6.2.2',
' installDate: 2026-03-30T00:00:00.000Z',
' lastUpdated: 2026-03-30T00:00:00.000Z',
'modules:',
' - name: core',
' version: 6.2.2',
' installDate: 2026-03-30T00:00:00.000Z',
' lastUpdated: 2026-03-30T00:00:00.000Z',
' source: built-in',
' npmPackage: null',
' repoUrl: null',
' - name: test-module',
' version: null',
' installDate: 2026-03-30T00:00:00.000Z',
' lastUpdated: 2026-03-30T00:00:00.000Z',
' source: custom',
' npmPackage: null',
' repoUrl: null',
'customModules:',
' - id: test-module',
' name: "Test Module"',
` sourcePath: ${JSON.stringify(moduleSourceDir)}`,
'ides:',
' - codex',
'',
].join('\n'),
);
return { root: fixtureRoot, bmadDir, manifestPath: path.join(configDir, 'manifest.yaml'), moduleSourceDir };
}
/** /**
* Test Suite * Test Suite
*/ */
@ -1773,107 +1723,6 @@ async function runTests() {
console.log(''); console.log('');
// ============================================================
// Suite 33: Main manifest preserves active customModules only
// ============================================================
console.log(`${colors.yellow}Test Suite 33: Preserve active customModules in main manifest${colors.reset}\n`);
let customManifestFixture = null;
try {
customManifestFixture = await createCustomModuleManifestFixture();
const yaml = require('yaml');
const originalManifest = yaml.parse(await fs.readFile(customManifestFixture.manifestPath, 'utf8'));
originalManifest.customModules.push({
id: 'removed-module',
name: 'Removed Module',
sourcePath: path.join(customManifestFixture.root, 'removed-module-source'),
});
await fs.writeFile(customManifestFixture.manifestPath, yaml.stringify(originalManifest), 'utf8');
const generator33 = new ManifestGenerator();
await generator33.generateManifests(customManifestFixture.bmadDir, ['core', 'test-module'], [], { ides: ['codex'] });
const updatedManifest = yaml.parse(await fs.readFile(customManifestFixture.manifestPath, 'utf8'));
const customModule = updatedManifest.customModules?.find((entry) => entry.id === 'test-module');
assert(Array.isArray(updatedManifest.customModules), 'Main manifest keeps customModules array');
assert(customModule !== undefined, 'Main manifest preserves existing custom module entry');
assert(
customModule && customModule.sourcePath === customManifestFixture.moduleSourceDir,
'Main manifest preserves custom module sourcePath',
);
assert(
!updatedManifest.customModules?.some((entry) => entry.id === 'removed-module'),
'Main manifest drops stale custom module entries',
);
} catch (error) {
assert(false, 'Main manifest preserves customModules test succeeds', error.message);
} finally {
if (customManifestFixture?.root) await fs.remove(customManifestFixture.root).catch(() => {});
}
console.log('');
// ============================================================
// Suite 34: Quick update uses manifest-backed custom sources
// ============================================================
console.log(`${colors.yellow}Test Suite 34: Quick update uses manifest-backed custom module sources${colors.reset}\n`);
let quickUpdateFixture = null;
const originalListAvailable34 = OfficialModules.prototype.listAvailable;
const originalLoadExistingConfig34 = OfficialModules.prototype.loadExistingConfig;
const originalCollectModuleConfigQuick34 = OfficialModules.prototype.collectModuleConfigQuick;
try {
quickUpdateFixture = await createCustomModuleManifestFixture();
const installer34 = new Installer();
installer34.externalModuleManager.hasModule = async () => false;
installer34.externalModuleManager.listAvailable = async () => [];
let capturedInstallConfig34 = null;
installer34.install = async (config) => {
capturedInstallConfig34 = config;
return { success: true };
};
OfficialModules.prototype.listAvailable = async function () {
return { modules: [], customModules: [] };
};
OfficialModules.prototype.loadExistingConfig = async function () {
this.collectedConfig = this.collectedConfig || {};
};
OfficialModules.prototype.collectModuleConfigQuick = async function (moduleName) {
this.collectedConfig = this.collectedConfig || {};
if (!this.collectedConfig[moduleName]) {
this.collectedConfig[moduleName] = {};
}
return false;
};
await installer34.quickUpdate({
directory: quickUpdateFixture.root,
skipPrompts: true,
});
const customModule34 = capturedInstallConfig34?._customModuleSources?.get('test-module');
assert(capturedInstallConfig34 !== null, 'Quick update forwards config to install');
assert(customModule34 !== undefined, 'Quick update keeps manifest-backed custom module updateable');
assert(customModule34 && customModule34.cached === false, 'Quick update uses manifest-backed source before cache');
assert(
customModule34 && customModule34.sourcePath === quickUpdateFixture.moduleSourceDir,
'Quick update uses preserved manifest sourcePath for custom modules',
);
} catch (error) {
assert(false, 'Quick update manifest-backed custom source test succeeds', error.message);
} finally {
OfficialModules.prototype.listAvailable = originalListAvailable34;
OfficialModules.prototype.loadExistingConfig = originalLoadExistingConfig34;
OfficialModules.prototype.collectModuleConfigQuick = originalCollectModuleConfigQuick34;
if (quickUpdateFixture?.root) await fs.remove(quickUpdateFixture.root).catch(() => {});
}
console.log('');
// ============================================================ // ============================================================
// Summary // Summary
// ============================================================ // ============================================================

View File

@ -17,7 +17,6 @@ module.exports = {
'--tools <tools>', '--tools <tools>',
'Comma-separated list of tool/IDE IDs to configure (e.g., "claude-code,cursor"). Use "none" to skip tool configuration.', 'Comma-separated list of tool/IDE IDs to configure (e.g., "claude-code,cursor"). Use "none" to skip tool configuration.',
], ],
['--custom-content <paths>', 'Comma-separated list of paths to custom modules/agents/workflows'],
['--action <type>', 'Action type for existing installations: install, update, or quick-update'], ['--action <type>', 'Action type for existing installations: install, update, or quick-update'],
['--user-name <name>', 'Name for agents to use (default: system username)'], ['--user-name <name>', 'Name for agents to use (default: system username)'],
['--communication-language <lang>', 'Language for agent communication (default: English)'], ['--communication-language <lang>', 'Language for agent communication (default: English)'],

View File

@ -1,260 +0,0 @@
/**
* Custom Module Source Cache
* Caches custom module sources under _config/custom/ to ensure they're never lost
* and can be checked into source control
*/
const fs = require('fs-extra');
const path = require('node:path');
const crypto = require('node:crypto');
const prompts = require('../prompts');
class CustomModuleCache {
constructor(bmadDir) {
this.bmadDir = bmadDir;
this.customCacheDir = path.join(bmadDir, '_config', 'custom');
this.manifestPath = path.join(this.customCacheDir, 'cache-manifest.yaml');
}
/**
* Ensure the custom cache directory exists
*/
async ensureCacheDir() {
await fs.ensureDir(this.customCacheDir);
}
/**
* Get cache manifest
*/
async getCacheManifest() {
if (!(await fs.pathExists(this.manifestPath))) {
return {};
}
const content = await fs.readFile(this.manifestPath, 'utf8');
const yaml = require('yaml');
return yaml.parse(content) || {};
}
/**
* Update cache manifest
*/
async updateCacheManifest(manifest) {
const yaml = require('yaml');
// Clean the manifest to remove any non-serializable values
const cleanManifest = structuredClone(manifest);
const content = yaml.stringify(cleanManifest, {
indent: 2,
lineWidth: 0,
sortKeys: false,
});
await fs.writeFile(this.manifestPath, content);
}
/**
* Stream a file into the hash to avoid loading entire file into memory
*/
async hashFileStream(filePath, hash) {
return new Promise((resolve, reject) => {
const stream = require('node:fs').createReadStream(filePath);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', resolve);
stream.on('error', reject);
});
}
/**
* Calculate hash of a file or directory using streaming to minimize memory usage
*/
async calculateHash(sourcePath) {
const hash = crypto.createHash('sha256');
const isDir = (await fs.stat(sourcePath)).isDirectory();
if (isDir) {
// For directories, hash all files
const files = [];
async function collectFiles(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile()) {
files.push(path.join(dir, entry.name));
} else if (entry.isDirectory() && !entry.name.startsWith('.')) {
await collectFiles(path.join(dir, entry.name));
}
}
}
await collectFiles(sourcePath);
files.sort(); // Ensure consistent order
for (const file of files) {
const relativePath = path.relative(sourcePath, file);
// Hash the path first, then stream file contents
hash.update(relativePath + '|');
await this.hashFileStream(file, hash);
}
} else {
// For single files, stream directly into hash
await this.hashFileStream(sourcePath, hash);
}
return hash.digest('hex');
}
/**
* Cache a custom module source
* @param {string} moduleId - Module ID
* @param {string} sourcePath - Original source path
* @param {Object} metadata - Additional metadata to store
* @returns {Object} Cached module info
*/
async cacheModule(moduleId, sourcePath, metadata = {}) {
await this.ensureCacheDir();
const cacheDir = path.join(this.customCacheDir, moduleId);
const cacheManifest = await this.getCacheManifest();
// Check if already cached and unchanged
if (cacheManifest[moduleId]) {
const cached = cacheManifest[moduleId];
if (cached.originalHash && cached.originalHash === (await this.calculateHash(sourcePath))) {
// Source unchanged, return existing cache info
return {
moduleId,
cachePath: cacheDir,
...cached,
};
}
}
// Remove existing cache if it exists
if (await fs.pathExists(cacheDir)) {
await fs.remove(cacheDir);
}
// Copy module to cache
await fs.copy(sourcePath, cacheDir, {
filter: (src) => {
const relative = path.relative(sourcePath, src);
// Skip node_modules, .git, and other common ignore patterns
return !relative.includes('node_modules') && !relative.startsWith('.git') && !relative.startsWith('.DS_Store');
},
});
// Calculate hash of the source
const sourceHash = await this.calculateHash(sourcePath);
const cacheHash = await this.calculateHash(cacheDir);
// Update manifest - don't store absolute paths for portability
// Clean metadata to remove absolute paths
const cleanMetadata = { ...metadata };
if (cleanMetadata.sourcePath) {
delete cleanMetadata.sourcePath;
}
cacheManifest[moduleId] = {
originalHash: sourceHash,
cacheHash: cacheHash,
cachedAt: new Date().toISOString(),
...cleanMetadata,
};
await this.updateCacheManifest(cacheManifest);
return {
moduleId,
cachePath: cacheDir,
...cacheManifest[moduleId],
};
}
/**
* Get cached module info
* @param {string} moduleId - Module ID
* @returns {Object|null} Cached module info or null
*/
async getCachedModule(moduleId) {
const cacheManifest = await this.getCacheManifest();
const cached = cacheManifest[moduleId];
if (!cached) {
return null;
}
const cacheDir = path.join(this.customCacheDir, moduleId);
if (!(await fs.pathExists(cacheDir))) {
// Cache dir missing, remove from manifest
delete cacheManifest[moduleId];
await this.updateCacheManifest(cacheManifest);
return null;
}
// Verify cache integrity
const currentCacheHash = await this.calculateHash(cacheDir);
if (currentCacheHash !== cached.cacheHash) {
await prompts.log.warn(`Cache integrity check failed for ${moduleId}`);
}
return {
moduleId,
cachePath: cacheDir,
...cached,
};
}
/**
* Get all cached modules
* @returns {Array} Array of cached module info
*/
async getAllCachedModules() {
const cacheManifest = await this.getCacheManifest();
const cached = [];
for (const [moduleId, info] of Object.entries(cacheManifest)) {
const cachedModule = await this.getCachedModule(moduleId);
if (cachedModule) {
cached.push(cachedModule);
}
}
return cached;
}
/**
* Remove a cached module
* @param {string} moduleId - Module ID to remove
*/
async removeCachedModule(moduleId) {
const cacheManifest = await this.getCacheManifest();
const cacheDir = path.join(this.customCacheDir, moduleId);
// Remove cache directory
if (await fs.pathExists(cacheDir)) {
await fs.remove(cacheDir);
}
// Remove from manifest
delete cacheManifest[moduleId];
await this.updateCacheManifest(cacheManifest);
}
/**
* Sync cached modules with a list of module IDs
* @param {Array<string>} moduleIds - Module IDs to keep
*/
async syncCache(moduleIds) {
const cached = await this.getAllCachedModules();
for (const cachedModule of cached) {
if (!moduleIds.includes(cachedModule.moduleId)) {
await this.removeCachedModule(cachedModule.moduleId);
}
}
}
}
module.exports = { CustomModuleCache };

View File

@ -10,14 +10,13 @@ const { Manifest } = require('./manifest');
class ExistingInstall { class ExistingInstall {
#version; #version;
constructor({ installed, version, hasCore, modules, ides, customModules }) { constructor({ installed, version, hasCore, modules, ides }) {
this.installed = installed; this.installed = installed;
this.#version = version; this.#version = version;
this.hasCore = hasCore; this.hasCore = hasCore;
this.modules = Object.freeze(modules.map((m) => Object.freeze({ ...m }))); this.modules = Object.freeze(modules.map((m) => Object.freeze({ ...m })));
this.moduleIds = Object.freeze(this.modules.map((m) => m.id)); this.moduleIds = Object.freeze(this.modules.map((m) => m.id));
this.ides = Object.freeze([...ides]); this.ides = Object.freeze([...ides]);
this.customModules = Object.freeze([...customModules]);
Object.freeze(this); Object.freeze(this);
} }
@ -35,7 +34,6 @@ class ExistingInstall {
hasCore: false, hasCore: false,
modules: [], modules: [],
ides: [], ides: [],
customModules: [],
}); });
} }
@ -53,15 +51,11 @@ class ExistingInstall {
let hasCore = false; let hasCore = false;
const modules = []; const modules = [];
let ides = []; let ides = [];
let customModules = [];
const manifest = new Manifest(); const manifest = new Manifest();
const manifestData = await manifest.read(bmadDir); const manifestData = await manifest.read(bmadDir);
if (manifestData) { if (manifestData) {
version = manifestData.version; version = manifestData.version;
if (manifestData.customModules) {
customModules = manifestData.customModules;
}
if (manifestData.ides) { if (manifestData.ides) {
ides = manifestData.ides.filter((ide) => ide && typeof ide === 'string'); ides = manifestData.ides.filter((ide) => ide && typeof ide === 'string');
} }
@ -120,7 +114,7 @@ class ExistingInstall {
return ExistingInstall.empty(); return ExistingInstall.empty();
} }
return new ExistingInstall({ installed, version, hasCore, modules, ides, customModules }); return new ExistingInstall({ installed, version, hasCore, modules, ides });
} }
} }

View File

@ -20,14 +20,12 @@ class InstallPaths {
const configDir = path.join(bmadDir, '_config'); const configDir = path.join(bmadDir, '_config');
const agentsDir = path.join(configDir, 'agents'); const agentsDir = path.join(configDir, 'agents');
const customCacheDir = path.join(configDir, 'custom');
const coreDir = path.join(bmadDir, 'core'); const coreDir = path.join(bmadDir, 'core');
for (const [dir, label] of [ for (const [dir, label] of [
[bmadDir, 'bmad directory'], [bmadDir, 'bmad directory'],
[configDir, 'config directory'], [configDir, 'config directory'],
[agentsDir, 'agents config directory'], [agentsDir, 'agents config directory'],
[customCacheDir, 'custom modules cache'],
[coreDir, 'core module directory'], [coreDir, 'core module directory'],
]) { ]) {
await ensureWritableDir(dir, label); await ensureWritableDir(dir, label);
@ -40,7 +38,6 @@ class InstallPaths {
bmadDir, bmadDir,
configDir, configDir,
agentsDir, agentsDir,
customCacheDir,
coreDir, coreDir,
isUpdate, isUpdate,
}); });

View File

@ -2,7 +2,6 @@ const path = require('node:path');
const fs = require('fs-extra'); const fs = require('fs-extra');
const { Manifest } = require('./manifest'); const { Manifest } = require('./manifest');
const { OfficialModules } = require('../modules/official-modules'); const { OfficialModules } = require('../modules/official-modules');
const { CustomModules } = require('../modules/custom-modules');
const { IdeManager } = require('../ide/manager'); const { IdeManager } = require('../ide/manager');
const { FileOps } = require('../file-ops'); const { FileOps } = require('../file-ops');
const { Config } = require('./config'); const { Config } = require('./config');
@ -19,7 +18,6 @@ class Installer {
constructor() { constructor() {
this.externalModuleManager = new ExternalModuleManager(); this.externalModuleManager = new ExternalModuleManager();
this.manifest = new Manifest(); this.manifest = new Manifest();
this.customModules = new CustomModules();
this.ideManager = new IdeManager(); this.ideManager = new IdeManager();
this.fileOps = new FileOps(); this.fileOps = new FileOps();
this.installedFiles = new Set(); // Track all installed files this.installedFiles = new Set(); // Track all installed files
@ -80,8 +78,6 @@ class Installer {
const officialModules = await OfficialModules.build(config, paths); const officialModules = await OfficialModules.build(config, paths);
const existingInstall = await ExistingInstall.detect(paths.bmadDir); const existingInstall = await ExistingInstall.detect(paths.bmadDir);
await this.customModules.discoverPaths(originalConfig, paths);
if (existingInstall.installed) { if (existingInstall.installed) {
await this._removeDeselectedModules(existingInstall, config, paths); await this._removeDeselectedModules(existingInstall, config, paths);
updateState = await this._prepareUpdateState(paths, config, existingInstall, officialModules); updateState = await this._prepareUpdateState(paths, config, existingInstall, officialModules);
@ -121,14 +117,9 @@ class Installer {
} }
} }
await this._cacheCustomModules(paths, addResult); const allModules = config.modules || [];
// Compute module lists: official = selected minus custom, all = both await this._installAndConfigure(config, originalConfig, paths, allModules, allModules, addResult, officialModules);
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, previousSkillIds); await this._setupIdes(config, allModules, paths, addResult, previousSkillIds);
@ -242,26 +233,6 @@ class Installer {
} }
} }
/**
* 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. * Install modules, create directories, generate configs and manifests.
*/ */
@ -284,11 +255,6 @@ class Installer {
installedModuleNames, installedModuleNames,
}); });
await this._installCustomModules(config, paths, addResult, officialModules, {
message,
installedModuleNames,
});
return `${allModules.length} module(s) ${isQuickUpdate ? 'updated' : 'installed'}`; return `${allModules.length} module(s) ${isQuickUpdate ? 'updated' : 'installed'}`;
}, },
}); });
@ -515,48 +481,7 @@ class Installer {
} }
/** /**
* Scan the custom module cache directory and register any cached custom modules * Common update preparation: detect files, preserve core config, back up.
* 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} paths - InstallPaths instance
* @param {Object} config - Clean config (may have coreConfig updated) * @param {Object} config - Clean config (may have coreConfig updated)
* @param {Object} existingInstall - Detection result * @param {Object} existingInstall - Detection result
@ -584,8 +509,6 @@ class Installer {
} }
} }
await this._scanCachedCustomModules(paths);
const backupDirs = await this._backupUserFiles(paths, customFiles, modifiedFiles); const backupDirs = await this._backupUserFiles(paths, customFiles, modifiedFiles);
return { return {
@ -677,38 +600,6 @@ class Installer {
} }
} }
/**
* 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 },
});
// Get display name from source module.yaml; version from marketplace.json
const moduleInfo = await officialModules.getModuleInfo(sourcePath, moduleName, '');
const displayName = moduleInfo?.name || moduleName;
const version = await this._getMarketplaceVersion(sourcePath);
addResult(displayName, 'ok', '', { moduleCode: moduleName, newVersion: version });
}
}
/** /**
* Read files-manifest.csv * Read files-manifest.csv
* @param {string} bmadDir - BMAD installation directory * @param {string} bmadDir - BMAD installation directory
@ -1253,16 +1144,9 @@ class Installer {
const configuredIdes = existingInstall.ides; const configuredIdes = existingInstall.ides;
const projectRoot = path.dirname(bmadDir); const projectRoot = path.dirname(bmadDir);
const customModuleSources = await this.customModules.assembleQuickUpdateSources(
config,
existingInstall,
bmadDir,
this.externalModuleManager,
);
// Get available modules (what we have source for) // Get available modules (what we have source for)
const availableModulesData = await new OfficialModules().listAvailable(); const availableModulesData = await new OfficialModules().listAvailable();
const availableModules = [...availableModulesData.modules, ...availableModulesData.customModules]; const availableModules = [...availableModulesData.modules];
// Add external official modules to available modules // Add external official modules to available modules
const externalModules = await this.externalModuleManager.listAvailable(); const externalModules = await this.externalModuleManager.listAvailable();
@ -1277,52 +1161,12 @@ class Installer {
} }
} }
// Add custom modules from manifest if their sources exist const availableModuleIds = new Set(availableModules.map((m) => m.id));
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) // Only update modules that are BOTH installed AND available (we have source for)
const modulesToUpdate = installedModules.filter((id) => availableModuleIds.has(id)); const modulesToUpdate = installedModules.filter((id) => availableModuleIds.has(id));
const skippedModules = 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) { if (skippedModules.length > 0) {
await prompts.log.warn(`Skipping ${skippedModules.length} module(s) - no source available: ${skippedModules.join(', ')}`); await prompts.log.warn(`Skipping ${skippedModules.length} module(s) - no source available: ${skippedModules.join(', ')}`);
} }
@ -1367,9 +1211,7 @@ class Installer {
actionType: 'install', actionType: 'install',
_quickUpdate: true, _quickUpdate: true,
_preserveModules: skippedModules, _preserveModules: skippedModules,
_customModuleSources: customModuleSources,
_existingModules: installedModules, _existingModules: installedModules,
customContent: config.customContent,
}; };
await this.install(installConfig); await this.install(installConfig);
@ -1504,239 +1346,6 @@ class Installer {
return this._readOutputFolder(bmadDir); 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 * Find the bmad installation directory in a project
* Always uses the standard _bmad folder name * Always uses the standard _bmad folder name

View File

@ -375,8 +375,6 @@ class ManifestGenerator {
// Read existing manifest to preserve install date // Read existing manifest to preserve install date
let existingInstallDate = null; let existingInstallDate = null;
const existingModulesMap = new Map(); const existingModulesMap = new Map();
let existingCustomModules = [];
if (await fs.pathExists(manifestPath)) { if (await fs.pathExists(manifestPath)) {
try { try {
const existingContent = await fs.readFile(manifestPath, 'utf8'); const existingContent = await fs.readFile(manifestPath, 'utf8');
@ -397,12 +395,6 @@ class ManifestGenerator {
} }
} }
} }
if (existingManifest.customModules && Array.isArray(existingManifest.customModules)) {
// We filter here so manifest regeneration preserves source metadata only for custom modules that
// are still installed. Without that, customModules can retain stale entries for modules that were removed.
existingCustomModules = existingManifest.customModules.filter((customModule) => installedModuleSet.has(customModule?.id));
}
} catch { } catch {
// If we can't read existing manifest, continue with defaults // If we can't read existing manifest, continue with defaults
} }
@ -438,7 +430,6 @@ class ManifestGenerator {
lastUpdated: new Date().toISOString(), lastUpdated: new Date().toISOString(),
}, },
modules: updatedModules, modules: updatedModules,
customModules: existingCustomModules,
ides: this.selectedIdes, ides: this.selectedIdes,
}; };

View File

@ -97,7 +97,6 @@ class Manifest {
lastUpdated: manifestData.installation?.lastUpdated, lastUpdated: manifestData.installation?.lastUpdated,
modules: moduleNames, // Simple array of module names for backward compatibility modules: moduleNames, // Simple array of module names for backward compatibility
modulesDetailed: hasDetailedModules ? modules : null, // New detailed format modulesDetailed: hasDetailedModules ? modules : null, // New detailed format
customModules: manifestData.customModules || [], // Keep for backward compatibility
ides: manifestData.ides || [], ides: manifestData.ides || [],
}; };
} catch (error) { } catch (error) {
@ -254,7 +253,6 @@ class Manifest {
lastUpdated: manifest.installation?.lastUpdated, lastUpdated: manifest.installation?.lastUpdated,
modules: moduleNames, modules: moduleNames,
modulesDetailed: hasDetailedModules ? modules : null, modulesDetailed: hasDetailedModules ? modules : null,
customModules: manifest.customModules || [],
ides: manifest.ides || [], ides: manifest.ides || [],
}; };
} }
@ -783,52 +781,6 @@ class Manifest {
return configs; return configs;
} }
/**
* Add a custom module to the manifest with its source path
* @param {string} bmadDir - Path to bmad directory
* @param {Object} customModule - Custom module info
*/
async addCustomModule(bmadDir, customModule) {
const manifest = await this.read(bmadDir);
if (!manifest) {
throw new Error('No manifest found');
}
if (!manifest.customModules) {
manifest.customModules = [];
}
// Check if custom module already exists
const existingIndex = manifest.customModules.findIndex((m) => m.id === customModule.id);
if (existingIndex === -1) {
// Add new entry
manifest.customModules.push(customModule);
} else {
// Update existing entry
manifest.customModules[existingIndex] = customModule;
}
await this.update(bmadDir, { customModules: manifest.customModules });
}
/**
* Remove a custom module from the manifest
* @param {string} bmadDir - Path to bmad directory
* @param {string} moduleId - Module ID to remove
*/
async removeCustomModule(bmadDir, moduleId) {
const manifest = await this.read(bmadDir);
if (!manifest || !manifest.customModules) {
return;
}
const index = manifest.customModules.findIndex((m) => m.id === moduleId);
if (index !== -1) {
manifest.customModules.splice(index, 1);
await this.update(bmadDir, { customModules: manifest.customModules });
}
}
/** /**
* Get module version info from source * Get module version info from source
* @param {string} moduleName - Module name/code * @param {string} moduleName - Module name/code
@ -866,29 +818,8 @@ class Manifest {
}; };
} }
// Custom module: resolve path from source or cache before reading version
const customSourcePath = moduleSourcePath || path.join(bmadDir, '_config', 'custom', moduleName);
const version = await this._readMarketplaceVersion(moduleName, customSourcePath);
const cacheDir = path.join(bmadDir, '_config', 'custom', moduleName);
const moduleYamlPath = path.join(cacheDir, 'module.yaml');
if (await fs.pathExists(moduleYamlPath)) {
try {
const yamlContent = await fs.readFile(moduleYamlPath, 'utf8');
const moduleConfig = yaml.parse(yamlContent);
return {
version: version || moduleConfig.version || null,
source: 'custom',
npmPackage: moduleConfig.npmPackage || null,
repoUrl: moduleConfig.repoUrl || null,
};
} catch (error) {
await prompts.log.warn(`Failed to read module.yaml for ${moduleName}: ${error.message}`);
}
}
// Unknown module // Unknown module
const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
return { return {
version, version,
source: 'unknown', source: 'unknown',

View File

@ -1,112 +0,0 @@
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('yaml');
const prompts = require('./prompts');
/**
* Handler for custom content (custom.yaml)
* Discovers custom agents and workflows in the project
*/
class CustomHandler {
/**
* Find all custom.yaml files in the project
* @param {string} projectRoot - Project root directory
* @returns {Array} List of custom content paths
*/
async findCustomContent(projectRoot) {
const customPaths = [];
// Helper function to recursively scan directories
async function scanDirectory(dir, excludePaths = []) {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
// Skip hidden directories and common exclusions
if (
entry.name.startsWith('.') ||
entry.name === 'node_modules' ||
entry.name === 'dist' ||
entry.name === 'build' ||
entry.name === '.git' ||
entry.name === 'bmad'
) {
continue;
}
// Skip excluded paths
if (excludePaths.some((exclude) => fullPath.startsWith(exclude))) {
continue;
}
if (entry.isDirectory()) {
// Recursively scan subdirectories
await scanDirectory(fullPath, excludePaths);
} else if (entry.name === 'custom.yaml') {
// Found a custom.yaml file
customPaths.push(fullPath);
} else if (
entry.name === 'module.yaml' && // Check if this is a custom module (in root directory)
// Skip if it's in src/modules (those are standard modules)
!fullPath.includes(path.join('src', 'modules'))
) {
customPaths.push(fullPath);
}
}
} catch {
// Ignore errors (e.g., permission denied)
}
}
// Scan the entire project, but exclude source directories
await scanDirectory(projectRoot, [path.join(projectRoot, 'src'), path.join(projectRoot, 'tools'), path.join(projectRoot, 'test')]);
return customPaths;
}
/**
* Get custom content info from a custom.yaml or module.yaml file
* @param {string} configPath - Path to config file
* @param {string} projectRoot - Project root directory for calculating relative paths
* @returns {Object|null} Custom content info
*/
async getCustomInfo(configPath, projectRoot = null) {
try {
const configContent = await fs.readFile(configPath, 'utf8');
// Try to parse YAML with error handling
let config;
try {
config = yaml.parse(configContent);
} catch (parseError) {
await prompts.log.warn('YAML parse error in ' + configPath + ': ' + parseError.message);
return null;
}
// Check if this is an module.yaml (module) or custom.yaml (custom content)
const isInstallConfig = configPath.endsWith('module.yaml');
const configDir = path.dirname(configPath);
// Use provided projectRoot or fall back to process.cwd()
const basePath = projectRoot || process.cwd();
const relativePath = path.relative(basePath, configDir);
return {
id: config.code || 'unknown-code',
name: config.name,
description: config.description || '',
path: configDir,
relativePath: relativePath,
defaultSelected: config.default_selected === true,
config: config,
isInstallConfig: isInstallConfig, // Track which type this is
};
} catch (error) {
await prompts.log.warn('Failed to read ' + configPath + ': ' + error.message);
return null;
}
}
}
module.exports = { CustomHandler };

View File

@ -1,302 +0,0 @@
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 (/<agent[^>]*\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<string, string>} 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;
}
/**
* Assemble quick-update source candidates before install() hands them to discoverPaths().
* This exists because discoverPaths() consumes already-prepared quick-update sources,
* while quickUpdate() still has to build that source map from manifest, explicit inputs,
* and cache conventions.
* Precedence: manifest-backed paths, explicit sources override them, then cached modules.
* @param {Object} config - Quick update configuration
* @param {Object} existingInstall - Existing installation snapshot
* @param {string} bmadDir - BMAD directory
* @param {Object} externalModuleManager - External module manager
* @returns {Promise<Map<string, Object>>} Map of custom module ID to source info
*/
async assembleQuickUpdateSources(config, existingInstall, bmadDir, externalModuleManager) {
const projectRoot = path.dirname(bmadDir);
const customModuleSources = new Map();
if (existingInstall.customModules) {
for (const customModule of existingInstall.customModules) {
// Skip if no ID - can't reliably track or re-cache without it
if (!customModule?.id) continue;
let sourcePath = customModule.sourcePath;
if (sourcePath && sourcePath.startsWith('_config')) {
// Paths are relative to BMAD dir, but we want absolute paths for install
sourcePath = path.join(bmadDir, sourcePath);
} else if (!sourcePath && customModule.relativePath) {
// Fall back to relativePath
sourcePath = path.resolve(projectRoot, customModule.relativePath);
} else if (sourcePath && !path.isAbsolute(sourcePath)) {
// If we have a sourcePath but it's not absolute, resolve it relative to project root
sourcePath = path.resolve(projectRoot, sourcePath);
}
// If we still don't have a valid source path, skip this module
if (!sourcePath || !(await fs.pathExists(sourcePath))) {
continue;
}
customModuleSources.set(customModule.id, {
id: customModule.id,
name: customModule.name || customModule.id,
sourcePath,
relativePath: customModule.relativePath,
cached: false,
});
}
}
if (config.customContent?.sources?.length > 0) {
for (const source of config.customContent.sources) {
if (source.id && 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))) {
return customModuleSources;
}
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 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,
});
}
}
return customModuleSources;
}
}
module.exports = { CustomModules };

View File

@ -102,7 +102,6 @@ class OfficialModules {
*/ */
async listAvailable() { async listAvailable() {
const modules = []; const modules = [];
const customModules = [];
// Add built-in core module (directly under src/core-skills) // Add built-in core module (directly under src/core-skills)
const corePath = getSourcePath('core-skills'); const corePath = getSourcePath('core-skills');
@ -122,7 +121,7 @@ class OfficialModules {
} }
} }
return { modules, customModules }; return { modules };
} }
/** /**
@ -133,25 +132,12 @@ class OfficialModules {
* @returns {Object|null} Module info or null if not a valid module * @returns {Object|null} Module info or null if not a valid module
*/ */
async getModuleInfo(modulePath, defaultName, sourceDescription) { async getModuleInfo(modulePath, defaultName, sourceDescription) {
// Check for module structure (module.yaml OR custom.yaml)
const moduleConfigPath = path.join(modulePath, 'module.yaml'); const moduleConfigPath = path.join(modulePath, 'module.yaml');
const rootCustomConfigPath = path.join(modulePath, 'custom.yaml');
let configPath = null;
if (await fs.pathExists(moduleConfigPath)) { 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; 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 = { const moduleInfo = {
id: defaultName, id: defaultName,
path: modulePath, path: modulePath,
@ -162,12 +148,11 @@ class OfficialModules {
description: 'BMAD Module', description: 'BMAD Module',
version: '5.0.0', version: '5.0.0',
source: sourceDescription, source: sourceDescription,
isCustom: configPath === rootCustomConfigPath || isCustomSource,
}; };
// Read module config for metadata // Read module config for metadata
try { try {
const configContent = await fs.readFile(configPath, 'utf8'); const configContent = await fs.readFile(moduleConfigPath, 'utf8');
const config = yaml.parse(configContent); const config = yaml.parse(configContent);
// Use the code property as the id if available // Use the code property as the id if available
@ -824,12 +809,8 @@ class OfficialModules {
const results = []; const results = [];
for (const moduleName of modules) { for (const moduleName of modules) {
// Resolve module.yaml path - custom paths first, then standard location, then OfficialModules search // Resolve module.yaml path - standard location first, then OfficialModules search
let moduleConfigPath = null; let moduleConfigPath = null;
const customPath = this.customModulePaths?.get(moduleName);
if (customPath) {
moduleConfigPath = path.join(customPath, 'module.yaml');
} else {
const standardPath = path.join(getModulePath(moduleName), 'module.yaml'); const standardPath = path.join(getModulePath(moduleName), 'module.yaml');
if (await fs.pathExists(standardPath)) { if (await fs.pathExists(standardPath)) {
moduleConfigPath = standardPath; moduleConfigPath = standardPath;
@ -839,7 +820,6 @@ class OfficialModules {
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
} }
} }
}
if (!moduleConfigPath || !(await fs.pathExists(moduleConfigPath))) { if (!moduleConfigPath || !(await fs.pathExists(moduleConfigPath))) {
continue; continue;
@ -882,12 +862,9 @@ class OfficialModules {
* @param {Array} modules - List of modules to configure (including 'core') * @param {Array} modules - List of modules to configure (including 'core')
* @param {string} projectDir - Target project directory * @param {string} projectDir - Target project directory
* @param {Object} options - Additional options * @param {Object} options - Additional options
* @param {Map} options.customModulePaths - Map of module ID to source path for custom modules
* @param {boolean} options.skipPrompts - Skip prompts and use defaults (for --yes flag) * @param {boolean} options.skipPrompts - Skip prompts and use defaults (for --yes flag)
*/ */
async collectAllConfigurations(modules, projectDir, options = {}) { async collectAllConfigurations(modules, projectDir, options = {}) {
// Store custom module paths for use in collectModuleConfig
this.customModulePaths = options.customModulePaths || new Map();
this.skipPrompts = options.skipPrompts || false; this.skipPrompts = options.skipPrompts || false;
this.modulesToCustomize = undefined; this.modulesToCustomize = undefined;
await this.loadExistingConfig(projectDir); await this.loadExistingConfig(projectDir);
@ -1042,25 +1019,7 @@ class OfficialModules {
} }
} }
let configPath = null; if (!(await fs.pathExists(moduleConfigPath))) {
let isCustomModule = false;
if (await fs.pathExists(moduleConfigPath)) {
configPath = moduleConfigPath;
} else {
// Check if this is a custom module with custom.yaml
const moduleSourcePath = await this.findModuleSource(moduleName, { silent: true });
if (moduleSourcePath) {
const rootCustomConfigPath = path.join(moduleSourcePath, 'custom.yaml');
if (await fs.pathExists(rootCustomConfigPath)) {
isCustomModule = true;
// For custom modules, we don't have an install-config schema, so just use existing values
// The custom.yaml values will be loaded and merged during installation
}
}
// No config schema for this module - use existing values // 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]) { if (!this.collectedConfig[moduleName]) {
@ -1071,7 +1030,7 @@ class OfficialModules {
return false; return false;
} }
const configContent = await fs.readFile(configPath, 'utf8'); const configContent = await fs.readFile(moduleConfigPath, 'utf8');
const moduleConfig = yaml.parse(configContent); const moduleConfig = yaml.parse(configContent);
if (!moduleConfig) { if (!moduleConfig) {
@ -1332,16 +1291,7 @@ class OfficialModules {
this.allAnswers = {}; this.allAnswers = {};
} }
// Load module's config // Load module's config
// First, check if we have a custom module path for this module let moduleConfigPath = path.join(getModulePath(moduleName), 'module.yaml');
let moduleConfigPath = null;
if (this.customModulePaths && this.customModulePaths.has(moduleName)) {
const customPath = this.customModulePaths.get(moduleName);
moduleConfigPath = path.join(customPath, 'module.yaml');
} else {
// Try the standard src/modules location
moduleConfigPath = path.join(getModulePath(moduleName), 'module.yaml');
}
// If not found in src/modules or custom paths, search the project // If not found in src/modules or custom paths, search the project
if (!(await fs.pathExists(moduleConfigPath))) { if (!(await fs.pathExists(moduleConfigPath))) {

View File

@ -2,7 +2,6 @@ const path = require('node:path');
const os = require('node:os'); const os = require('node:os');
const fs = require('fs-extra'); const fs = require('fs-extra');
const { CLIUtils } = require('./cli-utils'); const { CLIUtils } = require('./cli-utils');
const { CustomHandler } = require('./custom-handler');
const { ExternalModuleManager } = require('./modules/external-manager'); const { ExternalModuleManager } = require('./modules/external-manager');
const { getProjectRoot } = require('./project-root'); const { getProjectRoot } = require('./project-root');
const prompts = require('./prompts'); const prompts = require('./prompts');
@ -48,19 +47,6 @@ function _extractMarketplaceVersion(data) {
return best; return best;
} }
// Separator class for visual grouping in select/multiselect prompts
// Note: @clack/prompts doesn't support separators natively, they are filtered out
class Separator {
constructor(text = '────────') {
this.line = text;
this.name = text;
}
type = 'separator';
}
// Separator for choice lists (compatible interface)
const choiceUtils = { Separator };
/** /**
* UI utilities for the installer * UI utilities for the installer
*/ */
@ -100,11 +86,6 @@ class UI {
// Check if there's an existing BMAD installation // Check if there's an existing BMAD installation
const hasExistingInstall = await fs.pathExists(bmadDir); const hasExistingInstall = await fs.pathExists(bmadDir);
let customContentConfig = { hasCustomContent: false };
if (!hasExistingInstall) {
customContentConfig._shouldAsk = true;
}
// Track action type (only set if there's an existing installation) // Track action type (only set if there's an existing installation)
let actionType; let actionType;
@ -153,48 +134,9 @@ class UI {
// Handle quick update separately // Handle quick update separately
if (actionType === 'quick-update') { if (actionType === 'quick-update') {
// Pass --custom-content through so installer can re-cache if cache is missing
let customContentForQuickUpdate = { hasCustomContent: false };
if (options.customContent) {
const paths = options.customContent
.split(',')
.map((p) => p.trim())
.filter(Boolean);
if (paths.length > 0) {
const customPaths = [];
const selectedModuleIds = [];
const sources = [];
for (const customPath of paths) {
const expandedPath = this.expandUserPath(customPath);
const validation = this.validateCustomContentPathSync(expandedPath);
if (validation) continue;
let moduleMeta;
try {
const moduleYamlPath = path.join(expandedPath, 'module.yaml');
moduleMeta = require('yaml').parse(await fs.readFile(moduleYamlPath, 'utf-8'));
} catch {
continue;
}
if (!moduleMeta?.code) continue;
customPaths.push(expandedPath);
selectedModuleIds.push(moduleMeta.code);
sources.push({ path: expandedPath, id: moduleMeta.code, name: moduleMeta.name || moduleMeta.code });
}
if (customPaths.length > 0) {
customContentForQuickUpdate = {
hasCustomContent: true,
selected: true,
sources,
selectedFiles: customPaths.map((p) => path.join(p, 'module.yaml')),
selectedModuleIds,
};
}
}
}
return { return {
actionType: 'quick-update', actionType: 'quick-update',
directory: confirmedDirectory, directory: confirmedDirectory,
customContent: customContentForQuickUpdate,
skipPrompts: options.yes || false, skipPrompts: options.yes || false,
}; };
} }
@ -225,120 +167,6 @@ class UI {
selectedModules = await this.selectAllModules(installedModuleIds); selectedModules = await this.selectAllModules(installedModuleIds);
} }
// After module selection, ask about custom modules
let customModuleResult = { selectedCustomModules: [], customContentConfig: { hasCustomContent: false } };
if (options.customContent) {
// Use custom content from command-line
const paths = options.customContent
.split(',')
.map((p) => p.trim())
.filter(Boolean);
await prompts.log.info(`Using custom content from command-line: ${paths.join(', ')}`);
// Build custom content config similar to promptCustomContentSource
const customPaths = [];
const selectedModuleIds = [];
const sources = [];
for (const customPath of paths) {
const expandedPath = this.expandUserPath(customPath);
const validation = this.validateCustomContentPathSync(expandedPath);
if (validation) {
await prompts.log.warn(`Skipping invalid custom content path: ${customPath} - ${validation}`);
continue;
}
// Read module metadata
let moduleMeta;
try {
const moduleYamlPath = path.join(expandedPath, 'module.yaml');
const moduleYaml = await fs.readFile(moduleYamlPath, 'utf-8');
const yaml = require('yaml');
moduleMeta = yaml.parse(moduleYaml);
} catch (error) {
await prompts.log.warn(`Skipping custom content path: ${customPath} - failed to read module.yaml: ${error.message}`);
continue;
}
if (!moduleMeta) {
await prompts.log.warn(`Skipping custom content path: ${customPath} - module.yaml is empty`);
continue;
}
if (!moduleMeta.code) {
await prompts.log.warn(`Skipping custom content path: ${customPath} - module.yaml missing 'code' field`);
continue;
}
customPaths.push(expandedPath);
selectedModuleIds.push(moduleMeta.code);
sources.push({
path: expandedPath,
id: moduleMeta.code,
name: moduleMeta.name || moduleMeta.code,
});
}
if (customPaths.length > 0) {
customModuleResult = {
selectedCustomModules: selectedModuleIds,
customContentConfig: {
hasCustomContent: true,
selected: true,
sources,
selectedFiles: customPaths.map((p) => path.join(p, 'module.yaml')),
selectedModuleIds: selectedModuleIds,
},
};
}
} else if (options.yes) {
// Non-interactive mode: preserve existing custom modules (matches default: false)
const cacheDir = path.join(bmadDir, '_config', 'custom');
if (await fs.pathExists(cacheDir)) {
const entries = await fs.readdir(cacheDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
customModuleResult.selectedCustomModules.push(entry.name);
}
}
await prompts.log.info(
`Non-interactive mode (--yes): preserving ${customModuleResult.selectedCustomModules.length} existing custom module(s)`,
);
} else {
await prompts.log.info('Non-interactive mode (--yes): no existing custom modules found');
}
} else {
const changeCustomModules = await prompts.confirm({
message: 'Modify custom modules, agents, or workflows?',
default: false,
});
if (changeCustomModules) {
customModuleResult = await this.handleCustomModulesInModifyFlow(confirmedDirectory, selectedModules);
} else {
// Preserve existing custom modules if user doesn't want to modify them
const { Installer } = require('./core/installer');
const installer = new Installer();
const { bmadDir } = await installer.findBmadDir(confirmedDirectory);
const cacheDir = path.join(bmadDir, '_config', 'custom');
if (await fs.pathExists(cacheDir)) {
const entries = await fs.readdir(cacheDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
customModuleResult.selectedCustomModules.push(entry.name);
}
}
}
}
}
// Merge any selected custom modules
if (customModuleResult.selectedCustomModules.length > 0) {
selectedModules.push(...customModuleResult.selectedCustomModules);
}
// Ensure core is in the modules list // Ensure core is in the modules list
if (!selectedModules.includes('core')) { if (!selectedModules.includes('core')) {
selectedModules.unshift('core'); selectedModules.unshift('core');
@ -357,7 +185,6 @@ class UI {
skipIde: toolSelection.skipIde, skipIde: toolSelection.skipIde,
coreConfig: moduleConfigs.core || {}, coreConfig: moduleConfigs.core || {},
moduleConfigs: moduleConfigs, moduleConfigs: moduleConfigs,
customContent: customModuleResult.customContentConfig,
skipPrompts: options.yes || false, skipPrompts: options.yes || false,
}; };
} }
@ -383,84 +210,6 @@ class UI {
selectedModules = await this.selectAllModules(installedModuleIds); selectedModules = await this.selectAllModules(installedModuleIds);
} }
// Ask about custom content (local modules/agents/workflows)
if (options.customContent) {
// Use custom content from command-line
const paths = options.customContent
.split(',')
.map((p) => p.trim())
.filter(Boolean);
await prompts.log.info(`Using custom content from command-line: ${paths.join(', ')}`);
// Build custom content config similar to promptCustomContentSource
const customPaths = [];
const selectedModuleIds = [];
const sources = [];
for (const customPath of paths) {
const expandedPath = this.expandUserPath(customPath);
const validation = this.validateCustomContentPathSync(expandedPath);
if (validation) {
await prompts.log.warn(`Skipping invalid custom content path: ${customPath} - ${validation}`);
continue;
}
// Read module metadata
let moduleMeta;
try {
const moduleYamlPath = path.join(expandedPath, 'module.yaml');
const moduleYaml = await fs.readFile(moduleYamlPath, 'utf-8');
const yaml = require('yaml');
moduleMeta = yaml.parse(moduleYaml);
} catch (error) {
await prompts.log.warn(`Skipping custom content path: ${customPath} - failed to read module.yaml: ${error.message}`);
continue;
}
if (!moduleMeta) {
await prompts.log.warn(`Skipping custom content path: ${customPath} - module.yaml is empty`);
continue;
}
if (!moduleMeta.code) {
await prompts.log.warn(`Skipping custom content path: ${customPath} - module.yaml missing 'code' field`);
continue;
}
customPaths.push(expandedPath);
selectedModuleIds.push(moduleMeta.code);
sources.push({
path: expandedPath,
id: moduleMeta.code,
name: moduleMeta.name || moduleMeta.code,
});
}
if (customPaths.length > 0) {
customContentConfig = {
hasCustomContent: true,
selected: true,
sources,
selectedFiles: customPaths.map((p) => path.join(p, 'module.yaml')),
selectedModuleIds: selectedModuleIds,
};
}
} else if (!options.yes) {
const wantsCustomContent = await prompts.confirm({
message: 'Add custom modules, agents, or workflows from your computer?',
default: false,
});
if (wantsCustomContent) {
customContentConfig = await this.promptCustomContentSource();
}
}
// Add custom content modules if any were selected
if (customContentConfig && customContentConfig.selectedModuleIds) {
selectedModules.push(...customContentConfig.selectedModuleIds);
}
// Ensure core is in the modules list // Ensure core is in the modules list
if (!selectedModules.includes('core')) { if (!selectedModules.includes('core')) {
selectedModules.unshift('core'); selectedModules.unshift('core');
@ -476,7 +225,6 @@ class UI {
skipIde: toolSelection.skipIde, skipIde: toolSelection.skipIde,
coreConfig: moduleConfigs.core || {}, coreConfig: moduleConfigs.core || {},
moduleConfigs: moduleConfigs, moduleConfigs: moduleConfigs,
customContent: customContentConfig,
skipPrompts: options.yes || false, skipPrompts: options.yes || false,
}; };
} }
@ -814,90 +562,6 @@ class UI {
return configCollector.collectedConfig; return configCollector.collectedConfig;
} }
/**
* Get module choices for selection
* @param {Set} installedModuleIds - Currently installed module IDs
* @param {Object} customContentConfig - Custom content configuration
* @returns {Array} Module choices for prompt
*/
async getModuleChoices(installedModuleIds, customContentConfig = null) {
const color = await prompts.getColor();
const moduleChoices = [];
const isNewInstallation = installedModuleIds.size === 0;
const customContentItems = [];
// Add custom content items
if (customContentConfig && customContentConfig.hasCustomContent && customContentConfig.customPath) {
// Existing installation - show from directory
const customHandler = new CustomHandler();
const customFiles = await customHandler.findCustomContent(customContentConfig.customPath);
for (const customFile of customFiles) {
const customInfo = await customHandler.getCustomInfo(customFile);
if (customInfo) {
customContentItems.push({
name: `${color.cyan('\u2713')} ${customInfo.name} ${color.dim(`(${customInfo.relativePath})`)}`,
value: `__CUSTOM_CONTENT__${customFile}`, // Unique value for each custom content
checked: true, // Default to selected since user chose to provide custom content
path: customInfo.path, // Track path to avoid duplicates
hint: customInfo.description || undefined,
});
}
}
}
// Add official modules
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 = [];
// Add custom content items from directory
allCustomModules.push(...customContentItems);
// Add custom modules from cache
for (const mod of customModulesFromCache) {
// Skip if this module is already in customContentItems (by path)
const isDuplicate = allCustomModules.some((item) => item.path && mod.path && path.resolve(item.path) === path.resolve(mod.path));
if (!isDuplicate) {
allCustomModules.push({
name: `${color.cyan('\u2713')} ${mod.name} ${color.dim('(cached)')}`,
value: mod.id,
checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id),
hint: mod.description || undefined,
});
}
}
// Add separators and modules in correct order
if (allCustomModules.length > 0) {
// Add separator for custom content, all custom modules, and official content separator
moduleChoices.push(
new choiceUtils.Separator('── Custom Content ──'),
...allCustomModules,
new choiceUtils.Separator('── Official Content ──'),
);
}
// Add official modules (only non-custom ones)
for (const mod of availableModules) {
if (!mod.isCustom) {
moduleChoices.push({
name: mod.name,
value: mod.id,
checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id),
hint: mod.description || undefined,
});
}
}
return moduleChoices;
}
/** /**
* Select all modules (official + community) using grouped multiselect. * Select all modules (official + community) using grouped multiselect.
* Core is shown as locked but filtered from the result since it's always installed separately. * Core is shown as locked but filtered from the result since it's always installed separately.
@ -941,7 +605,7 @@ class UI {
// Local modules (BMM, BMB, etc.) // Local modules (BMM, BMB, etc.)
const localEntries = []; const localEntries = [];
for (const mod of localModules) { for (const mod of localModules) {
if (!mod.isCustom && mod.id !== 'core') { if (mod.id !== 'core') {
const entry = await buildModuleEntry(mod, mod.id, 'Local'); const entry = await buildModuleEntry(mod, mod.id, 'Local');
localEntries.push(entry); localEntries.push(entry);
if (entry.selected) { if (entry.selected) {
@ -1316,282 +980,6 @@ class UI {
return existingInstall.ides; return existingInstall.ides;
} }
/**
* Validate custom content path synchronously
* @param {string} input - User input path
* @returns {string|undefined} Error message or undefined if valid
*/
validateCustomContentPathSync(input) {
// Allow empty input to cancel
if (!input || input.trim() === '') {
return; // Allow empty to exit
}
try {
// Expand the path
const expandedPath = this.expandUserPath(input.trim());
// Check if path exists
if (!fs.pathExistsSync(expandedPath)) {
return 'Path does not exist';
}
// Check if it's a directory
const stat = fs.statSync(expandedPath);
if (!stat.isDirectory()) {
return 'Path must be a directory';
}
// Check for module.yaml in the root
const moduleYamlPath = path.join(expandedPath, 'module.yaml');
if (!fs.pathExistsSync(moduleYamlPath)) {
return 'Directory must contain a module.yaml file in the root';
}
// Try to parse the module.yaml to get the module ID
try {
const yaml = require('yaml');
const content = fs.readFileSync(moduleYamlPath, 'utf8');
const moduleData = yaml.parse(content);
if (!moduleData.code) {
return 'module.yaml must contain a "code" field for the module ID';
}
} catch (error) {
return 'Invalid module.yaml file: ' + error.message;
}
return; // Valid
} catch (error) {
return 'Error validating path: ' + error.message;
}
}
/**
* Prompt user for custom content source location
* @returns {Object} Custom content configuration
*/
async promptCustomContentSource() {
const customContentConfig = { hasCustomContent: true, sources: [] };
// Keep asking for more sources until user is done
while (true) {
// First ask if user wants to add another module or continue
if (customContentConfig.sources.length > 0) {
const action = await prompts.select({
message: 'Would you like to:',
choices: [
{ name: 'Add another custom module', value: 'add' },
{ name: 'Continue with installation', value: 'continue' },
],
default: 'continue',
});
if (action === 'continue') {
break;
}
}
let sourcePath;
let isValid = false;
while (!isValid) {
// Use sync validation because @clack/prompts doesn't support async validate
const inputPath = await prompts.text({
message: 'Path to custom module folder (press Enter to skip):',
validate: (input) => this.validateCustomContentPathSync(input),
});
// If user pressed Enter without typing anything, exit the loop
if (!inputPath || inputPath.trim() === '') {
// If we have no modules yet, return false for no custom content
if (customContentConfig.sources.length === 0) {
return { hasCustomContent: false };
}
return customContentConfig;
}
sourcePath = this.expandUserPath(inputPath);
isValid = true;
}
// Read module.yaml to get module info
const yaml = require('yaml');
const moduleYamlPath = path.join(sourcePath, 'module.yaml');
const moduleContent = await fs.readFile(moduleYamlPath, 'utf8');
const moduleData = yaml.parse(moduleContent);
// Add to sources
customContentConfig.sources.push({
path: sourcePath,
id: moduleData.code,
name: moduleData.name || moduleData.code,
});
await prompts.log.success(`Confirmed local custom module: ${moduleData.name || moduleData.code}`);
}
// Ask if user wants to add these to the installation
const shouldInstall = await prompts.confirm({
message: `Install these ${customContentConfig.sources.length} custom modules?`,
default: true,
});
if (shouldInstall) {
customContentConfig.selected = true;
// Store paths to module.yaml files, not directories
customContentConfig.selectedFiles = customContentConfig.sources.map((s) => path.join(s.path, 'module.yaml'));
// Also include module IDs for installation
customContentConfig.selectedModuleIds = customContentConfig.sources.map((s) => s.id);
}
return customContentConfig;
}
/**
* Handle custom modules in the modify flow
* @param {string} directory - Installation directory
* @param {Array} selectedModules - Currently selected modules
* @returns {Object} Result with selected custom modules and custom content config
*/
async handleCustomModulesInModifyFlow(directory, selectedModules) {
// Get existing installation to find custom modules
const { existingInstall } = await this.getExistingInstallation(directory);
// Check if there are any custom modules in cache
const { Installer } = require('./core/installer');
const installer = new Installer();
const { bmadDir } = await installer.findBmadDir(directory);
const cacheDir = path.join(bmadDir, '_config', 'custom');
const cachedCustomModules = [];
if (await fs.pathExists(cacheDir)) {
const entries = await fs.readdir(cacheDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const moduleYamlPath = path.join(cacheDir, entry.name, 'module.yaml');
if (await fs.pathExists(moduleYamlPath)) {
const yaml = require('yaml');
const content = await fs.readFile(moduleYamlPath, 'utf8');
const moduleData = yaml.parse(content);
cachedCustomModules.push({
id: entry.name,
name: moduleData.name || entry.name,
description: moduleData.description || 'Custom module from cache',
checked: selectedModules.includes(entry.name),
fromCache: true,
});
}
}
}
}
const result = {
selectedCustomModules: [],
customContentConfig: { hasCustomContent: false },
};
// Ask user about custom modules
await prompts.log.info('Custom Modules');
if (cachedCustomModules.length > 0) {
await prompts.log.message('Found custom modules in your installation:');
} else {
await prompts.log.message('No custom modules currently installed.');
}
// Build choices dynamically based on whether we have existing modules
const choices = [];
if (cachedCustomModules.length > 0) {
choices.push(
{ name: 'Keep all existing custom modules', value: 'keep' },
{ name: 'Select which custom modules to keep', value: 'select' },
{ name: 'Add new custom modules', value: 'add' },
{ name: 'Remove all custom modules', value: 'remove' },
);
} else {
choices.push({ name: 'Add new custom modules', value: 'add' }, { name: 'Cancel (no custom modules)', value: 'cancel' });
}
const customAction = await prompts.select({
message: cachedCustomModules.length > 0 ? 'Manage custom modules?' : 'Add custom modules?',
choices: choices,
default: cachedCustomModules.length > 0 ? 'keep' : 'add',
});
switch (customAction) {
case 'keep': {
// Keep all existing custom modules
result.selectedCustomModules = cachedCustomModules.map((m) => m.id);
await prompts.log.message(`Keeping ${result.selectedCustomModules.length} custom module(s)`);
break;
}
case 'select': {
// Let user choose which to keep
const selectChoices = cachedCustomModules.map((m) => ({
name: `${m.name} (${m.id})`,
value: m.id,
checked: m.checked,
}));
// Add "None / I changed my mind" option at the end
const choicesWithSkip = [
...selectChoices,
{
name: '⚠ None / I changed my mind - keep no custom modules',
value: '__NONE__',
checked: false,
},
];
const keepModules = await prompts.multiselect({
message: 'Select custom modules to keep (use arrow keys, space to toggle):',
choices: choicesWithSkip,
required: true,
});
// If user selected both "__NONE__" and other modules, honor the "None" choice
if (keepModules && keepModules.includes('__NONE__') && keepModules.length > 1) {
await prompts.log.warn('"None / I changed my mind" was selected, so no custom modules will be kept.');
result.selectedCustomModules = [];
} else {
// Filter out the special '__NONE__' value
result.selectedCustomModules = keepModules ? keepModules.filter((m) => m !== '__NONE__') : [];
}
break;
}
case 'add': {
// By default, keep existing modules when adding new ones
// User chose "Add new" not "Replace", so we assume they want to keep existing
result.selectedCustomModules = cachedCustomModules.map((m) => m.id);
// Then prompt for new ones (reuse existing method)
const newCustomContent = await this.promptCustomContentSource();
if (newCustomContent.hasCustomContent && newCustomContent.selected) {
result.selectedCustomModules.push(...newCustomContent.selectedModuleIds);
result.customContentConfig = newCustomContent;
}
break;
}
case 'remove': {
// Remove all custom modules
await prompts.log.warn('All custom modules will be removed from the installation');
break;
}
case 'cancel': {
// User cancelled - no custom modules
await prompts.log.message('No custom modules will be added');
break;
}
}
return result;
}
/** /**
* Display module versions with update availability * Display module versions with update availability
* @param {Array} modules - Array of module info objects with version info * @param {Array} modules - Array of module info objects with version info