dsh-github-flow 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +102 -15
- package/README.md +105 -15
- package/docs/UPGRADE-NOTES.md +12 -11
- package/docs/architecture.md +67 -14
- package/lib/api/route-helpers.d.ts +19 -0
- package/lib/api/route-helpers.js +54 -0
- package/lib/api/routes-actions.js +126 -2
- package/lib/api/routes-lifecycle.js +8 -1
- package/lib/api/routes-overview.d.ts +26 -0
- package/lib/api/routes-overview.js +276 -34
- package/lib/api/routes-proxy.js +8 -1
- package/lib/api/routes.d.ts +11 -1
- package/lib/api/routes.js +15 -2
- package/lib/cache.d.ts +0 -21
- package/lib/cache.js +0 -37
- package/lib/client.d.ts +2 -1
- package/lib/client.js +2108 -658
- package/lib/destructive.d.ts +59 -0
- package/lib/destructive.js +285 -0
- package/lib/executor/args-guard.d.ts +17 -0
- package/lib/executor/args-guard.js +23 -0
- package/lib/executor/failure-anchors.d.ts +52 -0
- package/lib/executor/failure-anchors.js +116 -0
- package/lib/executor/pr-review.d.ts +95 -0
- package/lib/executor/pr-review.js +175 -0
- package/lib/executor/run-log.d.ts +36 -0
- package/lib/executor/run-log.js +78 -0
- package/lib/executor.d.ts +204 -3
- package/lib/executor.js +670 -28
- package/lib/github-service.d.ts +1 -1
- package/lib/main.d.ts +92 -18
- package/lib/main.js +186 -95
- package/lib/renderers/common.d.ts +0 -3
- package/lib/renderers/common.js +8 -44
- package/lib/renderers/markdown.d.ts +1 -0
- package/lib/renderers/markdown.js +1 -0
- package/lib/renderers/pr.js +26 -1
- package/lib/renderers/task.d.ts +9 -0
- package/lib/renderers/task.js +120 -0
- package/lib/review-rules.d.ts +60 -0
- package/lib/review-rules.js +204 -0
- package/lib/shared/state-badges.d.ts +42 -0
- package/lib/shared/state-badges.js +102 -0
- package/lib/task-primitives.d.ts +109 -0
- package/lib/task-primitives.js +194 -0
- package/lib/tool-tiers.d.ts +57 -0
- package/lib/tool-tiers.js +168 -0
- package/lib/tools/agent-task.d.ts +4 -0
- package/lib/tools/agent-task.js +22 -9
- package/lib/tools/api.d.ts +4 -0
- package/lib/tools/api.js +48 -12
- package/lib/tools/body-input.d.ts +58 -0
- package/lib/tools/body-input.js +123 -0
- package/lib/tools/cache.js +42 -40
- package/lib/tools/codespace.js +4 -12
- package/lib/tools/discussion.d.ts +4 -0
- package/lib/tools/discussion.js +33 -17
- package/lib/tools/gist.d.ts +1 -39
- package/lib/tools/gist.js +4 -14
- package/lib/tools/help.js +65 -13
- package/lib/tools/issue.d.ts +4 -0
- package/lib/tools/issue.js +37 -38
- package/lib/tools/label.js +4 -15
- package/lib/tools/pr.d.ts +51 -0
- package/lib/tools/pr.js +204 -46
- package/lib/tools/project.js +4 -13
- package/lib/tools/release.d.ts +4 -0
- package/lib/tools/release.js +26 -26
- package/lib/tools/repo.js +4 -4
- package/lib/tools/ruleset.js +9 -6
- package/lib/tools/run.js +76 -24
- package/lib/tools/secret.js +11 -17
- package/lib/tools/skill.js +18 -22
- package/lib/tools/status.js +4 -4
- package/lib/tools/task.d.ts +223 -0
- package/lib/tools/task.js +464 -0
- package/lib/types.d.ts +183 -1
- package/lib/ui/commit-modal.js +108 -4
- package/lib/ui/diff-utils.d.ts +51 -0
- package/lib/ui/diff-utils.js +155 -0
- package/lib/ui/file-diff-modal.d.ts +33 -0
- package/lib/ui/file-diff-modal.js +250 -0
- package/lib/ui/help-modal.d.ts +1 -1
- package/lib/ui/help-modal.js +24 -12
- package/lib/ui/issue-modal.js +7 -3
- package/lib/ui/pr-modal.js +50 -11
- package/lib/ui/run-modal.d.ts +26 -0
- package/lib/ui/run-modal.js +486 -0
- package/lib/ui/styles.d.ts +1 -1
- package/lib/ui/styles.js +376 -44
- package/lib/ui/sync-settings-modal.js +1 -1
- package/lib/ui/workspace-panel.js +78 -23
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +9 -2
package/docs/UPGRADE-NOTES.md
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
| # | 文件 | 位置 | 当前值 | 说明 |
|
|
12
12
|
|---|---|---|---|---|
|
|
13
|
-
| 1 | [package.json](../package.json) | 第 3 行 `"version": "0.
|
|
14
|
-
| 2 | [src/version.ts](../src/version.ts) | 第 9 行 `CURRENT_PLUGIN_VERSION = '0.
|
|
13
|
+
| 1 | [package.json](../package.json) | 第 3 行 `"version": "0.7.1"` | `0.7.1` | npm 发布版本、CI 取版来源 |
|
|
14
|
+
| 2 | [src/version.ts](../src/version.ts) | 第 9 行 `CURRENT_PLUGIN_VERSION = '0.7.1'` | `'0.7.1'` | 运行时版本比较基准(假横幅的教训来源) |
|
|
15
15
|
|
|
16
16
|
**两处必须同值**。`pnpm run build` 会硬校验([scripts/build.mjs](../scripts/build.mjs) 的
|
|
17
17
|
`assertVersionConsistency`),不一致直接构建失败——但不要依赖拦截,发版前手动核对。
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
|
|
32
32
|
| # | 文件 | 位置 | 现状 | 为什么不能填死 |
|
|
33
33
|
|---|---|---|---|---|
|
|
34
|
-
| 5 | [README.md](../README.md) | 第
|
|
35
|
-
| 6 | [README.en.md](../README.en.md) | 第
|
|
34
|
+
| 5 | [README.md](../README.md) | 第 68 行 `dsh-github-flow@<最新版本号>` | 占位符 | 每次发版都改会漏;占位符引导用户自己查最新版 |
|
|
35
|
+
| 6 | [README.en.md](../README.en.md) | 第 68 行 `dsh-github-flow@<latest-version>` | 占位符 | 同上(英文版) |
|
|
36
36
|
| 7 | [src/updater.ts](../src/updater.ts) | 所有 `CURRENT_PLUGIN_VERSION` 引用 | 常量引用 | 全部从 version.ts 导入,无一硬编码——升级只改 version.ts 一处即全局生效 |
|
|
37
37
|
| 8 | [src/commands/gh.ts](../src/commands/gh.ts) | `/gh status`、`/gh upgrade` 输出 | 常量引用 | 同上 |
|
|
38
38
|
|
|
@@ -40,15 +40,15 @@
|
|
|
40
40
|
|
|
41
41
|
| # | 机制 | 说明 |
|
|
42
42
|
|---|---|---|
|
|
43
|
-
| 9 | CI 取版(.github/workflows/npm-publish.yml:
|
|
44
|
-
| 10 | Git tag | `git tag v0.
|
|
43
|
+
| 9 | CI 取版(.github/workflows/npm-publish.yml:74) | `VERSION=$(node -p "require('./package.json').version")`——从 package.json 读,无硬编码 |
|
|
44
|
+
| 10 | Git tag | `git tag v0.7.1` 手动打,值必须 = package.json 版本(tag 与 package.json 不一致时:CI 发布的 npm 版本以 package.json 为准,GitHub Release 标题以 tag 为准,出现两者错位) |
|
|
45
45
|
|
|
46
46
|
## 五、发版完整流程(含版本修改)
|
|
47
47
|
|
|
48
48
|
```powershell
|
|
49
49
|
# 1. 改版本(两处同值)
|
|
50
|
-
# package.json → "version": "0.
|
|
51
|
-
# src/version.ts → CURRENT_PLUGIN_VERSION = '0.
|
|
50
|
+
# package.json → "version": "0.7.1"
|
|
51
|
+
# src/version.ts → CURRENT_PLUGIN_VERSION = '0.7.1'
|
|
52
52
|
|
|
53
53
|
# 2. 文档已用占位符(npm-publish-guide 示例、README 安装命令),发版无需改文档版本号
|
|
54
54
|
|
|
@@ -57,12 +57,12 @@ pnpm run build
|
|
|
57
57
|
pnpm test # 全量测试全绿
|
|
58
58
|
|
|
59
59
|
# 4. 本机 e2e(必须先重启 DSH 桌面进程——Node 模块缓存,软链重建不注入运行中进程)
|
|
60
|
-
# 重启后验证:GET /api/github/check-update 的 currentVersion = 0.
|
|
60
|
+
# 重启后验证:GET /api/github/check-update 的 currentVersion = 0.7.1
|
|
61
61
|
|
|
62
62
|
# 5. 提交 + 打 tag + 推送(tag 值 = package.json 版本)
|
|
63
63
|
git add -A
|
|
64
|
-
git commit -m "chore(release): v0.
|
|
65
|
-
git tag v0.
|
|
64
|
+
git commit -m "chore(release): v0.7.1"
|
|
65
|
+
git tag v0.7.1
|
|
66
66
|
git push origin master --tags
|
|
67
67
|
# CI(npm-publish.yml)自动:构建→发布 npm→创建 GitHub Release
|
|
68
68
|
|
|
@@ -94,3 +94,4 @@ git push origin master --tags
|
|
|
94
94
|
| gh --json 字段集漂移 | Unknown JSON field 直接报错 | 新增字段前用 `gh <cmd> --json zzz` 探测合法集;不跨域复制字段名(checks≠run) |
|
|
95
95
|
| PowerShell 5.1 默认策略 | 唤起终端报 profile 禁止运行红错 | 唤起命令带 -NoProfile(Windows 自带基线优先) |
|
|
96
96
|
| 浏览器单文件脚本环境 | 客户端产物带未剥除的 export/import 导致语法报错白屏 | build.mjs 聚合器自动剔除 export/import 并将子模块内联注入单一闭包 |
|
|
97
|
+
| pnpm `file:` 快照与构建拦截 | 本地开发修改代码并重启 DSH,页面和接口依然为旧版本 | DSH 内置 pnpm 对 `file:` 依赖强制做快照缓存并拦截未经批准的构建脚本。本地二次开发必须通过原生 Junction 目录联接直连工作区(见 architecture.md §3.6) |
|
package/docs/architecture.md
CHANGED
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
│ │ │ │ │
|
|
36
36
|
│ ▼ ▼ ▼ │
|
|
37
37
|
│ ┌──────────────────┐ ┌──────────────────┐ ┌───────────────────────┐ │
|
|
38
|
-
│ │
|
|
39
|
-
│ │ (
|
|
38
|
+
│ │ 19 大 Agent 工具 │ │ 人类 Slash 指令 │ │ Host HTTP WebServer │ │
|
|
39
|
+
│ │ (手写descriptor)│ │ /gh status │ │ (Effect Disposer 清理)│ │
|
|
40
40
|
│ │ - pr (+attach) │ │ /gh upgrade │ │ - /api/github/overview│ │
|
|
41
41
|
│ │ - issue (+attach│ │ /gh help │ │ - /api/github/action │ │
|
|
42
42
|
│ │ - run (workflow)│ └──────────────────┘ │ - /api/github/refresh │ │
|
|
@@ -49,9 +49,9 @@
|
|
|
49
49
|
│ │ - ruleset(check)│ │ - /update-settings │ │
|
|
50
50
|
│ │ - cache (治理) │ │ - /proxy (全局代理) │ │
|
|
51
51
|
│ │ - gist (分享) │ │ - /auth (轻量探针) │ │
|
|
52
|
-
│ │ - status (健康) │ │ - /issue-detail
|
|
53
|
-
│ │ - agent_task │ │
|
|
54
|
-
│ │ - discussion │ │ - /
|
|
52
|
+
│ │ - status (健康) │ │ - /issue-detail │ │
|
|
53
|
+
│ │ - agent_task │ │ - /pr-detail (PR 审阅)│ │
|
|
54
|
+
│ │ - discussion │ │ - /run-detail (Run) │ │
|
|
55
55
|
│ │ - project (v2) │ └──────────┬────────────┘ │
|
|
56
56
|
│ │ - codespace(MVP)│ ┌───────────────────────────┐ │
|
|
57
57
|
│ │ - help (元指南) │ │ git-watcher (独立服务) │ │
|
|
@@ -83,14 +83,14 @@
|
|
|
83
83
|
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
|
84
84
|
│ │ 模块化 UI 架构 (src/ui/ ➔ 经 scripts/build.mjs 拓扑聚合单一 Bundle) │ │
|
|
85
85
|
│ │ │ │
|
|
86
|
-
│ │ · styles.ts (
|
|
86
|
+
│ │ · styles.ts (自适应 CSS, 构建期紧凑压缩) │ │
|
|
87
87
|
│ │ · icons.ts (Octocat, Git 官方菱形分支矢量组件, 插件徽标) │ │
|
|
88
88
|
│ │ · storage.ts (LocalStorage SWR 状态机与偏好存储) │ │
|
|
89
89
|
│ │ · components/ (分支下拉/认证引导/升级通知/离线横幅/状态胶囊) │ │
|
|
90
90
|
│ │ · modals/ (Commit 详情弹窗 / PR 审阅工作台 / Issue 在线查看 / 全局设置弹窗) │ │
|
|
91
91
|
│ │ · panels/ (首页全景大面板 workspace-panel.ts) │ │
|
|
92
92
|
│ │ │ │
|
|
93
|
-
│ │ 核心组装入口 (src/client.ts,
|
|
93
|
+
│ │ 核心组装入口 (src/client.ts, 高内聚): │ │
|
|
94
94
|
│ │ - applyTabState (纯 DOM 互斥状态应用, 满足单测白盒契约) │ │
|
|
95
95
|
│ │ - renderGithubRepoView (五 Tab 核心视图: Git/未提交/PR/Issue/Run) │ │
|
|
96
96
|
│ │ - GithubRightbarBody (右侧栏主体, 状态引用解耦与时序安全守护) │ │
|
|
@@ -130,6 +130,13 @@
|
|
|
130
130
|
- **低版本安全提示而非硬拒绝**:gh <2.102 时下载仍可执行,但返回值携带 `securityNote` 升级指引;需要自定义落盘位置的场景显式引导经 `github_api` 手动拉取;
|
|
131
131
|
- **下载后零本地符号化操作**:插件对落盘产物仅做递归枚举(相对路径 + 大小清单),不执行链接、移动或执行类操作。
|
|
132
132
|
|
|
133
|
+
### 2.3.3 本地文件入参 (Local File Inputs)
|
|
134
|
+
- **与 §2.3.2 方向相反,但不放松那条防线**:§2.3.2 管的是**产出写盘**——落盘路径由插件派生、绝不接受调用方给的路径(GHSA-39wj-f2f4-978v 防线);本节管的是**输入读盘**——路径由调用方显式给出,插件只读。两条控制流不共享任何代码路径:读进来的字节只进正文,不落盘、不回写、不参与任何目录派生,因此不会把「路径参数」重新引进下载/写盘通道;
|
|
135
|
+
- **统一入口与统一校验**:`src/tools/body-input.ts` 的 `resolveBodySource` 是唯一实现(纯函数、零 gh 依赖),issue / pr / discussion / release / task / api / agent-task 只传各自的 flag 与字段名,杜绝「某条通道忘了校验」。相对路径按当前工作区解析、绝对路径原样使用;文件必须存在且是普通文件、不超过 4MB、严格 UTF-8 解码(GBK/ANSI 存的中文 md 直接报错而不是产出乱码)、含 NUL 字节视为二进制拒绝、BOM 剥除;
|
|
136
|
+
- **长度口径分层**:GitHub 正文(Issue / PR / 评论 / 讨论)统一 65,536 字符上限,超限显式拒绝并提示拆分;**发版说明(`notes_file`)与 `github_api` 的 `input_file` 不套用这条口径**——前者在 GitHub 侧的上限宽得多,后者是请求体不是正文,两者都只保留 4MB 字节护栏,真正超限交由 GitHub 报错(错误信息带字段名,可定位);
|
|
137
|
+
- **二选一而非优先级**:`body` 与 `body_file`(`notes` 与 `notes_file`、`prompt` 与 `prompt_file`)同时出现即报错,不做静默覆盖——模型拿到一次明确失败,远好过「不知道哪一份真正生效」;空文件等同于没有正文。内联旧路径的 argv 与引入本能力前**逐字节一致**,新通道只在真的传了 `*_file` 时才改变 argv;
|
|
138
|
+
- **为什么不让 gh 自己读文件**:`gh pr review` 没有行级参数,行级评注的唯一通道是 `POST /pulls/{n}/reviews`(REST),那条链路根本没有 `--body-file` 一类 flag,只能插件侧读文件后写进 JSON 请求体。若 CLI 走 `--body-file -`、REST 各写一份,必然漂移成「带 comments 时 body_file 静默失效」;因此统一为**插件读一次** → CLI 通道 `[fileFlag, '-']` + stdin、REST/GraphQL 通道直接用文本。
|
|
139
|
+
|
|
133
140
|
---
|
|
134
141
|
|
|
135
142
|
## 3. 工程解耦与单文件瘦身规范 (Modularization Specs)
|
|
@@ -137,11 +144,12 @@
|
|
|
137
144
|
为彻底解决单文件臃肿过长(如旧版 `client.ts` 超过 5,200 行、`executor.ts` 超过 1,100 行)的技术债,项目已全面落地分层解耦与自包含构建规范:
|
|
138
145
|
|
|
139
146
|
### 3.1 后端执行引擎分层 (`src/executor/`)
|
|
140
|
-
- [src/executor.ts](../src/executor.ts)
|
|
147
|
+
- [src/executor.ts](../src/executor.ts)(瘦身至只保留编排层):保留 `GhExecutor` 类核心编排(命令白名单、执行通道、认证与版本缓存),完整 Re-export 子模块符号,保持 100% 向后兼容;
|
|
141
148
|
- [src/executor/git-parser.ts](../src/executor/git-parser.ts):纯文本解析器(`status -sb`、`log`、`branch -a`),无 I/O、无副作用;
|
|
142
149
|
- [src/executor/version-utils.ts](../src/executor/version-utils.ts):语义化版本数值分段比对纯算法;
|
|
143
150
|
- [src/executor/process-utils.ts](../src/executor/process-utils.ts):`spawnWithStdin` 进程管道流控与 UTF-8 字符边界截断算法;
|
|
144
|
-
- [src/executor/platform-guide.ts](../src/executor/platform-guide.ts):跨平台 CLI
|
|
151
|
+
- [src/executor/platform-guide.ts](../src/executor/platform-guide.ts):跨平台 CLI 安装引导与升级命令文本生成;
|
|
152
|
+
- [src/tools/body-input.ts](../src/tools/body-input.ts):正文入参解析纯函数(工具层共享,非执行引擎模块)——`resolveBodySource` 与 `GITHUB_BODY_MAX_CHARS` / `BODY_FILE_MAX_BYTES`,供 issue / pr / discussion / release / task / api / agent-task 复用,零 gh 依赖、无副作用,纪律见 §2.3.3。
|
|
145
153
|
|
|
146
154
|
### 3.2 渲染层领域化细分 (`src/renderers/`)
|
|
147
155
|
- [src/renderers/markdown.ts](../src/renderers/markdown.ts):门面出口,统一聚合 Re-export 全部领域子模块;
|
|
@@ -155,24 +163,54 @@
|
|
|
155
163
|
|
|
156
164
|
### 3.4 前端客户端解耦与自包含 Bundle 机制 (`src/ui/` + `src/client.ts`)
|
|
157
165
|
- **源码物理剥离**:
|
|
158
|
-
- [src/client.ts](../src/client.ts)
|
|
159
|
-
- **
|
|
166
|
+
- [src/client.ts](../src/client.ts)(保留右侧栏协同层与插件入口):保留右侧栏核心协同 `renderGithubRepoView` 与 `GithubRightbarBody` 及插件入口;新增 UI 优先下沉到 `src/ui/`,不在入口文件里堆叠;
|
|
167
|
+
- **21 个独立子模块**下沉至 [src/ui/](../src/ui/) 目录:样式(最大的单模块)、全景大面板、PR 审阅弹窗、Issue 查看弹窗、Run 详情弹窗、设置弹窗、使用指南;文件 Diff 能力拆成两个模块——`diff-utils.ts`(整包 Unified patch 的切分/重命名归一纯函数,零 React 依赖、可独立单测)与 `file-diff-modal.ts`(提交详情与未提交清单共用的 Diff 弹窗);
|
|
168
|
+
> 这里刻意不写绝对行数。行数会随每次重构变化,而本文档没人会跟着改——历史上同一个 `client.ts` 在本文件里同时存在「~1,260 行」与「约 2,400 行」两个互斥数字,`executor.ts` 也写着早已失效的「~820 行」。需要精确数字请直接看文件。
|
|
160
169
|
- **命名空间防护**:
|
|
161
170
|
- 严禁在与 `package.json` 的 `exports["./client"]` 同级创建名为 `client/` 的目录,统一命名为 `src/ui/` 与编译产物 `lib/ui/`,彻底杜绝模块解析器产生“同名文件 vs 目录”的歧义报错;
|
|
162
171
|
- **自包含 Bundle 聚合器 (`scripts/build.mjs`)**:
|
|
163
172
|
- 构建脚本按严格拓扑顺序将 `lib/ui/` 子模块内联注入 `lib/client.js` 的 `window.__ModuleLoader__.load` 闭包中,移除 `export` 关键字;
|
|
164
173
|
- **顺序即依赖**:`markdown-lite → pr-modal → issue-modal → …` 不可随意重排(`issue-modal` 复用了 `pr-modal` 的 `CollapsibleBody`);`workspace-panel` 置于后置列表,保证 `GithubRightbarBody` 位于 `GithubWorkspacePanel` 之前;
|
|
165
|
-
-
|
|
174
|
+
- 对嵌入的纯 CSS 实施紧凑压缩;压缩后强制校验「无注释定界符残留 / 无行首 `*` 悬空段 / 花括号配平」,任一项不过直接构建失败——非法 CSS 会被浏览器**静默丢弃整条规则**,只表现为「样式莫名不生效」,极难排查;
|
|
166
175
|
- **React Hook 时序安全防线**:
|
|
167
176
|
- 全部 Hook(含 `useEffect`)必须位于任何 early return 之前,否则「参数由空变有值」时两次渲染的 Hook 数量不一致,触发 `Rendered fewer hooks than expected` 崩溃;
|
|
168
177
|
- `renderGithubRepoView` 这类每次渲染都重新执行的普通函数内**禁止**调用 Hook,状态必须住在有 Hook 的组件体内再传参;
|
|
169
178
|
- 组件内引用的 `useRef`(如 `containerRef`)必须在组件声明最前列初始化,严禁跨行下置引发变量提升 `undefined`;所有的 Ref 访问点必须配备 `containerRef && containerRef.current` 守卫。
|
|
170
179
|
|
|
180
|
+
### 3.5 客户端加载器:对宿主 client 契约的适配层
|
|
181
|
+
|
|
182
|
+
`src/client.ts` 里的 `window.__ModuleLoader__.load({ id, factory })` **不是本插件的设计,而是宿主客户端的加载契约**:
|
|
183
|
+
|
|
184
|
+
- 全局声明在 `src/client.ts` 顶部(`Window.__ModuleLoader__`),唯一调用点在文件末尾的 `load({ id: "dsh-github-flow", factory })`;
|
|
185
|
+
- `factory` 收到的 `require` 是**宿主注入的模块解析器**(`react`、`react-dom` 等由宿主提供,插件不打包也不得打包自己的副本),`id` 必须与 `package.json` 的 `dsh.client.entry` 归属一致;
|
|
186
|
+
- 该 API **不是官方公开文档化的稳定接口**,语义由宿主实现决定。一旦宿主调整加载方式,**只改这一层**,`src/ui/` 下的 19 个子模块一行都不用动;
|
|
187
|
+
- 因为它是"适配层",`test/phase36-host-contract.test.mjs` 对它的**形状**做了断言(入口存在、id 固定、`apply`/`inject` 已导出、20 个子模块全部内联、闭包内无残留 ESM `export`),而不是只靠"跑起来没报错"来验证。
|
|
188
|
+
|
|
189
|
+
### 3.6 本地开发联调机制 (Local Development & Junction Linking)
|
|
190
|
+
|
|
191
|
+
当开发者在本地克隆代码库进行定制与二次开发时,需严格遵循本地联调机制:
|
|
192
|
+
|
|
193
|
+
1. **Junction 目录联接直通机制**:
|
|
194
|
+
- DSH 宿主环境的各个 Profile(如 `~/.dsh/profiles/web`、`~/.dsh/profiles/desktop`)采用 pnpm 管理依赖。若通过 `file:` 协议安装插件,pnpm 会将其作为独立快照拷贝到 `node_modules` 中,并在后续操作中直接复用内容缓存哈希(`resolved 0, reused 1`);
|
|
195
|
+
- 同时,DSH 内置的 pnpm (v11.x) 默认开启严格构建拦截策略,当检测到本地依赖包含 `prepare: "pnpm run build"` 脚本时,会直接抛出 `ERR_PNPM_IGNORED_BUILDS` 阻断安装;
|
|
196
|
+
- **解法**:在 Windows 环境下将 Profile 的 `node_modules\dsh-github-flow` 替换为指向本地 Git 工作区根目录的原生 **Junction(目录联接)**,直接消除拷贝过程,实现工作区产物与宿主运行时的物理同一性。
|
|
197
|
+
2. **两阶段生效边界(编译与热刷新)**:
|
|
198
|
+
- 宿主加载的入口为编译后的 `lib/main.js` 与 `lib/client.js`,修改 `src/` 源码后必须执行 `pnpm run build` 生成最新产物;
|
|
199
|
+
- **纯前端 UI / 样式变更**(`src/client.ts`、`src/ui/`):由于 DSH Web 客户端在页面载入时实时读取静态资源,按 **Ctrl + F5 强制刷新浏览器**即可即时呈现最新视图,**无需重启 DSH 进程**;
|
|
200
|
+
- **后端服务 / 执行引擎 / 工具实现**(`src/tools/`、`src/api/`、`src/executor.ts`、`src/main.ts`):受 Node.js 底层 ESM 模块缓存机制限制,必须**重启 DSH 进程**才能完成服务重载。
|
|
201
|
+
3. **联接保护与自愈**:
|
|
202
|
+
- 切勿在已建立 Junction 的 Profile 目录下执行 `dsh plugin install` 或 `pnpm install`,以防包管理器抹除联接并退化为静态拷贝;
|
|
203
|
+
- 若联接失效或被覆盖,可使用以下命令一键恢复直通:
|
|
204
|
+
```powershell
|
|
205
|
+
New-Item -ItemType Junction -Path "$env:USERPROFILE\.dsh\profiles\web\node_modules\dsh-github-flow" -Target "D:\Desktop\AgentWork\DSHQuestion\dsh-github-flow" -Force
|
|
206
|
+
```
|
|
207
|
+
|
|
171
208
|
---
|
|
172
209
|
|
|
173
210
|
## 4. 自动化测试体系 (Test Suite)
|
|
174
211
|
|
|
175
|
-
项目配备了 **
|
|
212
|
+
项目配备了 **49 个独立阶段的全量自动化回归测试套件(`test/run-all.mjs`)**,合计 **2000+ 项断言**(精确条数以 `node test/run-all.mjs` 末尾的汇总输出为准,此处只给量级):
|
|
213
|
+
- 阶段数与 `test/run-all.mjs` 的 `phases` 数组由 `test/phase35-doc.test.mjs` 对账,改一处漏一处会直接红;
|
|
176
214
|
- `phase1`:Issue / Run / PR / Repo / Help 命令装配与确认门禁;
|
|
177
215
|
- `phase2`:Native Markdown 渲染与 Token 压缩率实测(>60% 降幅);
|
|
178
216
|
- `phase3`:Release 资产交付、Secret 密文管道与 Skills 检索;
|
|
@@ -205,4 +243,19 @@
|
|
|
205
243
|
- `phase30`:PR 弹窗 Markdown 渲染与滚动适配(markdown-lite 零依赖解析器:标题/粗斜体/行内码/围栏代码块/列表/引用/分隔线/任务列表;链接协议白名单拒绝 javascript:/data:/vbscript:/协议相对;HTML 标签降级为纯文本;estimateContentLines 折叠阈值估算;卡片 flex:0 0 auto 修复「长内容无法滚动」;CollapsibleBody 提升到模块顶层避免内联组件导致展开状态被重置)。
|
|
206
244
|
- `phase31`:Issue 在线查看工作台(getIssueDetail 参数白名单含小数/负数拒绝、**2MB 字符阈值防长讨论被截断成假空详情**、老版本 gh `Unknown JSON field` 的 BASE 字段降级链与 fieldsDegraded 标记、非字段类失败不重试、截断与解析缺失显式失败并给出浏览器直达链接、state/author/labels/milestone/subIssuesSummary/closedByPullRequestsReferences 归一化、SPAM 隐藏评论标记保留、图片清单正文+评论合并去重、/api/github/issue-detail 路由 200/400/405/500 全路径、action 通道 issue_detail 对称可用、主面板与右侧栏 Issue 卡片改点击进弹窗 + 同行 GitHub ↗ 外跳 stopPropagation + 卡片禁内联 flex 布局、onSelectIssue 追加在形参末尾、React Hooks 顺序与 bundle 三模块拓扑顺序防回退)。
|
|
207
245
|
- `phase32`:右侧栏 PR/Issue 状态筛选(**单仓库查询显式 `--state all`**,修复「只显示已打开」根因——gh 默认 state 就是 open,数据在到达浏览器前已被过滤;条数上限 10→20 并抽成 RIGHTBAR_LIST_LIMIT 常量,依据 `gh pr list` 按 number 降序而非 updatedAt,取 10 条时非 OPEN 仅剩 5 条;触顶下发 prsTruncated/issuesTruncated 并渲染提示;PR 四档 all/open/merged/closed 与 Issue 三档 all/open/closed 胶囊复用既有样式类、计数先于过滤保证数字反映全量;偏好按 cwd 隔离存储、非法值与脏 JSON 归一回退、大写输入归一;空态区分「仓库无记录」与「筛选无结果」并给切换引导;Hook 位置(不可落在 renderGithubRepoView 内)、切仓库对齐只依赖 cwd、**作用域键无碰撞(/a/b 与 /a_b 不得压成同一个键)**、卡片布局纪律防回退)。
|
|
208
|
-
- `phase33`:使用指南内容与代码事实对账(help-modal 从 4 Tab 扩到 5 Tab,新增「18 大工具速查」;**工具数量对 src/main.ts
|
|
246
|
+
- `phase33`:使用指南内容与代码事实对账(help-modal 从 4 Tab 扩到 5 Tab,新增「18 大工具速查」;**工具数量对 src/main.ts 的工具工厂注册语句数量、action 总数对 src/tools 的 enum 累计、Tab 名称对 TAB_DEFS 与 workspace-panel 逐个核对**,代码演进而指南没跟上即失败;覆盖 PR/Issue 在线弹窗、状态筛选三档、Markdown 渲染、仓库切换器、提交详情、Diff 下钻、view_images 等近期能力;FAQ 扩至 6 条含「列表为空但网页有记录」与「为何需重启 DSH」;**防编造断言:指南中每条 `gh auth refresh -s <scope>` 必须能在源码找到出处**;**github_help 知识库同步对账:mapping 表声明的每组 action 数逐个对 src/tools 的 enum 长度、每个业务 action 必须在知识库有落点、规模口径统一为「18 工具 / 103 业务 action」(排除 help 的 21 查询主题与 api 的 4 HTTP method),Agent 侧知识库与 UI 侧使用指南必须同口径**)。
|
|
247
|
+
- `phase34`:**gh CLI 契约测试**(离线采集 18 工具 × 103 action 的全部 argv,逐条校验下发的每个 flag 与 `--json` 字段都真实存在于缓存下来的 `gh <cmd> --help` 里;`scripts/cache-gh-help.mjs` 抓取 124 个子命令的帮助并裁剪成 fixture;覆盖 124 argv / 317 flag / 256 个 `--json` 字段,SKIP 白名单显式登记「需真实凭证或真实文件」的少数条目)。
|
|
248
|
+
- `phase35`:**文档数字派生测试**(README / README.en / help.ts / help-modal.ts 里的「工具数 / 业务 action 数」全部从 `src/main.ts` 与 `lib/tools/*.js` 派生,一个都不手写;同时守卫三条历史漂移:不再出现 `104`、不再出现不可复现的「87%(104/120)」、README 的 `ctx.github` 示例必须是 `async`;并对账 `docs/architecture.md` 声明的测试阶段数与 `run-all.mjs` 的 `phases` 数组长度)。
|
|
249
|
+
- `phase36`:**宿主契约测试**(① 客户端 Bundle 形状:loader 入口 / id / apply+inject 导出 / 19 个 Bundle 子模块内联(18 UI + 1 shared)/ 闭包内无残留 ESM `export`;② 插件元数据:`inject` 收敛为唯一硬依赖 `tools`、`provide='github'`、无 `ctx.github` 裸赋值兜底、Config 已迁 Schemastery、`peerDependencies` 只声明宿主 cordis、`exports["./host"]` 指向的产物真实存在;③ 18 个工具的 descriptor 逐个复刻宿主 enforced JSON Schema 子集规则——关键字白名单、`type` 取值域、`properties`/`required`/`items`/`additionalProperties` 的归属约束、`required ⊆ properties`、`enum` 标量性,以及 `output.schema`/`output.render`/`execute` 的必备性;④ **真实 cordis Context 加载冒烟**——只 provide `tools` 时插件必须 ACTIVE 且 18 工具可用(这条专抓 mock 抓不到的缺陷:cordis 的 Context 是 Proxy,读未 inject 的服务属性会**抛错**而不是返回 undefined)、`commands`/`webServer` 晚于插件就绪仍能补注册、volatile 热更新不得冲掉显式配置的 `maxOutputBytes`)。
|
|
250
|
+
- `phase37`:**工具档位契约测试**(`applyTier` 纯函数:默认零破坏——18 工具 / 24,043 字符实测不变;`core` 5 / 8,534(−64.5%)、`ci` 7 / 9,943、`maintain` 8 / 13,274;裸工具名与预设可混用、未知档位 `console.warn` 一次后忽略而非静默清空;`read_only` 17 / 22,761——`github_api` 整体剔除、14 个混合工具剪 action enum 且**交集为空**、description 前置 `[只读模式]`;纯函数性质:`Object.freeze` 入参不抛错、重复调用结果一致、剪枝产物与入参解引用)。
|
|
251
|
+
- `phase38`:**破坏性操作闸门契约测试**(声明表覆盖 16 个 `(tool, action)`;`cache delete` 五条分支真值表——`all`/`cache_id`/`key` 三条都拦、无选择器不拦仍抛精确错、`list` 不误伤;条件闸门不**过度**拦截——`release upload` 不带 clobber、`skill publish` 带 `dry_run`/`fix`、`pr checkout` 不带 force、`issue transfer` 缺 `target_repo` 全部放行;9 条主文案逐字快照;`wrapWithGate` 只替换 `execute`,`name`/`parameters`/`output` 保持同一引用,拦截时**零副作用**不触达工具本体)。
|
|
252
|
+
- `phase39`:**状态徽章共享映射契约测试**(`src/shared/state-badges.ts` 必须零 import——构建期会被整体内联,有依赖就会在 bundle 作用域里变 undefined 导致白屏;六个纯函数的状态→图标/色调覆盖表;服务端 `format*Badge` 22 条输出与旧实现**逐字一致**;`lib/client.js` 内每个共享函数恰好 1 份定义、无残留 `shared/` import;`build.mjs` 把共享模块排在内联顺序最前且剥 import 的正则覆盖 `ui|shared` 两族)。
|
|
253
|
+
- `phase42`:**审阅锚点契约测试**(s7:`getPrDetail` 必须取回 40 位十六进制的 `headRefOid`/`baseRefOid`——分支会移动,SHA 才是可钉住的审查锚点;`renderers/pr.ts` 的分支行渲染成 `feat/x@a1b2c3d`,缺 SHA 时**不得**出现 `@undefined`;`changedFiles`/`filesTruncated`/`diffIncomplete` 三个截断字段各自成立且互不复用)。
|
|
254
|
+
- `phase43`:**行级评注写入契约测试**(s7:`gh pr review` 没有行级参数,唯一通道是 `POST /pulls/{n}/reviews`;`buildPrReviewPayload` 的 REST 字段形状与「一次说完所有校验错误」;`createPrReview` 的 argv 严格等于 `['api','--method','POST','repos/o/r/pulls/12/reviews','--input','-']` 且 stdin 含 event/comments[].side;**422 降级**把评注折叠进 body(内容不丢、显式 `degraded`);403 不降级;**不传 comments 时 argv 与改前逐字节一致**)。
|
|
255
|
+
- `phase44`:**仓库规则与严重度契约测试**(s7:`extractSection` 的标题边界——代码围栏内的 `##` 不算标题、一级标题同样终止截取;`normalizeSeverity` 只补不删、幂等、`[P0]` 永不降级;`readReviewRules` 无 `AGENTS.md` 时返回 `null` 且 `pr view` 输出**不含** `reviewRules` 键)。
|
|
256
|
+
- `phase45`:**CI 失败日志锚点萃取测试**(s7:pytest `FAILED` / node `AssertionError` / Go `panic:` / tsc `error TSxxxx` 四类锚点命中;相邻锚点合并成一块;单块行数 ≤ `2*radius+1`;`maxBlocks` 与 `droppedBlocks` 显式计数;无锚点走尾部 200 行回退;`log_failed` 已把输出预算放开到 2MB 字符/8MB 字节——否则截断发生在萃取之前)。
|
|
257
|
+
- `phase46`:**任务级原语契约测试**(s8:`classifyCommits` 的 `feat!:` 必须归 breaking(否则版本少跳一级);`suggestVersion` 的 auto 推导(breaking→major / feat→minor / 其余→patch)与无 tag 时从 0.1.0 起步;`selectFilesByBudget` 超预算只给元信息不硬塞;`finalizeEnvelope` 的 `cost.chars` 由信封体积计算而非手写;`github_task` 三个原语的端到端行为(review_pr 读模式零写回、写模式必须 confirm 且 commit_id 钉在 head SHA、diagnose_ci 只挑失败 job 并在日志截断时 `totalUnknown=true`、prepare_release **绝不自动发版**);档位层面 core=3 个工具、只读模式剪掉 review_pr 保留另两个原语)。
|
|
258
|
+
- `phase47`:**Actions Run 在线详情契约测试**(`getRunDetail` 的 `run_id` 只认纯数字——它是 `gh run view` 的位置参数,`--force`/`--web` 必须在触达子进程前拒绝且 argv 零产出;repo 白名单额外拒 `-` 开头与 `..`;`--json` 字段集含 `jobs` 且预算 2MB/8MB;缺 `steps` 归一化为空数组、`failedJobs`/`failedSteps` 只在服务端算一次;`truncated` 或解析缺失显式失败并给浏览器直达;`Unknown JSON field` 走 BASE 字段集重试且只重试一次;`getRunLogExcerpts` 用 `-j <jobId> --log-failed` **定向**拉取、最多 3 个 job、绝不使用 `--log` 整包、单 job 失败进 `errors[]` 而整体仍 `ok:true`;`run-log.ts` 的纯文本摘要(锚点块 / 尾部 200 行回退 / 两道截断声明 / 不产生 Markdown 围栏);`/api/github/run-detail` 与 `/api/github/run-logs` 的 200/400/405/500 与 `job_id` 逗号/重复参数两种写法;前端 `onSelectRun` 追加在形参末尾、Actions 卡片标题行拆分 + 外跳 `stopPropagation` + 禁内联 `display:flex`;弹窗 Hook 先于 early return、日志必须走 `<pre>` 且不接 markdown-lite;bundle 内联顺序 `issue-modal → run-modal` 与当时的子模块计数)。
|
|
259
|
+
- `phase41`:**侧栏入口图标契约测试**(issue #9 回归网:宿主 `renderSlot("sidebar.panellist", { size: wide ? 16 : 18, active })` 注入的 `size` 必须被透传——从 `lib/client.js` 抽出 `GithubSidebarIcon` 函数体、在沙箱里真调,断言 16/18 两档尺寸、根节点必须是裸 `svg`(有 wrapper 就会把图标格撑宽、标签右移 12px)、`fill` 用 `currentColor` 继承行颜色、缺省/非数字 `size` 兜底 16、`active` 不改变输出结构)。
|
|
260
|
+
- `phase48`:**本地文件入参契约测试**(`resolveBodySource` 的语义表逐行对账:文件来源走 `[fileFlag, '-']` + stdin、内联来源的 argv 与改造前**逐字节一致**、`body` 与 `body_file` 同时给出直接报错而不是静默选一个、空文件等同无正文;相对路径按工作区解析、严格 UTF-8 拒绝 GBK/ANSI 与 NUL 二进制、BOM 剥除、4MB 字节与 65,536 字符双上限(`input_file` 与 `notes_file` 关闭字符校验);七个工具的 flag 接线——`--body-file -` / `--notes-file -` / `--input -` / `--from-file -`,`review` 写回走 REST 请求体而非 flag)。
|
|
261
|
+
- `phase49`:**提交详情 / 未提交清单的文件 Diff 弹窗契约测试**(`diff-utils.ts` 四个纯函数:`parseDiffGitLine` 对含空格路径按**最后一个** `" b/"` 切出 b 侧(截错就点开是空的)、`normalizeRenamePath` 把 `src/{old => new}.ts` 与 `old => new` 两种 `--stat` 写法归一成新路径、`splitUnifiedPatch` 按 `diff --git` 边界切段并**丢弃首个 `diff --git` 之前的提交头**(否则第一段会渲染成幽灵差异)、`Binary files` 判二进制、`rename from`/`rename to` 定新旧路径、**按「首个 `@@` 之前才算文件头」的状态机解析**(正文里以 `---`/`+++` 开头的行是 Markdown 分隔线,按行首前缀判会把它们当文件头,导致增删计数为 0 且染错颜色)、`findPatchSection` 支持新路径 / 旧路径 / `{old => new}` 原文三态命中;`executor.getCommitDiff` 在临时 git 仓库真跑——两文件提交切出两段且 `truncated=false`、`git mv` 提交的 patch 含 `rename from`/`rename to`、非法 sha 在触达子进程前拒绝;整包 patch 超过 400k 字符时在**最后一个 `diff --git` 边界**截断(绝不切半个 hunk,只有单文件巨型 diff 找不到边界才硬切)并置 `truncated`;`/api/github/commit-diff` 路由与 `get_commit_diff` action 双向可用;`lib/client.js` 内联 `FileDiffModal`/`splitUnifiedPatch`、`commit-modal.ts` 的文件变更统计行绑 `onClick` 弹窗)。
|
|
@@ -39,6 +39,25 @@ export declare function sendJson(res: any, status: number, data: any): void;
|
|
|
39
39
|
* 集中处理超限/畸形 JSON 的错误语义,避免每个 handler 复制流收集样板。
|
|
40
40
|
*/
|
|
41
41
|
export declare function readJsonBody(req: any, maxBytes?: number): Promise<any>;
|
|
42
|
+
/**
|
|
43
|
+
* 本地 HTTP 写端点的跨站门禁。
|
|
44
|
+
*
|
|
45
|
+
* 威胁模型(写准,别夸大):防的是「任意网页用 simple request 打本地端口」这一类
|
|
46
|
+
* CSRF——`Content-Type: text/plain` 的跨站 POST 不触发预检,浏览器会照发。
|
|
47
|
+
* **不防 DNS rebinding**:攻击者页面若与目标同 host 名,`Origin === Host` 就成立。
|
|
48
|
+
* 真正挡住 rebinding 需要校验 Host 白名单或引入 token,属另一件事。
|
|
49
|
+
*
|
|
50
|
+
* 两道门:
|
|
51
|
+
* 1) `Origin` 必须为空(同源导航或非浏览器客户端)或与 `Host` 同源;
|
|
52
|
+
* 2) 需要读 JSON body 的端点额外要求 media type 恰为 `application/json`
|
|
53
|
+
* ——它不在 simple request 白名单里,浏览器会强制先发预检。
|
|
54
|
+
*
|
|
55
|
+
* 为什么允许 Origin 为空:同源 fetch 在部分场景下不带 Origin,非浏览器客户端
|
|
56
|
+
* (curl / 脚本)也不带。这两类不构成跨站威胁,拦掉只会误伤自家前端。
|
|
57
|
+
*/
|
|
58
|
+
export declare function guardLocalWrite(req: any, res: any, opts?: {
|
|
59
|
+
requireJson?: boolean;
|
|
60
|
+
}): boolean;
|
|
42
61
|
/** 各路由分组共享的依赖包类型:注册器装配时一次性注入,分组内不再触碰全局 */
|
|
43
62
|
export interface RouteDeps {
|
|
44
63
|
bindRoute: (route: any) => void;
|
package/lib/api/route-helpers.js
CHANGED
|
@@ -132,3 +132,57 @@ export function readJsonBody(req, maxBytes = 256 * 1024) {
|
|
|
132
132
|
req.on('error', reject);
|
|
133
133
|
});
|
|
134
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* 本地 HTTP 写端点的跨站门禁。
|
|
137
|
+
*
|
|
138
|
+
* 威胁模型(写准,别夸大):防的是「任意网页用 simple request 打本地端口」这一类
|
|
139
|
+
* CSRF——`Content-Type: text/plain` 的跨站 POST 不触发预检,浏览器会照发。
|
|
140
|
+
* **不防 DNS rebinding**:攻击者页面若与目标同 host 名,`Origin === Host` 就成立。
|
|
141
|
+
* 真正挡住 rebinding 需要校验 Host 白名单或引入 token,属另一件事。
|
|
142
|
+
*
|
|
143
|
+
* 两道门:
|
|
144
|
+
* 1) `Origin` 必须为空(同源导航或非浏览器客户端)或与 `Host` 同源;
|
|
145
|
+
* 2) 需要读 JSON body 的端点额外要求 media type 恰为 `application/json`
|
|
146
|
+
* ——它不在 simple request 白名单里,浏览器会强制先发预检。
|
|
147
|
+
*
|
|
148
|
+
* 为什么允许 Origin 为空:同源 fetch 在部分场景下不带 Origin,非浏览器客户端
|
|
149
|
+
* (curl / 脚本)也不带。这两类不构成跨站威胁,拦掉只会误伤自家前端。
|
|
150
|
+
*/
|
|
151
|
+
export function guardLocalWrite(req, res, opts = {}) {
|
|
152
|
+
// 早退前必须把请求体排空:Node 对未被消费的请求体通常直接断连,
|
|
153
|
+
// 客户端 fetch 会拿到网络错误而不是我们刚写好的 403/415。
|
|
154
|
+
const reject = (status, error) => {
|
|
155
|
+
try {
|
|
156
|
+
req.resume?.();
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
/* 排空失败不影响响应 */
|
|
160
|
+
}
|
|
161
|
+
sendJson(res, status, { ok: false, error });
|
|
162
|
+
return false;
|
|
163
|
+
};
|
|
164
|
+
const origin = typeof req.headers?.origin === 'string' ? req.headers.origin : '';
|
|
165
|
+
const host = String(req.headers?.host || '');
|
|
166
|
+
if (origin) {
|
|
167
|
+
let originHost = '';
|
|
168
|
+
try {
|
|
169
|
+
originHost = new URL(origin).host;
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// Origin 形如 "null"(沙箱 iframe / file://)时 URL 解析失败 → 视为不同源
|
|
173
|
+
originHost = '';
|
|
174
|
+
}
|
|
175
|
+
if (!originHost || originHost !== host) {
|
|
176
|
+
return reject(403, '跨站请求已拒绝:Origin 与本地服务不同源');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (opts.requireJson) {
|
|
180
|
+
// 精确比对 media type:`text/plain; x=application/json` 这种"看起来带 json"的值
|
|
181
|
+
// 用 includes 会误判通过——那不是校验,是自我安慰。
|
|
182
|
+
const mediaType = String(req.headers?.['content-type'] || '').split(';')[0].trim().toLowerCase();
|
|
183
|
+
if (mediaType !== 'application/json') {
|
|
184
|
+
return reject(415, '请求必须使用 Content-Type: application/json');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
6
|
import { hasGitDir } from '../workspace.js';
|
|
7
7
|
import { watchGitMetadata } from '../git-watcher.js';
|
|
8
|
-
import { isSafeExternalUrl, sendJson } from './route-helpers.js';
|
|
8
|
+
import { isSafeExternalUrl, sendJson, guardLocalWrite } from './route-helpers.js';
|
|
9
9
|
/**
|
|
10
10
|
* 构造「在新终端窗口运行命令」的平台命令(execFile 数组形态,不经 shell)。
|
|
11
11
|
*
|
|
@@ -141,6 +141,15 @@ export function registerActionRoutes(deps, executor, cacheMap, globalCache) {
|
|
|
141
141
|
kind: 'exact',
|
|
142
142
|
path: '/api/github/refresh',
|
|
143
143
|
handler: async (req, res) => {
|
|
144
|
+
// 只接受 POST:这个端点没有方法约束时,任意网页可用 <img src> 触发 GET,
|
|
145
|
+
// 等于给「清空缓存」开了个跨站后门。前端两处调用都是 POST。
|
|
146
|
+
if (req.method !== 'POST') {
|
|
147
|
+
sendJson(res, 405, { ok: false, message: '仅支持 POST 请求' });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
// 无请求体,只校验 Origin。
|
|
151
|
+
if (!guardLocalWrite(req, res))
|
|
152
|
+
return;
|
|
144
153
|
// 连容灾底座一起清:用户点「重试刷新」的语义是「宁可空白也重来」。
|
|
145
154
|
// 只清活动缓存会留下一个致命死角——失败分支从不写缓存,于是 fallback
|
|
146
155
|
// 里的旧快照一旦冻结就永久生效(仓库身份改错了也永远出不来),
|
|
@@ -159,6 +168,9 @@ export function registerActionRoutes(deps, executor, cacheMap, globalCache) {
|
|
|
159
168
|
sendJson(res, 405, { ok: false, message: '仅支持 POST 请求' });
|
|
160
169
|
return;
|
|
161
170
|
}
|
|
171
|
+
// 跨站门禁:本地服务上的写操作不能被任意网页用 simple request 触发。
|
|
172
|
+
if (!guardLocalWrite(req, res, { requireJson: true }))
|
|
173
|
+
return;
|
|
162
174
|
// 请求体上限:这些 action 的载荷都是短参数(标题/分支/PR 编号),
|
|
163
175
|
// 正常远小于 1KB。裸 `body += chunk` 无上限意味着一个超大 POST 能把
|
|
164
176
|
// 整个请求体堆进进程内存——本地服务也不该留这种自伤通道。
|
|
@@ -302,6 +314,20 @@ export function registerActionRoutes(deps, executor, cacheMap, globalCache) {
|
|
|
302
314
|
sendJson(res, 200, { ok: r.ok, data: r, error: r.error });
|
|
303
315
|
break;
|
|
304
316
|
}
|
|
317
|
+
case 'commit_diff':
|
|
318
|
+
case 'get_commit_diff': {
|
|
319
|
+
// 与 get_file_diff 同构:只读查询走 POST 是为了让 Agent/脚本复用同一个
|
|
320
|
+
// action 通道,外层形状保持一致(ok/data/error),调用方不必区分两套契约
|
|
321
|
+
const commitSha = typeof payload.sha === 'string' ? payload.sha.trim() : '';
|
|
322
|
+
if (!commitSha) {
|
|
323
|
+
sendJson(res, 400, { ok: false, error: '缺少必需的 sha 参数' });
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const resolvedCwd = executor.resolveWorkspaceCwd(cwd);
|
|
327
|
+
const r = await executor.getCommitDiff(resolvedCwd, commitSha);
|
|
328
|
+
sendJson(res, 200, { ok: r.ok, data: r, error: r.error });
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
305
331
|
case 'uncommitted_files':
|
|
306
332
|
case 'get_uncommitted_files': {
|
|
307
333
|
const limit = Math.min(Math.max(1, Number(payload.limit) || 15), 100);
|
|
@@ -334,6 +360,11 @@ export function registerActionRoutes(deps, executor, cacheMap, globalCache) {
|
|
|
334
360
|
}
|
|
335
361
|
}
|
|
336
362
|
catch (err) {
|
|
363
|
+
// 客户端发来的畸形 JSON 是 400,不是服务端故障;顺带不再把解析器内部消息透出去
|
|
364
|
+
if (err instanceof SyntaxError) {
|
|
365
|
+
sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' });
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
337
368
|
sendJson(res, 500, { ok: false, error: err.message || '操作执行失败' });
|
|
338
369
|
}
|
|
339
370
|
});
|
|
@@ -372,6 +403,9 @@ export function registerActionRoutes(deps, executor, cacheMap, globalCache) {
|
|
|
372
403
|
sendJson(res, 405, { ok: false, message: '仅支持 POST 请求' });
|
|
373
404
|
return;
|
|
374
405
|
}
|
|
406
|
+
// 跨站门禁:切分支是本服务最危险的写操作,与 /action 同一套校验。
|
|
407
|
+
if (!guardLocalWrite(req, res, { requireJson: true }))
|
|
408
|
+
return;
|
|
375
409
|
// 请求体上限:这些 action 的载荷都是短参数(标题/分支/PR 编号),
|
|
376
410
|
// 正常远小于 1KB。裸 `body += chunk` 无上限意味着一个超大 POST 能把
|
|
377
411
|
// 整个请求体堆进进程内存——本地服务也不该留这种自伤通道。
|
|
@@ -468,6 +502,60 @@ export function registerActionRoutes(deps, executor, cacheMap, globalCache) {
|
|
|
468
502
|
}
|
|
469
503
|
},
|
|
470
504
|
});
|
|
505
|
+
// 6.1 Run 在线详情(run 元数据 + jobs/steps 树)。
|
|
506
|
+
// 与 pr-detail / issue-detail 同构:GET 读语义、单次往返、参数白名单全部下沉到
|
|
507
|
+
// executor(run_id 会被当作 `gh run view` 的位置参数,校验点只能有一处)。
|
|
508
|
+
bindRoute({
|
|
509
|
+
kind: 'exact',
|
|
510
|
+
path: '/api/github/run-detail',
|
|
511
|
+
handler: async (req, res) => {
|
|
512
|
+
if (req.method !== 'GET') {
|
|
513
|
+
sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
try {
|
|
517
|
+
const u = new URL(req.url || '', 'http://127.0.0.1');
|
|
518
|
+
const runId = u.searchParams.get('run_id') || u.searchParams.get('runId') || '';
|
|
519
|
+
const cwd = u.searchParams.get('cwd') || undefined;
|
|
520
|
+
const repo = u.searchParams.get('repo') || undefined;
|
|
521
|
+
const r = await executor.getRunDetail(cwd, runId, repo);
|
|
522
|
+
sendJson(res, r.ok ? 200 : 400, r);
|
|
523
|
+
}
|
|
524
|
+
catch (err) {
|
|
525
|
+
sendJson(res, 500, { ok: false, error: err?.message || '读取 Run 详情异常' });
|
|
526
|
+
}
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
// 6.2 Run 失败日志摘要(按需拉取,慢链路独立成路由)。
|
|
530
|
+
// 为什么不并进 run-detail:实测单次日志 4–40 秒且会被代理直接掐断,
|
|
531
|
+
// 合并后弹窗首屏要等最慢的那条链路——结构数据明明已经到手却显示不出来。
|
|
532
|
+
bindRoute({
|
|
533
|
+
kind: 'exact',
|
|
534
|
+
path: '/api/github/run-logs',
|
|
535
|
+
handler: async (req, res) => {
|
|
536
|
+
if (req.method !== 'GET') {
|
|
537
|
+
sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
try {
|
|
541
|
+
const u = new URL(req.url || '', 'http://127.0.0.1');
|
|
542
|
+
const runId = u.searchParams.get('run_id') || u.searchParams.get('runId') || '';
|
|
543
|
+
const cwd = u.searchParams.get('cwd') || undefined;
|
|
544
|
+
const repo = u.searchParams.get('repo') || undefined;
|
|
545
|
+
// 兼容 `job_id=1&job_id=2` 与 `job_id=1,2` 两种写法(上限由 executor 兜底)
|
|
546
|
+
const jobIds = u.searchParams
|
|
547
|
+
.getAll('job_id')
|
|
548
|
+
.flatMap((v) => String(v).split(','))
|
|
549
|
+
.map((v) => v.trim())
|
|
550
|
+
.filter(Boolean);
|
|
551
|
+
const r = await executor.getRunLogExcerpts(cwd, runId, jobIds, repo);
|
|
552
|
+
sendJson(res, r.ok ? 200 : 400, r);
|
|
553
|
+
}
|
|
554
|
+
catch (err) {
|
|
555
|
+
sendJson(res, 500, { ok: false, error: err?.message || '读取 Run 失败日志异常' });
|
|
556
|
+
}
|
|
557
|
+
},
|
|
558
|
+
});
|
|
471
559
|
// 7. 本地 Git 元数据定向监听(专供右侧栏开启「本地 Git 改动实时感知」时长轮询)
|
|
472
560
|
// 严格安全纪律:只针对 .git/index 与 .git/HEAD 实施定向非递归监听,严禁监听源码目录防句柄耗尽;
|
|
473
561
|
// 800ms 防抖合并;连接断开时自动关闭 watcher 释放系统资源(委托给底层 git-watcher 服务)。
|
|
@@ -589,7 +677,43 @@ export function registerActionRoutes(deps, executor, cacheMap, globalCache) {
|
|
|
589
677
|
}
|
|
590
678
|
},
|
|
591
679
|
});
|
|
592
|
-
// 10.
|
|
680
|
+
// 10. 单次提交的完整补丁(提交详情弹窗点某个文件后,前端按 `diff --git` 切段预览)
|
|
681
|
+
// 与 file-diff 的分工:那条读「工作区未提交改动」,这条读「历史某次提交」,
|
|
682
|
+
// 两者都只读、都走同一套 cwd 解析 + hasGitDir 前置门禁。
|
|
683
|
+
bindRoute({
|
|
684
|
+
kind: 'exact',
|
|
685
|
+
path: '/api/github/commit-diff',
|
|
686
|
+
handler: async (req, res) => {
|
|
687
|
+
if (req.method !== 'GET') {
|
|
688
|
+
sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
try {
|
|
692
|
+
const url = new URL(req.url || '', 'http://127.0.0.1');
|
|
693
|
+
const cwd = url.searchParams.get('cwd') || undefined;
|
|
694
|
+
const sha = url.searchParams.get('sha');
|
|
695
|
+
if (!sha || !sha.trim()) {
|
|
696
|
+
sendJson(res, 400, { ok: false, error: '缺少必需的 sha 参数' });
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
const resolvedCwd = executor.resolveWorkspaceCwd(cwd);
|
|
700
|
+
if (!hasGitDir(resolvedCwd)) {
|
|
701
|
+
sendJson(res, 400, { ok: false, error: '当前工作区不是 Git 仓库' });
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
const diffResult = await executor.getCommitDiff(resolvedCwd, sha.trim());
|
|
705
|
+
if (!diffResult.ok) {
|
|
706
|
+
sendJson(res, 400, { ok: false, error: diffResult.error || '获取提交补丁失败' });
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
sendJson(res, 200, { ok: true, data: diffResult });
|
|
710
|
+
}
|
|
711
|
+
catch (err) {
|
|
712
|
+
sendJson(res, 500, { ok: false, error: err?.message || '获取提交补丁失败' });
|
|
713
|
+
}
|
|
714
|
+
},
|
|
715
|
+
});
|
|
716
|
+
// 11. 本地未提交文件列表轻量流式分页查询(供右侧栏滑动加载调用)
|
|
593
717
|
bindRoute({
|
|
594
718
|
kind: 'exact',
|
|
595
719
|
path: '/api/github/uncommitted-files',
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { loadUpdateSettings, saveUpdateSettings, isValidUpdateMode, describeUpdateMode } from '../update-settings.js';
|
|
2
|
-
import { sendJson, readJsonBody } from './route-helpers.js';
|
|
2
|
+
import { sendJson, readJsonBody, guardLocalWrite } from './route-helpers.js';
|
|
3
3
|
export function registerLifecycleRoutes(deps, executor, checker, upgrader) {
|
|
4
4
|
const { bindRoute } = deps;
|
|
5
5
|
// 1. 闲时静默检查更新端点(前端在 gh 业务数据完全拉取就绪后,由 idle 延时静默触发)
|
|
@@ -31,6 +31,10 @@ export function registerLifecycleRoutes(deps, executor, checker, upgrader) {
|
|
|
31
31
|
sendJson(res, 405, { ok: false, message: '仅支持 POST 请求' });
|
|
32
32
|
return;
|
|
33
33
|
}
|
|
34
|
+
// 跨站门禁:一键升级是最敏感的写链路(会改动用户 profile 依赖)。
|
|
35
|
+
// 本端点无请求体,因此只校验 Origin,不要求 Content-Type。
|
|
36
|
+
if (!guardLocalWrite(req, res))
|
|
37
|
+
return;
|
|
34
38
|
try {
|
|
35
39
|
// 一键升级是用户显式动作,不受 checkUpdate 配置熔断:check() 在禁用时
|
|
36
40
|
// 会返回 latestVersion 缺失的兜底态,此时回退 'latest' 让 pnpm 自行解析
|
|
@@ -71,6 +75,9 @@ export function registerLifecycleRoutes(deps, executor, checker, upgrader) {
|
|
|
71
75
|
sendJson(res, 405, { ok: false, message: '仅支持 GET / POST 请求' });
|
|
72
76
|
return;
|
|
73
77
|
}
|
|
78
|
+
// 跨站门禁:更新源设置会写盘并改变后续所有版本探测的走向。
|
|
79
|
+
if (!guardLocalWrite(req, res, { requireJson: true }))
|
|
80
|
+
return;
|
|
74
81
|
try {
|
|
75
82
|
const body = await readJsonBody(req);
|
|
76
83
|
const mode = body?.mode;
|
|
@@ -8,4 +8,30 @@ import { TtlCache } from '../cache.js';
|
|
|
8
8
|
import type { UpdateChecker } from '../updater.js';
|
|
9
9
|
import type { GitHubOverviewData, GlobalOverviewData } from '../types.js';
|
|
10
10
|
import { type RouteDeps } from './route-helpers.js';
|
|
11
|
+
/**
|
|
12
|
+
* 右侧栏单仓库 PR / Issue 的展示条数上限。
|
|
13
|
+
*
|
|
14
|
+
* 为什么从 10 提到 20:加上 `--state all` 后列表里混入了 closed/merged,
|
|
15
|
+
* 而 `gh pr list` 的排序依据是 **number 降序**(实测 30 条验证:14763、14752、
|
|
16
|
+
* 14751 …… 严格递减),不是 updatedAt。取 10 条时实测 cli/cli 只剩 5 条非 OPEN,
|
|
17
|
+
* 用户切到「Merged」可能只有 2-3 条,筛选体验形同虚设。
|
|
18
|
+
* 多拉 10 条的代价可忽略(同一个 gh 进程,仅 JSON 体积略增),
|
|
19
|
+
* 但绝不能改成「每个状态各查一次」——那会把 3 个 gh 子进程变成 6~9 个,
|
|
20
|
+
* 右侧栏首屏本就受 Promise.all 8 秒超时约束。
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* 列表默认每页条数。
|
|
24
|
+
*
|
|
25
|
+
* 为什么是 15 而不是 20:与右侧栏「Git 提交」「未提交变更」两个已有分页的默认页大小对齐,
|
|
26
|
+
* 用户下滑即可继续加载(`prLimit/issueLimit/runLimit` 查询参数),所以首屏不必再靠
|
|
27
|
+
* 「多拉一点」来换取筛选体验。20 条时代首屏 JSON 实测可达 28,608 字符,直接撞上
|
|
28
|
+
* executor 的 24,000 字符输出上限 → 被截断 → JSON 解析被跳过 → 面板空列表且无报错。
|
|
29
|
+
*/
|
|
30
|
+
export declare const RIGHTBAR_LIST_LIMIT = 15;
|
|
31
|
+
/**
|
|
32
|
+
* 缓存键:必须把三个分页参数算进去。
|
|
33
|
+
* 只用 cwd 当键的话,「先加载 15 条」的缓存会喂给「请求 30 条」的那次刷新,
|
|
34
|
+
* 表现是点了「加载更多」列表不变——而且因为命中的是缓存,连请求日志都没有。
|
|
35
|
+
*/
|
|
36
|
+
export declare function overviewCacheKey(cwd: string, prLimit: number, issueLimit: number, runLimit: number): string;
|
|
11
37
|
export declare function registerOverviewRoutes(deps: RouteDeps, executor: GhExecutor, cacheMap: TtlCache<GitHubOverviewData>, globalCache: TtlCache<GlobalOverviewData>, checker: UpdateChecker): void;
|