dsh-mobile-plugin 0.1.96 → 0.1.98
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.md +73 -50
- package/config/agent-presets/standard/agent.cordis.yml +13 -0
- package/lib/git/exec.js +129 -0
- package/lib/git/package.json +3 -0
- package/lib/git/parse.js +247 -0
- package/lib/git/read.js +617 -0
- package/lib/index.js +23 -29
- package/lib/mobile/archive-clean.js +31 -1
- package/lib/mobile/channels.js +583 -0
- package/lib/mobile/events.js +10 -8
- package/lib/mobile/git-repos.js +210 -0
- package/lib/mobile/git.js +603 -0
- package/lib/mobile/history-derive.js +79 -1
- package/lib/mobile/host-bridge/endpoints.js +3 -0
- package/lib/mobile/host-bridge/event-synthesis.js +109 -1
- package/lib/mobile/host-bridge/shims.js +9 -2
- package/lib/mobile/host-settings.js +41 -0
- package/lib/mobile/inbox-clean.js +337 -0
- package/lib/mobile/pairing.js +18 -1
- package/lib/mobile/questions.js +31 -9
- package/lib/mobile/schedule.js +440 -0
- package/lib/mobile/session-cache.js +18 -0
- package/lib/mobile/session-file.js +192 -0
- package/lib/mobile/session-model-options.js +184 -0
- package/lib/mobile/session-models.js +24 -2
- package/lib/mobile/session-schedule-mark.js +70 -0
- package/lib/mobile/session-snapshot.js +12 -1
- package/lib/mobile/sessions.js +8 -0
- package/lib/mobile/stats-collect.cjs +257 -0
- package/lib/mobile/stats-ledger.cjs +407 -0
- package/lib/mobile/stats.js +38 -0
- package/lib/mobile/stream.js +18 -10
- package/lib/mobile/uploads.js +24 -6
- package/lib/mobile/workspaces.js +30 -6
- package/lib/panel/index.html +4 -4
- package/lib/relay/lan-proxy.js +10 -1
- package/lib/routes/control-plane.js +38 -0
- package/lib/routes/mobile-api.js +70 -1
- package/lib/session-cleanup.cjs +8 -0
- package/lib/shared/sse-write.js +17 -6
- package/lib/shell/device-ota.js +52 -2
- package/lib/shell/manager.js +15 -1
- package/lib/shell/updater.js +153 -6
- package/lib/update-install.cjs +11 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,81 +1,104 @@
|
|
|
1
|
-
|
|
1
|
+
<h3 align="center">AI-HUB 移动端插件</h3>
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="https://raw.githubusercontent.com/WNLUO/dsh-mobile-plugin/main/assets/banner.png" alt="AI-HUB:用手机接着电脑上的 DSH 往下用" width="100%">
|
|
5
|
+
</p>
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://apps.apple.com/us/app/ai-hub/id6821681438"><img alt="App Store" src="https://img.shields.io/badge/App_Store-%E4%B8%8B%E8%BD%BD-black?style=for-the-badge&logo=appstore&logoColor=white"></a>
|
|
9
|
+
<a href="https://app.wnluo.com/public/apps/14"><img alt="Android APK" src="https://img.shields.io/badge/Android_APK-%E4%B8%8B%E8%BD%BD-3DDC84?style=for-the-badge&logo=android&logoColor=white"></a>
|
|
10
|
+
<a href="https://github.com/WNLUO/dsh-mobile-plugin/releases/latest/download/ai-hub-android.apk"><img alt="GitHub APK" src="https://img.shields.io/badge/GitHub_APK-%E6%9C%80%E6%96%B0%E7%89%88-181717?style=for-the-badge&logo=github"></a>
|
|
11
|
+
<a href="https://testflight.apple.com/join/4fn5PCMD"><img alt="TestFlight" src="https://img.shields.io/badge/TestFlight-%E6%B5%8B%E8%AF%95%E7%89%88-0D96F6?style=for-the-badge&logo=apple&logoColor=white"></a>
|
|
12
|
+
</p>
|
|
6
13
|
|
|
7
|
-
|
|
14
|
+
<p align="center">
|
|
15
|
+
<a href="https://www.npmjs.com/package/dsh-mobile-plugin"><img alt="npm" src="https://img.shields.io/npm/v/dsh-mobile-plugin?style=flat-square"></a>
|
|
16
|
+
<img alt="license" src="https://img.shields.io/badge/license-MIT-blue?style=flat-square">
|
|
17
|
+
<img alt="platform" src="https://img.shields.io/badge/platform-iOS%20%7C%20Android-999999?style=flat-square">
|
|
18
|
+
<a href="EN-README.md"><img alt="English" src="https://img.shields.io/badge/README-English-2f81f7?style=flat-square"></a>
|
|
19
|
+
</p>
|
|
8
20
|
|
|
21
|
+
<p align="center">电脑上开着的 DSH,手机上接着用。</p>
|
|
22
|
+
|
|
23
|
+
**AI-HUB 的手机端插件。** 装在电脑这边,手机 App 就通过一条中转隧道或局域网配对连进你的 DSH —— 看会话、接着聊、管理文件和工作区,进度实时同步。它也可以脱离桌面外壳单独跑在服务器上,当作无头宿主用。
|
|
24
|
+
|
|
25
|
+
## 🌟 功能
|
|
26
|
+
|
|
27
|
+
- 🚀 **两种连法**:经 relay v2 隧道从公网连回来,或者在同一个 Wi-Fi 下走局域网代理直连。
|
|
28
|
+
- 📱 **受限的移动 API**:`/api/mobile/v1/*` 只开手机需要的口子 —— 不暴露 shell、系统设置与任意文件写入。
|
|
29
|
+
- 🗂️ **会话与工作区**:新建会话、归入指定工作区,手机端排的顺序也会同步回电脑。
|
|
30
|
+
- 📡 **实时事件**:设备事件 SSE 全程转发,手机上看到的就是电脑那边的进度。
|
|
31
|
+
- 🧹 **自动归档**:闲置会话自动收进归档,只从列表里隐藏,日志不删。
|
|
32
|
+
- 🔐 **配对与凭据**:本机配置接口、可选的配对策略,设备凭据可以单独吊销。
|
|
33
|
+
|
|
34
|
+
## 📥 下载 AI-HUB 手机 App
|
|
35
|
+
|
|
36
|
+
- [App Store](https://apps.apple.com/us/app/ai-hub/id6821681438)(正式版,已在架)
|
|
9
37
|
- [Android APK](https://app.wnluo.com/public/apps/14)
|
|
10
38
|
- [Android APK(GitHub 渠道)](https://github.com/WNLUO/dsh-mobile-plugin/releases/latest/download/ai-hub-android.apk);[历史版本](https://github.com/WNLUO/dsh-mobile-plugin/releases)
|
|
11
|
-
- [
|
|
12
|
-
- [TestFlight 测试版](https://testflight.apple.com/join/4fn5PCMD)
|
|
13
|
-
|
|
14
|
-
## 功能
|
|
39
|
+
- [TestFlight 测试版](https://testflight.apple.com/join/4fn5PCMD)(比商店版先拿到新构建,正式版照常从 App Store 更新)
|
|
15
40
|
|
|
16
|
-
|
|
17
|
-
- 提供受限的 `/api/mobile/v1/*` 移动 API。
|
|
18
|
-
- 支持会话创建、工作区归属与会话排序同步。
|
|
19
|
-
- 支持设备事件 SSE 转发、自动归档和会话清理。
|
|
20
|
-
- 提供本机配置接口与配对授权策略。
|
|
41
|
+
装好 App 只有一半,另一半是这个插件。
|
|
21
42
|
|
|
22
|
-
##
|
|
43
|
+
## 🚀 安装与更新
|
|
23
44
|
|
|
24
|
-
###
|
|
45
|
+
### 桌面端
|
|
25
46
|
|
|
26
|
-
在 DSH 的 **设置 → 插件**
|
|
47
|
+
在 DSH 的 **设置 → 插件** 里搜索并安装 `dsh-mobile-plugin`,然后打开 **设置 → 移动端 → 打开配置页面** 做配对。插件市场负责检查和安装更新,通常更新后不用重启 DSH。
|
|
27
48
|
|
|
28
49
|
### 无头 Host(Linux)
|
|
29
50
|
|
|
30
|
-
需要 Node.js、pnpm,以及可用的 DSH
|
|
51
|
+
需要 Node.js、pnpm,以及可用的 DSH 插件宿主依赖。仓库里的 `host.js` 就是独立入口:
|
|
31
52
|
|
|
32
53
|
```sh
|
|
33
54
|
pnpm install --frozen-lockfile
|
|
34
55
|
DSH_HOME=/opt/dsh-mobile/.dsh DSH_MOBILE_PROFILE=mobile node host.js
|
|
35
56
|
```
|
|
36
57
|
|
|
37
|
-
`DSH_HOME` 是数据根目录,默认 `~/.dsh`;`DSH_MOBILE_PROFILE` 是 profile 名称,默认 `mobile`。无头 Host 的 Web
|
|
58
|
+
`DSH_HOME` 是数据根目录,默认 `~/.dsh`;`DSH_MOBILE_PROFILE` 是 profile 名称,默认 `mobile`。无头 Host 的 Web 服务默认只监听 `127.0.0.1:3080`,不对局域网直接开放。
|
|
38
59
|
|
|
39
|
-
|
|
60
|
+
配置接口只限本机访问,看状态和拿配对信息:
|
|
40
61
|
|
|
41
62
|
```sh
|
|
42
63
|
curl http://127.0.0.1:3080/api/relay/status
|
|
43
64
|
curl http://127.0.0.1:3080/api/relay/pairing
|
|
44
65
|
```
|
|
45
66
|
|
|
46
|
-
|
|
67
|
+
更多的部署方式见下面的「无头部署」。
|
|
47
68
|
|
|
48
|
-
## 配置与 API
|
|
69
|
+
## 🔌 配置与 API
|
|
49
70
|
|
|
50
|
-
|
|
71
|
+
无头运行时用这几个本机接口管远程连接:
|
|
51
72
|
|
|
52
|
-
- `POST /api/relay/toggle
|
|
73
|
+
- `POST /api/relay/toggle`:开或关远程控制。
|
|
53
74
|
- `POST /api/relay/regenerate`:重新生成配对码。
|
|
54
75
|
- `POST /api/relay/config`:设置 `relayUrl`、`lanEnabled`、`lanPort`、`name` 和 `pairingPolicy`。
|
|
55
|
-
- `POST /api/relay/reconnect
|
|
76
|
+
- `POST /api/relay/reconnect`:重连隧道与局域网代理。
|
|
77
|
+
|
|
78
|
+
移动 API 要 Bearer token。设备事件流 `GET /api/mobile/v1/events/stream` 用 SSE 转发宿主事件。建会话时可以传 `workspaceId` 或 `cwd`,前者优先,并会把会话归进那个工作区;手机端的排序同样会同步到宿主工作区。
|
|
56
79
|
|
|
57
|
-
|
|
80
|
+
配对策略默认 `code`,凭配对码放行;设成 `deny` 就拒绝所有新配对。
|
|
58
81
|
|
|
59
|
-
|
|
82
|
+
`session_cleanup` 工具能归档会话,也能永久删除归档过的会话,删除前要显式确认,删掉不可恢复。**删除是排队的**:会话立刻从列表里消失,日志和投影缓存等宿主下次启动才从磁盘上删 —— 宿主没有「删除会话」的接口,进程活着的时候从它脚底下删文件,它会打开一个已经不在的日志,那是未捕获异常,会带走整个宿主进程。
|
|
60
83
|
|
|
61
|
-
## 无头部署
|
|
84
|
+
## 🖥️ 无头部署
|
|
62
85
|
|
|
63
|
-
|
|
86
|
+
无头服务器上,启动器和插件本体是分开管的:启动器由部署流程同步到 `/opt/dsh-mobile/`,插件本体装进 profile:
|
|
64
87
|
|
|
65
88
|
```sh
|
|
66
89
|
cd <DSH_HOME>/profiles/<profile>
|
|
67
90
|
pnpm add dsh-mobile-plugin@<版本>
|
|
68
91
|
```
|
|
69
92
|
|
|
70
|
-
|
|
93
|
+
宿主优先挂 profile 里装的那份,没装才用源码树版本。设备凭据库随 npm 包一起发,不用单独装。仓库锁文件用 `pnpm install --frozen-lockfile`。
|
|
71
94
|
|
|
72
|
-
>
|
|
95
|
+
> 别用 `npm install --legacy-peer-deps` 装插件;正式安装走 DSH 插件市场。
|
|
73
96
|
|
|
74
|
-
## 本机调试 OTA
|
|
97
|
+
## 🔄 本机调试 OTA
|
|
75
98
|
|
|
76
|
-
|
|
99
|
+
新版插件的管理面板里有张「本机调试 OTA」卡片,可以手动检查并安装中转发布的紧急完整包。入口只认宿主本机的访问,手机和公网都触发不了;装完靠切换实现代生效,切换失败会退回上一代。正式发布源仍然是 npm,等 npm 上的版本号追到 OTA 版本时就自动接管。第一次发带这个功能的 npm 包需要重启一次 DSH,让稳定外壳把 OTA 能力加载进来。
|
|
77
100
|
|
|
78
|
-
|
|
101
|
+
要发一个紧急完整包,用仓库里的打包脚本,别手工 `tar`:
|
|
79
102
|
|
|
80
103
|
```sh
|
|
81
104
|
# 只打包(产物在当前目录)
|
|
@@ -85,37 +108,37 @@ node scripts/pack-ota.mjs
|
|
|
85
108
|
node scripts/pack-ota.mjs --publish https://你的中转 --token <中转的 OTA 发布 token>
|
|
86
109
|
```
|
|
87
110
|
|
|
88
|
-
|
|
111
|
+
脚本干四件事:文件清单问 `npm pack --dry-run --json` 要(**不碰系统 tar**,三个平台同一份逻辑)、往包里写一份 `lib/ota-manifest.json`(版本 + 每个文件的路径与字节数)、用客户端的真解包器自检、发布后再下载回来比 sha256。中转那边也记哈希,客户端装之前会核对 —— 半个包、被中间层改过的包都装不上去。
|
|
89
112
|
|
|
90
113
|
`--platform` 默认 `process.platform`(`darwin` / `linux` / `win32`);这个插件是纯 JS,也可以 `--platform any` 发一份三个系统通用,客户端在自己平台没有包时会回落去问 `any`。`channel` 一般 `stable`。发布接口没配 token 时是关闭的,下载不受影响;版本按号比,最后发的那个才是「最新」。
|
|
91
114
|
|
|
92
|
-
## 开发
|
|
115
|
+
## 👨💻 开发
|
|
93
116
|
|
|
94
117
|
```sh
|
|
95
118
|
pnpm install --frozen-lockfile
|
|
96
119
|
npm test
|
|
97
120
|
```
|
|
98
121
|
|
|
99
|
-
|
|
122
|
+
改过 `React/` 面板源码,先在该目录跑 `pnpm run build`,并把生成的 `lib/panel/index.html` 和源码一起提交。
|
|
100
123
|
|
|
101
|
-
## 仓库与发布
|
|
124
|
+
## 📦 仓库与发布
|
|
102
125
|
|
|
103
|
-
|
|
126
|
+
这里是插件的公开发布仓库,仓库根就是 npm 包根。开发和 App、中转服务器一起放在私有 monorepo 里,所以这个仓库的提交历史从拆分那天开始;发布时要把同一次改动同步过来。
|
|
104
127
|
|
|
105
|
-
- npm 包的 `repository`
|
|
106
|
-
-
|
|
107
|
-
-
|
|
108
|
-
- GitHub 的 release 页放手机 App 的 APK(下载入口见开头那一节):资产名固定用 `ai-hub-android.apk
|
|
128
|
+
- npm 包的 `repository` 字段负责关联插件列表条目,改完要再发一次版,注册表里的信息才会更新。
|
|
129
|
+
- 收录靠往 awesome-dsh-plugin 提 `data/plugins/<owner>__<repo>.yml`。描述里的数字和 API 名称都要能在代码里核实;列表要求 `description.en`、`category: remote`,包要声明 `dsh.bundle`,仓库要有 `dsh-plugin` topic。
|
|
130
|
+
- 「仓库创建满一天」这条检查过一阵会自己重跑。截图可以用 `screenshots.json` 提供,也可以让市场从这个 README 里找。
|
|
131
|
+
- GitHub 的 release 页放手机 App 的 APK(下载入口见开头那一节):资产名固定用 `ai-hub-android.apk`,而且要发成正式版 —— 草稿和预发布都不会成为「最新」,README 里那条直连跟着就断了。插件自己走 npm 发布,不在这个仓库发 release。
|
|
109
132
|
- 隐私政策:[PRIVACY.md](PRIVACY.md)。技术支持与常见问题:[SUPPORT.md](SUPPORT.md)。
|
|
110
133
|
|
|
111
|
-
###
|
|
134
|
+
### 发版流程
|
|
112
135
|
|
|
113
|
-
1.
|
|
114
|
-
2.
|
|
115
|
-
3.
|
|
116
|
-
4.
|
|
117
|
-
5. 用 `npm view dsh-mobile-plugin version`
|
|
136
|
+
1. 跑 `node scripts/bump-version.mjs` 更新版本号。
|
|
137
|
+
2. 改过 `React/` 就跑 `cd React && pnpm run build`。
|
|
138
|
+
3. 跑 `npm test`,再用 `npm pack --dry-run` 看一眼要发出去的文件。
|
|
139
|
+
4. 跑 `npm publish`,按提示完成 npm 二次验证。
|
|
140
|
+
5. 用 `npm view dsh-mobile-plugin version` 确认注册表版本,再把改动同步到公开仓库。
|
|
118
141
|
|
|
119
|
-
## 远程控制元数据
|
|
142
|
+
## 📎 远程控制元数据
|
|
120
143
|
|
|
121
|
-
|
|
144
|
+
配对请求可以带设备信息,App 现在会发 `id`、`deviceId`、`name`、`platform`、`osVersion` 和 `appVersion`;局域网代理与中转会补上它们观察到的手机 IP。
|
|
@@ -171,3 +171,16 @@
|
|
|
171
171
|
# disables the whole row — rather than degrade — when one does not match.
|
|
172
172
|
- id: tool-ask-user
|
|
173
173
|
name: '@deepseek-ai/dsh-tool-ask-user'
|
|
174
|
+
# `present` 是模型**显式声明交付文件**的那一行。桌面 `standard` preset 里一直有它
|
|
175
|
+
# (`@deepseek-ai/dsh-web-app/presets/standard.patch.yml`),手机这份漏了 —— 于是手机
|
|
176
|
+
# 上的模型完全没有「把文件交付给用户」这个动作,只能写完文件什么也不说;App 那边只好
|
|
177
|
+
# 拿 write/edit 成功去反推,结果「每改一次都冒一张卡、用命令行改的却一张都没有」。
|
|
178
|
+
#
|
|
179
|
+
# 它只做两件事:校验路径是会话文件系统里真实存在的普通文件,然后把
|
|
180
|
+
# `deliverables/presented` 事件追加进会话日志。卡片该不该出现,由**模型叫不叫这个工具**
|
|
181
|
+
# 决定,不由「有没有文件被改」决定 —— 这是桌面的口径。
|
|
182
|
+
#
|
|
183
|
+
# 版本必须钉在运行时那一代(当前 0.2.1-rc.2):它的 peerDependencies 逐版本紧咬,
|
|
184
|
+
# 对不上时 `dsh-app-boot` 会**整行禁用**(不是降级),现象是模型说「没有这个工具」。
|
|
185
|
+
- id: tool-present
|
|
186
|
+
name: '@deepseek-ai/dsh-tool-present'
|
package/lib/git/exec.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 跑 git、按仓库排队:移动端 Git 能力的唯一进程入口。
|
|
5
|
+
*
|
|
6
|
+
* 三条前提:
|
|
7
|
+
* 1. **不接受外部传进来的命令**。这里只收参数数组,可执行文件写死 `git`,参数由调用方
|
|
8
|
+
* 拼、值一律走白名单校验(`--` 之后才是路径),所以移动 API 那条「不暴露 shell」的
|
|
9
|
+
* 边界仍然成立;
|
|
10
|
+
* 2. **失败不当异常抛**。非零退出是常态(没有上游、坏分支名、钩子拒绝),调用方要拿退出码
|
|
11
|
+
* 和 stderr 判断,所以一律 resolve 成 `{ code, stdout, stderr }`,只有「git 根本没装」
|
|
12
|
+
* 与「超时被杀」单独立成 `error` 字段;
|
|
13
|
+
* 3. **按仓库串行**。手机上一次点两下就会并发两次写,撞上 `.git/index.lock` 会互相踩死;
|
|
14
|
+
* 锁的粒度是仓库根,主仓库与它下面的独立子仓库各排各的队。
|
|
15
|
+
*
|
|
16
|
+
* 环境变量按「别挂住、别落锁、别本地化」设:`GIT_TERMINAL_PROMPT=0` 挡住凭据提示(移动请求
|
|
17
|
+
* 没有 stdin,挂住就只能等超时),`GIT_OPTIONAL_LOCKS=0` 让纯读不顺手刷新索引,
|
|
18
|
+
* `GIT_PAGER=cat` 免得哪天接了分页器就一直等,`LC_ALL=C` 只是让 stderr 进日志时统一。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const { execFile } = require('node:child_process');
|
|
22
|
+
|
|
23
|
+
const BASE_ENV = {
|
|
24
|
+
...process.env,
|
|
25
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
26
|
+
GIT_PAGER: 'cat',
|
|
27
|
+
GIT_OPTIONAL_LOCKS: '0',
|
|
28
|
+
GIT_ASKPASS: '',
|
|
29
|
+
LC_ALL: 'C',
|
|
30
|
+
LANG: 'C',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const DEFAULT_TIMEOUT_MS = 8000;
|
|
34
|
+
const DEFAULT_MAX_BYTES = 4 * 1024 * 1024;
|
|
35
|
+
|
|
36
|
+
/** 仓库根的串行队列:root → 这条链的尾巴(永远是个已收敛的 Promise)。 */
|
|
37
|
+
const chains = new Map();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 在某个仓库的独占队列里跑一段活。读也排队:写操作进行到一半时去读索引,拿到的是半成品。
|
|
41
|
+
*
|
|
42
|
+
* @template T
|
|
43
|
+
* @param {string} root 仓库根(绝对路径)
|
|
44
|
+
* @param {() => Promise<T>} task
|
|
45
|
+
* @returns {Promise<T>}
|
|
46
|
+
*/
|
|
47
|
+
function withRepoLock(root, task) {
|
|
48
|
+
const previous = chains.get(root) || Promise.resolve();
|
|
49
|
+
const next = previous.then(task, task);
|
|
50
|
+
// 队列尾巴吞掉失败:一次失败不能把后续排队的活一起带走。
|
|
51
|
+
chains.set(root, next.then(() => undefined, () => undefined));
|
|
52
|
+
return next;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 跑一次 git。
|
|
57
|
+
*
|
|
58
|
+
* @param {string} root 工作目录(仓库根)
|
|
59
|
+
* @param {string[]} args 参数数组(不含 `git` 本身)
|
|
60
|
+
* @param {{ timeoutMs?: number, maxBytes?: number }} [options]
|
|
61
|
+
* @returns {Promise<{ code: number, error: string|null, stdout: string, stderr: string, truncated: boolean }>}
|
|
62
|
+
* `error`:`GIT_UNAVAILABLE`(这台机器没有 git)/ `TIMEOUT` / `TOO_LARGE`;否则为 null。
|
|
63
|
+
*/
|
|
64
|
+
function runGit(root, args, options = {}) {
|
|
65
|
+
const timeout = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
66
|
+
const maxBuffer = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
execFile('git', args, {
|
|
69
|
+
cwd: root,
|
|
70
|
+
env: BASE_ENV,
|
|
71
|
+
timeout,
|
|
72
|
+
maxBuffer,
|
|
73
|
+
encoding: 'utf8',
|
|
74
|
+
windowsHide: true,
|
|
75
|
+
}, (error, stdout, stderr) => {
|
|
76
|
+
const out = typeof stdout === 'string' ? stdout : '';
|
|
77
|
+
const err = typeof stderr === 'string' ? stderr : '';
|
|
78
|
+
if (!error) {
|
|
79
|
+
resolve({ code: 0, error: null, stdout: out, stderr: err, truncated: false });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (error.code === 'ENOENT') {
|
|
83
|
+
resolve({ code: -1, error: 'GIT_UNAVAILABLE', stdout: out, stderr: err, truncated: false });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {
|
|
87
|
+
resolve({ code: -1, error: 'TOO_LARGE', stdout: out, stderr: err, truncated: true });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (error.killed) {
|
|
91
|
+
resolve({ code: -1, error: 'TIMEOUT', stdout: out, stderr: err, truncated: false });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const code = typeof error.code === 'number' ? error.code : -2;
|
|
95
|
+
resolve({ code, error: null, stdout: out, stderr: err, truncated: false });
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 跑一次 git 并要求成功;失败时带上退出码与 stderr(调用方据此映射错误码)。 */
|
|
101
|
+
async function runGitStrict(root, args, options) {
|
|
102
|
+
const result = await runGit(root, args, options);
|
|
103
|
+
if (result.error === 'GIT_UNAVAILABLE') {
|
|
104
|
+
const failure = new Error('这台电脑上没有可用的 git');
|
|
105
|
+
failure.code = 'GIT_UNAVAILABLE';
|
|
106
|
+
throw failure;
|
|
107
|
+
}
|
|
108
|
+
if (result.error === 'TIMEOUT') {
|
|
109
|
+
const failure = new Error('git 超时');
|
|
110
|
+
failure.code = 'TIMEOUT';
|
|
111
|
+
throw failure;
|
|
112
|
+
}
|
|
113
|
+
if (result.code !== 0) {
|
|
114
|
+
const failure = new Error((result.stderr || '').trim() || `git 退出码 ${result.code}`);
|
|
115
|
+
failure.code = 'GIT_FAILED';
|
|
116
|
+
failure.exitCode = result.code;
|
|
117
|
+
failure.stderr = result.stderr;
|
|
118
|
+
throw failure;
|
|
119
|
+
}
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = {
|
|
124
|
+
DEFAULT_TIMEOUT_MS,
|
|
125
|
+
BASE_ENV,
|
|
126
|
+
runGit,
|
|
127
|
+
runGitStrict,
|
|
128
|
+
withRepoLock,
|
|
129
|
+
};
|
package/lib/git/parse.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* git 机器可读输出的解析:全部是纯函数,喂字符串出结构化数据。
|
|
5
|
+
*
|
|
6
|
+
* 为什么不用人能读的那份输出:`git status`、`git log --stat` 这些是给眼睛看的,措辞随版本与
|
|
7
|
+
* 语言环境变(`LC_ALL=C` 也挡不住措辞在版本间的改动),拿正则去啃迟早会碎。这里只用
|
|
8
|
+
* `--porcelain=v2 -z` / `--numstat -z` / `--name-status -z` / `for-each-ref` 这些**约定为机器
|
|
9
|
+
* 输出**的格式,字段之间用 NUL 与 `\x1f` 分隔(文件名里可以有空格、冒号、中文,不能有 NUL)。
|
|
10
|
+
*
|
|
11
|
+
* 分隔符约定(都实测过,见 test/git-parse.test.cjs):
|
|
12
|
+
* - `status --porcelain=v2 -z`:状态行以 NUL 结尾;重命名那条(`2` 开头)后面紧跟一个 NUL
|
|
13
|
+
* 再跟旧路径;
|
|
14
|
+
* - `--numstat -z`:正常一条 `增\t删\t路径\0`;重命名的是 `增\t删\t\0旧\0新\0`,路径字段
|
|
15
|
+
* 为空 —— 空就是「后面还有两个」的信号;
|
|
16
|
+
* - `log --numstat -z --format=%x1e%H`:每条提交以 `\x1e` 开头,hash 之后是若干个 numstat
|
|
17
|
+
* 记录,同样 NUL 结尾;
|
|
18
|
+
* - `show --name-status -z`:`状态\0路径\0`,重命名是 `R100\0旧\0新\0`;
|
|
19
|
+
* - `for-each-ref`:一条一行,字段之间是 `%00`。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const NUL = '\0';
|
|
23
|
+
const UNIT = '\x1f';
|
|
24
|
+
const RECORD = '\x1e';
|
|
25
|
+
|
|
26
|
+
/** 把 NUL 分隔的 numstat 记录切成 `{ path, oldPath, additions, deletions, binary }`。 */
|
|
27
|
+
function parseNumstatZ(text) {
|
|
28
|
+
const tokens = String(text || '').split(NUL);
|
|
29
|
+
const out = [];
|
|
30
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
31
|
+
// `--format=` 的提交头后面会跟一个换行,第一条记录因此以 `\n` 开头(实测)。
|
|
32
|
+
const token = tokens[index].replace(/^\n+/, '');
|
|
33
|
+
const match = /^(\d+|-)\t(\d+|-)\t(.*)$/.exec(token);
|
|
34
|
+
if (!match) continue;
|
|
35
|
+
const binary = match[1] === '-';
|
|
36
|
+
let path = match[3];
|
|
37
|
+
let oldPath;
|
|
38
|
+
if (path === '') {
|
|
39
|
+
// 重命名:路径字段留空,后面紧跟旧路径与新路径。
|
|
40
|
+
oldPath = tokens[index + 1] || '';
|
|
41
|
+
path = tokens[index + 2] || '';
|
|
42
|
+
index += 2;
|
|
43
|
+
}
|
|
44
|
+
if (!path) continue;
|
|
45
|
+
out.push({
|
|
46
|
+
path,
|
|
47
|
+
...(oldPath ? { oldPath } : {}),
|
|
48
|
+
additions: binary ? 0 : Number(match[1]),
|
|
49
|
+
deletions: binary ? 0 : Number(match[2]),
|
|
50
|
+
binary,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 解析 `status --porcelain=v2 -z --branch`:返回状态条目与分支头信息。
|
|
58
|
+
*
|
|
59
|
+
* 条目里 `x` 是索引相对 HEAD 的状态(已暂存那批),`y` 是工作区相对索引的状态(未暂存那批),
|
|
60
|
+
* 未跟踪是 `?`,冲突是 `u` 那条。
|
|
61
|
+
*/
|
|
62
|
+
function parseStatusZ(text) {
|
|
63
|
+
const tokens = String(text || '').split(NUL);
|
|
64
|
+
const entries = [];
|
|
65
|
+
const branch = { head: null, oid: null, upstream: null, ahead: 0, behind: 0 };
|
|
66
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
67
|
+
const token = tokens[index];
|
|
68
|
+
if (!token) continue;
|
|
69
|
+
const kind = token[0];
|
|
70
|
+
if (kind === '#') {
|
|
71
|
+
const [, key, ...rest] = token.split(' ');
|
|
72
|
+
const value = rest.join(' ');
|
|
73
|
+
if (key === 'branch.head') branch.head = value === '(detached)' ? null : value;
|
|
74
|
+
else if (key === 'branch.oid') branch.oid = value === '(initial)' ? null : value;
|
|
75
|
+
else if (key === 'branch.upstream') branch.upstream = value;
|
|
76
|
+
else if (key === 'branch.ab') {
|
|
77
|
+
const match = /\+(-?\d+) -(-?\d+)/.exec(value);
|
|
78
|
+
if (match) {
|
|
79
|
+
branch.ahead = Number(match[1]);
|
|
80
|
+
branch.behind = Number(match[2]);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (kind === '?') {
|
|
86
|
+
entries.push({ path: token.slice(2), x: '?', y: '?', untracked: true });
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (kind === '!') continue;
|
|
90
|
+
if (kind === '1' || kind === 'u') {
|
|
91
|
+
const fields = kind === '1'
|
|
92
|
+
? /^1 (\S{2}) \S+ \S+ \S+ \S+ \S+ \S+ (.*)$/.exec(token)
|
|
93
|
+
: /^u (\S{2}) \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ (.*)$/.exec(token);
|
|
94
|
+
if (!fields) continue;
|
|
95
|
+
entries.push({
|
|
96
|
+
path: fields[2],
|
|
97
|
+
x: fields[1][0],
|
|
98
|
+
y: fields[1][1],
|
|
99
|
+
...(kind === 'u' ? { conflicted: true } : {}),
|
|
100
|
+
});
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (kind === '2') {
|
|
104
|
+
const fields = /^2 (\S{2}) \S+ \S+ \S+ \S+ \S+ \S+ \S+ (.*)$/.exec(token);
|
|
105
|
+
if (!fields) continue;
|
|
106
|
+
entries.push({
|
|
107
|
+
path: fields[2],
|
|
108
|
+
oldPath: tokens[index + 1] || '',
|
|
109
|
+
x: fields[1][0],
|
|
110
|
+
y: fields[1][1],
|
|
111
|
+
});
|
|
112
|
+
index += 1;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { entries, branch };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 解析 `--name-status -z`(提交详情里的文件清单):`状态\0路径\0`,重命名多一个旧路径。 */
|
|
119
|
+
function parseNameStatusZ(text) {
|
|
120
|
+
const tokens = String(text || '').split(NUL);
|
|
121
|
+
const out = [];
|
|
122
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
123
|
+
const status = tokens[index];
|
|
124
|
+
if (!status) continue;
|
|
125
|
+
if (status[0] === 'R' || status[0] === 'C') {
|
|
126
|
+
const oldPath = tokens[index + 1] || '';
|
|
127
|
+
const path = tokens[index + 2] || '';
|
|
128
|
+
index += 2;
|
|
129
|
+
if (path) out.push({ status: status[0], path, oldPath });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const path = tokens[index + 1] || '';
|
|
133
|
+
index += 1;
|
|
134
|
+
if (path) out.push({ status: status[0], path });
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 解析统一格式的 diff 文本成 hunk 列表(行号由插件算好,前端不再数)。
|
|
141
|
+
*
|
|
142
|
+
* `` 那种提示行丢掉:它不占行号,留着只会让前端画出一条空行。
|
|
143
|
+
*/
|
|
144
|
+
function parseDiffText(text) {
|
|
145
|
+
const lines = String(text || '').split('\n');
|
|
146
|
+
// 末尾那个换行会切出一个空串;不先摘掉它,最后一个 hunk 会多画一行空上下文。
|
|
147
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
|
148
|
+
const hunks = [];
|
|
149
|
+
let current = null;
|
|
150
|
+
let oldLine = 0;
|
|
151
|
+
let newLine = 0;
|
|
152
|
+
let binary = false;
|
|
153
|
+
for (const line of lines) {
|
|
154
|
+
if (line.startsWith('Binary files ') || line.startsWith('GIT binary patch')) {
|
|
155
|
+
binary = true;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const header = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/.exec(line);
|
|
159
|
+
if (header) {
|
|
160
|
+
oldLine = Number(header[1]);
|
|
161
|
+
newLine = Number(header[2]);
|
|
162
|
+
current = { header: line, lines: [] };
|
|
163
|
+
hunks.push(current);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (!current) continue;
|
|
167
|
+
if (line.startsWith('\\')) continue;
|
|
168
|
+
if (line.startsWith('+')) {
|
|
169
|
+
current.lines.push({ kind: 'add', text: line.slice(1), oldLine: null, newLine });
|
|
170
|
+
newLine += 1;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (line.startsWith('-')) {
|
|
174
|
+
current.lines.push({ kind: 'del', text: line.slice(1), oldLine, newLine: null });
|
|
175
|
+
oldLine += 1;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (line.startsWith(' ')) {
|
|
179
|
+
current.lines.push({ kind: 'context', text: line.slice(1), oldLine, newLine });
|
|
180
|
+
oldLine += 1;
|
|
181
|
+
newLine += 1;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { hunks, binary };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 解析 `log --numstat -z --format=%x1e%H%x1f%h%x1f%an%x1f%aI%x1f%s`:
|
|
189
|
+
* 每条提交以 `\x1e` 开头,第一段是按 `\x1f` 切开的元信息,后面是本条提交的 numstat 记录。
|
|
190
|
+
*/
|
|
191
|
+
function parseLogZ(text) {
|
|
192
|
+
const blocks = String(text || '').split(RECORD);
|
|
193
|
+
const commits = [];
|
|
194
|
+
for (const block of blocks) {
|
|
195
|
+
const trimmed = block.replace(/^\n/, '');
|
|
196
|
+
if (!trimmed) continue;
|
|
197
|
+
const [head, ...stats] = trimmed.split(NUL);
|
|
198
|
+
const fields = head.split(UNIT);
|
|
199
|
+
if (fields.length < 5) continue;
|
|
200
|
+
const [hash, shortHash, author, time, subject] = fields;
|
|
201
|
+
const files = parseNumstatZ(stats.join(NUL));
|
|
202
|
+
commits.push({
|
|
203
|
+
hash,
|
|
204
|
+
shortHash,
|
|
205
|
+
author,
|
|
206
|
+
time,
|
|
207
|
+
subject,
|
|
208
|
+
filesChanged: files.length,
|
|
209
|
+
additions: files.reduce((sum, file) => sum + file.additions, 0),
|
|
210
|
+
deletions: files.reduce((sum, file) => sum + file.deletions, 0),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return commits;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** 解析 `for-each-ref --format=%(refname:short)%00…`:一条一行,字段之间是 NUL。 */
|
|
217
|
+
function parseRefList(text) {
|
|
218
|
+
return String(text || '')
|
|
219
|
+
.split('\n')
|
|
220
|
+
.map((line) => line.split(NUL))
|
|
221
|
+
.filter((fields) => fields.length >= 2 && fields[0])
|
|
222
|
+
.map((fields) => ({ name: fields[0], upstream: fields[1] || '', shortHash: fields[2] || '', time: fields[3] || '' }));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* `%(upstream:track)` 是 git 给眼睛看的一行(`[ahead 1, behind 2]`),只认 ahead/behind 两个
|
|
227
|
+
* 数字:措辞可能随版本变,数字不会。拿不到就返回空串,界面按「没上游」处理。
|
|
228
|
+
*/
|
|
229
|
+
function parseTrack(text) {
|
|
230
|
+
const match = /ahead (\d+)/.exec(String(text || ''));
|
|
231
|
+
const behind = /behind (\d+)/.exec(String(text || ''));
|
|
232
|
+
if (!match && !behind) return null;
|
|
233
|
+
return { ahead: match ? Number(match[1]) : 0, behind: behind ? Number(behind[1]) : 0 };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
module.exports = {
|
|
237
|
+
NUL,
|
|
238
|
+
UNIT,
|
|
239
|
+
RECORD,
|
|
240
|
+
parseNumstatZ,
|
|
241
|
+
parseStatusZ,
|
|
242
|
+
parseNameStatusZ,
|
|
243
|
+
parseDiffText,
|
|
244
|
+
parseLogZ,
|
|
245
|
+
parseRefList,
|
|
246
|
+
parseTrack,
|
|
247
|
+
};
|