Compare commits
7 Commits
d82ace0c43
...
f761fe0a05
| Author | SHA1 | Date |
|---|---|---|
|
|
f761fe0a05 | |
|
|
b744408783 | |
|
|
5e038a8ce4 | |
|
|
5dbfb588ee | |
|
|
9ca0316674 | |
|
|
6cecab2626 | |
|
|
c90bdce306 |
|
|
@ -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` |
|
||||
| `--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` |
|
||||
| `--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` |
|
||||
|
||||
### Základní konfigurace
|
||||
|
|
@ -108,16 +107,6 @@ npx bmad-method install \
|
|||
--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
|
||||
|
||||
- Plně nakonfigurovaný adresář `_bmad/` ve vašem projektu
|
||||
|
|
@ -159,13 +148,6 @@ Neplatné hodnoty buď:
|
|||
- Ověřte, že ID modulu je správné
|
||||
- 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?]
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
| `--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` |
|
||||
| `--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` |
|
||||
|
||||
### Configuration principale
|
||||
|
|
@ -120,16 +119,6 @@ npx bmad-method install \
|
|||
--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
|
||||
|
||||
- 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
|
||||
- **Modules** — Avertit des IDs de modules 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`
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
:::tip[Bonnes pratiques]
|
||||
|
|
@ -172,13 +160,6 @@ Les valeurs invalides entraîneront soit :
|
|||
- Vérifiez que l'ID du module est correct
|
||||
- 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é ?]
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ Requires [Node.js](https://nodejs.org) v20+ and `npx` (included with npm).
|
|||
| `--directory <path>` | Installation directory | `--directory ~/projects/myapp` |
|
||||
| `--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` |
|
||||
| `--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` |
|
||||
|
||||
### Core Configuration
|
||||
|
|
@ -120,16 +119,6 @@ npx bmad-method install \
|
|||
--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
|
||||
|
||||
- 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
|
||||
- **Modules** — Warns about invalid module 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`
|
||||
|
||||
Invalid values will either:
|
||||
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)
|
||||
|
||||
:::tip[Best Practices]
|
||||
|
|
@ -172,13 +160,6 @@ Invalid values will either:
|
|||
- Verify the module ID is correct
|
||||
- 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?]
|
||||
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
|
||||
|
||||
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ì
|
||||
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
| `--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` |
|
||||
| `--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` |
|
||||
|
||||
### Cấu hình cốt lõi
|
||||
|
|
@ -120,16 +119,6 @@ npx bmad-method install \
|
|||
--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ì
|
||||
|
||||
- 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
|
||||
- **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)
|
||||
- **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`
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
:::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
|
||||
- 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?]
|
||||
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. 按照提示操作
|
||||
|
||||
安装程序会引导你完成剩余步骤——自定义内容、设置等。
|
||||
安装程序会引导你完成剩余步骤——设置、工具集成等。
|
||||
|
||||
## 你将获得
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ sidebar:
|
|||
| `--directory <path>` | 安装目录 | `--directory ~/projects/myapp` |
|
||||
| `--modules <modules>` | 逗号分隔的模块 ID | `--modules bmm,bmb` |
|
||||
| `--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` |
|
||||
|
||||
### 核心配置
|
||||
|
|
@ -108,16 +107,6 @@ npx bmad-method install \
|
|||
--action quick-update
|
||||
```
|
||||
|
||||
### 使用自定义内容安装
|
||||
|
||||
```bash
|
||||
npx bmad-method install \
|
||||
--directory ~/projects/myapp \
|
||||
--modules bmm \
|
||||
--custom-content ~/my-custom-module,~/another-module \
|
||||
--tools claude-code
|
||||
```
|
||||
|
||||
## 安装结果
|
||||
|
||||
- 项目中完全配置的 `_bmad/` 目录
|
||||
|
|
@ -131,12 +120,11 @@ BMad 会验证你提供的所有参数:
|
|||
- **目录** — 必须是具有写入权限的有效路径
|
||||
- **模块** — 对无效的模块 ID 发出警告(但不会失败)
|
||||
- **工具** — 对无效的工具 ID 发出警告(但不会失败)
|
||||
- **自定义内容** — 每个路径必须包含有效的 `module.yaml` 文件
|
||||
- **操作** — 必须是以下之一:`install`、`update`、`quick-update`
|
||||
|
||||
无效值将:
|
||||
1. 显示错误并退出(对于目录等关键选项)
|
||||
2. 显示警告并跳过(对于自定义内容等可选项目)
|
||||
2. 显示警告并跳过(对于可选项目)
|
||||
3. 回退到交互式提示(对于缺失的必需值)
|
||||
|
||||
:::tip[最佳实践]
|
||||
|
|
@ -159,13 +147,6 @@ BMad 会验证你提供的所有参数:
|
|||
- 验证模块 ID 是否正确
|
||||
- 外部模块必须在注册表中可用
|
||||
|
||||
### 自定义内容路径无效
|
||||
|
||||
确保每个自定义内容路径:
|
||||
- 指向一个目录
|
||||
- 在根目录中包含 `module.yaml` 文件
|
||||
- 在 `module.yaml` 中有 `code` 字段
|
||||
|
||||
:::note[仍然卡住了?]
|
||||
使用 `--debug` 获取详细输出,尝试交互模式定位问题,或在 <https://github.com/bmad-code-org/BMAD-METHOD/issues> 提交反馈。
|
||||
:::
|
||||
|
|
|
|||
|
|
@ -0,0 +1,197 @@
|
|||
# BMAD PRD Purpose
|
||||
|
||||
**The PRD is the top of the required funnel that feeds all subsequent product development work in rhw BMad Method.**
|
||||
|
||||
---
|
||||
|
||||
## What is a BMAD PRD?
|
||||
|
||||
A dual-audience document serving:
|
||||
1. **Human Product Managers and builders** - Vision, strategy, stakeholder communication
|
||||
2. **LLM Downstream Consumption** - UX Design → Architecture → Epics → Development AI Agents
|
||||
|
||||
Each successive document becomes more AI-tailored and granular.
|
||||
|
||||
---
|
||||
|
||||
## Core Philosophy: Information Density
|
||||
|
||||
**High Signal-to-Noise Ratio**
|
||||
|
||||
Every sentence must carry information weight. LLMs consume precise, dense content efficiently.
|
||||
|
||||
**Anti-Patterns (Eliminate These):**
|
||||
- ❌ "The system will allow users to..." → ✅ "Users can..."
|
||||
- ❌ "It is important to note that..." → ✅ State the fact directly
|
||||
- ❌ "In order to..." → ✅ "To..."
|
||||
- ❌ Conversational filler and padding → ✅ Direct, concise statements
|
||||
|
||||
**Goal:** Maximum information per word. Zero fluff.
|
||||
|
||||
---
|
||||
|
||||
## The Traceability Chain
|
||||
|
||||
**PRD starts the chain:**
|
||||
```
|
||||
Vision → Success Criteria → User Journeys → Functional Requirements → (future: User Stories)
|
||||
```
|
||||
|
||||
**In the PRD, establish:**
|
||||
- Vision → Success Criteria alignment
|
||||
- Success Criteria → User Journey coverage
|
||||
- User Journey → Functional Requirement mapping
|
||||
- All requirements traceable to user needs
|
||||
|
||||
**Why:** Each downstream artifact (UX, Architecture, Epics, Stories) must trace back to documented user needs and business objectives. This chain ensures we build the right thing.
|
||||
|
||||
---
|
||||
|
||||
## What Makes Great Functional Requirements?
|
||||
|
||||
### FRs are Capabilities, Not Implementation
|
||||
|
||||
**Good FR:** "Users can reset their password via email link"
|
||||
**Bad FR:** "System sends JWT via email and validates with database" (implementation leakage)
|
||||
|
||||
**Good FR:** "Dashboard loads in under 2 seconds for 95th percentile"
|
||||
**Bad FR:** "Fast loading time" (subjective, unmeasurable)
|
||||
|
||||
### SMART Quality Criteria
|
||||
|
||||
**Specific:** Clear, precisely defined capability
|
||||
**Measurable:** Quantifiable with test criteria
|
||||
**Attainable:** Realistic within constraints
|
||||
**Relevant:** Aligns with business objectives
|
||||
**Traceable:** Links to source (executive summary or user journey)
|
||||
|
||||
### FR Anti-Patterns
|
||||
|
||||
**Subjective Adjectives:**
|
||||
- ❌ "easy to use", "intuitive", "user-friendly", "fast", "responsive"
|
||||
- ✅ Use metrics: "completes task in under 3 clicks", "loads in under 2 seconds"
|
||||
|
||||
**Implementation Leakage:**
|
||||
- ❌ Technology names, specific libraries, implementation details
|
||||
- ✅ Focus on capability and measurable outcomes
|
||||
|
||||
**Vague Quantifiers:**
|
||||
- ❌ "multiple users", "several options", "various formats"
|
||||
- ✅ "up to 100 concurrent users", "3-5 options", "PDF, DOCX, TXT formats"
|
||||
|
||||
**Missing Test Criteria:**
|
||||
- ❌ "The system shall provide notifications"
|
||||
- ✅ "The system shall send email notifications within 30 seconds of trigger event"
|
||||
|
||||
---
|
||||
|
||||
## What Makes Great Non-Functional Requirements?
|
||||
|
||||
### NFRs Must Be Measurable
|
||||
|
||||
**Template:**
|
||||
```
|
||||
"The system shall [metric] [condition] [measurement method]"
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
- ✅ "The system shall respond to API requests in under 200ms for 95th percentile as measured by APM monitoring"
|
||||
- ✅ "The system shall maintain 99.9% uptime during business hours as measured by cloud provider SLA"
|
||||
- ✅ "The system shall support 10,000 concurrent users as measured by load testing"
|
||||
|
||||
### NFR Anti-Patterns
|
||||
|
||||
**Unmeasurable Claims:**
|
||||
- ❌ "The system shall be scalable" → ✅ "The system shall handle 10x load growth through horizontal scaling"
|
||||
- ❌ "High availability required" → ✅ "99.9% uptime as measured by cloud provider SLA"
|
||||
|
||||
**Missing Context:**
|
||||
- ❌ "Response time under 1 second" → ✅ "API response time under 1 second for 95th percentile under normal load"
|
||||
|
||||
---
|
||||
|
||||
## Domain-Specific Requirements
|
||||
|
||||
**Auto-Detect and Enforce Based on Project Context**
|
||||
|
||||
Certain industries have mandatory requirements that must be present:
|
||||
|
||||
- **Healthcare:** HIPAA Privacy & Security Rules, PHI encryption, audit logging, MFA
|
||||
- **Fintech:** PCI-DSS Level 1, AML/KYC compliance, SOX controls, financial audit trails
|
||||
- **GovTech:** NIST framework, Section 508 accessibility (WCAG 2.1 AA), FedRAMP, data residency
|
||||
- **E-Commerce:** PCI-DSS for payments, inventory accuracy, tax calculation by jurisdiction
|
||||
|
||||
**Why:** Missing these requirements in the PRD means they'll be missed in architecture and implementation, creating expensive rework. During PRD creation there is a step to cover this - during validation we want to make sure it was covered. For this purpose steps will utilize a domain-complexity.csv and project-types.csv.
|
||||
|
||||
---
|
||||
|
||||
## Document Structure (Markdown, Human-Readable)
|
||||
|
||||
### Required Sections
|
||||
1. **Executive Summary** - Vision, differentiator, target users
|
||||
2. **Success Criteria** - Measurable outcomes (SMART)
|
||||
3. **Product Scope** - MVP, Growth, Vision phases
|
||||
4. **User Journeys** - Comprehensive coverage
|
||||
5. **Domain Requirements** - Industry-specific compliance (if applicable)
|
||||
6. **Innovation Analysis** - Competitive differentiation (if applicable)
|
||||
7. **Project-Type Requirements** - Platform-specific needs
|
||||
8. **Functional Requirements** - Capability contract (FRs)
|
||||
9. **Non-Functional Requirements** - Quality attributes (NFRs)
|
||||
|
||||
### Formatting for Dual Consumption
|
||||
|
||||
**For Humans:**
|
||||
- Clear, professional language
|
||||
- Logical flow from vision to requirements
|
||||
- Easy for stakeholders to review and approve
|
||||
|
||||
**For LLMs:**
|
||||
- ## Level 2 headers for all main sections (enables extraction)
|
||||
- Consistent structure and patterns
|
||||
- Precise, testable language
|
||||
- High information density
|
||||
|
||||
---
|
||||
|
||||
## Downstream Impact
|
||||
|
||||
**How the PRD Feeds Next Artifacts:**
|
||||
|
||||
**UX Design:**
|
||||
- User journeys → interaction flows
|
||||
- FRs → design requirements
|
||||
- Success criteria → UX metrics
|
||||
|
||||
**Architecture:**
|
||||
- FRs → system capabilities
|
||||
- NFRs → architecture decisions
|
||||
- Domain requirements → compliance architecture
|
||||
- Project-type requirements → platform choices
|
||||
|
||||
**Epics & Stories (created after architecture):**
|
||||
- FRs → user stories (1 FR could map to 1-3 stories potentially)
|
||||
- Acceptance criteria → story acceptance tests
|
||||
- Priority → sprint sequencing
|
||||
- Traceability → stories map back to vision
|
||||
|
||||
**Development AI Agents:**
|
||||
- Precise requirements → implementation clarity
|
||||
- Test criteria → automated test generation
|
||||
- Domain requirements → compliance enforcement
|
||||
- Measurable NFRs → performance targets
|
||||
|
||||
---
|
||||
|
||||
## Summary: What Makes a Great BMAD PRD?
|
||||
|
||||
✅ **High Information Density** - Every sentence carries weight, zero fluff
|
||||
✅ **Measurable Requirements** - All FRs and NFRs are testable with specific criteria
|
||||
✅ **Clear Traceability** - Each requirement links to user need and business objective
|
||||
✅ **Domain Awareness** - Industry-specific requirements auto-detected and included
|
||||
✅ **Zero Anti-Patterns** - No subjective adjectives, implementation leakage, or vague quantifiers
|
||||
✅ **Dual Audience Optimized** - Human-readable AND LLM-consumable
|
||||
✅ **Markdown Format** - Professional, clean, accessible to all stakeholders
|
||||
|
||||
---
|
||||
|
||||
**Remember:** The PRD is the foundation. Quality here ripples through every subsequent phase. A dense, precise, well-traced PRD makes UX design, architecture, epic breakdown, and AI development dramatically more effective.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
# File references (ONLY variables used in this step)
|
||||
prdPurpose: '{project-root}/_bmad/bmm-skills/2-plan-workflows/bmad-create-prd/data/prd-purpose.md'
|
||||
prdPurpose: '../data/prd-purpose.md'
|
||||
---
|
||||
|
||||
# Step E-1: Discovery & Understanding
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
# File references (ONLY variables used in this step)
|
||||
prdFile: '{prd_file_path}'
|
||||
prdPurpose: '{project-root}/_bmad/bmm-skills/2-plan-workflows/bmad-create-prd/data/prd-purpose.md'
|
||||
prdPurpose: '../data/prd-purpose.md'
|
||||
---
|
||||
|
||||
# Step E-1B: Legacy PRD Conversion Assessment
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
# File references (ONLY variables used in this step)
|
||||
prdFile: '{prd_file_path}'
|
||||
validationReport: '{validation_report_path}' # If provided
|
||||
prdPurpose: '{project-root}/_bmad/bmm-skills/2-plan-workflows/bmad-create-prd/data/prd-purpose.md'
|
||||
prdPurpose: '../data/prd-purpose.md'
|
||||
---
|
||||
|
||||
# Step E-2: Deep Review & Analysis
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
# File references (ONLY variables used in this step)
|
||||
prdFile: '{prd_file_path}'
|
||||
prdPurpose: '{project-root}/_bmad/bmm-skills/2-plan-workflows/bmad-create-prd/data/prd-purpose.md'
|
||||
prdPurpose: '../data/prd-purpose.md'
|
||||
---
|
||||
|
||||
# Step E-3: Edit & Update
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
---
|
||||
# File references (ONLY variables used in this step)
|
||||
prdFile: '{prd_file_path}'
|
||||
validationWorkflow: '{project-root}/_bmad/bmm-skills/2-plan-workflows/bmad-validate-prd/steps-v/step-v-01-discovery.md'
|
||||
---
|
||||
|
||||
# Step E-4: Complete & Validate
|
||||
|
|
@ -117,8 +116,7 @@ Display:
|
|||
- Display: "This will run all 13 validation checks on the updated PRD."
|
||||
- Display: "Preparing to validate: {prd_file_path}"
|
||||
- Display: "**Proceeding to validation...**"
|
||||
- Read fully and follow: {validationWorkflow} (steps-v/step-v-01-discovery.md)
|
||||
- Note: This hands off to the validation workflow which will run its complete 13-step process
|
||||
- Invoke the `bmad-validate-prd` skill to run the complete validation workflow
|
||||
|
||||
- **IF E (Edit More):**
|
||||
- Display: "**Additional Edits**"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
**Your Role:** Developer implementing the story.
|
||||
- Communicate all responses in {communication_language} and language MUST be tailored to {user_skill_level}
|
||||
- Generate all documents in {document_output_language}
|
||||
- Only modify the story file in these areas: Tasks/Subtasks checkboxes, Dev Agent Record (Debug Log, Completion Notes), File List, Change Log, and Status
|
||||
- Only modify the story file in these areas: Tasks/Subtasks checkboxes, Dev Agent Record (Debug Log, Completion Notes), File List, Change Log, Review Findings, and Status
|
||||
- Execute ALL steps in exact order; do NOT skip steps
|
||||
- Absolutely DO NOT stop because of "milestones", "significant progress", or "session boundaries". Continue in a single execution until the story is COMPLETE (all ACs satisfied and all tasks/subtasks checked) UNLESS a HALT condition is triggered or the USER gives other instruction.
|
||||
- Do NOT schedule a "next session" or request review pauses unless a HALT condition applies. Only Step 6 decides completion.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
wipFile: '{implementation_artifacts}/spec-wip.md'
|
||||
deferred_work_file: '{implementation_artifacts}/deferred-work.md'
|
||||
spec_file: '' # set at runtime for both routes before leaving this step
|
||||
---
|
||||
|
|
@ -21,7 +20,7 @@ Before listing artifacts or prompting the user, check whether you already know t
|
|||
|
||||
1. Explicit argument
|
||||
Did the user pass a specific file path, spec name, or clear instruction this message?
|
||||
- If it points to a file that matches the spec template (has `status` frontmatter with a recognized value: ready-for-dev, in-progress, or in-review) → set `spec_file` and **EARLY EXIT** to the appropriate step (step-03 for ready/in-progress, step-04 for review).
|
||||
- If it points to a file that matches the spec template (has `status` frontmatter with a recognized value: draft, ready-for-dev, in-progress, in-review, or done) → set `spec_file` and **EARLY EXIT** to the appropriate step (step-02 for draft, step-03 for ready/in-progress, step-04 for review). For `done`, ingest as context and proceed to INSTRUCTIONS — do not resume.
|
||||
- Anything else (intent files, external docs, plans, descriptions) → ingest it as starting intent and proceed to INSTRUCTIONS. Do not attempt to infer a workflow state from it.
|
||||
|
||||
2. Recent conversation
|
||||
|
|
@ -29,8 +28,8 @@ Before listing artifacts or prompting the user, check whether you already know t
|
|||
Use the same routing as above.
|
||||
|
||||
3. Otherwise — scan artifacts and ask
|
||||
- `{wipFile}` exists? → Offer resume or archive.
|
||||
- Active specs (`ready-for-dev`, `in-progress`, `in-review`) in `{implementation_artifacts}`? → List them and HALT. Ask user which to resume (or `[N]` for new).
|
||||
- Active specs (`draft`, `ready-for-dev`, `in-progress`, `in-review`) in `{implementation_artifacts}`? → List them and HALT. Ask user which to resume (or `[N]` for new).
|
||||
- If `draft` selected: Set `spec_file`. **EARLY EXIT** → `./step-02-plan.md` (resume planning from the draft)
|
||||
- If `ready-for-dev` or `in-progress` selected: Set `spec_file`. **EARLY EXIT** → `./step-03-implement.md`
|
||||
- If `in-review` selected: Set `spec_file`. **EARLY EXIT** → `./step-04-review.md`
|
||||
- Unformatted spec or intent file lacking `status` frontmatter? → Suggest treating its contents as the starting intent. Do NOT attempt to infer a state and resume it.
|
||||
|
|
@ -65,7 +64,7 @@ Never ask extra questions if you already understand what the user intends.
|
|||
- On **K**: Proceed as-is.
|
||||
5. Route — choose exactly one:
|
||||
|
||||
Derive a valid kebab-case slug from the clarified intent. If the intent references a tracking identifier (story number, issue number, ticket ID), lead the slug with it (e.g. `3-2-digest-delivery`, `gh-47-fix-auth`). If `{implementation_artifacts}/spec-{slug}.md` already exists, append `-2`, `-3`, etc. Set `spec_file` = `{implementation_artifacts}/spec-{slug}.md`.
|
||||
Derive a valid kebab-case slug from the clarified intent. If the intent references a tracking identifier (story number, issue number, ticket ID), lead the slug with it (e.g. `3-2-digest-delivery`, `gh-47-fix-auth`). If `{implementation_artifacts}/spec-{slug}.md` already exists: if its status is `draft`, treat it as the same work and resume it (set `spec_file` to that path, **EARLY EXIT** → `./step-02-plan.md`); otherwise append `-2`, `-3`, etc. Set `spec_file` = `{implementation_artifacts}/spec-{slug}.md`.
|
||||
|
||||
**a) One-shot** — zero blast radius: no plausible path by which this change causes unintended consequences elsewhere. Clear intent, no architectural decisions.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
wipFile: '{implementation_artifacts}/spec-wip.md'
|
||||
deferred_work_file: '{implementation_artifacts}/deferred-work.md'
|
||||
---
|
||||
|
||||
|
|
@ -12,11 +11,12 @@ deferred_work_file: '{implementation_artifacts}/deferred-work.md'
|
|||
|
||||
## INSTRUCTIONS
|
||||
|
||||
1. Investigate codebase. _Isolate deep exploration in sub-agents/tasks where available. To prevent context snowballing, instruct subagents to give you distilled summaries only._
|
||||
2. Read `./spec-template.md` fully. Fill it out based on the intent and investigation, and write the result to `{wipFile}`.
|
||||
3. Self-review against READY FOR DEVELOPMENT standard.
|
||||
4. If intent gaps exist, do not fantasize, do not leave open questions, HALT and ask the human.
|
||||
5. Token count check (see SCOPE STANDARD). If spec exceeds 1600 tokens:
|
||||
1. Draft resume check. If `{spec_file}` exists with `status: draft`, read it and capture the verbatim `<frozen-after-approval>...</frozen-after-approval>` block as `preserved_intent`. Otherwise `preserved_intent` is empty.
|
||||
2. Investigate codebase. _Isolate deep exploration in sub-agents/tasks where available. To prevent context snowballing, instruct subagents to give you distilled summaries only._
|
||||
3. Read `./spec-template.md` fully. Fill it out based on the intent and investigation. If `{preserved_intent}` is non-empty, substitute it for the `<frozen-after-approval>` block in your filled spec before writing. Write the result to `{spec_file}`.
|
||||
4. Self-review against READY FOR DEVELOPMENT standard.
|
||||
5. If intent gaps exist, do not fantasize, do not leave open questions, HALT and ask the human.
|
||||
6. Token count check (see SCOPE STANDARD). If spec exceeds 1600 tokens:
|
||||
- Show user the token count.
|
||||
- HALT and ask human: `[S] Split — carve off secondary goals` | `[K] Keep full spec — accept the risks`
|
||||
- On **S**: Propose the split — name each secondary goal. Append deferred goals to `{deferred_work_file}`. Rewrite the current spec to cover only the main goal — do not surgically carve sections out; regenerate the spec for the narrowed scope. Continue to checkpoint.
|
||||
|
|
@ -26,7 +26,7 @@ deferred_work_file: '{implementation_artifacts}/deferred-work.md'
|
|||
|
||||
Present summary. If token count exceeded 1600 and user chose [K], include the token count and explain why it may be a problem. HALT and ask human: `[A] Approve` | `[E] Edit`
|
||||
|
||||
- **A**: Rename `{wipFile}` to `{spec_file}`, set status `ready-for-dev`. Everything inside `<frozen-after-approval>` is now locked — only the human can change it. Display the finalized spec path to the user as a CWD-relative path (no leading `/`) so it is clickable in the terminal. → Step 3.
|
||||
- **A**: Set status `ready-for-dev` in `{spec_file}`. Everything inside `<frozen-after-approval>` is now locked — only the human can change it. Display the finalized spec path to the user as a CWD-relative path (no leading `/`) so it is clickable in the terminal. → Step 3.
|
||||
- **E**: Apply changes, then return to CHECKPOINT 1.
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
---
|
||||
deferred_work_file: '{implementation_artifacts}/deferred-work.md'
|
||||
spec_file: '' # set by step-01 before entering this step
|
||||
---
|
||||
|
||||
# Step One-Shot: Implement, Review, Present
|
||||
|
|
|
|||
|
|
@ -70,10 +70,6 @@ Load and read full config from `{main_config}` and resolve:
|
|||
|
||||
YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{communication_language}`.
|
||||
|
||||
### 2. Paths
|
||||
|
||||
- `wipFile` = `{implementation_artifacts}/spec-wip.md`
|
||||
|
||||
### 3. First Step Execution
|
||||
### 2. First Step Execution
|
||||
|
||||
Read fully and follow: `./step-01-clarify-and-route.md` to begin the workflow.
|
||||
|
|
|
|||
|
|
@ -1,154 +0,0 @@
|
|||
/**
|
||||
* install_to_bmad Flag — Design Contract Tests
|
||||
*
|
||||
* Unit tests against the functions that implement the install_to_bmad flag.
|
||||
* These nail down the 4 core design decisions:
|
||||
*
|
||||
* 1. true/omitted → skill stays in _bmad/ (default behavior)
|
||||
* 2. false → skill removed from _bmad/ after IDE install
|
||||
* 3. No platform → no cleanup runs (cleanup lives in installVerbatimSkills)
|
||||
* 4. Mixed flags → each skill evaluated independently
|
||||
*
|
||||
* Usage: node test/test-install-to-bmad.js
|
||||
*/
|
||||
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const fs = require('fs-extra');
|
||||
const { loadSkillManifest, getInstallToBmad } = require('../tools/installer/ide/shared/skill-manifest');
|
||||
|
||||
// ANSI colors
|
||||
const colors = {
|
||||
reset: '\u001B[0m',
|
||||
green: '\u001B[32m',
|
||||
red: '\u001B[31m',
|
||||
yellow: '\u001B[33m',
|
||||
cyan: '\u001B[36m',
|
||||
dim: '\u001B[2m',
|
||||
};
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function assert(condition, testName, errorMessage = '') {
|
||||
if (condition) {
|
||||
console.log(`${colors.green}✓${colors.reset} ${testName}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(`${colors.red}✗${colors.reset} ${testName}`);
|
||||
if (errorMessage) {
|
||||
console.log(` ${colors.dim}${errorMessage}${colors.reset}`);
|
||||
}
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
console.log(`${colors.cyan}========================================`);
|
||||
console.log('install_to_bmad — Design Contract Tests');
|
||||
console.log(`========================================${colors.reset}\n`);
|
||||
|
||||
// ============================================================
|
||||
// 1. true/omitted → getInstallToBmad returns true (keep in _bmad/)
|
||||
// ============================================================
|
||||
console.log(`${colors.yellow}Design decision 1: true or omitted → skill stays in _bmad/${colors.reset}\n`);
|
||||
|
||||
// Null manifest (no bmad-skill-manifest.yaml) → true
|
||||
assert(getInstallToBmad(null, 'workflow.md') === true, 'null manifest defaults to true');
|
||||
|
||||
// Single-entry, flag omitted → true
|
||||
assert(
|
||||
getInstallToBmad({ __single: { type: 'skill' } }, 'workflow.md') === true,
|
||||
'single-entry manifest with flag omitted defaults to true',
|
||||
);
|
||||
|
||||
// Single-entry, explicit true → true
|
||||
assert(
|
||||
getInstallToBmad({ __single: { type: 'skill', install_to_bmad: true } }, 'workflow.md') === true,
|
||||
'single-entry manifest with explicit true returns true',
|
||||
);
|
||||
|
||||
console.log('');
|
||||
|
||||
// ============================================================
|
||||
// 2. false → getInstallToBmad returns false (remove from _bmad/)
|
||||
// ============================================================
|
||||
console.log(`${colors.yellow}Design decision 2: false → skill removed from _bmad/${colors.reset}\n`);
|
||||
|
||||
// Single-entry, explicit false → false
|
||||
assert(
|
||||
getInstallToBmad({ __single: { type: 'skill', install_to_bmad: false } }, 'workflow.md') === false,
|
||||
'single-entry manifest with explicit false returns false',
|
||||
);
|
||||
|
||||
// loadSkillManifest round-trip: YAML with false is preserved through load
|
||||
{
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-itb-'));
|
||||
await fs.writeFile(path.join(tmpDir, 'bmad-skill-manifest.yaml'), 'type: skill\ninstall_to_bmad: false\n');
|
||||
const loaded = await loadSkillManifest(tmpDir);
|
||||
assert(getInstallToBmad(loaded, 'workflow.md') === false, 'loadSkillManifest preserves install_to_bmad: false through round-trip');
|
||||
await fs.remove(tmpDir);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// ============================================================
|
||||
// 3. No platform → cleanup only runs inside installVerbatimSkills
|
||||
// (This is a design invariant: getInstallToBmad is only consulted
|
||||
// during IDE install. Without a platform, the flag has no effect.)
|
||||
// ============================================================
|
||||
console.log(`${colors.yellow}Design decision 3: flag is a per-skill property, not a pipeline gate${colors.reset}\n`);
|
||||
|
||||
// The flag value is stored but doesn't trigger any side effects by itself.
|
||||
// Cleanup is driven by reading the CSV column inside installVerbatimSkills.
|
||||
// We verify the flag is just data — getInstallToBmad doesn't touch the filesystem.
|
||||
{
|
||||
const manifest = { __single: { type: 'skill', install_to_bmad: false } };
|
||||
const result = getInstallToBmad(manifest, 'workflow.md');
|
||||
assert(typeof result === 'boolean', 'getInstallToBmad returns a boolean (pure data, no side effects)');
|
||||
assert(result === false, 'false value is faithfully returned for consumer to act on');
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// ============================================================
|
||||
// 4. Mixed flags → each skill evaluated independently
|
||||
// ============================================================
|
||||
console.log(`${colors.yellow}Design decision 4: mixed flags — each skill independent${colors.reset}\n`);
|
||||
|
||||
// Multi-entry manifest: different files can have different flags
|
||||
{
|
||||
const manifest = {
|
||||
'workflow.md': { type: 'skill', install_to_bmad: false },
|
||||
'other.md': { type: 'skill', install_to_bmad: true },
|
||||
};
|
||||
assert(getInstallToBmad(manifest, 'workflow.md') === false, 'multi-entry: workflow.md with false returns false');
|
||||
assert(getInstallToBmad(manifest, 'other.md') === true, 'multi-entry: other.md with true returns true');
|
||||
assert(getInstallToBmad(manifest, 'unknown.md') === true, 'multi-entry: unknown file defaults to true');
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// ============================================================
|
||||
// Summary
|
||||
// ============================================================
|
||||
console.log(`${colors.cyan}========================================`);
|
||||
console.log('Results:');
|
||||
console.log(` Passed: ${colors.green}${passed}${colors.reset}`);
|
||||
console.log(` Failed: ${colors.red}${failed}${colors.reset}`);
|
||||
console.log(`========================================${colors.reset}\n`);
|
||||
|
||||
if (failed === 0) {
|
||||
console.log(`${colors.green}All install_to_bmad contract tests passed!${colors.reset}\n`);
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log(`${colors.red}Some install_to_bmad contract tests failed${colors.reset}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runTests().catch((error) => {
|
||||
console.error(`${colors.red}Test runner failed:${colors.reset}`, error.message);
|
||||
console.error(error.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -59,8 +59,8 @@ async function createTestBmadFixture() {
|
|||
await fs.writeFile(
|
||||
path.join(fixtureDir, '_config', 'skill-manifest.csv'),
|
||||
[
|
||||
'canonicalId,name,description,module,path,install_to_bmad',
|
||||
'"bmad-master","bmad-master","Minimal test agent fixture","core","_bmad/core/bmad-master/SKILL.md","true"',
|
||||
'canonicalId,name,description,module,path',
|
||||
'"bmad-master","bmad-master","Minimal test agent fixture","core","_bmad/core/bmad-master/SKILL.md"',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
|
@ -103,8 +103,8 @@ async function createSkillCollisionFixture() {
|
|||
await fs.writeFile(
|
||||
path.join(configDir, 'skill-manifest.csv'),
|
||||
[
|
||||
'canonicalId,name,description,module,path,install_to_bmad',
|
||||
'"bmad-help","bmad-help","Native help skill","core","_bmad/core/tasks/bmad-help/SKILL.md","true"',
|
||||
'canonicalId,name,description,module,path',
|
||||
'"bmad-help","bmad-help","Native help skill","core","_bmad/core/tasks/bmad-help/SKILL.md"',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
|
@ -128,56 +128,6 @@ async function createSkillCollisionFixture() {
|
|||
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
|
||||
*/
|
||||
|
|
@ -1306,7 +1256,7 @@ async function runTests() {
|
|||
const existingCsv27 = await fs.readFile(path.join(configDir27, 'skill-manifest.csv'), 'utf8');
|
||||
await fs.writeFile(
|
||||
path.join(configDir27, 'skill-manifest.csv'),
|
||||
existingCsv27.trimEnd() + '\n"bmad-architect","bmad-architect","Architect","bmm","_bmad/bmm/agents/bmad-architect/SKILL.md","true"\n',
|
||||
existingCsv27.trimEnd() + '\n"bmad-architect","bmad-architect","Architect","bmm","_bmad/bmm/agents/bmad-architect/SKILL.md"\n',
|
||||
);
|
||||
|
||||
// Run Claude Code setup (which triggers cleanup then install)
|
||||
|
|
@ -1774,102 +1724,253 @@ async function runTests() {
|
|||
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;
|
||||
try {
|
||||
customManifestFixture = await createCustomModuleManifestFixture();
|
||||
const yaml = require('yaml');
|
||||
const originalManifest = yaml.parse(await fs.readFile(customManifestFixture.manifestPath, 'utf8'));
|
||||
originalManifest.customModules.push({
|
||||
id: 'removed-module',
|
||||
name: 'Removed Module',
|
||||
sourcePath: path.join(customManifestFixture.root, 'removed-module-source'),
|
||||
});
|
||||
await fs.writeFile(customManifestFixture.manifestPath, yaml.stringify(originalManifest), 'utf8');
|
||||
// --- CustomModuleManager.validateGitHubUrl ---
|
||||
{
|
||||
const { CustomModuleManager } = require('../tools/installer/modules/custom-module-manager');
|
||||
const mgr = new CustomModuleManager();
|
||||
|
||||
const generator33 = new ManifestGenerator();
|
||||
await generator33.generateManifests(customManifestFixture.bmadDir, ['core', 'test-module'], [], { ides: ['codex'] });
|
||||
const https1 = mgr.validateGitHubUrl('https://github.com/owner/repo');
|
||||
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 customModule = updatedManifest.customModules?.find((entry) => entry.id === 'test-module');
|
||||
const https2 = mgr.validateGitHubUrl('https://github.com/owner/repo.git');
|
||||
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');
|
||||
assert(customModule !== undefined, 'Main manifest preserves existing custom module entry');
|
||||
assert(
|
||||
customModule && customModule.sourcePath === customManifestFixture.moduleSourceDir,
|
||||
'Main manifest preserves custom module sourcePath',
|
||||
);
|
||||
assert(
|
||||
!updatedManifest.customModules?.some((entry) => entry.id === 'removed-module'),
|
||||
'Main manifest drops stale custom module entries',
|
||||
);
|
||||
} catch (error) {
|
||||
assert(false, 'Main manifest preserves customModules test succeeds', error.message);
|
||||
} finally {
|
||||
if (customManifestFixture?.root) await fs.remove(customManifestFixture.root).catch(() => {});
|
||||
const ssh1 = mgr.validateGitHubUrl('git@github.com:owner/repo.git');
|
||||
assert(ssh1.isValid === true, 'validateGitHubUrl accepts SSH URL');
|
||||
assert(ssh1.owner === 'owner' && ssh1.repo === 'repo', 'validateGitHubUrl extracts owner/repo from SSH');
|
||||
|
||||
const bad1 = mgr.validateGitHubUrl('https://gitlab.com/owner/repo');
|
||||
assert(bad1.isValid === false, 'validateGitHubUrl rejects non-GitHub URL');
|
||||
|
||||
const bad2 = mgr.validateGitHubUrl('');
|
||||
assert(bad2.isValid === false, 'validateGitHubUrl rejects empty string');
|
||||
|
||||
const bad3 = mgr.validateGitHubUrl(null);
|
||||
assert(bad3.isValid === false, 'validateGitHubUrl rejects null');
|
||||
|
||||
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();
|
||||
|
||||
// ============================================================
|
||||
// Suite 34: Quick update uses manifest-backed custom sources
|
||||
// ============================================================
|
||||
console.log(`${colors.yellow}Test Suite 34: Quick update uses manifest-backed custom module sources${colors.reset}\n`);
|
||||
const plugin = { name: 'test-plugin', description: 'A test', version: '1.0.0', author: 'tester', source: './src' };
|
||||
const data = { owner: 'Fallback Owner' };
|
||||
const result = mgr._normalizeCustomModule(plugin, 'https://github.com/o/r', data);
|
||||
|
||||
let quickUpdateFixture = null;
|
||||
const originalListAvailable34 = OfficialModules.prototype.listAvailable;
|
||||
const originalLoadExistingConfig34 = OfficialModules.prototype.loadExistingConfig;
|
||||
const originalCollectModuleConfigQuick34 = OfficialModules.prototype.collectModuleConfigQuick;
|
||||
try {
|
||||
quickUpdateFixture = await createCustomModuleManifestFixture();
|
||||
const installer34 = new Installer();
|
||||
installer34.externalModuleManager.hasModule = async () => false;
|
||||
installer34.externalModuleManager.listAvailable = async () => [];
|
||||
assert(result.code === 'test-plugin', 'normalizeCustomModule sets code from plugin name');
|
||||
assert(result.type === 'custom', 'normalizeCustomModule sets type to custom');
|
||||
assert(result.trustTier === 'unverified', 'normalizeCustomModule sets trustTier to unverified');
|
||||
assert(result.version === '1.0.0', 'normalizeCustomModule preserves version');
|
||||
assert(result.author === 'tester', 'normalizeCustomModule uses plugin author over data.owner');
|
||||
|
||||
let capturedInstallConfig34 = null;
|
||||
installer34.install = async (config) => {
|
||||
capturedInstallConfig34 = config;
|
||||
return { success: true };
|
||||
const pluginNoAuthor = { name: 'x', description: '', version: null };
|
||||
const result2 = mgr._normalizeCustomModule(pluginNoAuthor, 'https://github.com/o/r', data);
|
||||
assert(result2.author === 'Fallback Owner', 'normalizeCustomModule falls back to data.owner');
|
||||
}
|
||||
|
||||
// --- 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 () {
|
||||
return { modules: [], customModules: [] };
|
||||
};
|
||||
OfficialModules.prototype.loadExistingConfig = async function () {
|
||||
this.collectedConfig = this.collectedConfig || {};
|
||||
};
|
||||
OfficialModules.prototype.collectModuleConfigQuick = async function (moduleName) {
|
||||
this.collectedConfig = this.collectedConfig || {};
|
||||
if (!this.collectedConfig[moduleName]) {
|
||||
this.collectedConfig[moduleName] = {};
|
||||
}
|
||||
return false;
|
||||
const r1 = await mgr.searchByKeyword('test');
|
||||
assert(r1.length === 1 && r1[0].code === 'a', 'searchByKeyword matches keyword');
|
||||
|
||||
const r2 = await mgr.searchByKeyword('design');
|
||||
assert(r2.length === 1 && r2[0].code === 'b', 'searchByKeyword matches description');
|
||||
|
||||
const r3 = await mgr.searchByKeyword('alpha');
|
||||
assert(r3.length === 1 && r3[0].code === 'a', 'searchByKeyword matches display name');
|
||||
|
||||
const r4 = await mgr.searchByKeyword('xyz');
|
||||
assert(r4.length === 0, 'searchByKeyword returns empty for no match');
|
||||
|
||||
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({
|
||||
directory: quickUpdateFixture.root,
|
||||
skipPrompts: true,
|
||||
const featured = await mgr.listFeatured();
|
||||
assert(featured.length === 2, 'listFeatured returns only promoted modules');
|
||||
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');
|
||||
assert(customModule34 !== undefined, 'Quick update keeps manifest-backed custom module updateable');
|
||||
assert(customModule34 && customModule34.cached === false, 'Quick update uses manifest-backed source before cache');
|
||||
// --- CommunityModuleManager.listByCategory (with injected 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(
|
||||
customModule34 && customModule34.sourcePath === quickUpdateFixture.moduleSourceDir,
|
||||
'Quick update uses preserved manifest sourcePath for custom modules',
|
||||
design.every((m) => m.category === 'design-and-creative'),
|
||||
'listByCategory returns only matching modules',
|
||||
);
|
||||
} catch (error) {
|
||||
assert(false, 'Quick update manifest-backed custom source test succeeds', error.message);
|
||||
} finally {
|
||||
OfficialModules.prototype.listAvailable = originalListAvailable34;
|
||||
OfficialModules.prototype.loadExistingConfig = originalLoadExistingConfig34;
|
||||
OfficialModules.prototype.collectModuleConfigQuick = originalCollectModuleConfigQuick34;
|
||||
if (quickUpdateFixture?.root) await fs.remove(quickUpdateFixture.root).catch(() => {});
|
||||
|
||||
const empty = await mgr.listByCategory('nonexistent');
|
||||
assert(empty.length === 0, 'listByCategory returns empty for unknown category');
|
||||
}
|
||||
|
||||
// --- CommunityModuleManager.getModuleByCode (with injected cache) ---
|
||||
{
|
||||
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('');
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ module.exports = {
|
|||
'--tools <tools>',
|
||||
'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'],
|
||||
['--user-name <name>', 'Name for agents to use (default: system username)'],
|
||||
['--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 {
|
||||
#version;
|
||||
|
||||
constructor({ installed, version, hasCore, modules, ides, customModules }) {
|
||||
constructor({ installed, version, hasCore, modules, ides }) {
|
||||
this.installed = installed;
|
||||
this.#version = version;
|
||||
this.hasCore = hasCore;
|
||||
this.modules = Object.freeze(modules.map((m) => Object.freeze({ ...m })));
|
||||
this.moduleIds = Object.freeze(this.modules.map((m) => m.id));
|
||||
this.ides = Object.freeze([...ides]);
|
||||
this.customModules = Object.freeze([...customModules]);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
|
|
@ -35,7 +34,6 @@ class ExistingInstall {
|
|||
hasCore: false,
|
||||
modules: [],
|
||||
ides: [],
|
||||
customModules: [],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -53,15 +51,11 @@ class ExistingInstall {
|
|||
let hasCore = false;
|
||||
const modules = [];
|
||||
let ides = [];
|
||||
let customModules = [];
|
||||
|
||||
const manifest = new Manifest();
|
||||
const manifestData = await manifest.read(bmadDir);
|
||||
if (manifestData) {
|
||||
version = manifestData.version;
|
||||
if (manifestData.customModules) {
|
||||
customModules = manifestData.customModules;
|
||||
}
|
||||
if (manifestData.ides) {
|
||||
ides = manifestData.ides.filter((ide) => ide && typeof ide === 'string');
|
||||
}
|
||||
|
|
@ -120,7 +114,7 @@ class ExistingInstall {
|
|||
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 agentsDir = path.join(configDir, 'agents');
|
||||
const customCacheDir = path.join(configDir, 'custom');
|
||||
const coreDir = path.join(bmadDir, 'core');
|
||||
|
||||
for (const [dir, label] of [
|
||||
[bmadDir, 'bmad directory'],
|
||||
[configDir, 'config directory'],
|
||||
[agentsDir, 'agents config directory'],
|
||||
[customCacheDir, 'custom modules cache'],
|
||||
[coreDir, 'core module directory'],
|
||||
]) {
|
||||
await ensureWritableDir(dir, label);
|
||||
|
|
@ -40,7 +38,6 @@ class InstallPaths {
|
|||
bmadDir,
|
||||
configDir,
|
||||
agentsDir,
|
||||
customCacheDir,
|
||||
coreDir,
|
||||
isUpdate,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ const path = require('node:path');
|
|||
const fs = require('fs-extra');
|
||||
const { Manifest } = require('./manifest');
|
||||
const { OfficialModules } = require('../modules/official-modules');
|
||||
const { CustomModules } = require('../modules/custom-modules');
|
||||
const { IdeManager } = require('../ide/manager');
|
||||
const { FileOps } = require('../file-ops');
|
||||
const { Config } = require('./config');
|
||||
|
|
@ -19,7 +18,6 @@ class Installer {
|
|||
constructor() {
|
||||
this.externalModuleManager = new ExternalModuleManager();
|
||||
this.manifest = new Manifest();
|
||||
this.customModules = new CustomModules();
|
||||
this.ideManager = new IdeManager();
|
||||
this.fileOps = new FileOps();
|
||||
this.installedFiles = new Set(); // Track all installed files
|
||||
|
|
@ -80,8 +78,6 @@ class Installer {
|
|||
const officialModules = await OfficialModules.build(config, paths);
|
||||
const existingInstall = await ExistingInstall.detect(paths.bmadDir);
|
||||
|
||||
await this.customModules.discoverPaths(originalConfig, paths);
|
||||
|
||||
if (existingInstall.installed) {
|
||||
await this._removeDeselectedModules(existingInstall, config, paths);
|
||||
updateState = await this._prepareUpdateState(paths, config, existingInstall, officialModules);
|
||||
|
|
@ -121,17 +117,16 @@ class Installer {
|
|||
}
|
||||
}
|
||||
|
||||
await this._cacheCustomModules(paths, addResult);
|
||||
const allModules = config.modules || [];
|
||||
|
||||
// Compute module lists: official = selected minus custom, all = both
|
||||
const customModuleIds = new Set(this.customModules.paths.keys());
|
||||
const officialModuleIds = (config.modules || []).filter((m) => !customModuleIds.has(m));
|
||||
const allModules = [...officialModuleIds, ...[...customModuleIds].filter((id) => !officialModuleIds.includes(id))];
|
||||
|
||||
await this._installAndConfigure(config, originalConfig, paths, officialModuleIds, allModules, addResult, officialModules);
|
||||
await this._installAndConfigure(config, originalConfig, paths, allModules, allModules, addResult, officialModules);
|
||||
|
||||
await this._setupIdes(config, allModules, paths, addResult, previousSkillIds);
|
||||
|
||||
// Skills are now in IDE directories — remove redundant copies from _bmad/.
|
||||
// Also cleans up skill dirs left by older installer versions.
|
||||
await this._cleanupSkillDirs(paths.bmadDir);
|
||||
|
||||
const restoreResult = await this._restoreUserFiles(paths, updateState);
|
||||
|
||||
// Render consolidated summary
|
||||
|
|
@ -238,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.
|
||||
*/
|
||||
|
|
@ -280,11 +255,6 @@ class Installer {
|
|||
installedModuleNames,
|
||||
});
|
||||
|
||||
await this._installCustomModules(config, paths, addResult, officialModules, {
|
||||
message,
|
||||
installedModuleNames,
|
||||
});
|
||||
|
||||
return `${allModules.length} module(s) ${isQuickUpdate ? 'updated' : 'installed'}`;
|
||||
},
|
||||
});
|
||||
|
|
@ -413,6 +383,33 @@ class Installer {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove skill directories from _bmad/ after IDE installation.
|
||||
* Skills are self-contained in IDE directories, so _bmad/ only needs
|
||||
* module-level files (config.yaml, _config/, etc.).
|
||||
* Also cleans up skill dirs left by older installer versions.
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
*/
|
||||
async _cleanupSkillDirs(bmadDir) {
|
||||
const csv = require('csv-parse/sync');
|
||||
const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
|
||||
if (!(await fs.pathExists(csvPath))) return;
|
||||
|
||||
const csvContent = await fs.readFile(csvPath, 'utf8');
|
||||
const records = csv.parse(csvContent, { columns: true, skip_empty_lines: true });
|
||||
const bmadFolderName = path.basename(bmadDir);
|
||||
const bmadPrefix = bmadFolderName + '/';
|
||||
|
||||
for (const record of records) {
|
||||
if (!record.path) continue;
|
||||
const relativePath = record.path.startsWith(bmadPrefix) ? record.path.slice(bmadPrefix.length) : record.path;
|
||||
const sourceDir = path.dirname(path.join(bmadDir, relativePath));
|
||||
if (await fs.pathExists(sourceDir)) {
|
||||
await fs.remove(sourceDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore custom and modified files that were backed up before the update.
|
||||
* No-op for fresh installs (updateState is null).
|
||||
|
|
@ -484,48 +481,7 @@ class Installer {
|
|||
}
|
||||
|
||||
/**
|
||||
* Scan the custom module cache directory and register any cached custom modules
|
||||
* that aren't already known from the manifest or external module list.
|
||||
* @param {Object} paths - InstallPaths instance
|
||||
*/
|
||||
async _scanCachedCustomModules(paths) {
|
||||
const cacheDir = paths.customCacheDir;
|
||||
if (!(await fs.pathExists(cacheDir))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true });
|
||||
|
||||
for (const cachedModule of cachedModules) {
|
||||
const moduleId = cachedModule.name;
|
||||
const cachedPath = path.join(cacheDir, moduleId);
|
||||
|
||||
// Skip if path doesn't exist (broken symlink, deleted dir) - avoids lstat ENOENT
|
||||
if (!(await fs.pathExists(cachedPath)) || !cachedModule.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if we already have this module from manifest
|
||||
if (this.customModules.paths.has(moduleId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is an external official module - skip cache for those
|
||||
const isExternal = await this.externalModuleManager.hasModule(moduleId);
|
||||
if (isExternal) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is actually a custom module (has module.yaml)
|
||||
const moduleYamlPath = path.join(cachedPath, 'module.yaml');
|
||||
if (await fs.pathExists(moduleYamlPath)) {
|
||||
this.customModules.paths.set(moduleId, cachedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common update preparation: detect files, preserve core config, scan cache, back up.
|
||||
* Common update preparation: detect files, preserve core config, back up.
|
||||
* @param {Object} paths - InstallPaths instance
|
||||
* @param {Object} config - Clean config (may have coreConfig updated)
|
||||
* @param {Object} existingInstall - Detection result
|
||||
|
|
@ -553,8 +509,6 @@ class Installer {
|
|||
}
|
||||
}
|
||||
|
||||
await this._scanCachedCustomModules(paths);
|
||||
|
||||
const backupDirs = await this._backupUserFiles(paths, customFiles, modifiedFiles);
|
||||
|
||||
return {
|
||||
|
|
@ -646,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
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
|
|
@ -1222,16 +1144,9 @@ class Installer {
|
|||
const configuredIdes = existingInstall.ides;
|
||||
const projectRoot = path.dirname(bmadDir);
|
||||
|
||||
const customModuleSources = await this.customModules.assembleQuickUpdateSources(
|
||||
config,
|
||||
existingInstall,
|
||||
bmadDir,
|
||||
this.externalModuleManager,
|
||||
);
|
||||
|
||||
// Get available modules (what we have source for)
|
||||
const availableModulesData = await new OfficialModules().listAvailable();
|
||||
const availableModules = [...availableModulesData.modules, ...availableModulesData.customModules];
|
||||
const availableModules = [...availableModulesData.modules];
|
||||
|
||||
// Add external official modules to available modules
|
||||
const externalModules = await this.externalModuleManager.listAvailable();
|
||||
|
|
@ -1246,52 +1161,44 @@ class Installer {
|
|||
}
|
||||
}
|
||||
|
||||
// Add custom modules from manifest if their sources exist
|
||||
for (const [moduleId, customModule] of customModuleSources) {
|
||||
const sourcePath = customModule.sourcePath;
|
||||
if (sourcePath && (await fs.pathExists(sourcePath)) && !availableModules.some((m) => m.id === moduleId)) {
|
||||
// Add installed community modules to available modules
|
||||
const { CommunityModuleManager } = require('../modules/community-manager');
|
||||
const communityMgr = new CommunityModuleManager();
|
||||
const communityModules = await communityMgr.listAll();
|
||||
for (const communityModule of communityModules) {
|
||||
if (installedModules.includes(communityModule.code) && !availableModules.some((m) => m.id === communityModule.code)) {
|
||||
availableModules.push({
|
||||
id: moduleId,
|
||||
name: customModule.name || moduleId,
|
||||
path: sourcePath,
|
||||
isCustom: true,
|
||||
fromManifest: true,
|
||||
id: communityModule.code,
|
||||
name: communityModule.displayName,
|
||||
isExternal: true,
|
||||
fromCommunity: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle missing custom module sources
|
||||
const customModuleResult = await this.handleMissingCustomSources(
|
||||
customModuleSources,
|
||||
bmadDir,
|
||||
projectRoot,
|
||||
'update',
|
||||
installedModules,
|
||||
config.skipPrompts || false,
|
||||
);
|
||||
// Add installed custom modules to available modules
|
||||
const { CustomModuleManager } = require('../modules/custom-module-manager');
|
||||
const customMgr = new CustomModuleManager();
|
||||
for (const moduleId of installedModules) {
|
||||
if (!availableModules.some((m) => m.id === moduleId)) {
|
||||
const customSource = await customMgr.findModuleSourceByCode(moduleId);
|
||||
if (customSource) {
|
||||
availableModules.push({
|
||||
id: moduleId,
|
||||
name: moduleId,
|
||||
isExternal: true,
|
||||
fromCustom: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { validCustomModules, keptModulesWithoutSources } = customModuleResult;
|
||||
|
||||
const customModulesFromManifest = validCustomModules.map((m) => ({
|
||||
...m,
|
||||
isCustom: true,
|
||||
hasUpdate: true,
|
||||
}));
|
||||
|
||||
const allAvailableModules = [...availableModules, ...customModulesFromManifest];
|
||||
const availableModuleIds = new Set(allAvailableModules.map((m) => m.id));
|
||||
const availableModuleIds = new Set(availableModules.map((m) => m.id));
|
||||
|
||||
// Only update modules that are BOTH installed AND available (we have source for)
|
||||
const modulesToUpdate = installedModules.filter((id) => availableModuleIds.has(id));
|
||||
const skippedModules = installedModules.filter((id) => !availableModuleIds.has(id));
|
||||
|
||||
// Add custom modules that were kept without sources to the skipped modules
|
||||
for (const keptModule of keptModulesWithoutSources) {
|
||||
if (!skippedModules.includes(keptModule)) {
|
||||
skippedModules.push(keptModule);
|
||||
}
|
||||
}
|
||||
|
||||
if (skippedModules.length > 0) {
|
||||
await prompts.log.warn(`Skipping ${skippedModules.length} module(s) - no source available: ${skippedModules.join(', ')}`);
|
||||
}
|
||||
|
|
@ -1336,9 +1243,7 @@ class Installer {
|
|||
actionType: 'install',
|
||||
_quickUpdate: true,
|
||||
_preserveModules: skippedModules,
|
||||
_customModuleSources: customModuleSources,
|
||||
_existingModules: installedModules,
|
||||
customContent: config.customContent,
|
||||
};
|
||||
|
||||
await this.install(installConfig);
|
||||
|
|
@ -1473,239 +1378,6 @@ class Installer {
|
|||
return this._readOutputFolder(bmadDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle missing custom module sources interactively
|
||||
* @param {Map} customModuleSources - Map of custom module ID to info
|
||||
* @param {string} bmadDir - BMAD directory
|
||||
* @param {string} projectRoot - Project root directory
|
||||
* @param {string} operation - Current operation ('update', 'compile', etc.)
|
||||
* @param {Array} installedModules - Array of installed module IDs (will be modified)
|
||||
* @param {boolean} [skipPrompts=false] - Skip interactive prompts and keep all modules with missing sources
|
||||
* @returns {Object} Object with validCustomModules array and keptModulesWithoutSources array
|
||||
*/
|
||||
async handleMissingCustomSources(customModuleSources, bmadDir, projectRoot, operation, installedModules, skipPrompts = false) {
|
||||
const validCustomModules = [];
|
||||
const keptModulesWithoutSources = []; // Track modules kept without sources
|
||||
const customModulesWithMissingSources = [];
|
||||
|
||||
// Check which sources exist
|
||||
for (const [moduleId, customInfo] of customModuleSources) {
|
||||
if (await fs.pathExists(customInfo.sourcePath)) {
|
||||
validCustomModules.push({
|
||||
id: moduleId,
|
||||
name: customInfo.name,
|
||||
path: customInfo.sourcePath,
|
||||
info: customInfo,
|
||||
});
|
||||
} else {
|
||||
// For cached modules that are missing, we just skip them without prompting
|
||||
if (customInfo.cached) {
|
||||
// Skip cached modules without prompting
|
||||
keptModulesWithoutSources.push({
|
||||
id: moduleId,
|
||||
name: customInfo.name,
|
||||
cached: true,
|
||||
});
|
||||
} else {
|
||||
customModulesWithMissingSources.push({
|
||||
id: moduleId,
|
||||
name: customInfo.name,
|
||||
sourcePath: customInfo.sourcePath,
|
||||
relativePath: customInfo.relativePath,
|
||||
info: customInfo,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no missing sources, return immediately
|
||||
if (customModulesWithMissingSources.length === 0) {
|
||||
return {
|
||||
validCustomModules,
|
||||
keptModulesWithoutSources: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Non-interactive mode: keep all modules with missing sources
|
||||
if (skipPrompts) {
|
||||
for (const missing of customModulesWithMissingSources) {
|
||||
keptModulesWithoutSources.push(missing.id);
|
||||
}
|
||||
return { validCustomModules, keptModulesWithoutSources };
|
||||
}
|
||||
|
||||
await prompts.log.warn(`Found ${customModulesWithMissingSources.length} custom module(s) with missing sources:`);
|
||||
|
||||
let keptCount = 0;
|
||||
let updatedCount = 0;
|
||||
let removedCount = 0;
|
||||
|
||||
for (const missing of customModulesWithMissingSources) {
|
||||
await prompts.log.message(
|
||||
`${missing.name} (${missing.id})\n Original source: ${missing.relativePath}\n Full path: ${missing.sourcePath}`,
|
||||
);
|
||||
|
||||
const choices = [
|
||||
{
|
||||
name: 'Keep installed (will not be processed)',
|
||||
value: 'keep',
|
||||
hint: 'Keep',
|
||||
},
|
||||
{
|
||||
name: 'Specify new source location',
|
||||
value: 'update',
|
||||
hint: 'Update',
|
||||
},
|
||||
];
|
||||
|
||||
// Only add remove option if not just compiling agents
|
||||
if (operation !== 'compile-agents') {
|
||||
choices.push({
|
||||
name: '⚠️ REMOVE module completely (destructive!)',
|
||||
value: 'remove',
|
||||
hint: 'Remove',
|
||||
});
|
||||
}
|
||||
|
||||
const action = await prompts.select({
|
||||
message: `How would you like to handle "${missing.name}"?`,
|
||||
choices,
|
||||
});
|
||||
|
||||
switch (action) {
|
||||
case 'update': {
|
||||
// Use sync validation because @clack/prompts doesn't support async validate
|
||||
const newSourcePath = await prompts.text({
|
||||
message: 'Enter the new path to the custom module:',
|
||||
default: missing.sourcePath,
|
||||
validate: (input) => {
|
||||
if (!input || input.trim() === '') {
|
||||
return 'Please enter a path';
|
||||
}
|
||||
const expandedPath = path.resolve(input.trim());
|
||||
if (!fs.pathExistsSync(expandedPath)) {
|
||||
return 'Path does not exist';
|
||||
}
|
||||
// Check if it looks like a valid module
|
||||
const moduleYamlPath = path.join(expandedPath, 'module.yaml');
|
||||
const agentsPath = path.join(expandedPath, 'agents');
|
||||
const workflowsPath = path.join(expandedPath, 'workflows');
|
||||
|
||||
if (!fs.pathExistsSync(moduleYamlPath) && !fs.pathExistsSync(agentsPath) && !fs.pathExistsSync(workflowsPath)) {
|
||||
return 'Path does not appear to contain a valid custom module';
|
||||
}
|
||||
return; // clack expects undefined for valid input
|
||||
},
|
||||
});
|
||||
|
||||
// Defensive: handleCancel should have exited, but guard against symbol propagation
|
||||
if (typeof newSourcePath !== 'string') {
|
||||
keptCount++;
|
||||
keptModulesWithoutSources.push(missing.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update the source in manifest
|
||||
const resolvedPath = path.resolve(newSourcePath.trim());
|
||||
missing.info.sourcePath = resolvedPath;
|
||||
// Remove relativePath - we only store absolute sourcePath now
|
||||
delete missing.info.relativePath;
|
||||
await this.manifest.addCustomModule(bmadDir, missing.info);
|
||||
|
||||
validCustomModules.push({
|
||||
id: missing.id,
|
||||
name: missing.name,
|
||||
path: resolvedPath,
|
||||
info: missing.info,
|
||||
});
|
||||
|
||||
updatedCount++;
|
||||
await prompts.log.success('Updated source location');
|
||||
|
||||
break;
|
||||
}
|
||||
case 'remove': {
|
||||
// Extra confirmation for destructive remove
|
||||
await prompts.log.error(
|
||||
`WARNING: This will PERMANENTLY DELETE "${missing.name}" and all its files!\n Module location: ${path.join(bmadDir, missing.id)}`,
|
||||
);
|
||||
|
||||
const confirmDelete = await prompts.confirm({
|
||||
message: 'Are you absolutely sure you want to delete this module?',
|
||||
default: false,
|
||||
});
|
||||
|
||||
if (confirmDelete) {
|
||||
const typedConfirm = await prompts.text({
|
||||
message: 'Type "DELETE" to confirm permanent deletion:',
|
||||
validate: (input) => {
|
||||
if (input !== 'DELETE') {
|
||||
return 'You must type "DELETE" exactly to proceed';
|
||||
}
|
||||
return; // clack expects undefined for valid input
|
||||
},
|
||||
});
|
||||
|
||||
if (typedConfirm === 'DELETE') {
|
||||
// Remove the module from filesystem and manifest
|
||||
const modulePath = path.join(bmadDir, missing.id);
|
||||
if (await fs.pathExists(modulePath)) {
|
||||
const fsExtra = require('fs-extra');
|
||||
await fsExtra.remove(modulePath);
|
||||
await prompts.log.warn(`Deleted module directory: ${path.relative(projectRoot, modulePath)}`);
|
||||
}
|
||||
|
||||
await this.manifest.removeModule(bmadDir, missing.id);
|
||||
await this.manifest.removeCustomModule(bmadDir, missing.id);
|
||||
await prompts.log.warn('Removed from manifest');
|
||||
|
||||
// Also remove from installedModules list
|
||||
if (installedModules && installedModules.includes(missing.id)) {
|
||||
const index = installedModules.indexOf(missing.id);
|
||||
if (index !== -1) {
|
||||
installedModules.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
removedCount++;
|
||||
await prompts.log.error(`"${missing.name}" has been permanently removed`);
|
||||
} else {
|
||||
await prompts.log.message('Removal cancelled - module will be kept');
|
||||
keptCount++;
|
||||
}
|
||||
} else {
|
||||
await prompts.log.message('Removal cancelled - module will be kept');
|
||||
keptCount++;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case 'keep': {
|
||||
keptCount++;
|
||||
keptModulesWithoutSources.push(missing.id);
|
||||
await prompts.log.message('Module will be kept as-is');
|
||||
|
||||
break;
|
||||
}
|
||||
// No default
|
||||
}
|
||||
}
|
||||
|
||||
// Show summary
|
||||
if (keptCount > 0 || updatedCount > 0 || removedCount > 0) {
|
||||
let summary = 'Summary for custom modules with missing sources:';
|
||||
if (keptCount > 0) summary += `\n • ${keptCount} module(s) kept as-is`;
|
||||
if (updatedCount > 0) summary += `\n • ${updatedCount} module(s) updated with new sources`;
|
||||
if (removedCount > 0) summary += `\n • ${removedCount} module(s) permanently deleted`;
|
||||
await prompts.log.message(summary);
|
||||
}
|
||||
|
||||
return {
|
||||
validCustomModules,
|
||||
keptModulesWithoutSources,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the bmad installation directory in a project
|
||||
* Always uses the standard _bmad folder name
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ const {
|
|||
loadSkillManifest: loadSkillManifestShared,
|
||||
getCanonicalId: getCanonicalIdShared,
|
||||
getArtifactType: getArtifactTypeShared,
|
||||
getInstallToBmad: getInstallToBmadShared,
|
||||
} = require('../ide/shared/skill-manifest');
|
||||
|
||||
// Load package.json for version info
|
||||
|
|
@ -42,11 +41,6 @@ class ManifestGenerator {
|
|||
return getArtifactTypeShared(manifest, filename);
|
||||
}
|
||||
|
||||
/** Delegate to shared skill-manifest module */
|
||||
getInstallToBmad(manifest, filename) {
|
||||
return getInstallToBmadShared(manifest, filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean text for CSV output by normalizing whitespace.
|
||||
* Note: Quote escaping is handled by escapeCsv() at write time.
|
||||
|
|
@ -127,7 +121,7 @@ class ManifestGenerator {
|
|||
* Recursively walk a module directory tree, collecting native SKILL.md entrypoints.
|
||||
* A directory is discovered as a skill when it contains a SKILL.md file with
|
||||
* valid name/description frontmatter (name must match directory name).
|
||||
* Manifest YAML is loaded only when present — for install_to_bmad and agent metadata.
|
||||
* Manifest YAML is loaded only when present — for agent metadata.
|
||||
* Populates this.skills[] and this.skillClaimedDirs (Set of absolute paths).
|
||||
*/
|
||||
async collectSkills() {
|
||||
|
|
@ -156,7 +150,7 @@ class ManifestGenerator {
|
|||
const skillMeta = await this.parseSkillMd(skillMdPath, dir, dirName, debug);
|
||||
|
||||
if (skillMeta) {
|
||||
// Load manifest when present (for install_to_bmad and agent metadata)
|
||||
// Load manifest when present (for agent metadata)
|
||||
const manifest = await this.loadSkillManifest(dir);
|
||||
const artifactType = this.getArtifactType(manifest, skillFile);
|
||||
|
||||
|
|
@ -182,7 +176,6 @@ class ManifestGenerator {
|
|||
module: moduleName,
|
||||
path: installPath,
|
||||
canonicalId,
|
||||
install_to_bmad: this.getInstallToBmad(manifest, skillFile),
|
||||
});
|
||||
|
||||
// Add to files list
|
||||
|
|
@ -382,8 +375,6 @@ class ManifestGenerator {
|
|||
// Read existing manifest to preserve install date
|
||||
let existingInstallDate = null;
|
||||
const existingModulesMap = new Map();
|
||||
let existingCustomModules = [];
|
||||
|
||||
if (await fs.pathExists(manifestPath)) {
|
||||
try {
|
||||
const existingContent = await fs.readFile(manifestPath, 'utf8');
|
||||
|
|
@ -404,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 {
|
||||
// If we can't read existing manifest, continue with defaults
|
||||
}
|
||||
|
|
@ -445,7 +430,6 @@ class ManifestGenerator {
|
|||
lastUpdated: new Date().toISOString(),
|
||||
},
|
||||
modules: updatedModules,
|
||||
customModules: existingCustomModules,
|
||||
ides: this.selectedIdes,
|
||||
};
|
||||
|
||||
|
|
@ -472,7 +456,7 @@ class ManifestGenerator {
|
|||
const csvPath = path.join(cfgDir, 'skill-manifest.csv');
|
||||
const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
|
||||
|
||||
let csvContent = 'canonicalId,name,description,module,path,install_to_bmad\n';
|
||||
let csvContent = 'canonicalId,name,description,module,path\n';
|
||||
|
||||
for (const skill of this.skills) {
|
||||
const row = [
|
||||
|
|
@ -481,7 +465,6 @@ class ManifestGenerator {
|
|||
escapeCsv(skill.description),
|
||||
escapeCsv(skill.module),
|
||||
escapeCsv(skill.path),
|
||||
escapeCsv(skill.install_to_bmad),
|
||||
].join(',');
|
||||
csvContent += row + '\n';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,7 +97,6 @@ class Manifest {
|
|||
lastUpdated: manifestData.installation?.lastUpdated,
|
||||
modules: moduleNames, // Simple array of module names for backward compatibility
|
||||
modulesDetailed: hasDetailedModules ? modules : null, // New detailed format
|
||||
customModules: manifestData.customModules || [], // Keep for backward compatibility
|
||||
ides: manifestData.ides || [],
|
||||
};
|
||||
} catch (error) {
|
||||
|
|
@ -254,7 +253,6 @@ class Manifest {
|
|||
lastUpdated: manifest.installation?.lastUpdated,
|
||||
modules: moduleNames,
|
||||
modulesDetailed: hasDetailedModules ? modules : null,
|
||||
customModules: manifest.customModules || [],
|
||||
ides: manifest.ides || [],
|
||||
};
|
||||
}
|
||||
|
|
@ -783,52 +781,6 @@ class Manifest {
|
|||
|
||||
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
|
||||
* @param {string} moduleName - Module name/code
|
||||
|
|
@ -866,29 +818,36 @@ class Manifest {
|
|||
};
|
||||
}
|
||||
|
||||
// Custom module: resolve path from source or cache before reading version
|
||||
const customSourcePath = moduleSourcePath || path.join(bmadDir, '_config', 'custom', moduleName);
|
||||
const version = await this._readMarketplaceVersion(moduleName, customSourcePath);
|
||||
// Check if this is a community module
|
||||
const { CommunityModuleManager } = require('../modules/community-manager');
|
||||
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);
|
||||
const moduleYamlPath = path.join(cacheDir, 'module.yaml');
|
||||
|
||||
if (await fs.pathExists(moduleYamlPath)) {
|
||||
try {
|
||||
const yamlContent = await fs.readFile(moduleYamlPath, 'utf8');
|
||||
const moduleConfig = yaml.parse(yamlContent);
|
||||
return {
|
||||
version: version || moduleConfig.version || null,
|
||||
source: 'custom',
|
||||
npmPackage: moduleConfig.npmPackage || null,
|
||||
repoUrl: moduleConfig.repoUrl || null,
|
||||
};
|
||||
} catch (error) {
|
||||
await prompts.log.warn(`Failed to read module.yaml for ${moduleName}: ${error.message}`);
|
||||
}
|
||||
// Check if this is a custom module (from user-provided URL)
|
||||
const { CustomModuleManager } = require('../modules/custom-module-manager');
|
||||
const customMgr = new CustomModuleManager();
|
||||
const customSource = await customMgr.findModuleSourceByCode(moduleName);
|
||||
if (customSource) {
|
||||
const customVersion = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
|
||||
return {
|
||||
version: customVersion,
|
||||
source: 'custom',
|
||||
npmPackage: null,
|
||||
repoUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Unknown module
|
||||
const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
|
||||
return {
|
||||
version,
|
||||
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 };
|
||||
|
|
@ -183,18 +183,6 @@ class ConfigDrivenIdeSetup {
|
|||
count++;
|
||||
}
|
||||
|
||||
// Post-install cleanup: remove _bmad/ directories for skills with install_to_bmad === "false"
|
||||
for (const record of records) {
|
||||
if (record.install_to_bmad === 'false') {
|
||||
const relativePath = record.path.startsWith(bmadPrefix) ? record.path.slice(bmadPrefix.length) : record.path;
|
||||
const sourceFile = path.join(bmadDir, relativePath);
|
||||
const sourceDir = path.dirname(sourceFile);
|
||||
if (await fs.pathExists(sourceDir)) {
|
||||
await fs.remove(sourceDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
|
|
@ -237,13 +225,20 @@ class ConfigDrivenIdeSetup {
|
|||
// Migrate legacy target directories (e.g. .opencode/agent → .opencode/agents)
|
||||
// Legacy dirs are abandoned entirely, so use prefix matching (null removalSet)
|
||||
if (this.installerConfig?.legacy_targets) {
|
||||
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);
|
||||
const legacyDirsExist = await Promise.all(
|
||||
this.installerConfig.legacy_targets.map((d) =>
|
||||
this.isGlobalPath(d) ? fs.pathExists(d.replace(/^~/, os.homedir())) : fs.pathExists(path.join(projectDir, d)),
|
||||
),
|
||||
);
|
||||
if (legacyDirsExist.some(Boolean)) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,19 +54,4 @@ function getArtifactType(manifest, filename) {
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the install_to_bmad flag for a specific file from a loaded skill manifest.
|
||||
* @param {Object|null} manifest - Loaded manifest (from loadSkillManifest)
|
||||
* @param {string} filename - Source filename to look up
|
||||
* @returns {boolean} install_to_bmad value (defaults to true)
|
||||
*/
|
||||
function getInstallToBmad(manifest, filename) {
|
||||
if (!manifest) return true;
|
||||
// Single-entry manifest applies to all files in the directory
|
||||
if (manifest.__single) return manifest.__single.install_to_bmad !== false;
|
||||
// Multi-entry: look up by filename directly
|
||||
if (manifest[filename]) return manifest[filename].install_to_bmad !== false;
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = { loadSkillManifest, getCanonicalId, getArtifactType, getInstallToBmad };
|
||||
module.exports = { loadSkillManifest, getCanonicalId, getArtifactType };
|
||||
|
|
|
|||
|
|
@ -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 yaml = require('yaml');
|
||||
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
|
||||
* These are modules hosted in external repositories that can be installed
|
||||
* Manages official modules from the remote BMad marketplace registry.
|
||||
* Fetches registry/official.yaml from GitHub; falls back to the bundled
|
||||
* external-official-modules.yaml when the network is unavailable.
|
||||
*
|
||||
* @class ExternalModuleManager
|
||||
*/
|
||||
class ExternalModuleManager {
|
||||
constructor() {
|
||||
this.externalModulesConfigPath = path.join(__dirname, '../external-official-modules.yaml');
|
||||
this.cachedModules = null;
|
||||
this._client = new RegistryClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and parse the external-official-modules.yaml file
|
||||
* @returns {Object} Parsed YAML content with modules object
|
||||
* Load the official modules registry from GitHub, falling back to the
|
||||
* bundled YAML file if the fetch fails.
|
||||
* @returns {Object} Parsed YAML content with modules array
|
||||
*/
|
||||
async loadExternalModulesConfig() {
|
||||
if (this.cachedModules) {
|
||||
return this.cachedModules;
|
||||
}
|
||||
|
||||
// Try remote registry first
|
||||
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);
|
||||
this.cachedModules = config;
|
||||
await prompts.log.warn('Could not reach BMad registry; using bundled module list.');
|
||||
return config;
|
||||
} catch (error) {
|
||||
await prompts.log.warn(`Failed to load external modules config: ${error.message}`);
|
||||
return { modules: {} };
|
||||
await prompts.log.warn(`Failed to load modules config: ${error.message}`);
|
||||
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
|
||||
*/
|
||||
async listAvailable() {
|
||||
const config = await this.loadExternalModulesConfig();
|
||||
const modules = [];
|
||||
|
||||
for (const [key, moduleConfig] of Object.entries(config.modules || {})) {
|
||||
modules.push({
|
||||
key,
|
||||
url: moduleConfig.url,
|
||||
moduleDefinition: moduleConfig['module-definition'],
|
||||
code: moduleConfig.code,
|
||||
name: moduleConfig.name,
|
||||
header: moduleConfig.header,
|
||||
subheader: moduleConfig.subheader,
|
||||
description: moduleConfig.description || '',
|
||||
defaultSelected: moduleConfig.defaultSelected === true,
|
||||
type: moduleConfig.type || 'community', // bmad-org or community
|
||||
npmPackage: moduleConfig.npmPackage || null, // Include npm package name
|
||||
isExternal: true,
|
||||
});
|
||||
// Remote format: modules is an array
|
||||
if (Array.isArray(config.modules)) {
|
||||
return config.modules.map((mod) => this._normalizeModule(mod));
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
|
@ -81,27 +115,8 @@ class ExternalModuleManager {
|
|||
* @returns {Object|null} Module info or null if not found
|
||||
*/
|
||||
async getModuleByKey(key) {
|
||||
const config = await this.loadExternalModulesConfig();
|
||||
const moduleConfig = config.modules?.[key];
|
||||
|
||||
if (!moduleConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
url: moduleConfig.url,
|
||||
moduleDefinition: moduleConfig['module-definition'],
|
||||
code: moduleConfig.code,
|
||||
name: moduleConfig.name,
|
||||
header: moduleConfig.header,
|
||||
subheader: moduleConfig.subheader,
|
||||
description: moduleConfig.description || '',
|
||||
defaultSelected: moduleConfig.defaultSelected === true,
|
||||
type: moduleConfig.type || 'community', // bmad-org or community
|
||||
npmPackage: moduleConfig.npmPackage || null, // Include npm package name
|
||||
isExternal: true,
|
||||
};
|
||||
const modules = await this.listAvailable();
|
||||
return modules.find((m) => m.key === key) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -154,7 +169,7 @@ class ExternalModuleManager {
|
|||
const moduleInfo = await this.getModuleByCode(moduleCode);
|
||||
|
||||
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();
|
||||
|
|
@ -304,7 +319,7 @@ class ExternalModuleManager {
|
|||
async findExternalModuleSource(moduleCode, options = {}) {
|
||||
const moduleInfo = await this.getModuleByCode(moduleCode);
|
||||
|
||||
if (!moduleInfo) {
|
||||
if (!moduleInfo || moduleInfo.builtIn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -349,6 +364,7 @@ class ExternalModuleManager {
|
|||
// Nothing found: return configured path (preserves old behavior for error messaging)
|
||||
return path.dirname(configuredPath);
|
||||
}
|
||||
cachedModules = null;
|
||||
}
|
||||
|
||||
module.exports = { ExternalModuleManager };
|
||||
|
|
|
|||
|
|
@ -98,11 +98,10 @@ class OfficialModules {
|
|||
/**
|
||||
* List all available built-in modules (core and bmm).
|
||||
* All other modules come from external-official-modules.yaml
|
||||
* @returns {Object} Object with modules array and customModules array
|
||||
* @returns {Object} Object with modules array
|
||||
*/
|
||||
async listAvailable() {
|
||||
const modules = [];
|
||||
const customModules = [];
|
||||
|
||||
// Add built-in core module (directly under src/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
|
||||
*/
|
||||
async getModuleInfo(modulePath, defaultName, sourceDescription) {
|
||||
// Check for module structure (module.yaml OR custom.yaml)
|
||||
const moduleConfigPath = path.join(modulePath, 'module.yaml');
|
||||
const rootCustomConfigPath = path.join(modulePath, 'custom.yaml');
|
||||
let configPath = null;
|
||||
|
||||
if (await fs.pathExists(moduleConfigPath)) {
|
||||
configPath = moduleConfigPath;
|
||||
} else if (await fs.pathExists(rootCustomConfigPath)) {
|
||||
configPath = rootCustomConfigPath;
|
||||
}
|
||||
|
||||
// Skip if this doesn't look like a module
|
||||
if (!configPath) {
|
||||
if (!(await fs.pathExists(moduleConfigPath))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Mark as custom if it's using custom.yaml OR if it's outside src/bmm or src/core
|
||||
const isCustomSource =
|
||||
sourceDescription !== 'src/bmm-skills' && sourceDescription !== 'src/core-skills' && sourceDescription !== 'src/modules';
|
||||
const moduleInfo = {
|
||||
id: defaultName,
|
||||
path: modulePath,
|
||||
|
|
@ -162,12 +148,11 @@ class OfficialModules {
|
|||
description: 'BMAD Module',
|
||||
version: '5.0.0',
|
||||
source: sourceDescription,
|
||||
isCustom: configPath === rootCustomConfigPath || isCustomSource,
|
||||
};
|
||||
|
||||
// Read module config for metadata
|
||||
try {
|
||||
const configContent = await fs.readFile(configPath, 'utf8');
|
||||
const configContent = await fs.readFile(moduleConfigPath, 'utf8');
|
||||
const config = yaml.parse(configContent);
|
||||
|
||||
// Use the code property as the id if available
|
||||
|
|
@ -217,6 +202,22 @@ class OfficialModules {
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
@ -824,20 +825,15 @@ class OfficialModules {
|
|||
const results = [];
|
||||
|
||||
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;
|
||||
const customPath = this.customModulePaths?.get(moduleName);
|
||||
if (customPath) {
|
||||
moduleConfigPath = path.join(customPath, 'module.yaml');
|
||||
const standardPath = path.join(getModulePath(moduleName), 'module.yaml');
|
||||
if (await fs.pathExists(standardPath)) {
|
||||
moduleConfigPath = standardPath;
|
||||
} else {
|
||||
const standardPath = path.join(getModulePath(moduleName), 'module.yaml');
|
||||
if (await fs.pathExists(standardPath)) {
|
||||
moduleConfigPath = standardPath;
|
||||
} else {
|
||||
const moduleSourcePath = await this.findModuleSource(moduleName, { silent: true });
|
||||
if (moduleSourcePath) {
|
||||
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
|
||||
}
|
||||
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 {string} projectDir - Target project directory
|
||||
* @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)
|
||||
*/
|
||||
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.modulesToCustomize = undefined;
|
||||
await this.loadExistingConfig(projectDir);
|
||||
|
|
@ -1042,25 +1035,7 @@ class OfficialModules {
|
|||
}
|
||||
}
|
||||
|
||||
let configPath = null;
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if (!(await fs.pathExists(moduleConfigPath))) {
|
||||
// No config schema for this module - use existing values
|
||||
if (this._existingConfig && this._existingConfig[moduleName]) {
|
||||
if (!this.collectedConfig[moduleName]) {
|
||||
|
|
@ -1071,7 +1046,7 @@ class OfficialModules {
|
|||
return false;
|
||||
}
|
||||
|
||||
const configContent = await fs.readFile(configPath, 'utf8');
|
||||
const configContent = await fs.readFile(moduleConfigPath, 'utf8');
|
||||
const moduleConfig = yaml.parse(configContent);
|
||||
|
||||
if (!moduleConfig) {
|
||||
|
|
@ -1172,7 +1147,13 @@ class OfficialModules {
|
|||
// Collect all answers (static + prompted)
|
||||
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
|
||||
await CLIUtils.displayModuleConfigHeader(moduleName, moduleConfig.header, moduleConfig.subheader);
|
||||
await prompts.log.message('');
|
||||
|
|
@ -1332,16 +1313,7 @@ class OfficialModules {
|
|||
this.allAnswers = {};
|
||||
}
|
||||
// Load module's config
|
||||
// First, check if we have a custom module path for this module
|
||||
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');
|
||||
}
|
||||
let moduleConfigPath = path.join(getModulePath(moduleName), 'module.yaml');
|
||||
|
||||
// If not found in src/modules or custom paths, search the project
|
||||
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
|
||||
# allowing us to keep the source of these projects in separate repos.
|
||||
# Fallback module registry — used only when the BMad Marketplace repo
|
||||
# (bmad-code-org/bmad-plugins-marketplace) is unreachable.
|
||||
# The remote registry/official.yaml is the source of truth.
|
||||
|
||||
modules:
|
||||
bmad-builder:
|
||||
|
|
@ -41,13 +42,3 @@ modules:
|
|||
defaultSelected: false
|
||||
type: bmad-org
|
||||
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
|
|
@ -156,8 +156,15 @@ function mapInstalledToSource(refPath) {
|
|||
// Skip install-only paths (generated at install time, not in source)
|
||||
if (isInstallOnly(cleaned)) return null;
|
||||
|
||||
// core/, bmm/, and utility/ are directly under src/
|
||||
if (cleaned.startsWith('core/') || cleaned.startsWith('bmm/') || cleaned.startsWith('utility/')) {
|
||||
// Map installed module names to their source directory names
|
||||
// _bmad/core/ → src/core-skills/, _bmad/bmm/ → src/bmm-skills/
|
||||
if (cleaned.startsWith('core/')) {
|
||||
return path.join(SRC_DIR, 'core-skills', cleaned.slice('core/'.length));
|
||||
}
|
||||
if (cleaned.startsWith('bmm/')) {
|
||||
return path.join(SRC_DIR, 'bmm-skills', cleaned.slice('bmm/'.length));
|
||||
}
|
||||
if (cleaned.startsWith('utility/')) {
|
||||
return path.join(SRC_DIR, cleaned);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue