Compare commits

...

8 Commits

Author SHA1 Message Date
Murat K Ozcan fc4c2eff74
Merge 1cc433e543 into d09363b1b2 2026-04-18 13:32:06 -04:00
Alex Verkhovsky d09363b1b2
feat(installer): use GitHub API as primary fetch with raw CDN fallback (#2248)
* feat(installer): use GitHub API as primary fetch with raw CDN fallback

Corporate proxies commonly block raw.githubusercontent.com while allowing
api.github.com. Add fetchGitHubFile() to RegistryClient that tries the
GitHub Contents API first, falling back to the raw CDN transparently.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(installer): cap redirect depth and preserve dual-fallback errors

Add maxRedirects parameter to fetch() and _fetchWithHeaders() to prevent
unbounded redirect recursion. Wrap CDN fallback in try/catch and throw
AggregateError with both API and CDN errors for better diagnostics.
Extract marketplace repo coordinates into named constants in
external-manager.

* chore(installer): drop unused fetchJson and fetchGitHubJson

Neither method has any callers. Also drop the corresponding test.

* refactor(test): fold registry tests into test-installation-components

No reason for RegistryClient tests to be a separate runner — the same
file already tests the registry consumers in Suite 33. Drop test:registry
from package.json scripts and quality gate.

* fix(installer): include URL, API message, and rate-limit info in HTTP errors

Non-2xx responses previously yielded bare `HTTP 403`. Now surface the
request URL, GitHub's JSON error message (or body snippet), X-RateLimit-Reset
when quota is exhausted, and Retry-After. Turns a mystery 403 into
'rate limit exhausted; resets at 2026-04-15T18:00:00Z' — the difference
between 'try GITHUB_TOKEN' and a wild goose chase.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 08:53:23 -07:00
Brian 6b964acd56
Merge pull request #2254 from lrliang/docs/zh-cn-missing-translations
docs(zh-cn): add missing Chinese translations
2026-04-13 18:13:06 -05:00
Brian 723bca4e38
Merge branch 'main' into docs/zh-cn-missing-translations 2026-04-13 18:12:56 -05:00
Brian 262fa882ef
Merge pull request #2257 from bmad-code-org/issue-fixes
fix(installer): add missing fs-native exports
2026-04-13 18:06:56 -05:00
Brian Madison 0f958cf713 fix(installer): add missing sync and async methods to fs-native wrapper
Closes #2256
2026-04-13 09:59:41 -05:00
梁山河 8ee35aaea3
Merge branch 'main' into docs/zh-cn-missing-translations 2026-04-13 13:55:50 +08:00
leon 10c194c2a6 docs(zh-cn): add missing Chinese translations for 3 documents
Translate the remaining untranslated English docs to Chinese:
- explanation/analysis-phase.md
- explanation/checkpoint-preview.md
- how-to/install-custom-modules.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 10:35:23 +08:00
8 changed files with 606 additions and 15 deletions

View File

@ -0,0 +1,70 @@
---
title: "分析阶段:从想法到基础"
description: 头脑风暴、调研、产品简报和 PRFAQ 分别是什么——以及何时使用
sidebar:
order: 1
---
分析阶段Phase 1帮助你在决定动手构建之前把产品想清楚。这个阶段的每个工具都是可选的但如果完全跳过分析你的 PRD 就是建立在假设而非洞察之上。
## 为什么先分析再规划?
PRD 回答的是"我们应该构建什么、为什么?"如果输入的是模糊的思考,得到的就是模糊的 PRD——而下游的每一份文档都会继承这种模糊。基于薄弱 PRD 搭建的架构会押错技术方向;从薄弱架构派生的 story 会遗漏边界场景。代价是层层叠加的。
分析工具的作用就是让你的 PRD 变得锐利。它们从不同角度攻击问题——创意探索、市场现实、客户画像、可行性——这样当你坐下来和 PM agent 协作时,你已经清楚要构建什么、为谁构建。
## 工具介绍
### 头脑风暴
**是什么。** 一个使用经过验证的创意技法的引导式创意会议。AI 充当教练,通过结构化练习从你身上引出想法——而不是替你生成想法。
**为什么在这里。** 原始想法需要发展空间,然后才能被锁定为需求。头脑风暴创造了这个空间。当你有一个问题领域但还没有清晰的解决方案时,或者你想在确定方向之前探索多种可能性时,它尤其有价值。
**何时使用。** 你对想要构建什么有一个模糊的感觉,但概念尚未结晶。或者你有了概念,但想在备选方案中做压力测试。
详见[头脑风暴](./brainstorming.md)了解会议的具体运作方式。
### 调研(市场、领域、技术)
**是什么。** 三个聚焦的调研工作流,分别调查你的想法的不同维度。市场调研考察竞争对手、趋势和用户情绪;领域调研建立专业知识和术语体系;技术调研评估可行性、架构选项和实现方案。
**为什么在这里。** 基于假设构建产品是最快做出没人需要的东西的方式。调研让你的概念扎根于现实——已有哪些竞争对手、用户真正的痛点是什么、技术上是否可行、所在行业有哪些特定约束。
**何时使用。** 你正在进入一个不熟悉的领域,你怀疑竞品存在但还没有做过梳理,或者你的概念依赖于尚未验证的技术能力。可以只做一项、两项或三项全做——每项都是独立的。
### 产品简报
**是什么。** 一个引导式发现会议,输出 1-2 页的产品概念执行摘要。AI 充当协作式业务分析师,帮你阐明愿景、目标受众、价值主张和范围。
**为什么在这里。** 产品简报是进入规划阶段的较温和路径。它以结构化格式捕获你的战略愿景,可以直接输入到 PRD 的创建中。当你已经对概念有了信心——你了解客户、了解问题、大致知道想构建什么时——它效果最好。简报的作用是组织和打磨这些思考。
**何时使用。** 你的概念相对清晰,希望在创建 PRD 之前高效地记录下来。你对方向有信心,不需要有人来激烈挑战你的假设。
### PRFAQ逆向工作法
**是什么。** 亚马逊的逆向工作法Working Backwards改编为交互式挑战。你在写一行代码之前先撰写宣布成品的新闻稿然后回答客户和利益相关者会提出的最刁钻的问题。AI 充当不留情面但有建设性的产品教练。
**为什么在这里。** PRFAQ 是进入规划阶段的严格路径。它通过让你为每一个论断辩护,来强制实现以客户为中心的清晰度。如果你写不出一篇有说服力的新闻稿,说明产品还没准备好。如果客户 FAQ 的回答暴露了缺口,那些就是你在实现阶段才会——以更高代价——发现的缺口。这道关卡在成本最低的时候暴露薄弱的思考。
**何时使用。** 你希望在投入资源之前对概念进行压力测试。你不确定用户是否真的在意。你想验证自己能否阐述一个清晰、站得住脚的价值主张。或者你只是想借助逆向工作法的纪律来打磨你的思考。
## 我该用哪个?
| 情境 | 推荐工具 |
| ---- | -------- |
| "我有一个模糊的想法,不知道从哪里开始" | 头脑风暴 |
| "我需要先了解市场再做决定" | 调研 |
| "我知道要构建什么,只需要记录下来" | 产品简报 |
| "我想确认这个想法是否真的值得构建" | PRFAQ |
| "我想先探索,再验证,再记录" | 头脑风暴 → 调研 → PRFAQ 或 简报 |
产品简报和 PRFAQ 都会为 PRD 提供输入——根据你想要多大程度的挑战来选择。简报是协作式发现PRFAQ 是严格的关卡挑战。两者通往同一个目的地PRFAQ 检验你的概念是否配得上到达那里。
:::tip[不确定?]
运行 `bmad-help`,描述你的情况。它会根据你已经做了什么、想达成什么来推荐合适的起点。
:::
## 分析之后呢?
分析阶段的输出直接进入 Phase 2规划。PRD 工作流接受产品简报、PRFAQ 文档、调研成果和头脑风暴报告作为输入——它会将你产出的所有内容综合成结构化需求。分析做得越充分PRD 就越锐利。

View File

@ -0,0 +1,92 @@
---
title: "检查点预览"
description: LLM 辅助的人机协作审查,引导你从目的到细节逐步走过一个变更
sidebar:
order: 3
---
`bmad-checkpoint-preview` 是一个交互式的、LLM 辅助的人机协作审查工作流。它带你逐步走过一个代码变更——从目的和上下文到细节——让你能做出知情决策:是发布、返工,还是深入挖掘。
![检查点预览工作流图](/diagrams/checkpoint-preview-diagram.png)
## 典型流程
你运行 `bmad-quick-dev`。它澄清你的意图、构建规范、实现变更,完成后将审查线索追加到 spec 文件并在编辑器中打开。你查看 spec发现这次变更涉及跨多个模块的 20 个文件。
你可以肉眼扫一遍 diff。但 20 个文件正是肉眼审查开始失效的临界点——你会丢失线索,漏掉两个相距甚远的变更之间的关联,或者批准了自己没有完全理解的东西。所以你改为说 "checkpoint",让 LLM 带你走一遍。
这种交接——从自主实现回到人工判断——就是核心使用场景。Quick-dev 以最少的监督长时间运行,检查点预览则是你重新掌舵的地方。
## 为什么需要它
代码审查有两种失败模式。一种是审查者浏览 diff什么也没发现直接批准。另一种是逐文件仔细阅读但丢失了全局线索——见树不见林。两种模式的结果相同审查没有抓住真正重要的东西。
根本问题在于顺序。原始 diff 按文件顺序呈现变更,而这几乎从来不是构建理解的顺序。你先看到一个辅助函数,却不知道它存在的原因;先看到一个 schema 变更,却不了解它支撑什么功能。审查者必须从零散的线索中重建作者的意图,而这个重建过程正是注意力失效的地方。
检查点预览通过让 LLM 完成重建工作来解决这个问题。它读取 diff、spec如果有的话和周围的代码库然后按照有利于理解的顺序——而不是 `git diff` 的顺序——呈现变更。
## 工作原理
工作流分为五个步骤。每一步都建立在前一步的基础上,逐步从"这是什么?"过渡到"我们该不该发布?"
### 1. 定向
工作流识别变更来源(来自 PR、commit、分支、spec 文件或当前 git 状态),生成一行意图摘要以及表面积统计:变更文件数、涉及模块数、逻辑行数、边界穿越数和新增公共接口数。
这是"这是不是我以为的那个东西?"的时刻。在阅读任何代码之前,审查者确认自己看的是正确的东西,并对范围建立预期。
### 2. 走查
变更按**关注点**——而非按文件——组织。关注点是内聚的设计意图,例如"输入验证"或"API 契约"。每个关注点附带简短说明——*为什么选择这种方案*,然后列出可点击的 `path:line` 停靠点,审查者可以沿着这些停靠点在代码中导航。
这是设计判断步骤。审查者评估的是方案对系统是否合理,而不是代码是否正确。关注点按自顶向下排列:最高层意图在前,支撑实现在后。审查者永远不会遇到引用了自己尚未看过的内容。
### 3. 细节审视
在审查者理解了设计之后,工作流浮出 2-5 个"出错代价最高"的位置。这些位置按风险类别标记——`[auth]`、`[schema]`、`[billing]`、`[public API]`、`[security]` 等——并按出错后的影响范围排序。
这不是找 bug。自动化测试和 CI 负责正确性。细节审视激活的是风险意识:"这些是出错成本最高的地方。"如果审查者想在某个领域深入,可以说 "dig into [area]" 来触发一次聚焦正确性的重新审查。
如果 spec 经过了对抗性审查循环(机器硬化),那些发现也会在这里浮出——不是已修复的 bug而是审查循环标记出的、审查者应当知晓的决策。
### 4. 测试
建议 2-5 种手动观察变更生效的方式。不是自动化测试命令——而是能构建信心、但测试套件无法提供的手动观察。一个可以尝试的 UI 交互、一条可以运行的 CLI 命令、一个可以发送的 API 请求,以及每项的预期结果。
如果变更没有用户可见的行为,它会明确说明。不发明多余的忙活。
### 5. 总结
审查者做出决定:批准、返工或继续讨论。如果批准 PR工作流可以协助执行 `gh pr review --approve`。如果需要返工它帮助诊断问题出在方案、spec 还是实现,并帮助起草与具体代码位置关联的可操作反馈。
## 它是对话,不是报告
工作流将每一步呈现为起点,而非定论。在步骤之间——或步骤中间——你可以与 LLM 对话、提问、挑战它的框架,或调用其他技能来获取不同视角:
- **"run advanced elicitation on the error handling"** — 推动 LLM 重新思考并细化对特定领域的分析
- **"party mode on whether this schema migration is safe"** — 引入多个 agent 视角进行聚焦辩论
- **"run code review"** — 生成包含对抗性和边界场景分析的结构化 agentic 审查报告
检查点工作流不会把你锁在线性路径上。它在你需要结构时提供结构,在你想探索时让开。五个步骤确保你看到全貌,但每一步深入到什么程度——以及调用什么工具——完全由你决定。
## 审查线索
走查步骤在有**建议审查顺序**时效果最好——这是 spec 作者编写的停靠点列表,用于引导审查者走过变更。当 spec 包含此内容时,工作流直接使用它。
当没有作者提供的线索时,工作流会从 diff 和代码库上下文生成一份。生成的线索质量不如作者编写的,但远好于按文件顺序阅读变更。
## 何时使用
主要场景是 `bmad-quick-dev` 的交接实现完成spec 文件在编辑器中打开并追加了审查线索,你需要决定是否发布。说 "checkpoint" 即可开始。
它也可以独立使用:
- **审查 PR** — 尤其是涉及多个文件或跨模块变更的 PR
- **了解一个变更** — 当你需要理解一个不是你写的分支上发生了什么
- **Sprint 审查** — 工作流可以提取 sprint 状态文件中标记为 `review` 的 story
通过说 "checkpoint" 或 "walk me through this change" 来调用。它在任何终端中都能工作,但在 IDE 中——VS Code、Cursor 或类似工具——你会获得更多,因为工作流在每一步都生成 `path:line` 引用。在嵌入 IDE 的终端中,这些引用是可点击的,你可以沿着审查线索在文件间跳转。
## 它不是什么
检查点预览不是自动化审查的替代品。它不运行 linter、类型检查器或测试套件。它不打分也不给出通过/不通过的判定。它是一份阅读指南,帮助人类在最重要的地方运用自己的判断力。

View File

@ -0,0 +1,180 @@
---
title: "安装自定义和社区模块"
description: 从社区注册表、Git 仓库或本地路径安装第三方模块
sidebar:
order: 3
---
使用 BMad 安装程序从社区注册表、第三方 Git 仓库或本地文件路径添加模块。
## 何时使用
- 从 BMad 注册表安装社区贡献的模块
- 从第三方 Git 仓库安装模块GitHub、GitLab、Bitbucket、自托管
- 使用 BMad Builder 测试本地开发中的模块
- 从私有或自托管 Git 服务器安装模块
:::note[前置条件]
需要 [Node.js](https://nodejs.org) v20+ 和 `npx`npm 自带)。自定义和社区模块可以在全新安装时选择,也可以添加到现有安装中。
:::
## 社区模块
社区模块收录在 [BMad 插件市场](https://github.com/bmad-code-org/bmad-plugins-marketplace)。它们按类别组织,并锁定在经过审核的 commit 上以确保安全。
### 1. 运行安装程序
```bash
npx bmad-method install
```
### 2. 浏览社区目录
选择官方模块后,安装程序会询问:
```
Would you like to browse community modules?
```
选择 **Yes** 进入目录浏览器。你可以:
- 按类别浏览
- 查看推荐模块
- 查看所有可用模块
- 按关键词搜索
### 3. 选择模块
从任意类别中选取模块。安装程序显示描述、版本和信任等级。已安装的模块会预选以便更新。
### 4. 继续安装
选择社区模块后,安装程序将继续到自定义来源,然后是工具/IDE 配置及其余安装流程。
## 自定义来源Git URL 和本地路径)
自定义模块可以来自任何 Git 仓库或本地目录。安装程序会解析来源、分析模块结构,并将其与其他模块一起安装。
### 交互式安装
安装过程中,在社区模块步骤之后,安装程序会询问:
```
Would you like to install from a custom source (Git URL or local path)?
```
选择 **Yes**,然后提供来源:
| 输入类型 | 示例 |
| -------- | ---- |
| HTTPS URL任意主机 | `https://github.com/org/repo` |
| 带子目录的 HTTPS URL | `https://github.com/org/repo/tree/main/my-module` |
| SSH URL | `git@github.com:org/repo.git` |
| 本地路径 | `/Users/me/projects/my-module` |
| 使用 ~ 的本地路径 | `~/projects/my-module` |
安装程序会克隆仓库URL 来源)或直接从磁盘读取(本地路径),然后展示发现的模块供你选择。
### 非交互式安装
使用 `--custom-source` 标志从命令行安装自定义模块:
```bash
npx bmad-method install \
--directory . \
--custom-source /path/to/my-module \
--tools claude-code \
--yes
```
提供 `--custom-source` 但未指定 `--modules` 时,只安装 core 和自定义模块。要同时包含官方模块,需添加 `--modules`
```bash
npx bmad-method install \
--directory . \
--modules bmm \
--custom-source https://gitlab.com/myorg/my-module \
--tools claude-code \
--yes
```
多个来源可用逗号分隔:
```bash
--custom-source /path/one,https://github.com/org/repo,/path/two
```
## 模块发现机制
安装程序使用两种模式在来源中查找可安装的模块:
| 模式 | 触发条件 | 行为 |
| ---- | -------- | ---- |
| 发现模式 | 来源包含 `.claude-plugin/marketplace.json` | 列出清单中的所有插件;你选择要安装哪些 |
| 直接模式 | 未找到 marketplace.json | 扫描目录中的 skill包含 `SKILL.md` 的子目录),作为单个模块解析 |
发现模式适用于已发布的模块。直接模式适合本地开发时指向 skills 目录。
:::note[关于 `.claude-plugin/`]
`.claude-plugin/marketplace.json` 路径是多个 AI 工具安装程序采用的标准约定,用于插件可发现性。它不依赖 Claude不使用 Claude API也不影响你使用哪个 AI 工具。任何包含此文件的模块都可以被遵循此约定的安装程序发现。
:::
## 本地开发工作流
如果你正在使用 [BMad Builder](https://github.com/bmad-code-org/bmad-builder) 构建模块,可以直接从工作目录安装:
```bash
npx bmad-method install \
--directory ~/my-project \
--custom-source ~/my-module-repo/skills \
--tools claude-code \
--yes
```
本地来源通过路径引用,不会复制到缓存。当你更新模块源码并重新安装时,安装程序会获取最新变更。
:::caution[来源移除]
如果你在安装后删除了本地来源目录,`_bmad/` 中已安装的模块文件会保留。在恢复来源路径之前,该模块在更新时会被跳过。
:::
## 安装结果
安装后,自定义模块与官方模块一起出现在 `_bmad/` 中:
```
your-project/
├── _bmad/
│ ├── core/ # 内置核心模块
│ ├── bmm/ # 官方模块(如已选择)
│ ├── my-module/ # 你的自定义模块
│ │ ├── my-skill/
│ │ │ └── SKILL.md
│ │ └── module-help.csv
│ └── _config/
│ └── manifest.yaml # 跟踪所有模块、版本和来源
└── ...
```
manifest 记录每个自定义模块的来源Git 来源为 `repoUrl`,本地来源为 `localPath`),以便快速更新时能重新定位来源。
## 更新自定义模块
自定义模块参与正常的更新流程:
- **快速更新**`--action quick-update`):从原始来源刷新所有模块。基于 Git 的模块会重新拉取;本地模块会从来源路径重新读取。
- **完整更新**:重新运行模块选择,你可以添加或移除自定义模块。
## 创建自己的模块
使用 [BMad Builder](https://github.com/bmad-code-org/bmad-builder) 创建可供他人安装的模块:
1. 运行 `bmad-module-builder` 搭建模块结构
2. 使用各种 BMad Builder 工具添加 skill、agent 和 workflow
3. 发布到 Git 仓库或共享文件夹集合
4. 他人使用 `--custom-source <your-repo-url>` 安装
要让模块支持发现模式,请在仓库根目录包含 `.claude-plugin/marketplace.json`(这是跨工具约定,非 Claude 专属)。格式详见 [BMad Builder 文档](https://github.com/bmad-code-org/bmad-builder)。
:::tip[先在本地测试]
开发期间,使用本地路径安装模块以快速迭代,发布到 Git 仓库之前先确认一切正常。
:::

View File

@ -1926,6 +1926,112 @@ async function runTests() {
console.log(''); console.log('');
// ============================================================
// Test Suite 34: RegistryClient GitHub API Cascade
// ============================================================
console.log(`${colors.yellow}Test Suite 34: RegistryClient GitHub API Cascade${colors.reset}\n`);
{
const { RegistryClient } = require('../tools/installer/modules/registry-client');
// Build a RegistryClient with stubbed fetch paths so we can assert on cascade behavior
// without making real network calls.
function createStubbedClient({ apiResult, rawResult }) {
const client = new RegistryClient();
const calls = [];
// Stub _fetchWithHeaders (GitHub API path)
client._fetchWithHeaders = async (url) => {
calls.push(`api:${url}`);
if (apiResult instanceof Error) throw apiResult;
return apiResult;
};
// Stub fetch (raw CDN path) — only intercept raw.githubusercontent.com calls
const originalFetch = client.fetch.bind(client);
client.fetch = async (url, timeout) => {
if (url.includes('raw.githubusercontent.com')) {
calls.push(`raw:${url}`);
if (rawResult instanceof Error) throw rawResult;
return rawResult;
}
return originalFetch(url, timeout);
};
return { client, calls };
}
// --- API success skips raw CDN ---
{
const { client, calls } = createStubbedClient({ apiResult: 'api-content', rawResult: 'raw-content' });
const result = await client.fetchGitHubFile('owner', 'repo', 'path/file.txt', 'main');
assert(result === 'api-content', 'RegistryClient API success returns API content');
assert(calls.length === 1, 'RegistryClient API success makes exactly one call');
assert(calls[0].startsWith('api:'), 'RegistryClient API success calls API endpoint');
}
// --- API failure falls back to raw CDN ---
{
const { client, calls } = createStubbedClient({ apiResult: new Error('HTTP 403'), rawResult: 'raw-content' });
const result = await client.fetchGitHubFile('owner', 'repo', 'path/file.txt', 'main');
assert(result === 'raw-content', 'RegistryClient API failure returns raw CDN content');
assert(calls.length === 2, 'RegistryClient API failure makes two calls');
assert(calls[0].startsWith('api:'), 'RegistryClient first call is to API');
assert(calls[1].startsWith('raw:'), 'RegistryClient second call is to raw CDN');
}
// --- Both endpoints failing throws ---
{
const { client } = createStubbedClient({ apiResult: new Error('HTTP 403'), rawResult: new Error('HTTP 404') });
let threw = false;
try {
await client.fetchGitHubFile('owner', 'repo', 'path/file.txt', 'main');
} catch {
threw = true;
}
assert(threw, 'RegistryClient both endpoints failing throws an error');
}
// --- API URL construction ---
{
const { client, calls } = createStubbedClient({ apiResult: 'content', rawResult: 'content' });
await client.fetchGitHubFile('bmad-code-org', 'bmad-plugins-marketplace', 'registry/official.yaml', 'main');
const apiCall = calls[0];
assert(
apiCall.includes('api.github.com/repos/bmad-code-org/bmad-plugins-marketplace/contents/registry/official.yaml'),
'RegistryClient API URL contains correct path',
);
assert(apiCall.includes('ref=main'), 'RegistryClient API URL contains ref parameter');
}
// --- Raw CDN URL construction ---
{
const { client, calls } = createStubbedClient({ apiResult: new Error('fail'), rawResult: 'content' });
await client.fetchGitHubFile('bmad-code-org', 'bmad-plugins-marketplace', 'registry/official.yaml', 'main');
const rawCall = calls[1];
assert(
rawCall.includes('raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main/registry/official.yaml'),
'RegistryClient raw CDN URL contains correct path',
);
}
// --- fetchGitHubYaml parses YAML ---
{
const yamlContent = 'modules:\n - name: test\n description: A test module\n';
const { client } = createStubbedClient({ apiResult: yamlContent, rawResult: yamlContent });
const result = await client.fetchGitHubYaml('owner', 'repo', 'file.yaml', 'main');
assert(Array.isArray(result.modules), 'fetchGitHubYaml parses YAML correctly');
assert(result.modules[0].name === 'test', 'fetchGitHubYaml preserves YAML values');
}
}
console.log('');
// ============================================================ // ============================================================
// Summary // Summary
// ============================================================ // ============================================================

View File

@ -82,7 +82,9 @@ module.exports = {
stat: fsp.stat, stat: fsp.stat,
readdir: fsp.readdir, readdir: fsp.readdir,
access: fsp.access, access: fsp.access,
realpath: fsp.realpath,
rename: fsp.rename, rename: fsp.rename,
rmdir: fsp.rmdir,
unlink: fsp.unlink, unlink: fsp.unlink,
chmod: fsp.chmod, chmod: fsp.chmod,
mkdir: fsp.mkdir, mkdir: fsp.mkdir,
@ -103,6 +105,9 @@ module.exports = {
existsSync: fs.existsSync.bind(fs), existsSync: fs.existsSync.bind(fs),
readFileSync: fs.readFileSync.bind(fs), readFileSync: fs.readFileSync.bind(fs),
writeFileSync: fs.writeFileSync.bind(fs), writeFileSync: fs.writeFileSync.bind(fs),
statSync: fs.statSync.bind(fs),
accessSync: fs.accessSync.bind(fs),
readdirSync: fs.readdirSync.bind(fs),
createReadStream: fs.createReadStream.bind(fs), createReadStream: fs.createReadStream.bind(fs),
pathExistsSync: fs.existsSync.bind(fs), pathExistsSync: fs.existsSync.bind(fs),

View File

@ -5,9 +5,9 @@ const { execSync } = require('node:child_process');
const prompts = require('../prompts'); const prompts = require('../prompts');
const { RegistryClient } = require('./registry-client'); const { RegistryClient } = require('./registry-client');
const MARKETPLACE_BASE = 'https://raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main'; const MARKETPLACE_OWNER = 'bmad-code-org';
const COMMUNITY_INDEX_URL = `${MARKETPLACE_BASE}/registry/community-index.yaml`; const MARKETPLACE_REPO = 'bmad-plugins-marketplace';
const CATEGORIES_URL = `${MARKETPLACE_BASE}/categories.yaml`; const MARKETPLACE_REF = 'main';
/** /**
* Manages community modules from the BMad marketplace registry. * Manages community modules from the BMad marketplace registry.
@ -33,7 +33,12 @@ class CommunityModuleManager {
if (this._cachedIndex) return this._cachedIndex; if (this._cachedIndex) return this._cachedIndex;
try { try {
const config = await this._client.fetchYaml(COMMUNITY_INDEX_URL); const config = await this._client.fetchGitHubYaml(
MARKETPLACE_OWNER,
MARKETPLACE_REPO,
'registry/community-index.yaml',
MARKETPLACE_REF,
);
if (config?.modules?.length) { if (config?.modules?.length) {
this._cachedIndex = config; this._cachedIndex = config;
return config; return config;
@ -54,7 +59,7 @@ class CommunityModuleManager {
if (this._cachedCategories) return this._cachedCategories; if (this._cachedCategories) return this._cachedCategories;
try { try {
const config = await this._client.fetchYaml(CATEGORIES_URL); const config = await this._client.fetchGitHubYaml(MARKETPLACE_OWNER, MARKETPLACE_REPO, 'categories.yaml', MARKETPLACE_REF);
if (config?.categories) { if (config?.categories) {
this._cachedCategories = config; this._cachedCategories = config;
return config; return config;

View File

@ -6,7 +6,9 @@ const yaml = require('yaml');
const prompts = require('../prompts'); const prompts = require('../prompts');
const { RegistryClient } = require('./registry-client'); const { RegistryClient } = require('./registry-client');
const REGISTRY_RAW_URL = 'https://raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main/registry/official.yaml'; const MARKETPLACE_OWNER = 'bmad-code-org';
const MARKETPLACE_REPO = 'bmad-plugins-marketplace';
const MARKETPLACE_REF = 'main';
const FALLBACK_CONFIG_PATH = path.join(__dirname, 'registry-fallback.yaml'); const FALLBACK_CONFIG_PATH = path.join(__dirname, 'registry-fallback.yaml');
/** /**
@ -33,8 +35,7 @@ class ExternalModuleManager {
// Try remote registry first // Try remote registry first
try { try {
const content = await this._client.fetch(REGISTRY_RAW_URL); const config = await this._client.fetchGitHubYaml(MARKETPLACE_OWNER, MARKETPLACE_REPO, 'registry/official.yaml', MARKETPLACE_REF);
const config = yaml.parse(content);
if (config?.modules?.length) { if (config?.modules?.length) {
this.cachedModules = config; this.cachedModules = config;
return config; return config;

View File

@ -1,6 +1,37 @@
const https = require('node:https'); const https = require('node:https');
const yaml = require('yaml'); const yaml = require('yaml');
/**
* Build a rich Error from a non-2xx response. Includes the URL, the GitHub
* JSON error message (or a truncated body snippet), rate-limit reset time,
* and Retry-After anything present that would help a user recover.
*/
function buildHttpError(url, res, body) {
const parts = [`HTTP ${res.statusCode} ${url}`];
if (body) {
try {
const parsed = JSON.parse(body);
if (parsed.message) parts.push(parsed.message);
if (parsed.documentation_url) parts.push(`(see ${parsed.documentation_url})`);
} catch {
const snippet = body.slice(0, 200).trim();
if (snippet) parts.push(snippet);
}
}
const remaining = res.headers['x-ratelimit-remaining'];
const reset = res.headers['x-ratelimit-reset'];
if (remaining === '0' && reset) {
parts.push(`rate limit exhausted; resets at ${new Date(Number(reset) * 1000).toISOString()}`);
}
const retryAfter = res.headers['retry-after'];
if (retryAfter) parts.push(`retry after ${retryAfter}`);
return new Error(parts.join(' — '));
}
/** /**
* Shared HTTP client for fetching registry data from GitHub. * Shared HTTP client for fetching registry data from GitHub.
* Used by ExternalModuleManager, CommunityModuleManager, and CustomModuleManager. * Used by ExternalModuleManager, CommunityModuleManager, and CustomModuleManager.
@ -12,25 +43,31 @@ class RegistryClient {
/** /**
* Fetch a URL and return the response body as a string. * Fetch a URL and return the response body as a string.
* Follows one redirect (GitHub sometimes 301s). * Follows up to 3 redirects (GitHub sometimes 301s).
* @param {string} url - URL to fetch * @param {string} url - URL to fetch
* @param {number} [timeout] - Timeout in ms (overrides default) * @param {number} [timeout] - Timeout in ms (overrides default)
* @param {number} [maxRedirects=3] - Maximum redirects to follow
* @returns {Promise<string>} Response body * @returns {Promise<string>} Response body
*/ */
fetch(url, timeout) { fetch(url, timeout, maxRedirects = 3) {
const timeoutMs = timeout || this.timeout; const timeoutMs = timeout || this.timeout;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = https const req = https
.get(url, { timeout: timeoutMs }, (res) => { .get(url, { timeout: timeoutMs }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
return this.fetch(res.headers.location, timeoutMs).then(resolve, reject); if (maxRedirects <= 0) {
} return reject(new Error('Too many redirects'));
if (res.statusCode !== 200) { }
return reject(new Error(`HTTP ${res.statusCode}`)); return this.fetch(res.headers.location, timeoutMs, maxRedirects - 1).then(resolve, reject);
} }
let data = ''; let data = '';
res.on('data', (chunk) => (data += chunk)); res.on('data', (chunk) => (data += chunk));
res.on('end', () => resolve(data)); res.on('end', () => {
if (res.statusCode !== 200) {
return reject(buildHttpError(url, res, data));
}
resolve(data);
});
}) })
.on('error', reject) .on('error', reject)
.on('timeout', () => { .on('timeout', () => {
@ -50,6 +87,101 @@ class RegistryClient {
const content = await this.fetch(url, timeout); const content = await this.fetch(url, timeout);
return yaml.parse(content); return yaml.parse(content);
} }
/**
* Fetch a file from a GitHub repo using the Contents API first,
* falling back to raw.githubusercontent.com if the API fails.
*
* The API endpoint (`api.github.com`) is tried first because corporate
* proxies commonly block `raw.githubusercontent.com` while allowing
* `api.github.com` under the "Software Development" category.
*
* @param {string} owner - Repository owner (e.g., 'bmad-code-org')
* @param {string} repo - Repository name (e.g., 'bmad-plugins-marketplace')
* @param {string} filePath - Path within the repo (e.g., 'registry/official.yaml')
* @param {string} ref - Git ref (branch, tag, or SHA; e.g., 'main')
* @param {number} [timeout] - Timeout in ms (overrides default)
* @returns {Promise<string>} Raw file content
*/
async fetchGitHubFile(owner, repo, filePath, ref, timeout) {
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}?ref=${ref}`;
const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${filePath}`;
// Try GitHub Contents API first (with raw content accept header)
try {
return await this._fetchWithHeaders(apiUrl, { Accept: 'application/vnd.github.raw+json' }, timeout);
} catch (apiError) {
// API failed — fall back to raw CDN
try {
return await this.fetch(rawUrl, timeout);
} catch (cdnError) {
throw new AggregateError([apiError, cdnError], `Both GitHub API and raw CDN failed for ${filePath}`);
}
}
}
/**
* Fetch a file from GitHub and parse as YAML.
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @param {string} filePath - Path within the repo
* @param {string} ref - Git ref
* @param {number} [timeout] - Timeout in ms
* @returns {Promise<Object>} Parsed YAML content
*/
async fetchGitHubYaml(owner, repo, filePath, ref, timeout) {
const content = await this.fetchGitHubFile(owner, repo, filePath, ref, timeout);
return yaml.parse(content);
}
/**
* Fetch a URL with custom headers. Used for GitHub API requests.
* Follows up to 3 redirects.
* @param {string} url - URL to fetch
* @param {Object} headers - Request headers
* @param {number} [timeout] - Timeout in ms
* @param {number} [maxRedirects=3] - Maximum redirects to follow
* @returns {Promise<string>} Response body
* @private
*/
_fetchWithHeaders(url, headers, timeout, maxRedirects = 3) {
const timeoutMs = timeout || this.timeout;
const parsed = new URL(url);
const options = {
hostname: parsed.hostname,
path: parsed.pathname + parsed.search,
timeout: timeoutMs,
headers: {
'User-Agent': 'bmad-installer',
...headers,
},
};
return new Promise((resolve, reject) => {
const req = https
.get(options, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
if (maxRedirects <= 0) {
return reject(new Error('Too many redirects'));
}
return this._fetchWithHeaders(res.headers.location, headers, timeoutMs, maxRedirects - 1).then(resolve, reject);
}
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
if (res.statusCode !== 200) {
return reject(buildHttpError(url, res, data));
}
resolve(data);
});
})
.on('error', reject)
.on('timeout', () => {
req.destroy();
reject(new Error('Request timed out'));
});
});
}
} }
module.exports = { RegistryClient }; module.exports = { RegistryClient };