Compare commits
1 Commits
fc4c2eff74
...
830af5732c
| Author | SHA1 | Date |
|---|---|---|
|
|
830af5732c |
|
|
@ -1,70 +0,0 @@
|
||||||
---
|
|
||||||
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 就越锐利。
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
---
|
|
||||||
title: "检查点预览"
|
|
||||||
description: LLM 辅助的人机协作审查,引导你从目的到细节逐步走过一个变更
|
|
||||||
sidebar:
|
|
||||||
order: 3
|
|
||||||
---
|
|
||||||
|
|
||||||
`bmad-checkpoint-preview` 是一个交互式的、LLM 辅助的人机协作审查工作流。它带你逐步走过一个代码变更——从目的和上下文到细节——让你能做出知情决策:是发布、返工,还是深入挖掘。
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## 典型流程
|
|
||||||
|
|
||||||
你运行 `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、类型检查器或测试套件。它不打分也不给出通过/不通过的判定。它是一份阅读指南,帮助人类在最重要的地方运用自己的判断力。
|
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
---
|
|
||||||
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 仓库之前先确认一切正常。
|
|
||||||
:::
|
|
||||||
|
|
@ -1926,112 +1926,6 @@ 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
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -82,9 +82,7 @@ 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,
|
||||||
|
|
@ -105,9 +103,6 @@ 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),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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_OWNER = 'bmad-code-org';
|
const MARKETPLACE_BASE = 'https://raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main';
|
||||||
const MARKETPLACE_REPO = 'bmad-plugins-marketplace';
|
const COMMUNITY_INDEX_URL = `${MARKETPLACE_BASE}/registry/community-index.yaml`;
|
||||||
const MARKETPLACE_REF = 'main';
|
const CATEGORIES_URL = `${MARKETPLACE_BASE}/categories.yaml`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages community modules from the BMad marketplace registry.
|
* Manages community modules from the BMad marketplace registry.
|
||||||
|
|
@ -33,12 +33,7 @@ class CommunityModuleManager {
|
||||||
if (this._cachedIndex) return this._cachedIndex;
|
if (this._cachedIndex) return this._cachedIndex;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await this._client.fetchGitHubYaml(
|
const config = await this._client.fetchYaml(COMMUNITY_INDEX_URL);
|
||||||
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;
|
||||||
|
|
@ -59,7 +54,7 @@ class CommunityModuleManager {
|
||||||
if (this._cachedCategories) return this._cachedCategories;
|
if (this._cachedCategories) return this._cachedCategories;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await this._client.fetchGitHubYaml(MARKETPLACE_OWNER, MARKETPLACE_REPO, 'categories.yaml', MARKETPLACE_REF);
|
const config = await this._client.fetchYaml(CATEGORIES_URL);
|
||||||
if (config?.categories) {
|
if (config?.categories) {
|
||||||
this._cachedCategories = config;
|
this._cachedCategories = config;
|
||||||
return config;
|
return config;
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,7 @@ const yaml = require('yaml');
|
||||||
const prompts = require('../prompts');
|
const prompts = require('../prompts');
|
||||||
const { RegistryClient } = require('./registry-client');
|
const { RegistryClient } = require('./registry-client');
|
||||||
|
|
||||||
const MARKETPLACE_OWNER = 'bmad-code-org';
|
const REGISTRY_RAW_URL = 'https://raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main/registry/official.yaml';
|
||||||
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');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -35,7 +33,8 @@ class ExternalModuleManager {
|
||||||
|
|
||||||
// Try remote registry first
|
// Try remote registry first
|
||||||
try {
|
try {
|
||||||
const config = await this._client.fetchGitHubYaml(MARKETPLACE_OWNER, MARKETPLACE_REPO, 'registry/official.yaml', MARKETPLACE_REF);
|
const content = await this._client.fetch(REGISTRY_RAW_URL);
|
||||||
|
const config = yaml.parse(content);
|
||||||
if (config?.modules?.length) {
|
if (config?.modules?.length) {
|
||||||
this.cachedModules = config;
|
this.cachedModules = config;
|
||||||
return config;
|
return config;
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,6 @@
|
||||||
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.
|
||||||
|
|
@ -43,31 +12,25 @@ 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 up to 3 redirects (GitHub sometimes 301s).
|
* Follows one redirect (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, maxRedirects = 3) {
|
fetch(url, timeout) {
|
||||||
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) {
|
||||||
if (maxRedirects <= 0) {
|
return this.fetch(res.headers.location, timeoutMs).then(resolve, reject);
|
||||||
return reject(new Error('Too many redirects'));
|
}
|
||||||
}
|
if (res.statusCode !== 200) {
|
||||||
return this.fetch(res.headers.location, timeoutMs, maxRedirects - 1).then(resolve, reject);
|
return reject(new Error(`HTTP ${res.statusCode}`));
|
||||||
}
|
}
|
||||||
let data = '';
|
let data = '';
|
||||||
res.on('data', (chunk) => (data += chunk));
|
res.on('data', (chunk) => (data += chunk));
|
||||||
res.on('end', () => {
|
res.on('end', () => resolve(data));
|
||||||
if (res.statusCode !== 200) {
|
|
||||||
return reject(buildHttpError(url, res, data));
|
|
||||||
}
|
|
||||||
resolve(data);
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.on('error', reject)
|
.on('error', reject)
|
||||||
.on('timeout', () => {
|
.on('timeout', () => {
|
||||||
|
|
@ -87,101 +50,6 @@ 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 };
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue