refactor(installer): remove custom content installation feature (#2227)
* refactor(installer): remove custom content installation feature Remove the entire local filesystem custom content feature from the installer to make way for marketplace-based plugin installation. Deleted: custom-handler.js, custom-module-cache.js, custom-modules.js Removed: --custom-content CLI flag, interactive custom content prompts, custom module caching, manifest tracking, missing-source resolution, and related test suites. Updated docs across all translations. * fix: address review findings from Augment Fix admonition syntax (remove accidental space in :::note) across 4 translated docs files, and update stale JSDoc on listAvailable().
This commit is contained in:
parent
9ca0316674
commit
5dbfb588ee
|
|
@ -27,7 +27,6 @@ Vyžaduje [Node.js](https://nodejs.org) v20+ a `npx` (součástí npm).
|
|||
| `--directory <cesta>` | Instalační adresář | `--directory ~/projects/myapp` |
|
||||
| `--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> 提交反馈。
|
||||
:::
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
@ -1773,107 +1723,6 @@ async function runTests() {
|
|||
|
||||
console.log('');
|
||||
|
||||
// ============================================================
|
||||
// Suite 33: Main manifest preserves active customModules only
|
||||
// ============================================================
|
||||
console.log(`${colors.yellow}Test Suite 33: Preserve active customModules in main manifest${colors.reset}\n`);
|
||||
|
||||
let customManifestFixture = null;
|
||||
try {
|
||||
customManifestFixture = await createCustomModuleManifestFixture();
|
||||
const yaml = require('yaml');
|
||||
const originalManifest = yaml.parse(await fs.readFile(customManifestFixture.manifestPath, 'utf8'));
|
||||
originalManifest.customModules.push({
|
||||
id: 'removed-module',
|
||||
name: 'Removed Module',
|
||||
sourcePath: path.join(customManifestFixture.root, 'removed-module-source'),
|
||||
});
|
||||
await fs.writeFile(customManifestFixture.manifestPath, yaml.stringify(originalManifest), 'utf8');
|
||||
|
||||
const generator33 = new ManifestGenerator();
|
||||
await generator33.generateManifests(customManifestFixture.bmadDir, ['core', 'test-module'], [], { ides: ['codex'] });
|
||||
|
||||
const updatedManifest = yaml.parse(await fs.readFile(customManifestFixture.manifestPath, 'utf8'));
|
||||
const customModule = updatedManifest.customModules?.find((entry) => entry.id === 'test-module');
|
||||
|
||||
assert(Array.isArray(updatedManifest.customModules), 'Main manifest keeps customModules array');
|
||||
assert(customModule !== undefined, 'Main manifest preserves existing custom module entry');
|
||||
assert(
|
||||
customModule && customModule.sourcePath === customManifestFixture.moduleSourceDir,
|
||||
'Main manifest preserves custom module sourcePath',
|
||||
);
|
||||
assert(
|
||||
!updatedManifest.customModules?.some((entry) => entry.id === 'removed-module'),
|
||||
'Main manifest drops stale custom module entries',
|
||||
);
|
||||
} catch (error) {
|
||||
assert(false, 'Main manifest preserves customModules test succeeds', error.message);
|
||||
} finally {
|
||||
if (customManifestFixture?.root) await fs.remove(customManifestFixture.root).catch(() => {});
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// ============================================================
|
||||
// Suite 34: Quick update uses manifest-backed custom sources
|
||||
// ============================================================
|
||||
console.log(`${colors.yellow}Test Suite 34: Quick update uses manifest-backed custom module sources${colors.reset}\n`);
|
||||
|
||||
let quickUpdateFixture = null;
|
||||
const originalListAvailable34 = OfficialModules.prototype.listAvailable;
|
||||
const originalLoadExistingConfig34 = OfficialModules.prototype.loadExistingConfig;
|
||||
const originalCollectModuleConfigQuick34 = OfficialModules.prototype.collectModuleConfigQuick;
|
||||
try {
|
||||
quickUpdateFixture = await createCustomModuleManifestFixture();
|
||||
const installer34 = new Installer();
|
||||
installer34.externalModuleManager.hasModule = async () => false;
|
||||
installer34.externalModuleManager.listAvailable = async () => [];
|
||||
|
||||
let capturedInstallConfig34 = null;
|
||||
installer34.install = async (config) => {
|
||||
capturedInstallConfig34 = config;
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
OfficialModules.prototype.listAvailable = async function () {
|
||||
return { modules: [], customModules: [] };
|
||||
};
|
||||
OfficialModules.prototype.loadExistingConfig = async function () {
|
||||
this.collectedConfig = this.collectedConfig || {};
|
||||
};
|
||||
OfficialModules.prototype.collectModuleConfigQuick = async function (moduleName) {
|
||||
this.collectedConfig = this.collectedConfig || {};
|
||||
if (!this.collectedConfig[moduleName]) {
|
||||
this.collectedConfig[moduleName] = {};
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
await installer34.quickUpdate({
|
||||
directory: quickUpdateFixture.root,
|
||||
skipPrompts: true,
|
||||
});
|
||||
|
||||
const customModule34 = capturedInstallConfig34?._customModuleSources?.get('test-module');
|
||||
|
||||
assert(capturedInstallConfig34 !== null, 'Quick update forwards config to install');
|
||||
assert(customModule34 !== undefined, 'Quick update keeps manifest-backed custom module updateable');
|
||||
assert(customModule34 && customModule34.cached === false, 'Quick update uses manifest-backed source before cache');
|
||||
assert(
|
||||
customModule34 && customModule34.sourcePath === quickUpdateFixture.moduleSourceDir,
|
||||
'Quick update uses preserved manifest sourcePath for custom modules',
|
||||
);
|
||||
} catch (error) {
|
||||
assert(false, 'Quick update manifest-backed custom source test succeeds', error.message);
|
||||
} finally {
|
||||
OfficialModules.prototype.listAvailable = originalListAvailable34;
|
||||
OfficialModules.prototype.loadExistingConfig = originalLoadExistingConfig34;
|
||||
OfficialModules.prototype.collectModuleConfigQuick = originalCollectModuleConfigQuick34;
|
||||
if (quickUpdateFixture?.root) await fs.remove(quickUpdateFixture.root).catch(() => {});
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// ============================================================
|
||||
// Summary
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -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,14 +117,9 @@ 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);
|
||||
|
||||
|
|
@ -242,26 +233,6 @@ class Installer {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache custom modules into the local cache directory.
|
||||
* Updates this.customModules.paths in place with cached locations.
|
||||
*/
|
||||
async _cacheCustomModules(paths, addResult) {
|
||||
if (!this.customModules.paths || this.customModules.paths.size === 0) return;
|
||||
|
||||
const { CustomModuleCache } = require('./custom-module-cache');
|
||||
const customCache = new CustomModuleCache(paths.bmadDir);
|
||||
|
||||
for (const [moduleId, sourcePath] of this.customModules.paths) {
|
||||
const cachedInfo = await customCache.cacheModule(moduleId, sourcePath, {
|
||||
sourcePath: sourcePath,
|
||||
});
|
||||
this.customModules.paths.set(moduleId, cachedInfo.cachePath);
|
||||
}
|
||||
|
||||
addResult('Custom modules cached', 'ok');
|
||||
}
|
||||
|
||||
/**
|
||||
* Install modules, create directories, generate configs and manifests.
|
||||
*/
|
||||
|
|
@ -284,11 +255,6 @@ class Installer {
|
|||
installedModuleNames,
|
||||
});
|
||||
|
||||
await this._installCustomModules(config, paths, addResult, officialModules, {
|
||||
message,
|
||||
installedModuleNames,
|
||||
});
|
||||
|
||||
return `${allModules.length} module(s) ${isQuickUpdate ? 'updated' : 'installed'}`;
|
||||
},
|
||||
});
|
||||
|
|
@ -515,48 +481,7 @@ class Installer {
|
|||
}
|
||||
|
||||
/**
|
||||
* Scan the custom module cache directory and register any cached custom modules
|
||||
* 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
|
||||
|
|
@ -584,8 +509,6 @@ class Installer {
|
|||
}
|
||||
}
|
||||
|
||||
await this._scanCachedCustomModules(paths);
|
||||
|
||||
const backupDirs = await this._backupUserFiles(paths, customFiles, modifiedFiles);
|
||||
|
||||
return {
|
||||
|
|
@ -677,38 +600,6 @@ class Installer {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install custom modules using CustomModules.install().
|
||||
* Source paths come from this.customModules.paths (populated by discoverPaths).
|
||||
*/
|
||||
async _installCustomModules(config, paths, addResult, officialModules, ctx) {
|
||||
const { message, installedModuleNames } = ctx;
|
||||
const isQuickUpdate = config.isQuickUpdate();
|
||||
|
||||
for (const [moduleName, sourcePath] of this.customModules.paths) {
|
||||
if (installedModuleNames.has(moduleName)) continue;
|
||||
installedModuleNames.add(moduleName);
|
||||
|
||||
message(`${isQuickUpdate ? 'Updating' : 'Installing'} ${moduleName}...`);
|
||||
|
||||
const collectedModuleConfig = officialModules.moduleConfigs[moduleName] || {};
|
||||
const result = await this.customModules.install(moduleName, paths.bmadDir, (filePath) => this.installedFiles.add(filePath), {
|
||||
moduleConfig: collectedModuleConfig,
|
||||
});
|
||||
|
||||
// Generate runtime config.yaml with merged values
|
||||
await this.generateModuleConfigs(paths.bmadDir, {
|
||||
[moduleName]: { ...config.coreConfig, ...result.moduleConfig, ...collectedModuleConfig },
|
||||
});
|
||||
|
||||
// Get display name from source module.yaml; version from marketplace.json
|
||||
const moduleInfo = await officialModules.getModuleInfo(sourcePath, moduleName, '');
|
||||
const displayName = moduleInfo?.name || moduleName;
|
||||
const version = await this._getMarketplaceVersion(sourcePath);
|
||||
addResult(displayName, 'ok', '', { moduleCode: moduleName, newVersion: version });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read files-manifest.csv
|
||||
* @param {string} bmadDir - BMAD installation directory
|
||||
|
|
@ -1253,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();
|
||||
|
|
@ -1277,52 +1161,12 @@ 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)) {
|
||||
availableModules.push({
|
||||
id: moduleId,
|
||||
name: customModule.name || moduleId,
|
||||
path: sourcePath,
|
||||
isCustom: true,
|
||||
fromManifest: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle missing custom module sources
|
||||
const customModuleResult = await this.handleMissingCustomSources(
|
||||
customModuleSources,
|
||||
bmadDir,
|
||||
projectRoot,
|
||||
'update',
|
||||
installedModules,
|
||||
config.skipPrompts || false,
|
||||
);
|
||||
|
||||
const { validCustomModules, keptModulesWithoutSources } = customModuleResult;
|
||||
|
||||
const customModulesFromManifest = validCustomModules.map((m) => ({
|
||||
...m,
|
||||
isCustom: true,
|
||||
hasUpdate: true,
|
||||
}));
|
||||
|
||||
const allAvailableModules = [...availableModules, ...customModulesFromManifest];
|
||||
const availableModuleIds = new Set(allAvailableModules.map((m) => m.id));
|
||||
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(', ')}`);
|
||||
}
|
||||
|
|
@ -1367,9 +1211,7 @@ class Installer {
|
|||
actionType: 'install',
|
||||
_quickUpdate: true,
|
||||
_preserveModules: skippedModules,
|
||||
_customModuleSources: customModuleSources,
|
||||
_existingModules: installedModules,
|
||||
customContent: config.customContent,
|
||||
};
|
||||
|
||||
await this.install(installConfig);
|
||||
|
|
@ -1504,239 +1346,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
|
||||
|
|
|
|||
|
|
@ -375,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');
|
||||
|
|
@ -397,12 +395,6 @@ class ManifestGenerator {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (existingManifest.customModules && Array.isArray(existingManifest.customModules)) {
|
||||
// We filter here so manifest regeneration preserves source metadata only for custom modules that
|
||||
// are still installed. Without that, customModules can retain stale entries for modules that were removed.
|
||||
existingCustomModules = existingManifest.customModules.filter((customModule) => installedModuleSet.has(customModule?.id));
|
||||
}
|
||||
} catch {
|
||||
// If we can't read existing manifest, continue with defaults
|
||||
}
|
||||
|
|
@ -438,7 +430,6 @@ class ManifestGenerator {
|
|||
lastUpdated: new Date().toISOString(),
|
||||
},
|
||||
modules: updatedModules,
|
||||
customModules: existingCustomModules,
|
||||
ides: this.selectedIdes,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,8 @@ class Manifest {
|
|||
};
|
||||
}
|
||||
|
||||
// Custom module: resolve path from source or cache before reading version
|
||||
const customSourcePath = moduleSourcePath || path.join(bmadDir, '_config', 'custom', moduleName);
|
||||
const version = await this._readMarketplaceVersion(moduleName, customSourcePath);
|
||||
|
||||
const cacheDir = path.join(bmadDir, '_config', 'custom', moduleName);
|
||||
const moduleYamlPath = path.join(cacheDir, 'module.yaml');
|
||||
|
||||
if (await fs.pathExists(moduleYamlPath)) {
|
||||
try {
|
||||
const yamlContent = await fs.readFile(moduleYamlPath, 'utf8');
|
||||
const moduleConfig = yaml.parse(yamlContent);
|
||||
return {
|
||||
version: version || moduleConfig.version || null,
|
||||
source: 'custom',
|
||||
npmPackage: moduleConfig.npmPackage || null,
|
||||
repoUrl: moduleConfig.repoUrl || null,
|
||||
};
|
||||
} catch (error) {
|
||||
await prompts.log.warn(`Failed to read module.yaml for ${moduleName}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown module
|
||||
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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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
|
||||
|
|
@ -824,12 +809,8 @@ 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');
|
||||
} else {
|
||||
const standardPath = path.join(getModulePath(moduleName), 'module.yaml');
|
||||
if (await fs.pathExists(standardPath)) {
|
||||
moduleConfigPath = standardPath;
|
||||
|
|
@ -839,7 +820,6 @@ class OfficialModules {
|
|||
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!moduleConfigPath || !(await fs.pathExists(moduleConfigPath))) {
|
||||
continue;
|
||||
|
|
@ -882,12 +862,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 +1019,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 +1030,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) {
|
||||
|
|
@ -1332,16 +1291,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))) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ const path = require('node:path');
|
|||
const os = require('node:os');
|
||||
const fs = require('fs-extra');
|
||||
const { CLIUtils } = require('./cli-utils');
|
||||
const { CustomHandler } = require('./custom-handler');
|
||||
const { ExternalModuleManager } = require('./modules/external-manager');
|
||||
const { getProjectRoot } = require('./project-root');
|
||||
const prompts = require('./prompts');
|
||||
|
|
@ -48,19 +47,6 @@ function _extractMarketplaceVersion(data) {
|
|||
return best;
|
||||
}
|
||||
|
||||
// Separator class for visual grouping in select/multiselect prompts
|
||||
// Note: @clack/prompts doesn't support separators natively, they are filtered out
|
||||
class Separator {
|
||||
constructor(text = '────────') {
|
||||
this.line = text;
|
||||
this.name = text;
|
||||
}
|
||||
type = 'separator';
|
||||
}
|
||||
|
||||
// Separator for choice lists (compatible interface)
|
||||
const choiceUtils = { Separator };
|
||||
|
||||
/**
|
||||
* UI utilities for the installer
|
||||
*/
|
||||
|
|
@ -100,11 +86,6 @@ class UI {
|
|||
// Check if there's an existing BMAD installation
|
||||
const hasExistingInstall = await fs.pathExists(bmadDir);
|
||||
|
||||
let customContentConfig = { hasCustomContent: false };
|
||||
if (!hasExistingInstall) {
|
||||
customContentConfig._shouldAsk = true;
|
||||
}
|
||||
|
||||
// Track action type (only set if there's an existing installation)
|
||||
let actionType;
|
||||
|
||||
|
|
@ -153,48 +134,9 @@ class UI {
|
|||
|
||||
// Handle quick update separately
|
||||
if (actionType === 'quick-update') {
|
||||
// Pass --custom-content through so installer can re-cache if cache is missing
|
||||
let customContentForQuickUpdate = { hasCustomContent: false };
|
||||
if (options.customContent) {
|
||||
const paths = options.customContent
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
if (paths.length > 0) {
|
||||
const customPaths = [];
|
||||
const selectedModuleIds = [];
|
||||
const sources = [];
|
||||
for (const customPath of paths) {
|
||||
const expandedPath = this.expandUserPath(customPath);
|
||||
const validation = this.validateCustomContentPathSync(expandedPath);
|
||||
if (validation) continue;
|
||||
let moduleMeta;
|
||||
try {
|
||||
const moduleYamlPath = path.join(expandedPath, 'module.yaml');
|
||||
moduleMeta = require('yaml').parse(await fs.readFile(moduleYamlPath, 'utf-8'));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!moduleMeta?.code) continue;
|
||||
customPaths.push(expandedPath);
|
||||
selectedModuleIds.push(moduleMeta.code);
|
||||
sources.push({ path: expandedPath, id: moduleMeta.code, name: moduleMeta.name || moduleMeta.code });
|
||||
}
|
||||
if (customPaths.length > 0) {
|
||||
customContentForQuickUpdate = {
|
||||
hasCustomContent: true,
|
||||
selected: true,
|
||||
sources,
|
||||
selectedFiles: customPaths.map((p) => path.join(p, 'module.yaml')),
|
||||
selectedModuleIds,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
actionType: 'quick-update',
|
||||
directory: confirmedDirectory,
|
||||
customContent: customContentForQuickUpdate,
|
||||
skipPrompts: options.yes || false,
|
||||
};
|
||||
}
|
||||
|
|
@ -225,120 +167,6 @@ class UI {
|
|||
selectedModules = await this.selectAllModules(installedModuleIds);
|
||||
}
|
||||
|
||||
// After module selection, ask about custom modules
|
||||
let customModuleResult = { selectedCustomModules: [], customContentConfig: { hasCustomContent: false } };
|
||||
|
||||
if (options.customContent) {
|
||||
// Use custom content from command-line
|
||||
const paths = options.customContent
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
await prompts.log.info(`Using custom content from command-line: ${paths.join(', ')}`);
|
||||
|
||||
// Build custom content config similar to promptCustomContentSource
|
||||
const customPaths = [];
|
||||
const selectedModuleIds = [];
|
||||
const sources = [];
|
||||
|
||||
for (const customPath of paths) {
|
||||
const expandedPath = this.expandUserPath(customPath);
|
||||
const validation = this.validateCustomContentPathSync(expandedPath);
|
||||
if (validation) {
|
||||
await prompts.log.warn(`Skipping invalid custom content path: ${customPath} - ${validation}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read module metadata
|
||||
let moduleMeta;
|
||||
try {
|
||||
const moduleYamlPath = path.join(expandedPath, 'module.yaml');
|
||||
const moduleYaml = await fs.readFile(moduleYamlPath, 'utf-8');
|
||||
const yaml = require('yaml');
|
||||
moduleMeta = yaml.parse(moduleYaml);
|
||||
} catch (error) {
|
||||
await prompts.log.warn(`Skipping custom content path: ${customPath} - failed to read module.yaml: ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!moduleMeta) {
|
||||
await prompts.log.warn(`Skipping custom content path: ${customPath} - module.yaml is empty`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!moduleMeta.code) {
|
||||
await prompts.log.warn(`Skipping custom content path: ${customPath} - module.yaml missing 'code' field`);
|
||||
continue;
|
||||
}
|
||||
|
||||
customPaths.push(expandedPath);
|
||||
selectedModuleIds.push(moduleMeta.code);
|
||||
sources.push({
|
||||
path: expandedPath,
|
||||
id: moduleMeta.code,
|
||||
name: moduleMeta.name || moduleMeta.code,
|
||||
});
|
||||
}
|
||||
|
||||
if (customPaths.length > 0) {
|
||||
customModuleResult = {
|
||||
selectedCustomModules: selectedModuleIds,
|
||||
customContentConfig: {
|
||||
hasCustomContent: true,
|
||||
selected: true,
|
||||
sources,
|
||||
selectedFiles: customPaths.map((p) => path.join(p, 'module.yaml')),
|
||||
selectedModuleIds: selectedModuleIds,
|
||||
},
|
||||
};
|
||||
}
|
||||
} else if (options.yes) {
|
||||
// Non-interactive mode: preserve existing custom modules (matches default: false)
|
||||
const cacheDir = path.join(bmadDir, '_config', 'custom');
|
||||
if (await fs.pathExists(cacheDir)) {
|
||||
const entries = await fs.readdir(cacheDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
customModuleResult.selectedCustomModules.push(entry.name);
|
||||
}
|
||||
}
|
||||
await prompts.log.info(
|
||||
`Non-interactive mode (--yes): preserving ${customModuleResult.selectedCustomModules.length} existing custom module(s)`,
|
||||
);
|
||||
} else {
|
||||
await prompts.log.info('Non-interactive mode (--yes): no existing custom modules found');
|
||||
}
|
||||
} else {
|
||||
const changeCustomModules = await prompts.confirm({
|
||||
message: 'Modify custom modules, agents, or workflows?',
|
||||
default: false,
|
||||
});
|
||||
|
||||
if (changeCustomModules) {
|
||||
customModuleResult = await this.handleCustomModulesInModifyFlow(confirmedDirectory, selectedModules);
|
||||
} else {
|
||||
// Preserve existing custom modules if user doesn't want to modify them
|
||||
const { Installer } = require('./core/installer');
|
||||
const installer = new Installer();
|
||||
const { bmadDir } = await installer.findBmadDir(confirmedDirectory);
|
||||
|
||||
const cacheDir = path.join(bmadDir, '_config', 'custom');
|
||||
if (await fs.pathExists(cacheDir)) {
|
||||
const entries = await fs.readdir(cacheDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
customModuleResult.selectedCustomModules.push(entry.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge any selected custom modules
|
||||
if (customModuleResult.selectedCustomModules.length > 0) {
|
||||
selectedModules.push(...customModuleResult.selectedCustomModules);
|
||||
}
|
||||
|
||||
// Ensure core is in the modules list
|
||||
if (!selectedModules.includes('core')) {
|
||||
selectedModules.unshift('core');
|
||||
|
|
@ -357,7 +185,6 @@ class UI {
|
|||
skipIde: toolSelection.skipIde,
|
||||
coreConfig: moduleConfigs.core || {},
|
||||
moduleConfigs: moduleConfigs,
|
||||
customContent: customModuleResult.customContentConfig,
|
||||
skipPrompts: options.yes || false,
|
||||
};
|
||||
}
|
||||
|
|
@ -383,84 +210,6 @@ class UI {
|
|||
selectedModules = await this.selectAllModules(installedModuleIds);
|
||||
}
|
||||
|
||||
// Ask about custom content (local modules/agents/workflows)
|
||||
if (options.customContent) {
|
||||
// Use custom content from command-line
|
||||
const paths = options.customContent
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
await prompts.log.info(`Using custom content from command-line: ${paths.join(', ')}`);
|
||||
|
||||
// Build custom content config similar to promptCustomContentSource
|
||||
const customPaths = [];
|
||||
const selectedModuleIds = [];
|
||||
const sources = [];
|
||||
|
||||
for (const customPath of paths) {
|
||||
const expandedPath = this.expandUserPath(customPath);
|
||||
const validation = this.validateCustomContentPathSync(expandedPath);
|
||||
if (validation) {
|
||||
await prompts.log.warn(`Skipping invalid custom content path: ${customPath} - ${validation}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read module metadata
|
||||
let moduleMeta;
|
||||
try {
|
||||
const moduleYamlPath = path.join(expandedPath, 'module.yaml');
|
||||
const moduleYaml = await fs.readFile(moduleYamlPath, 'utf-8');
|
||||
const yaml = require('yaml');
|
||||
moduleMeta = yaml.parse(moduleYaml);
|
||||
} catch (error) {
|
||||
await prompts.log.warn(`Skipping custom content path: ${customPath} - failed to read module.yaml: ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!moduleMeta) {
|
||||
await prompts.log.warn(`Skipping custom content path: ${customPath} - module.yaml is empty`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!moduleMeta.code) {
|
||||
await prompts.log.warn(`Skipping custom content path: ${customPath} - module.yaml missing 'code' field`);
|
||||
continue;
|
||||
}
|
||||
|
||||
customPaths.push(expandedPath);
|
||||
selectedModuleIds.push(moduleMeta.code);
|
||||
sources.push({
|
||||
path: expandedPath,
|
||||
id: moduleMeta.code,
|
||||
name: moduleMeta.name || moduleMeta.code,
|
||||
});
|
||||
}
|
||||
|
||||
if (customPaths.length > 0) {
|
||||
customContentConfig = {
|
||||
hasCustomContent: true,
|
||||
selected: true,
|
||||
sources,
|
||||
selectedFiles: customPaths.map((p) => path.join(p, 'module.yaml')),
|
||||
selectedModuleIds: selectedModuleIds,
|
||||
};
|
||||
}
|
||||
} else if (!options.yes) {
|
||||
const wantsCustomContent = await prompts.confirm({
|
||||
message: 'Add custom modules, agents, or workflows from your computer?',
|
||||
default: false,
|
||||
});
|
||||
|
||||
if (wantsCustomContent) {
|
||||
customContentConfig = await this.promptCustomContentSource();
|
||||
}
|
||||
}
|
||||
|
||||
// Add custom content modules if any were selected
|
||||
if (customContentConfig && customContentConfig.selectedModuleIds) {
|
||||
selectedModules.push(...customContentConfig.selectedModuleIds);
|
||||
}
|
||||
|
||||
// Ensure core is in the modules list
|
||||
if (!selectedModules.includes('core')) {
|
||||
selectedModules.unshift('core');
|
||||
|
|
@ -476,7 +225,6 @@ class UI {
|
|||
skipIde: toolSelection.skipIde,
|
||||
coreConfig: moduleConfigs.core || {},
|
||||
moduleConfigs: moduleConfigs,
|
||||
customContent: customContentConfig,
|
||||
skipPrompts: options.yes || false,
|
||||
};
|
||||
}
|
||||
|
|
@ -814,90 +562,6 @@ class UI {
|
|||
return configCollector.collectedConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get module choices for selection
|
||||
* @param {Set} installedModuleIds - Currently installed module IDs
|
||||
* @param {Object} customContentConfig - Custom content configuration
|
||||
* @returns {Array} Module choices for prompt
|
||||
*/
|
||||
async getModuleChoices(installedModuleIds, customContentConfig = null) {
|
||||
const color = await prompts.getColor();
|
||||
const moduleChoices = [];
|
||||
const isNewInstallation = installedModuleIds.size === 0;
|
||||
|
||||
const customContentItems = [];
|
||||
|
||||
// Add custom content items
|
||||
if (customContentConfig && customContentConfig.hasCustomContent && customContentConfig.customPath) {
|
||||
// Existing installation - show from directory
|
||||
const customHandler = new CustomHandler();
|
||||
const customFiles = await customHandler.findCustomContent(customContentConfig.customPath);
|
||||
|
||||
for (const customFile of customFiles) {
|
||||
const customInfo = await customHandler.getCustomInfo(customFile);
|
||||
if (customInfo) {
|
||||
customContentItems.push({
|
||||
name: `${color.cyan('\u2713')} ${customInfo.name} ${color.dim(`(${customInfo.relativePath})`)}`,
|
||||
value: `__CUSTOM_CONTENT__${customFile}`, // Unique value for each custom content
|
||||
checked: true, // Default to selected since user chose to provide custom content
|
||||
path: customInfo.path, // Track path to avoid duplicates
|
||||
hint: customInfo.description || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add official modules
|
||||
const { OfficialModules } = require('./modules/official-modules');
|
||||
const officialModules = new OfficialModules();
|
||||
const { modules: availableModules, customModules: customModulesFromCache } = await officialModules.listAvailable();
|
||||
|
||||
// First, add all items to appropriate sections
|
||||
const allCustomModules = [];
|
||||
|
||||
// Add custom content items from directory
|
||||
allCustomModules.push(...customContentItems);
|
||||
|
||||
// Add custom modules from cache
|
||||
for (const mod of customModulesFromCache) {
|
||||
// Skip if this module is already in customContentItems (by path)
|
||||
const isDuplicate = allCustomModules.some((item) => item.path && mod.path && path.resolve(item.path) === path.resolve(mod.path));
|
||||
|
||||
if (!isDuplicate) {
|
||||
allCustomModules.push({
|
||||
name: `${color.cyan('\u2713')} ${mod.name} ${color.dim('(cached)')}`,
|
||||
value: mod.id,
|
||||
checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id),
|
||||
hint: mod.description || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add separators and modules in correct order
|
||||
if (allCustomModules.length > 0) {
|
||||
// Add separator for custom content, all custom modules, and official content separator
|
||||
moduleChoices.push(
|
||||
new choiceUtils.Separator('── Custom Content ──'),
|
||||
...allCustomModules,
|
||||
new choiceUtils.Separator('── Official Content ──'),
|
||||
);
|
||||
}
|
||||
|
||||
// Add official modules (only non-custom ones)
|
||||
for (const mod of availableModules) {
|
||||
if (!mod.isCustom) {
|
||||
moduleChoices.push({
|
||||
name: mod.name,
|
||||
value: mod.id,
|
||||
checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id),
|
||||
hint: mod.description || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return moduleChoices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select all modules (official + community) using grouped multiselect.
|
||||
* Core is shown as locked but filtered from the result since it's always installed separately.
|
||||
|
|
@ -941,7 +605,7 @@ class UI {
|
|||
// Local modules (BMM, BMB, etc.)
|
||||
const localEntries = [];
|
||||
for (const mod of localModules) {
|
||||
if (!mod.isCustom && mod.id !== 'core') {
|
||||
if (mod.id !== 'core') {
|
||||
const entry = await buildModuleEntry(mod, mod.id, 'Local');
|
||||
localEntries.push(entry);
|
||||
if (entry.selected) {
|
||||
|
|
@ -1316,282 +980,6 @@ class UI {
|
|||
return existingInstall.ides;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate custom content path synchronously
|
||||
* @param {string} input - User input path
|
||||
* @returns {string|undefined} Error message or undefined if valid
|
||||
*/
|
||||
validateCustomContentPathSync(input) {
|
||||
// Allow empty input to cancel
|
||||
if (!input || input.trim() === '') {
|
||||
return; // Allow empty to exit
|
||||
}
|
||||
|
||||
try {
|
||||
// Expand the path
|
||||
const expandedPath = this.expandUserPath(input.trim());
|
||||
|
||||
// Check if path exists
|
||||
if (!fs.pathExistsSync(expandedPath)) {
|
||||
return 'Path does not exist';
|
||||
}
|
||||
|
||||
// Check if it's a directory
|
||||
const stat = fs.statSync(expandedPath);
|
||||
if (!stat.isDirectory()) {
|
||||
return 'Path must be a directory';
|
||||
}
|
||||
|
||||
// Check for module.yaml in the root
|
||||
const moduleYamlPath = path.join(expandedPath, 'module.yaml');
|
||||
if (!fs.pathExistsSync(moduleYamlPath)) {
|
||||
return 'Directory must contain a module.yaml file in the root';
|
||||
}
|
||||
|
||||
// Try to parse the module.yaml to get the module ID
|
||||
try {
|
||||
const yaml = require('yaml');
|
||||
const content = fs.readFileSync(moduleYamlPath, 'utf8');
|
||||
const moduleData = yaml.parse(content);
|
||||
if (!moduleData.code) {
|
||||
return 'module.yaml must contain a "code" field for the module ID';
|
||||
}
|
||||
} catch (error) {
|
||||
return 'Invalid module.yaml file: ' + error.message;
|
||||
}
|
||||
|
||||
return; // Valid
|
||||
} catch (error) {
|
||||
return 'Error validating path: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt user for custom content source location
|
||||
* @returns {Object} Custom content configuration
|
||||
*/
|
||||
async promptCustomContentSource() {
|
||||
const customContentConfig = { hasCustomContent: true, sources: [] };
|
||||
|
||||
// Keep asking for more sources until user is done
|
||||
while (true) {
|
||||
// First ask if user wants to add another module or continue
|
||||
if (customContentConfig.sources.length > 0) {
|
||||
const action = await prompts.select({
|
||||
message: 'Would you like to:',
|
||||
choices: [
|
||||
{ name: 'Add another custom module', value: 'add' },
|
||||
{ name: 'Continue with installation', value: 'continue' },
|
||||
],
|
||||
default: 'continue',
|
||||
});
|
||||
|
||||
if (action === 'continue') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let sourcePath;
|
||||
let isValid = false;
|
||||
|
||||
while (!isValid) {
|
||||
// Use sync validation because @clack/prompts doesn't support async validate
|
||||
const inputPath = await prompts.text({
|
||||
message: 'Path to custom module folder (press Enter to skip):',
|
||||
validate: (input) => this.validateCustomContentPathSync(input),
|
||||
});
|
||||
|
||||
// If user pressed Enter without typing anything, exit the loop
|
||||
if (!inputPath || inputPath.trim() === '') {
|
||||
// If we have no modules yet, return false for no custom content
|
||||
if (customContentConfig.sources.length === 0) {
|
||||
return { hasCustomContent: false };
|
||||
}
|
||||
return customContentConfig;
|
||||
}
|
||||
|
||||
sourcePath = this.expandUserPath(inputPath);
|
||||
isValid = true;
|
||||
}
|
||||
|
||||
// Read module.yaml to get module info
|
||||
const yaml = require('yaml');
|
||||
const moduleYamlPath = path.join(sourcePath, 'module.yaml');
|
||||
const moduleContent = await fs.readFile(moduleYamlPath, 'utf8');
|
||||
const moduleData = yaml.parse(moduleContent);
|
||||
|
||||
// Add to sources
|
||||
customContentConfig.sources.push({
|
||||
path: sourcePath,
|
||||
id: moduleData.code,
|
||||
name: moduleData.name || moduleData.code,
|
||||
});
|
||||
|
||||
await prompts.log.success(`Confirmed local custom module: ${moduleData.name || moduleData.code}`);
|
||||
}
|
||||
|
||||
// Ask if user wants to add these to the installation
|
||||
const shouldInstall = await prompts.confirm({
|
||||
message: `Install these ${customContentConfig.sources.length} custom modules?`,
|
||||
default: true,
|
||||
});
|
||||
|
||||
if (shouldInstall) {
|
||||
customContentConfig.selected = true;
|
||||
// Store paths to module.yaml files, not directories
|
||||
customContentConfig.selectedFiles = customContentConfig.sources.map((s) => path.join(s.path, 'module.yaml'));
|
||||
// Also include module IDs for installation
|
||||
customContentConfig.selectedModuleIds = customContentConfig.sources.map((s) => s.id);
|
||||
}
|
||||
|
||||
return customContentConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle custom modules in the modify flow
|
||||
* @param {string} directory - Installation directory
|
||||
* @param {Array} selectedModules - Currently selected modules
|
||||
* @returns {Object} Result with selected custom modules and custom content config
|
||||
*/
|
||||
async handleCustomModulesInModifyFlow(directory, selectedModules) {
|
||||
// Get existing installation to find custom modules
|
||||
const { existingInstall } = await this.getExistingInstallation(directory);
|
||||
|
||||
// Check if there are any custom modules in cache
|
||||
const { Installer } = require('./core/installer');
|
||||
const installer = new Installer();
|
||||
const { bmadDir } = await installer.findBmadDir(directory);
|
||||
|
||||
const cacheDir = path.join(bmadDir, '_config', 'custom');
|
||||
const cachedCustomModules = [];
|
||||
|
||||
if (await fs.pathExists(cacheDir)) {
|
||||
const entries = await fs.readdir(cacheDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const moduleYamlPath = path.join(cacheDir, entry.name, 'module.yaml');
|
||||
if (await fs.pathExists(moduleYamlPath)) {
|
||||
const yaml = require('yaml');
|
||||
const content = await fs.readFile(moduleYamlPath, 'utf8');
|
||||
const moduleData = yaml.parse(content);
|
||||
|
||||
cachedCustomModules.push({
|
||||
id: entry.name,
|
||||
name: moduleData.name || entry.name,
|
||||
description: moduleData.description || 'Custom module from cache',
|
||||
checked: selectedModules.includes(entry.name),
|
||||
fromCache: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
selectedCustomModules: [],
|
||||
customContentConfig: { hasCustomContent: false },
|
||||
};
|
||||
|
||||
// Ask user about custom modules
|
||||
await prompts.log.info('Custom Modules');
|
||||
if (cachedCustomModules.length > 0) {
|
||||
await prompts.log.message('Found custom modules in your installation:');
|
||||
} else {
|
||||
await prompts.log.message('No custom modules currently installed.');
|
||||
}
|
||||
|
||||
// Build choices dynamically based on whether we have existing modules
|
||||
const choices = [];
|
||||
if (cachedCustomModules.length > 0) {
|
||||
choices.push(
|
||||
{ name: 'Keep all existing custom modules', value: 'keep' },
|
||||
{ name: 'Select which custom modules to keep', value: 'select' },
|
||||
{ name: 'Add new custom modules', value: 'add' },
|
||||
{ name: 'Remove all custom modules', value: 'remove' },
|
||||
);
|
||||
} else {
|
||||
choices.push({ name: 'Add new custom modules', value: 'add' }, { name: 'Cancel (no custom modules)', value: 'cancel' });
|
||||
}
|
||||
|
||||
const customAction = await prompts.select({
|
||||
message: cachedCustomModules.length > 0 ? 'Manage custom modules?' : 'Add custom modules?',
|
||||
choices: choices,
|
||||
default: cachedCustomModules.length > 0 ? 'keep' : 'add',
|
||||
});
|
||||
|
||||
switch (customAction) {
|
||||
case 'keep': {
|
||||
// Keep all existing custom modules
|
||||
result.selectedCustomModules = cachedCustomModules.map((m) => m.id);
|
||||
await prompts.log.message(`Keeping ${result.selectedCustomModules.length} custom module(s)`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'select': {
|
||||
// Let user choose which to keep
|
||||
const selectChoices = cachedCustomModules.map((m) => ({
|
||||
name: `${m.name} (${m.id})`,
|
||||
value: m.id,
|
||||
checked: m.checked,
|
||||
}));
|
||||
|
||||
// Add "None / I changed my mind" option at the end
|
||||
const choicesWithSkip = [
|
||||
...selectChoices,
|
||||
{
|
||||
name: '⚠ None / I changed my mind - keep no custom modules',
|
||||
value: '__NONE__',
|
||||
checked: false,
|
||||
},
|
||||
];
|
||||
|
||||
const keepModules = await prompts.multiselect({
|
||||
message: 'Select custom modules to keep (use arrow keys, space to toggle):',
|
||||
choices: choicesWithSkip,
|
||||
required: true,
|
||||
});
|
||||
|
||||
// If user selected both "__NONE__" and other modules, honor the "None" choice
|
||||
if (keepModules && keepModules.includes('__NONE__') && keepModules.length > 1) {
|
||||
await prompts.log.warn('"None / I changed my mind" was selected, so no custom modules will be kept.');
|
||||
result.selectedCustomModules = [];
|
||||
} else {
|
||||
// Filter out the special '__NONE__' value
|
||||
result.selectedCustomModules = keepModules ? keepModules.filter((m) => m !== '__NONE__') : [];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'add': {
|
||||
// By default, keep existing modules when adding new ones
|
||||
// User chose "Add new" not "Replace", so we assume they want to keep existing
|
||||
result.selectedCustomModules = cachedCustomModules.map((m) => m.id);
|
||||
|
||||
// Then prompt for new ones (reuse existing method)
|
||||
const newCustomContent = await this.promptCustomContentSource();
|
||||
if (newCustomContent.hasCustomContent && newCustomContent.selected) {
|
||||
result.selectedCustomModules.push(...newCustomContent.selectedModuleIds);
|
||||
result.customContentConfig = newCustomContent;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'remove': {
|
||||
// Remove all custom modules
|
||||
await prompts.log.warn('All custom modules will be removed from the installation');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'cancel': {
|
||||
// User cancelled - no custom modules
|
||||
await prompts.log.message('No custom modules will be added');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display module versions with update availability
|
||||
* @param {Array} modules - Array of module info objects with version info
|
||||
|
|
|
|||
Loading…
Reference in New Issue