Compare commits
3 Commits
9ca0316674
...
b744408783
| Author | SHA1 | Date |
|---|---|---|
|
|
b744408783 | |
|
|
5e038a8ce4 | |
|
|
5dbfb588ee |
|
|
@ -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>.
|
||||||
:::
|
:::
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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. L’affichage d’un message d'erreur suivi d’un exit (pour les options critiques comme le répertoire)
|
1. L’affichage d’un message d'erreur suivi d’un exit (pour les options critiques comme le répertoire)
|
||||||
2. Un avertissement puis la continuation de l’installation (pour les éléments optionnels comme le contenu personnalisé)
|
2. Un avertissement puis la continuation de l’installation (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>.
|
||||||
:::
|
:::
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>.
|
||||||
:::
|
:::
|
||||||
|
|
|
||||||
|
|
@ -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ì
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>.
|
||||||
:::
|
:::
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ npx github:bmad-code-org/BMAD-METHOD install
|
||||||
|
|
||||||
### 5. 按照提示操作
|
### 5. 按照提示操作
|
||||||
|
|
||||||
安装程序会引导你完成剩余步骤——自定义内容、设置等。
|
安装程序会引导你完成剩余步骤——设置、工具集成等。
|
||||||
|
|
||||||
## 你将获得
|
## 你将获得
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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> 提交反馈。
|
||||||
:::
|
:::
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
@ -1774,102 +1724,253 @@ async function runTests() {
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Suite 33: Main manifest preserves active customModules only
|
// Test Suite 33: Community & Custom Module Managers
|
||||||
// ============================================================
|
// ============================================================
|
||||||
console.log(`${colors.yellow}Test Suite 33: Preserve active customModules in main manifest${colors.reset}\n`);
|
console.log(`${colors.yellow}Test Suite 33: Community & Custom Module Managers${colors.reset}\n`);
|
||||||
|
|
||||||
let customManifestFixture = null;
|
// --- CustomModuleManager.validateGitHubUrl ---
|
||||||
try {
|
{
|
||||||
customManifestFixture = await createCustomModuleManifestFixture();
|
const { CustomModuleManager } = require('../tools/installer/modules/custom-module-manager');
|
||||||
const yaml = require('yaml');
|
const mgr = new CustomModuleManager();
|
||||||
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();
|
const https1 = mgr.validateGitHubUrl('https://github.com/owner/repo');
|
||||||
await generator33.generateManifests(customManifestFixture.bmadDir, ['core', 'test-module'], [], { ides: ['codex'] });
|
assert(https1.isValid === true, 'validateGitHubUrl accepts HTTPS URL');
|
||||||
|
assert(https1.owner === 'owner' && https1.repo === 'repo', 'validateGitHubUrl extracts owner/repo from HTTPS');
|
||||||
|
|
||||||
const updatedManifest = yaml.parse(await fs.readFile(customManifestFixture.manifestPath, 'utf8'));
|
const https2 = mgr.validateGitHubUrl('https://github.com/owner/repo.git');
|
||||||
const customModule = updatedManifest.customModules?.find((entry) => entry.id === 'test-module');
|
assert(https2.isValid === true, 'validateGitHubUrl accepts HTTPS URL with .git');
|
||||||
|
assert(https2.repo === 'repo', 'validateGitHubUrl strips .git suffix');
|
||||||
|
|
||||||
assert(Array.isArray(updatedManifest.customModules), 'Main manifest keeps customModules array');
|
const ssh1 = mgr.validateGitHubUrl('git@github.com:owner/repo.git');
|
||||||
assert(customModule !== undefined, 'Main manifest preserves existing custom module entry');
|
assert(ssh1.isValid === true, 'validateGitHubUrl accepts SSH URL');
|
||||||
assert(
|
assert(ssh1.owner === 'owner' && ssh1.repo === 'repo', 'validateGitHubUrl extracts owner/repo from SSH');
|
||||||
customModule && customModule.sourcePath === customManifestFixture.moduleSourceDir,
|
|
||||||
'Main manifest preserves custom module sourcePath',
|
const bad1 = mgr.validateGitHubUrl('https://gitlab.com/owner/repo');
|
||||||
);
|
assert(bad1.isValid === false, 'validateGitHubUrl rejects non-GitHub URL');
|
||||||
assert(
|
|
||||||
!updatedManifest.customModules?.some((entry) => entry.id === 'removed-module'),
|
const bad2 = mgr.validateGitHubUrl('');
|
||||||
'Main manifest drops stale custom module entries',
|
assert(bad2.isValid === false, 'validateGitHubUrl rejects empty string');
|
||||||
);
|
|
||||||
} catch (error) {
|
const bad3 = mgr.validateGitHubUrl(null);
|
||||||
assert(false, 'Main manifest preserves customModules test succeeds', error.message);
|
assert(bad3.isValid === false, 'validateGitHubUrl rejects null');
|
||||||
} finally {
|
|
||||||
if (customManifestFixture?.root) await fs.remove(customManifestFixture.root).catch(() => {});
|
const bad4 = mgr.validateGitHubUrl('https://github.com/owner');
|
||||||
|
assert(bad4.isValid === false, 'validateGitHubUrl rejects URL without repo');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('');
|
// --- CustomModuleManager._normalizeCustomModule ---
|
||||||
|
{
|
||||||
|
const { CustomModuleManager } = require('../tools/installer/modules/custom-module-manager');
|
||||||
|
const mgr = new CustomModuleManager();
|
||||||
|
|
||||||
// ============================================================
|
const plugin = { name: 'test-plugin', description: 'A test', version: '1.0.0', author: 'tester', source: './src' };
|
||||||
// Suite 34: Quick update uses manifest-backed custom sources
|
const data = { owner: 'Fallback Owner' };
|
||||||
// ============================================================
|
const result = mgr._normalizeCustomModule(plugin, 'https://github.com/o/r', data);
|
||||||
console.log(`${colors.yellow}Test Suite 34: Quick update uses manifest-backed custom module sources${colors.reset}\n`);
|
|
||||||
|
|
||||||
let quickUpdateFixture = null;
|
assert(result.code === 'test-plugin', 'normalizeCustomModule sets code from plugin name');
|
||||||
const originalListAvailable34 = OfficialModules.prototype.listAvailable;
|
assert(result.type === 'custom', 'normalizeCustomModule sets type to custom');
|
||||||
const originalLoadExistingConfig34 = OfficialModules.prototype.loadExistingConfig;
|
assert(result.trustTier === 'unverified', 'normalizeCustomModule sets trustTier to unverified');
|
||||||
const originalCollectModuleConfigQuick34 = OfficialModules.prototype.collectModuleConfigQuick;
|
assert(result.version === '1.0.0', 'normalizeCustomModule preserves version');
|
||||||
try {
|
assert(result.author === 'tester', 'normalizeCustomModule uses plugin author over data.owner');
|
||||||
quickUpdateFixture = await createCustomModuleManifestFixture();
|
|
||||||
const installer34 = new Installer();
|
|
||||||
installer34.externalModuleManager.hasModule = async () => false;
|
|
||||||
installer34.externalModuleManager.listAvailable = async () => [];
|
|
||||||
|
|
||||||
let capturedInstallConfig34 = null;
|
const pluginNoAuthor = { name: 'x', description: '', version: null };
|
||||||
installer34.install = async (config) => {
|
const result2 = mgr._normalizeCustomModule(pluginNoAuthor, 'https://github.com/o/r', data);
|
||||||
capturedInstallConfig34 = config;
|
assert(result2.author === 'Fallback Owner', 'normalizeCustomModule falls back to data.owner');
|
||||||
return { success: true };
|
}
|
||||||
|
|
||||||
|
// --- CommunityModuleManager._normalizeCommunityModule ---
|
||||||
|
{
|
||||||
|
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
|
||||||
|
const mgr = new CommunityModuleManager();
|
||||||
|
|
||||||
|
const mod = {
|
||||||
|
name: 'test-mod',
|
||||||
|
display_name: 'Test Module',
|
||||||
|
code: 'tm',
|
||||||
|
description: 'desc',
|
||||||
|
repository: 'https://github.com/o/r',
|
||||||
|
module_definition: 'src/module.yaml',
|
||||||
|
category: 'software-development',
|
||||||
|
subcategory: 'dev-tools',
|
||||||
|
trust_tier: 'bmad-certified',
|
||||||
|
version: '2.0.0',
|
||||||
|
approved_sha: 'abc123',
|
||||||
|
promoted: true,
|
||||||
|
promoted_rank: 1,
|
||||||
|
keywords: ['test', 'module'],
|
||||||
|
};
|
||||||
|
const result = mgr._normalizeCommunityModule(mod);
|
||||||
|
|
||||||
|
assert(result.code === 'tm', 'normalizeCommunityModule sets code');
|
||||||
|
assert(result.displayName === 'Test Module', 'normalizeCommunityModule sets displayName from display_name');
|
||||||
|
assert(result.type === 'community', 'normalizeCommunityModule sets type to community');
|
||||||
|
assert(result.category === 'software-development', 'normalizeCommunityModule preserves category');
|
||||||
|
assert(result.trustTier === 'bmad-certified', 'normalizeCommunityModule maps trust_tier');
|
||||||
|
assert(result.approvedSha === 'abc123', 'normalizeCommunityModule maps approved_sha');
|
||||||
|
assert(result.promoted === true, 'normalizeCommunityModule maps promoted');
|
||||||
|
assert(result.promotedRank === 1, 'normalizeCommunityModule maps promoted_rank');
|
||||||
|
assert(result.builtIn === false, 'normalizeCommunityModule sets builtIn false');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CommunityModuleManager.searchByKeyword (with injected cache) ---
|
||||||
|
{
|
||||||
|
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
|
||||||
|
const mgr = new CommunityModuleManager();
|
||||||
|
|
||||||
|
// Inject cached index to avoid network call
|
||||||
|
mgr._cachedIndex = {
|
||||||
|
modules: [
|
||||||
|
{ name: 'mod-a', display_name: 'Alpha', code: 'a', description: 'testing tools', category: 'dev', keywords: ['test'] },
|
||||||
|
{ name: 'mod-b', display_name: 'Beta', code: 'b', description: 'design suite', category: 'design', keywords: ['ux'] },
|
||||||
|
{ name: 'mod-c', display_name: 'Gamma', code: 'c', description: 'game engine', category: 'game', keywords: ['unity'] },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
OfficialModules.prototype.listAvailable = async function () {
|
const r1 = await mgr.searchByKeyword('test');
|
||||||
return { modules: [], customModules: [] };
|
assert(r1.length === 1 && r1[0].code === 'a', 'searchByKeyword matches keyword');
|
||||||
};
|
|
||||||
OfficialModules.prototype.loadExistingConfig = async function () {
|
const r2 = await mgr.searchByKeyword('design');
|
||||||
this.collectedConfig = this.collectedConfig || {};
|
assert(r2.length === 1 && r2[0].code === 'b', 'searchByKeyword matches description');
|
||||||
};
|
|
||||||
OfficialModules.prototype.collectModuleConfigQuick = async function (moduleName) {
|
const r3 = await mgr.searchByKeyword('alpha');
|
||||||
this.collectedConfig = this.collectedConfig || {};
|
assert(r3.length === 1 && r3[0].code === 'a', 'searchByKeyword matches display name');
|
||||||
if (!this.collectedConfig[moduleName]) {
|
|
||||||
this.collectedConfig[moduleName] = {};
|
const r4 = await mgr.searchByKeyword('xyz');
|
||||||
}
|
assert(r4.length === 0, 'searchByKeyword returns empty for no match');
|
||||||
return false;
|
|
||||||
|
const r5 = await mgr.searchByKeyword('UNITY');
|
||||||
|
assert(r5.length === 1 && r5[0].code === 'c', 'searchByKeyword is case-insensitive');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CommunityModuleManager.listFeatured (with injected cache) ---
|
||||||
|
{
|
||||||
|
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
|
||||||
|
const mgr = new CommunityModuleManager();
|
||||||
|
|
||||||
|
mgr._cachedIndex = {
|
||||||
|
modules: [
|
||||||
|
{ name: 'a', code: 'a', promoted: true, promoted_rank: 3 },
|
||||||
|
{ name: 'b', code: 'b', promoted: false },
|
||||||
|
{ name: 'c', code: 'c', promoted: true, promoted_rank: 1 },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
await installer34.quickUpdate({
|
const featured = await mgr.listFeatured();
|
||||||
directory: quickUpdateFixture.root,
|
assert(featured.length === 2, 'listFeatured returns only promoted modules');
|
||||||
skipPrompts: true,
|
assert(featured[0].code === 'c' && featured[1].code === 'a', 'listFeatured sorts by promoted_rank ascending');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CommunityModuleManager.getCategoryList (with injected cache) ---
|
||||||
|
{
|
||||||
|
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
|
||||||
|
const mgr = new CommunityModuleManager();
|
||||||
|
|
||||||
|
mgr._cachedIndex = {
|
||||||
|
modules: [
|
||||||
|
{ name: 'a', code: 'a', category: 'software-development' },
|
||||||
|
{ name: 'b', code: 'b', category: 'design-and-creative' },
|
||||||
|
{ name: 'c', code: 'c', category: 'software-development' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mgr._cachedCategories = {
|
||||||
|
categories: {
|
||||||
|
'software-development': { name: 'Software Development' },
|
||||||
|
'design-and-creative': { name: 'Design & Creative' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const cats = await mgr.getCategoryList();
|
||||||
|
assert(cats.length === 2, 'getCategoryList returns categories with modules');
|
||||||
|
const swDev = cats.find((c) => c.slug === 'software-development');
|
||||||
|
assert(swDev && swDev.moduleCount === 2, 'getCategoryList counts modules per category');
|
||||||
|
assert(cats[0].name === 'Design & Creative', 'getCategoryList sorts alphabetically');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CommunityModuleManager SHA pinning normalization ---
|
||||||
|
{
|
||||||
|
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
|
||||||
|
const mgr = new CommunityModuleManager();
|
||||||
|
|
||||||
|
// Module with SHA set
|
||||||
|
const withSha = mgr._normalizeCommunityModule({
|
||||||
|
name: 'pinned-mod',
|
||||||
|
code: 'pm',
|
||||||
|
approved_sha: 'abc123def456',
|
||||||
|
approved_tag: 'v1.0.0',
|
||||||
});
|
});
|
||||||
|
assert(withSha.approvedSha === 'abc123def456', 'SHA is preserved when set');
|
||||||
|
assert(withSha.approvedTag === 'v1.0.0', 'Tag is preserved as metadata');
|
||||||
|
|
||||||
const customModule34 = capturedInstallConfig34?._customModuleSources?.get('test-module');
|
// Module with null SHA (trusted contributor)
|
||||||
|
const noSha = mgr._normalizeCommunityModule({
|
||||||
|
name: 'trusted-mod',
|
||||||
|
code: 'tm',
|
||||||
|
approved_sha: null,
|
||||||
|
});
|
||||||
|
assert(noSha.approvedSha === null, 'Null SHA means no pinning (trusted contributor)');
|
||||||
|
}
|
||||||
|
|
||||||
assert(capturedInstallConfig34 !== null, 'Quick update forwards config to install');
|
// --- CommunityModuleManager.listByCategory (with injected cache) ---
|
||||||
assert(customModule34 !== undefined, 'Quick update keeps manifest-backed custom module updateable');
|
{
|
||||||
assert(customModule34 && customModule34.cached === false, 'Quick update uses manifest-backed source before cache');
|
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
|
||||||
|
const mgr = new CommunityModuleManager();
|
||||||
|
|
||||||
|
mgr._cachedIndex = {
|
||||||
|
modules: [
|
||||||
|
{ name: 'a', code: 'a', category: 'design-and-creative' },
|
||||||
|
{ name: 'b', code: 'b', category: 'software-development' },
|
||||||
|
{ name: 'c', code: 'c', category: 'design-and-creative' },
|
||||||
|
{ name: 'd', code: 'd', category: 'game-development' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const design = await mgr.listByCategory('design-and-creative');
|
||||||
|
assert(design.length === 2, 'listByCategory filters to matching category');
|
||||||
assert(
|
assert(
|
||||||
customModule34 && customModule34.sourcePath === quickUpdateFixture.moduleSourceDir,
|
design.every((m) => m.category === 'design-and-creative'),
|
||||||
'Quick update uses preserved manifest sourcePath for custom modules',
|
'listByCategory returns only matching modules',
|
||||||
);
|
);
|
||||||
} catch (error) {
|
|
||||||
assert(false, 'Quick update manifest-backed custom source test succeeds', error.message);
|
const empty = await mgr.listByCategory('nonexistent');
|
||||||
} finally {
|
assert(empty.length === 0, 'listByCategory returns empty for unknown category');
|
||||||
OfficialModules.prototype.listAvailable = originalListAvailable34;
|
}
|
||||||
OfficialModules.prototype.loadExistingConfig = originalLoadExistingConfig34;
|
|
||||||
OfficialModules.prototype.collectModuleConfigQuick = originalCollectModuleConfigQuick34;
|
// --- CommunityModuleManager.getModuleByCode (with injected cache) ---
|
||||||
if (quickUpdateFixture?.root) await fs.remove(quickUpdateFixture.root).catch(() => {});
|
{
|
||||||
|
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
|
||||||
|
const mgr = new CommunityModuleManager();
|
||||||
|
|
||||||
|
mgr._cachedIndex = {
|
||||||
|
modules: [
|
||||||
|
{ name: 'test-mod', code: 'tm', display_name: 'Test Module' },
|
||||||
|
{ name: 'other-mod', code: 'om', display_name: 'Other Module' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const found = await mgr.getModuleByCode('tm');
|
||||||
|
assert(found !== null && found.code === 'tm', 'getModuleByCode finds existing module');
|
||||||
|
|
||||||
|
const notFound = await mgr.getModuleByCode('xyz');
|
||||||
|
assert(notFound === null, 'getModuleByCode returns null for unknown code');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CustomModuleManager URL edge cases ---
|
||||||
|
{
|
||||||
|
const { CustomModuleManager } = require('../tools/installer/modules/custom-module-manager');
|
||||||
|
const mgr = new CustomModuleManager();
|
||||||
|
|
||||||
|
// HTTP (not HTTPS) should work
|
||||||
|
const http = mgr.validateGitHubUrl('http://github.com/owner/repo');
|
||||||
|
assert(http.isValid === true, 'validateGitHubUrl accepts HTTP URL');
|
||||||
|
|
||||||
|
// Trailing slash should be rejected (strict matching)
|
||||||
|
const trailing = mgr.validateGitHubUrl('https://github.com/owner/repo/');
|
||||||
|
assert(trailing.isValid === false, 'validateGitHubUrl rejects trailing slash');
|
||||||
|
|
||||||
|
// SSH without .git should work
|
||||||
|
const sshNoDotGit = mgr.validateGitHubUrl('git@github.com:owner/repo');
|
||||||
|
assert(sshNoDotGit.isValid === true, 'validateGitHubUrl accepts SSH without .git');
|
||||||
|
assert(sshNoDotGit.repo === 'repo', 'validateGitHubUrl extracts repo from SSH without .git');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
|
||||||
|
|
@ -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)'],
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,44 @@ class Installer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add custom modules from manifest if their sources exist
|
// Add installed community modules to available modules
|
||||||
for (const [moduleId, customModule] of customModuleSources) {
|
const { CommunityModuleManager } = require('../modules/community-manager');
|
||||||
const sourcePath = customModule.sourcePath;
|
const communityMgr = new CommunityModuleManager();
|
||||||
if (sourcePath && (await fs.pathExists(sourcePath)) && !availableModules.some((m) => m.id === moduleId)) {
|
const communityModules = await communityMgr.listAll();
|
||||||
|
for (const communityModule of communityModules) {
|
||||||
|
if (installedModules.includes(communityModule.code) && !availableModules.some((m) => m.id === communityModule.code)) {
|
||||||
availableModules.push({
|
availableModules.push({
|
||||||
id: moduleId,
|
id: communityModule.code,
|
||||||
name: customModule.name || moduleId,
|
name: communityModule.displayName,
|
||||||
path: sourcePath,
|
isExternal: true,
|
||||||
isCustom: true,
|
fromCommunity: true,
|
||||||
fromManifest: true,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle missing custom module sources
|
// Add installed custom modules to available modules
|
||||||
const customModuleResult = await this.handleMissingCustomSources(
|
const { CustomModuleManager } = require('../modules/custom-module-manager');
|
||||||
customModuleSources,
|
const customMgr = new CustomModuleManager();
|
||||||
bmadDir,
|
for (const moduleId of installedModules) {
|
||||||
projectRoot,
|
if (!availableModules.some((m) => m.id === moduleId)) {
|
||||||
'update',
|
const customSource = await customMgr.findModuleSourceByCode(moduleId);
|
||||||
installedModules,
|
if (customSource) {
|
||||||
config.skipPrompts || false,
|
availableModules.push({
|
||||||
);
|
id: moduleId,
|
||||||
|
name: moduleId,
|
||||||
|
isExternal: true,
|
||||||
|
fromCustom: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { validCustomModules, keptModulesWithoutSources } = customModuleResult;
|
const availableModuleIds = new Set(availableModules.map((m) => m.id));
|
||||||
|
|
||||||
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 +1243,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 +1378,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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,36 @@ class Manifest {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom module: resolve path from source or cache before reading version
|
// Check if this is a community module
|
||||||
const customSourcePath = moduleSourcePath || path.join(bmadDir, '_config', 'custom', moduleName);
|
const { CommunityModuleManager } = require('../modules/community-manager');
|
||||||
const version = await this._readMarketplaceVersion(moduleName, customSourcePath);
|
const communityMgr = new CommunityModuleManager();
|
||||||
|
const communityInfo = await communityMgr.getModuleByCode(moduleName);
|
||||||
|
if (communityInfo) {
|
||||||
|
const communityVersion = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
|
||||||
|
return {
|
||||||
|
version: communityVersion || communityInfo.version,
|
||||||
|
source: 'community',
|
||||||
|
npmPackage: communityInfo.npmPackage || null,
|
||||||
|
repoUrl: communityInfo.url || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const cacheDir = path.join(bmadDir, '_config', 'custom', moduleName);
|
// Check if this is a custom module (from user-provided URL)
|
||||||
const moduleYamlPath = path.join(cacheDir, 'module.yaml');
|
const { CustomModuleManager } = require('../modules/custom-module-manager');
|
||||||
|
const customMgr = new CustomModuleManager();
|
||||||
if (await fs.pathExists(moduleYamlPath)) {
|
const customSource = await customMgr.findModuleSourceByCode(moduleName);
|
||||||
try {
|
if (customSource) {
|
||||||
const yamlContent = await fs.readFile(moduleYamlPath, 'utf8');
|
const customVersion = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
|
||||||
const moduleConfig = yaml.parse(yamlContent);
|
return {
|
||||||
return {
|
version: customVersion,
|
||||||
version: version || moduleConfig.version || null,
|
source: 'custom',
|
||||||
source: 'custom',
|
npmPackage: null,
|
||||||
npmPackage: moduleConfig.npmPackage || null,
|
repoUrl: 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',
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -225,13 +225,20 @@ class ConfigDrivenIdeSetup {
|
||||||
// Migrate legacy target directories (e.g. .opencode/agent → .opencode/agents)
|
// Migrate legacy target directories (e.g. .opencode/agent → .opencode/agents)
|
||||||
// Legacy dirs are abandoned entirely, so use prefix matching (null removalSet)
|
// Legacy dirs are abandoned entirely, so use prefix matching (null removalSet)
|
||||||
if (this.installerConfig?.legacy_targets) {
|
if (this.installerConfig?.legacy_targets) {
|
||||||
if (!options.silent) await prompts.log.message(' Migrating legacy directories...');
|
const legacyDirsExist = await Promise.all(
|
||||||
for (const legacyDir of this.installerConfig.legacy_targets) {
|
this.installerConfig.legacy_targets.map((d) =>
|
||||||
if (this.isGlobalPath(legacyDir)) {
|
this.isGlobalPath(d) ? fs.pathExists(d.replace(/^~/, os.homedir())) : fs.pathExists(path.join(projectDir, d)),
|
||||||
await this.warnGlobalLegacy(legacyDir, options);
|
),
|
||||||
} else {
|
);
|
||||||
await this.cleanupTarget(projectDir, legacyDir, options, null);
|
if (legacyDirsExist.some(Boolean)) {
|
||||||
await this.removeEmptyParents(projectDir, legacyDir);
|
if (!options.silent) await prompts.log.message(' Migrating legacy directories...');
|
||||||
|
for (const legacyDir of this.installerConfig.legacy_targets) {
|
||||||
|
if (this.isGlobalPath(legacyDir)) {
|
||||||
|
await this.warnGlobalLegacy(legacyDir, options);
|
||||||
|
} else {
|
||||||
|
await this.cleanupTarget(projectDir, legacyDir, options, null);
|
||||||
|
await this.removeEmptyParents(projectDir, legacyDir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,377 @@
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { execSync } = require('node:child_process');
|
||||||
|
const prompts = require('../prompts');
|
||||||
|
const { RegistryClient } = require('./registry-client');
|
||||||
|
|
||||||
|
const MARKETPLACE_BASE = 'https://raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main';
|
||||||
|
const COMMUNITY_INDEX_URL = `${MARKETPLACE_BASE}/registry/community-index.yaml`;
|
||||||
|
const CATEGORIES_URL = `${MARKETPLACE_BASE}/categories.yaml`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages community modules from the BMad marketplace registry.
|
||||||
|
* Fetches community-index.yaml and categories.yaml from GitHub.
|
||||||
|
* Returns empty results when the registry is unreachable.
|
||||||
|
* Community modules are pinned to approved SHA when set; uses HEAD otherwise.
|
||||||
|
*/
|
||||||
|
class CommunityModuleManager {
|
||||||
|
constructor() {
|
||||||
|
this._client = new RegistryClient();
|
||||||
|
this._cachedIndex = null;
|
||||||
|
this._cachedCategories = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Data Loading ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the community module index from the marketplace repo.
|
||||||
|
* Returns empty when the registry is unreachable.
|
||||||
|
* @returns {Object} Parsed YAML with modules array
|
||||||
|
*/
|
||||||
|
async loadCommunityIndex() {
|
||||||
|
if (this._cachedIndex) return this._cachedIndex;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await this._client.fetchYaml(COMMUNITY_INDEX_URL);
|
||||||
|
if (config?.modules?.length) {
|
||||||
|
this._cachedIndex = config;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Registry unreachable - no community modules available
|
||||||
|
}
|
||||||
|
|
||||||
|
return { modules: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load categories from the marketplace repo.
|
||||||
|
* Returns empty when the registry is unreachable.
|
||||||
|
* @returns {Object} Parsed categories.yaml content
|
||||||
|
*/
|
||||||
|
async loadCategories() {
|
||||||
|
if (this._cachedCategories) return this._cachedCategories;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await this._client.fetchYaml(CATEGORIES_URL);
|
||||||
|
if (config?.categories) {
|
||||||
|
this._cachedCategories = config;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Registry unreachable - no categories available
|
||||||
|
}
|
||||||
|
|
||||||
|
return { categories: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Listing & Filtering ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all community modules, normalized.
|
||||||
|
* @returns {Array<Object>} Normalized community modules
|
||||||
|
*/
|
||||||
|
async listAll() {
|
||||||
|
const index = await this.loadCommunityIndex();
|
||||||
|
return (index.modules || []).map((mod) => this._normalizeCommunityModule(mod));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get community modules filtered to a category.
|
||||||
|
* @param {string} categorySlug - Category slug (e.g., 'design-and-creative')
|
||||||
|
* @returns {Array<Object>} Filtered modules
|
||||||
|
*/
|
||||||
|
async listByCategory(categorySlug) {
|
||||||
|
const all = await this.listAll();
|
||||||
|
return all.filter((mod) => mod.category === categorySlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get promoted/featured community modules, sorted by rank.
|
||||||
|
* @returns {Array<Object>} Featured modules
|
||||||
|
*/
|
||||||
|
async listFeatured() {
|
||||||
|
const all = await this.listAll();
|
||||||
|
return all.filter((mod) => mod.promoted === true).sort((a, b) => (a.promotedRank || 999) - (b.promotedRank || 999));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search community modules by keyword.
|
||||||
|
* Matches against name, display name, description, and keywords array.
|
||||||
|
* @param {string} query - Search query
|
||||||
|
* @returns {Array<Object>} Matching modules
|
||||||
|
*/
|
||||||
|
async searchByKeyword(query) {
|
||||||
|
const all = await this.listAll();
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return all.filter((mod) => {
|
||||||
|
const searchable = [mod.name, mod.displayName, mod.description, ...(mod.keywords || [])].join(' ').toLowerCase();
|
||||||
|
return searchable.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get categories with module counts for UI display.
|
||||||
|
* Only returns categories that have at least one community module.
|
||||||
|
* @returns {Array<Object>} Array of { slug, name, moduleCount }
|
||||||
|
*/
|
||||||
|
async getCategoryList() {
|
||||||
|
const all = await this.listAll();
|
||||||
|
const categoriesData = await this.loadCategories();
|
||||||
|
const categories = categoriesData.categories || {};
|
||||||
|
|
||||||
|
// Count modules per category
|
||||||
|
const counts = {};
|
||||||
|
for (const mod of all) {
|
||||||
|
counts[mod.category] = (counts[mod.category] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build list with display names from categories.yaml
|
||||||
|
const result = [];
|
||||||
|
for (const [slug, count] of Object.entries(counts)) {
|
||||||
|
const catInfo = categories[slug];
|
||||||
|
result.push({
|
||||||
|
slug,
|
||||||
|
name: catInfo?.name || slug,
|
||||||
|
moduleCount: count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort alphabetically by name
|
||||||
|
result.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Module Lookup ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a community module by its code.
|
||||||
|
* @param {string} code - Module code (e.g., 'wds')
|
||||||
|
* @returns {Object|null} Normalized module or null
|
||||||
|
*/
|
||||||
|
async getModuleByCode(code) {
|
||||||
|
const all = await this.listAll();
|
||||||
|
return all.find((m) => m.code === code) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Clone with Tag Pinning ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cache directory for community modules.
|
||||||
|
* @returns {string} Path to the community modules cache directory
|
||||||
|
*/
|
||||||
|
getCacheDir() {
|
||||||
|
return path.join(os.homedir(), '.bmad', 'cache', 'community-modules');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clone a community module repository, pinned to its approved tag.
|
||||||
|
* @param {string} moduleCode - Module code
|
||||||
|
* @param {Object} [options] - Clone options
|
||||||
|
* @param {boolean} [options.silent] - Suppress spinner output
|
||||||
|
* @returns {string} Path to the cloned repository
|
||||||
|
*/
|
||||||
|
async cloneModule(moduleCode, options = {}) {
|
||||||
|
const moduleInfo = await this.getModuleByCode(moduleCode);
|
||||||
|
if (!moduleInfo) {
|
||||||
|
throw new Error(`Community module '${moduleCode}' not found in the registry`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheDir = this.getCacheDir();
|
||||||
|
const moduleCacheDir = path.join(cacheDir, moduleCode);
|
||||||
|
const silent = options.silent || false;
|
||||||
|
|
||||||
|
await fs.ensureDir(cacheDir);
|
||||||
|
|
||||||
|
const createSpinner = async () => {
|
||||||
|
if (silent) {
|
||||||
|
return { start() {}, stop() {}, error() {}, message() {} };
|
||||||
|
}
|
||||||
|
return await prompts.spinner();
|
||||||
|
};
|
||||||
|
|
||||||
|
const sha = moduleInfo.approvedSha;
|
||||||
|
let needsDependencyInstall = false;
|
||||||
|
let wasNewClone = false;
|
||||||
|
|
||||||
|
if (await fs.pathExists(moduleCacheDir)) {
|
||||||
|
// Already cloned - update to latest HEAD
|
||||||
|
const fetchSpinner = await createSpinner();
|
||||||
|
fetchSpinner.start(`Checking ${moduleInfo.displayName}...`);
|
||||||
|
try {
|
||||||
|
const currentRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
|
||||||
|
execSync('git fetch origin --depth 1', {
|
||||||
|
cwd: moduleCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||||
|
});
|
||||||
|
execSync('git reset --hard origin/HEAD', {
|
||||||
|
cwd: moduleCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
|
||||||
|
if (currentRef !== newRef) needsDependencyInstall = true;
|
||||||
|
fetchSpinner.stop(`Verified ${moduleInfo.displayName}`);
|
||||||
|
} catch {
|
||||||
|
fetchSpinner.error(`Fetch failed, re-downloading ${moduleInfo.displayName}`);
|
||||||
|
await fs.remove(moduleCacheDir);
|
||||||
|
wasNewClone = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
wasNewClone = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wasNewClone) {
|
||||||
|
const fetchSpinner = await createSpinner();
|
||||||
|
fetchSpinner.start(`Fetching ${moduleInfo.displayName}...`);
|
||||||
|
try {
|
||||||
|
execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||||
|
});
|
||||||
|
fetchSpinner.stop(`Fetched ${moduleInfo.displayName}`);
|
||||||
|
needsDependencyInstall = true;
|
||||||
|
} catch (error) {
|
||||||
|
fetchSpinner.error(`Failed to fetch ${moduleInfo.displayName}`);
|
||||||
|
throw new Error(`Failed to clone community module '${moduleCode}': ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If pinned to a specific SHA, check out that exact commit.
|
||||||
|
// Refuse to install if the approved SHA cannot be reached - security requirement.
|
||||||
|
if (sha) {
|
||||||
|
const headSha = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
|
||||||
|
if (headSha !== sha) {
|
||||||
|
try {
|
||||||
|
execSync(`git fetch --depth 1 origin ${sha}`, {
|
||||||
|
cwd: moduleCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||||
|
});
|
||||||
|
execSync(`git checkout ${sha}`, {
|
||||||
|
cwd: moduleCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
needsDependencyInstall = true;
|
||||||
|
} catch {
|
||||||
|
await fs.remove(moduleCacheDir);
|
||||||
|
throw new Error(
|
||||||
|
`Community module '${moduleCode}' could not be pinned to its approved commit (${sha}). ` +
|
||||||
|
`Installation refused for security. The module registry entry may need updating.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install dependencies if needed
|
||||||
|
const packageJsonPath = path.join(moduleCacheDir, 'package.json');
|
||||||
|
if ((needsDependencyInstall || wasNewClone) && (await fs.pathExists(packageJsonPath))) {
|
||||||
|
const installSpinner = await createSpinner();
|
||||||
|
installSpinner.start(`Installing dependencies for ${moduleInfo.displayName}...`);
|
||||||
|
try {
|
||||||
|
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
|
||||||
|
cwd: moduleCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
|
installSpinner.stop(`Installed dependencies for ${moduleInfo.displayName}`);
|
||||||
|
} catch (error) {
|
||||||
|
installSpinner.error(`Failed to install dependencies for ${moduleInfo.displayName}`);
|
||||||
|
if (!silent) await prompts.log.warn(` ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return moduleCacheDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Source Finding ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the source path for a community module (clone + locate module.yaml).
|
||||||
|
* @param {string} moduleCode - Module code
|
||||||
|
* @param {Object} [options] - Options passed to cloneModule
|
||||||
|
* @returns {string|null} Path to the module source or null
|
||||||
|
*/
|
||||||
|
async findModuleSource(moduleCode, options = {}) {
|
||||||
|
const moduleInfo = await this.getModuleByCode(moduleCode);
|
||||||
|
if (!moduleInfo) return null;
|
||||||
|
|
||||||
|
const cloneDir = await this.cloneModule(moduleCode, options);
|
||||||
|
|
||||||
|
// Check configured module_definition path first
|
||||||
|
if (moduleInfo.moduleDefinition) {
|
||||||
|
const configuredPath = path.join(cloneDir, moduleInfo.moduleDefinition);
|
||||||
|
if (await fs.pathExists(configuredPath)) {
|
||||||
|
return path.dirname(configuredPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: search skills/ and src/ directories
|
||||||
|
for (const dir of ['skills', 'src']) {
|
||||||
|
const rootCandidate = path.join(cloneDir, dir, 'module.yaml');
|
||||||
|
if (await fs.pathExists(rootCandidate)) {
|
||||||
|
return path.dirname(rootCandidate);
|
||||||
|
}
|
||||||
|
const dirPath = path.join(cloneDir, dir);
|
||||||
|
if (await fs.pathExists(dirPath)) {
|
||||||
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const subCandidate = path.join(dirPath, entry.name, 'module.yaml');
|
||||||
|
if (await fs.pathExists(subCandidate)) {
|
||||||
|
return path.dirname(subCandidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check repo root
|
||||||
|
const rootCandidate = path.join(cloneDir, 'module.yaml');
|
||||||
|
if (await fs.pathExists(rootCandidate)) {
|
||||||
|
return path.dirname(rootCandidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return moduleInfo.moduleDefinition ? path.dirname(path.join(cloneDir, moduleInfo.moduleDefinition)) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Normalization ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a community module entry to a consistent shape.
|
||||||
|
* @param {Object} mod - Raw module from community-index.yaml
|
||||||
|
* @returns {Object} Normalized module info
|
||||||
|
*/
|
||||||
|
_normalizeCommunityModule(mod) {
|
||||||
|
return {
|
||||||
|
key: mod.name,
|
||||||
|
code: mod.code,
|
||||||
|
name: mod.display_name || mod.name,
|
||||||
|
displayName: mod.display_name || mod.name,
|
||||||
|
description: mod.description || '',
|
||||||
|
url: mod.repository || mod.url,
|
||||||
|
moduleDefinition: mod.module_definition || mod['module-definition'],
|
||||||
|
npmPackage: mod.npm_package || mod.npmPackage || null,
|
||||||
|
author: mod.author || '',
|
||||||
|
license: mod.license || '',
|
||||||
|
type: 'community',
|
||||||
|
category: mod.category || '',
|
||||||
|
subcategory: mod.subcategory || '',
|
||||||
|
keywords: mod.keywords || [],
|
||||||
|
version: mod.version || null,
|
||||||
|
approvedTag: mod.approved_tag || null,
|
||||||
|
approvedSha: mod.approved_sha || null,
|
||||||
|
approvedDate: mod.approved_date || null,
|
||||||
|
reviewer: mod.reviewer || null,
|
||||||
|
trustTier: mod.trust_tier || 'unverified',
|
||||||
|
promoted: mod.promoted === true,
|
||||||
|
promotedRank: mod.promoted_rank || null,
|
||||||
|
defaultSelected: false,
|
||||||
|
builtIn: false,
|
||||||
|
isExternal: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { CommunityModuleManager };
|
||||||
|
|
@ -0,0 +1,308 @@
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { execSync } = require('node:child_process');
|
||||||
|
const prompts = require('../prompts');
|
||||||
|
const { RegistryClient } = require('./registry-client');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages custom modules installed from user-provided GitHub URLs.
|
||||||
|
* Validates URLs, fetches .claude-plugin/marketplace.json, clones repos.
|
||||||
|
*/
|
||||||
|
class CustomModuleManager {
|
||||||
|
constructor() {
|
||||||
|
this._client = new RegistryClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── URL Validation ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and validate a GitHub repository URL.
|
||||||
|
* Supports HTTPS and SSH formats.
|
||||||
|
* @param {string} url - GitHub URL to validate
|
||||||
|
* @returns {Object} { owner, repo, isValid, error }
|
||||||
|
*/
|
||||||
|
validateGitHubUrl(url) {
|
||||||
|
if (!url || typeof url !== 'string') {
|
||||||
|
return { owner: null, repo: null, isValid: false, error: 'URL is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = url.trim();
|
||||||
|
|
||||||
|
// HTTPS format: https://github.com/owner/repo[.git]
|
||||||
|
const httpsMatch = trimmed.match(/^https?:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
||||||
|
if (httpsMatch) {
|
||||||
|
return { owner: httpsMatch[1], repo: httpsMatch[2], isValid: true, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSH format: git@github.com:owner/repo.git
|
||||||
|
const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
||||||
|
if (sshMatch) {
|
||||||
|
return { owner: sshMatch[1], repo: sshMatch[2], isValid: true, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { owner: null, repo: null, isValid: false, error: 'Not a valid GitHub URL (expected https://github.com/owner/repo)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Discovery ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch .claude-plugin/marketplace.json from a GitHub repository.
|
||||||
|
* @param {string} repoUrl - GitHub repository URL
|
||||||
|
* @returns {Object} Parsed marketplace.json content
|
||||||
|
*/
|
||||||
|
async fetchMarketplaceJson(repoUrl) {
|
||||||
|
const { owner, repo, isValid, error } = this.validateGitHubUrl(repoUrl);
|
||||||
|
if (!isValid) throw new Error(error);
|
||||||
|
|
||||||
|
const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/HEAD/.claude-plugin/marketplace.json`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this._client.fetchJson(rawUrl);
|
||||||
|
} catch (error_) {
|
||||||
|
if (error_.message.includes('404')) {
|
||||||
|
throw new Error(`No .claude-plugin/marketplace.json found in ${owner}/${repo}. This repository may not be a BMad module.`);
|
||||||
|
}
|
||||||
|
if (error_.message.includes('403')) {
|
||||||
|
throw new Error(`Repository ${owner}/${repo} is not accessible. Make sure it is public.`);
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to fetch marketplace.json from ${owner}/${repo}: ${error_.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover modules from a GitHub repository's marketplace.json.
|
||||||
|
* @param {string} repoUrl - GitHub repository URL
|
||||||
|
* @returns {Array<Object>} Normalized plugin list
|
||||||
|
*/
|
||||||
|
async discoverModules(repoUrl) {
|
||||||
|
const data = await this.fetchMarketplaceJson(repoUrl);
|
||||||
|
const plugins = data?.plugins;
|
||||||
|
|
||||||
|
if (!Array.isArray(plugins) || plugins.length === 0) {
|
||||||
|
throw new Error('marketplace.json contains no plugins');
|
||||||
|
}
|
||||||
|
|
||||||
|
return plugins.map((plugin) => this._normalizeCustomModule(plugin, repoUrl, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Clone ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cache directory for custom modules.
|
||||||
|
* @returns {string} Path to the custom modules cache directory
|
||||||
|
*/
|
||||||
|
getCacheDir() {
|
||||||
|
return path.join(os.homedir(), '.bmad', 'cache', 'custom-modules');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clone a custom module repository to cache.
|
||||||
|
* @param {string} repoUrl - GitHub repository URL
|
||||||
|
* @param {Object} [options] - Clone options
|
||||||
|
* @param {boolean} [options.silent] - Suppress spinner output
|
||||||
|
* @returns {string} Path to the cloned repository
|
||||||
|
*/
|
||||||
|
async cloneRepo(repoUrl, options = {}) {
|
||||||
|
const { owner, repo, isValid, error } = this.validateGitHubUrl(repoUrl);
|
||||||
|
if (!isValid) throw new Error(error);
|
||||||
|
|
||||||
|
const cacheDir = this.getCacheDir();
|
||||||
|
const repoCacheDir = path.join(cacheDir, owner, repo);
|
||||||
|
const silent = options.silent || false;
|
||||||
|
|
||||||
|
await fs.ensureDir(path.join(cacheDir, owner));
|
||||||
|
|
||||||
|
const createSpinner = async () => {
|
||||||
|
if (silent) {
|
||||||
|
return { start() {}, stop() {}, error() {} };
|
||||||
|
}
|
||||||
|
return await prompts.spinner();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (await fs.pathExists(repoCacheDir)) {
|
||||||
|
// Update existing clone
|
||||||
|
const fetchSpinner = await createSpinner();
|
||||||
|
fetchSpinner.start(`Updating ${owner}/${repo}...`);
|
||||||
|
try {
|
||||||
|
execSync('git fetch origin --depth 1', {
|
||||||
|
cwd: repoCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||||
|
});
|
||||||
|
execSync('git reset --hard origin/HEAD', {
|
||||||
|
cwd: repoCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
fetchSpinner.stop(`Updated ${owner}/${repo}`);
|
||||||
|
} catch {
|
||||||
|
fetchSpinner.error(`Update failed, re-downloading ${owner}/${repo}`);
|
||||||
|
await fs.remove(repoCacheDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(repoCacheDir))) {
|
||||||
|
const fetchSpinner = await createSpinner();
|
||||||
|
fetchSpinner.start(`Cloning ${owner}/${repo}...`);
|
||||||
|
try {
|
||||||
|
execSync(`git clone --depth 1 "${repoUrl}" "${repoCacheDir}"`, {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||||
|
});
|
||||||
|
fetchSpinner.stop(`Cloned ${owner}/${repo}`);
|
||||||
|
} catch (error_) {
|
||||||
|
fetchSpinner.error(`Failed to clone ${owner}/${repo}`);
|
||||||
|
throw new Error(`Failed to clone ${repoUrl}: ${error_.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install dependencies if package.json exists
|
||||||
|
const packageJsonPath = path.join(repoCacheDir, 'package.json');
|
||||||
|
if (await fs.pathExists(packageJsonPath)) {
|
||||||
|
const installSpinner = await createSpinner();
|
||||||
|
installSpinner.start(`Installing dependencies for ${owner}/${repo}...`);
|
||||||
|
try {
|
||||||
|
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
|
||||||
|
cwd: repoCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
|
installSpinner.stop(`Installed dependencies for ${owner}/${repo}`);
|
||||||
|
} catch (error_) {
|
||||||
|
installSpinner.error(`Failed to install dependencies for ${owner}/${repo}`);
|
||||||
|
if (!silent) await prompts.log.warn(` ${error_.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return repoCacheDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Source Finding ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the module source path within a cloned custom repo.
|
||||||
|
* @param {string} repoUrl - GitHub repository URL (for cache location)
|
||||||
|
* @param {string} [pluginSource] - Plugin source path from marketplace.json
|
||||||
|
* @returns {string|null} Path to directory containing module.yaml
|
||||||
|
*/
|
||||||
|
async findModuleSource(repoUrl, pluginSource) {
|
||||||
|
const { owner, repo } = this.validateGitHubUrl(repoUrl);
|
||||||
|
const repoCacheDir = path.join(this.getCacheDir(), owner, repo);
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(repoCacheDir))) return null;
|
||||||
|
|
||||||
|
// Try plugin source path first (e.g., "./src/pro-skills")
|
||||||
|
if (pluginSource) {
|
||||||
|
const sourcePath = path.join(repoCacheDir, pluginSource);
|
||||||
|
const moduleYaml = path.join(sourcePath, 'module.yaml');
|
||||||
|
if (await fs.pathExists(moduleYaml)) {
|
||||||
|
return sourcePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: search skills/ and src/ directories
|
||||||
|
for (const dir of ['skills', 'src']) {
|
||||||
|
const rootCandidate = path.join(repoCacheDir, dir, 'module.yaml');
|
||||||
|
if (await fs.pathExists(rootCandidate)) {
|
||||||
|
return path.dirname(rootCandidate);
|
||||||
|
}
|
||||||
|
const dirPath = path.join(repoCacheDir, dir);
|
||||||
|
if (await fs.pathExists(dirPath)) {
|
||||||
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const subCandidate = path.join(dirPath, entry.name, 'module.yaml');
|
||||||
|
if (await fs.pathExists(subCandidate)) {
|
||||||
|
return path.dirname(subCandidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check repo root
|
||||||
|
const rootCandidate = path.join(repoCacheDir, 'module.yaml');
|
||||||
|
if (await fs.pathExists(rootCandidate)) {
|
||||||
|
return repoCacheDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find module source by module code, searching the custom cache.
|
||||||
|
* @param {string} moduleCode - Module code to search for
|
||||||
|
* @param {Object} [options] - Options
|
||||||
|
* @returns {string|null} Path to the module source or null
|
||||||
|
*/
|
||||||
|
async findModuleSourceByCode(moduleCode, options = {}) {
|
||||||
|
const cacheDir = this.getCacheDir();
|
||||||
|
if (!(await fs.pathExists(cacheDir))) return null;
|
||||||
|
|
||||||
|
// Search through all custom repo caches
|
||||||
|
try {
|
||||||
|
const owners = await fs.readdir(cacheDir, { withFileTypes: true });
|
||||||
|
for (const ownerEntry of owners) {
|
||||||
|
if (!ownerEntry.isDirectory()) continue;
|
||||||
|
const ownerPath = path.join(cacheDir, ownerEntry.name);
|
||||||
|
const repos = await fs.readdir(ownerPath, { withFileTypes: true });
|
||||||
|
for (const repoEntry of repos) {
|
||||||
|
if (!repoEntry.isDirectory()) continue;
|
||||||
|
const repoPath = path.join(ownerPath, repoEntry.name);
|
||||||
|
|
||||||
|
// Check marketplace.json for matching module code
|
||||||
|
const marketplacePath = path.join(repoPath, '.claude-plugin', 'marketplace.json');
|
||||||
|
if (await fs.pathExists(marketplacePath)) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
|
||||||
|
for (const plugin of data.plugins || []) {
|
||||||
|
if (plugin.name === moduleCode) {
|
||||||
|
// Found the module - find its source
|
||||||
|
const sourcePath = plugin.source ? path.join(repoPath, plugin.source) : repoPath;
|
||||||
|
const moduleYaml = path.join(sourcePath, 'module.yaml');
|
||||||
|
if (await fs.pathExists(moduleYaml)) {
|
||||||
|
return sourcePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip malformed marketplace.json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Cache doesn't exist or is inaccessible
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Normalization ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a plugin from marketplace.json to a consistent shape.
|
||||||
|
* @param {Object} plugin - Plugin object from marketplace.json
|
||||||
|
* @param {string} repoUrl - Source repository URL
|
||||||
|
* @param {Object} data - Full marketplace.json data
|
||||||
|
* @returns {Object} Normalized module info
|
||||||
|
*/
|
||||||
|
_normalizeCustomModule(plugin, repoUrl, data) {
|
||||||
|
return {
|
||||||
|
code: plugin.name,
|
||||||
|
name: plugin.name,
|
||||||
|
displayName: plugin.name,
|
||||||
|
description: plugin.description || '',
|
||||||
|
version: plugin.version || null,
|
||||||
|
author: plugin.author || data.owner || '',
|
||||||
|
url: repoUrl,
|
||||||
|
source: plugin.source || null,
|
||||||
|
type: 'custom',
|
||||||
|
trustTier: 'unverified',
|
||||||
|
builtIn: false,
|
||||||
|
isExternal: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { CustomModuleManager };
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -4,64 +4,98 @@ const path = require('node:path');
|
||||||
const { execSync } = require('node:child_process');
|
const { execSync } = require('node:child_process');
|
||||||
const yaml = require('yaml');
|
const yaml = require('yaml');
|
||||||
const prompts = require('../prompts');
|
const prompts = require('../prompts');
|
||||||
|
const { RegistryClient } = require('./registry-client');
|
||||||
|
|
||||||
|
const REGISTRY_RAW_URL = 'https://raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main/registry/official.yaml';
|
||||||
|
const FALLBACK_CONFIG_PATH = path.join(__dirname, 'registry-fallback.yaml');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages external official modules defined in external-official-modules.yaml
|
* Manages official modules from the remote BMad marketplace registry.
|
||||||
* These are modules hosted in external repositories that can be installed
|
* Fetches registry/official.yaml from GitHub; falls back to the bundled
|
||||||
|
* external-official-modules.yaml when the network is unavailable.
|
||||||
*
|
*
|
||||||
* @class ExternalModuleManager
|
* @class ExternalModuleManager
|
||||||
*/
|
*/
|
||||||
class ExternalModuleManager {
|
class ExternalModuleManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.externalModulesConfigPath = path.join(__dirname, '../external-official-modules.yaml');
|
this._client = new RegistryClient();
|
||||||
this.cachedModules = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load and parse the external-official-modules.yaml file
|
* Load the official modules registry from GitHub, falling back to the
|
||||||
* @returns {Object} Parsed YAML content with modules object
|
* bundled YAML file if the fetch fails.
|
||||||
|
* @returns {Object} Parsed YAML content with modules array
|
||||||
*/
|
*/
|
||||||
async loadExternalModulesConfig() {
|
async loadExternalModulesConfig() {
|
||||||
if (this.cachedModules) {
|
if (this.cachedModules) {
|
||||||
return this.cachedModules;
|
return this.cachedModules;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try remote registry first
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(this.externalModulesConfigPath, 'utf8');
|
const content = await this._client.fetch(REGISTRY_RAW_URL);
|
||||||
|
const config = yaml.parse(content);
|
||||||
|
if (config?.modules?.length) {
|
||||||
|
this.cachedModules = config;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to local fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to bundled file
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(FALLBACK_CONFIG_PATH, 'utf8');
|
||||||
const config = yaml.parse(content);
|
const config = yaml.parse(content);
|
||||||
this.cachedModules = config;
|
this.cachedModules = config;
|
||||||
|
await prompts.log.warn('Could not reach BMad registry; using bundled module list.');
|
||||||
return config;
|
return config;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await prompts.log.warn(`Failed to load external modules config: ${error.message}`);
|
await prompts.log.warn(`Failed to load modules config: ${error.message}`);
|
||||||
return { modules: {} };
|
return { modules: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get list of available external modules
|
* Normalize a module entry from either the remote registry format
|
||||||
|
* (snake_case, array) or the legacy bundled format (kebab-case, object map).
|
||||||
|
* @param {Object} mod - Raw module config from YAML
|
||||||
|
* @param {string} [key] - Key name (only for legacy map format)
|
||||||
|
* @returns {Object} Normalized module info
|
||||||
|
*/
|
||||||
|
_normalizeModule(mod, key) {
|
||||||
|
return {
|
||||||
|
key: key || mod.name,
|
||||||
|
url: mod.repository || mod.url,
|
||||||
|
moduleDefinition: mod.module_definition || mod['module-definition'],
|
||||||
|
code: mod.code,
|
||||||
|
name: mod.display_name || mod.name,
|
||||||
|
description: mod.description || '',
|
||||||
|
defaultSelected: mod.default_selected === true || mod.defaultSelected === true,
|
||||||
|
type: mod.type || 'bmad-org',
|
||||||
|
npmPackage: mod.npm_package || mod.npmPackage || null,
|
||||||
|
builtIn: mod.built_in === true,
|
||||||
|
isExternal: mod.built_in !== true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of available modules from the registry
|
||||||
* @returns {Array<Object>} Array of module info objects
|
* @returns {Array<Object>} Array of module info objects
|
||||||
*/
|
*/
|
||||||
async listAvailable() {
|
async listAvailable() {
|
||||||
const config = await this.loadExternalModulesConfig();
|
const config = await this.loadExternalModulesConfig();
|
||||||
const modules = [];
|
|
||||||
|
|
||||||
for (const [key, moduleConfig] of Object.entries(config.modules || {})) {
|
// Remote format: modules is an array
|
||||||
modules.push({
|
if (Array.isArray(config.modules)) {
|
||||||
key,
|
return config.modules.map((mod) => this._normalizeModule(mod));
|
||||||
url: moduleConfig.url,
|
|
||||||
moduleDefinition: moduleConfig['module-definition'],
|
|
||||||
code: moduleConfig.code,
|
|
||||||
name: moduleConfig.name,
|
|
||||||
header: moduleConfig.header,
|
|
||||||
subheader: moduleConfig.subheader,
|
|
||||||
description: moduleConfig.description || '',
|
|
||||||
defaultSelected: moduleConfig.defaultSelected === true,
|
|
||||||
type: moduleConfig.type || 'community', // bmad-org or community
|
|
||||||
npmPackage: moduleConfig.npmPackage || null, // Include npm package name
|
|
||||||
isExternal: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legacy bundled format: modules is an object map
|
||||||
|
const modules = [];
|
||||||
|
for (const [key, mod] of Object.entries(config.modules || {})) {
|
||||||
|
modules.push(this._normalizeModule(mod, key));
|
||||||
|
}
|
||||||
return modules;
|
return modules;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,27 +115,8 @@ class ExternalModuleManager {
|
||||||
* @returns {Object|null} Module info or null if not found
|
* @returns {Object|null} Module info or null if not found
|
||||||
*/
|
*/
|
||||||
async getModuleByKey(key) {
|
async getModuleByKey(key) {
|
||||||
const config = await this.loadExternalModulesConfig();
|
const modules = await this.listAvailable();
|
||||||
const moduleConfig = config.modules?.[key];
|
return modules.find((m) => m.key === key) || null;
|
||||||
|
|
||||||
if (!moduleConfig) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
url: moduleConfig.url,
|
|
||||||
moduleDefinition: moduleConfig['module-definition'],
|
|
||||||
code: moduleConfig.code,
|
|
||||||
name: moduleConfig.name,
|
|
||||||
header: moduleConfig.header,
|
|
||||||
subheader: moduleConfig.subheader,
|
|
||||||
description: moduleConfig.description || '',
|
|
||||||
defaultSelected: moduleConfig.defaultSelected === true,
|
|
||||||
type: moduleConfig.type || 'community', // bmad-org or community
|
|
||||||
npmPackage: moduleConfig.npmPackage || null, // Include npm package name
|
|
||||||
isExternal: true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -154,7 +169,7 @@ class ExternalModuleManager {
|
||||||
const moduleInfo = await this.getModuleByCode(moduleCode);
|
const moduleInfo = await this.getModuleByCode(moduleCode);
|
||||||
|
|
||||||
if (!moduleInfo) {
|
if (!moduleInfo) {
|
||||||
throw new Error(`External module '${moduleCode}' not found in external-official-modules.yaml`);
|
throw new Error(`External module '${moduleCode}' not found in the BMad registry`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheDir = this.getExternalCacheDir();
|
const cacheDir = this.getExternalCacheDir();
|
||||||
|
|
@ -304,7 +319,7 @@ class ExternalModuleManager {
|
||||||
async findExternalModuleSource(moduleCode, options = {}) {
|
async findExternalModuleSource(moduleCode, options = {}) {
|
||||||
const moduleInfo = await this.getModuleByCode(moduleCode);
|
const moduleInfo = await this.getModuleByCode(moduleCode);
|
||||||
|
|
||||||
if (!moduleInfo) {
|
if (!moduleInfo || moduleInfo.builtIn) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -349,6 +364,7 @@ class ExternalModuleManager {
|
||||||
// Nothing found: return configured path (preserves old behavior for error messaging)
|
// Nothing found: return configured path (preserves old behavior for error messaging)
|
||||||
return path.dirname(configuredPath);
|
return path.dirname(configuredPath);
|
||||||
}
|
}
|
||||||
|
cachedModules = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { ExternalModuleManager };
|
module.exports = { ExternalModuleManager };
|
||||||
|
|
|
||||||
|
|
@ -98,11 +98,10 @@ class OfficialModules {
|
||||||
/**
|
/**
|
||||||
* List all available built-in modules (core and bmm).
|
* List all available built-in modules (core and bmm).
|
||||||
* All other modules come from external-official-modules.yaml
|
* All other modules come from external-official-modules.yaml
|
||||||
* @returns {Object} Object with modules array and customModules array
|
* @returns {Object} Object with modules array
|
||||||
*/
|
*/
|
||||||
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
|
||||||
|
|
@ -217,6 +202,22 @@ class OfficialModules {
|
||||||
return externalSource;
|
return externalSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check community modules
|
||||||
|
const { CommunityModuleManager } = require('./community-manager');
|
||||||
|
const communityMgr = new CommunityModuleManager();
|
||||||
|
const communitySource = await communityMgr.findModuleSource(moduleCode, options);
|
||||||
|
if (communitySource) {
|
||||||
|
return communitySource;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check custom modules (from user-provided URLs, already cloned to cache)
|
||||||
|
const { CustomModuleManager } = require('./custom-module-manager');
|
||||||
|
const customMgr = new CustomModuleManager();
|
||||||
|
const customSource = await customMgr.findModuleSourceByCode(moduleCode, options);
|
||||||
|
if (customSource) {
|
||||||
|
return customSource;
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -824,20 +825,15 @@ 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);
|
const standardPath = path.join(getModulePath(moduleName), 'module.yaml');
|
||||||
if (customPath) {
|
if (await fs.pathExists(standardPath)) {
|
||||||
moduleConfigPath = path.join(customPath, 'module.yaml');
|
moduleConfigPath = standardPath;
|
||||||
} else {
|
} else {
|
||||||
const standardPath = path.join(getModulePath(moduleName), 'module.yaml');
|
const moduleSourcePath = await this.findModuleSource(moduleName, { silent: true });
|
||||||
if (await fs.pathExists(standardPath)) {
|
if (moduleSourcePath) {
|
||||||
moduleConfigPath = standardPath;
|
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
|
||||||
} else {
|
|
||||||
const moduleSourcePath = await this.findModuleSource(moduleName, { silent: true });
|
|
||||||
if (moduleSourcePath) {
|
|
||||||
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -882,12 +878,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 +1035,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 +1046,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) {
|
||||||
|
|
@ -1172,7 +1147,13 @@ class OfficialModules {
|
||||||
// Collect all answers (static + prompted)
|
// Collect all answers (static + prompted)
|
||||||
let allAnswers = { ...staticAnswers };
|
let allAnswers = { ...staticAnswers };
|
||||||
|
|
||||||
if (questions.length > 0) {
|
if (questions.length > 0 && silentMode) {
|
||||||
|
// In silent mode (quick update), use defaults for new fields instead of prompting
|
||||||
|
for (const q of questions) {
|
||||||
|
allAnswers[q.name] = typeof q.default === 'function' ? q.default({}) : q.default;
|
||||||
|
}
|
||||||
|
await prompts.log.message(` \u2713 ${moduleName.toUpperCase()} module configured with defaults`);
|
||||||
|
} else if (questions.length > 0) {
|
||||||
// Only show header if we actually have questions
|
// Only show header if we actually have questions
|
||||||
await CLIUtils.displayModuleConfigHeader(moduleName, moduleConfig.header, moduleConfig.subheader);
|
await CLIUtils.displayModuleConfigHeader(moduleName, moduleConfig.header, moduleConfig.subheader);
|
||||||
await prompts.log.message('');
|
await prompts.log.message('');
|
||||||
|
|
@ -1332,16 +1313,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))) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
const https = require('node:https');
|
||||||
|
const yaml = require('yaml');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared HTTP client for fetching registry data from GitHub.
|
||||||
|
* Used by ExternalModuleManager, CommunityModuleManager, and CustomModuleManager.
|
||||||
|
*/
|
||||||
|
class RegistryClient {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.timeout = options.timeout || 10_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a URL and return the response body as a string.
|
||||||
|
* Follows one redirect (GitHub sometimes 301s).
|
||||||
|
* @param {string} url - URL to fetch
|
||||||
|
* @param {number} [timeout] - Timeout in ms (overrides default)
|
||||||
|
* @returns {Promise<string>} Response body
|
||||||
|
*/
|
||||||
|
fetch(url, timeout) {
|
||||||
|
const timeoutMs = timeout || this.timeout;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = https
|
||||||
|
.get(url, { timeout: timeoutMs }, (res) => {
|
||||||
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||||
|
return this.fetch(res.headers.location, timeoutMs).then(resolve, reject);
|
||||||
|
}
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
return reject(new Error(`HTTP ${res.statusCode}`));
|
||||||
|
}
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => (data += chunk));
|
||||||
|
res.on('end', () => resolve(data));
|
||||||
|
})
|
||||||
|
.on('error', reject)
|
||||||
|
.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error('Request timed out'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a URL and parse the response as YAML.
|
||||||
|
* @param {string} url - URL to fetch
|
||||||
|
* @param {number} [timeout] - Timeout in ms
|
||||||
|
* @returns {Promise<Object>} Parsed YAML content
|
||||||
|
*/
|
||||||
|
async fetchYaml(url, timeout) {
|
||||||
|
const content = await this.fetch(url, timeout);
|
||||||
|
return yaml.parse(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a URL and parse the response as JSON.
|
||||||
|
* @param {string} url - URL to fetch
|
||||||
|
* @param {number} [timeout] - Timeout in ms
|
||||||
|
* @returns {Promise<Object>} Parsed JSON content
|
||||||
|
*/
|
||||||
|
async fetchJson(url, timeout) {
|
||||||
|
const content = await this.fetch(url, timeout);
|
||||||
|
return JSON.parse(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { RegistryClient };
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
# This file allows these modules under bmad-code-org to also be installed with the bmad method installer, while
|
# Fallback module registry — used only when the BMad Marketplace repo
|
||||||
# allowing us to keep the source of these projects in separate repos.
|
# (bmad-code-org/bmad-plugins-marketplace) is unreachable.
|
||||||
|
# The remote registry/official.yaml is the source of truth.
|
||||||
|
|
||||||
modules:
|
modules:
|
||||||
bmad-builder:
|
bmad-builder:
|
||||||
|
|
@ -41,13 +42,3 @@ modules:
|
||||||
defaultSelected: false
|
defaultSelected: false
|
||||||
type: bmad-org
|
type: bmad-org
|
||||||
npmPackage: bmad-method-test-architecture-enterprise
|
npmPackage: bmad-method-test-architecture-enterprise
|
||||||
|
|
||||||
whiteport-design-studio:
|
|
||||||
url: https://github.com/bmad-code-org/bmad-method-wds-expansion
|
|
||||||
module-definition: src/module.yaml
|
|
||||||
code: wds
|
|
||||||
name: "Whiteport Design Studio (For UX Professionals)"
|
|
||||||
description: "Whiteport Design Studio (For UX Professionals)"
|
|
||||||
defaultSelected: false
|
|
||||||
type: community
|
|
||||||
npmPackage: bmad-method-wds-expansion
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue