dsh-mobile-plugin 0.1.95 → 0.1.97

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.
Files changed (45) hide show
  1. package/README.md +73 -50
  2. package/config/agent-presets/standard/agent.cordis.yml +13 -0
  3. package/dsh-mobile-credentials/lib/credentials.js +24 -1
  4. package/lib/git/exec.js +129 -0
  5. package/lib/git/package.json +3 -0
  6. package/lib/git/parse.js +247 -0
  7. package/lib/git/read.js +617 -0
  8. package/lib/impl.js +2 -2
  9. package/lib/mirror/pusher.cjs +25 -2
  10. package/lib/mobile/archive-clean.js +31 -1
  11. package/lib/mobile/channels.js +583 -0
  12. package/lib/mobile/events.js +10 -8
  13. package/lib/mobile/git-repos.js +210 -0
  14. package/lib/mobile/git.js +603 -0
  15. package/lib/mobile/history-derive.js +79 -1
  16. package/lib/mobile/host-bridge/endpoints.js +3 -0
  17. package/lib/mobile/host-bridge/event-synthesis.js +109 -1
  18. package/lib/mobile/host-bridge/shims.js +9 -2
  19. package/lib/mobile/host-bridge/transport.js +30 -1
  20. package/lib/mobile/host-settings.js +41 -0
  21. package/lib/mobile/inbox-clean.js +337 -0
  22. package/lib/mobile/pairing.js +18 -1
  23. package/lib/mobile/questions.js +31 -9
  24. package/lib/mobile/schedule.js +440 -0
  25. package/lib/mobile/session-cache.js +18 -0
  26. package/lib/mobile/session-file.js +192 -0
  27. package/lib/mobile/session-model-options.js +184 -0
  28. package/lib/mobile/session-models.js +24 -2
  29. package/lib/mobile/session-schedule-mark.js +70 -0
  30. package/lib/mobile/session-snapshot.js +12 -1
  31. package/lib/mobile/sessions.js +8 -0
  32. package/lib/mobile/stats-collect.cjs +257 -0
  33. package/lib/mobile/stats-ledger.cjs +407 -0
  34. package/lib/mobile/stats.js +38 -0
  35. package/lib/mobile/stream.js +18 -10
  36. package/lib/mobile/uploads.js +24 -6
  37. package/lib/mobile/workspaces.js +30 -6
  38. package/lib/panel/index.html +4 -4
  39. package/lib/relay/lan-proxy.js +10 -1
  40. package/lib/routes/control-plane.js +38 -0
  41. package/lib/routes/mobile-api.js +70 -1
  42. package/lib/session-cleanup.cjs +8 -0
  43. package/lib/shared/sse-write.js +17 -6
  44. package/lib/shell/updater.js +3 -3
  45. package/package.json +3 -2
package/README.md CHANGED
@@ -1,81 +1,104 @@
1
- # dsh-mobile-plugin
1
+ <h3 align="center">AI-HUB 移动端插件</h3>
2
2
 
3
- [English](EN-README.md)
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
- AI-HUB 手机端插件,为 DSH 提供移动 API、远程连接和局域网代理。插件也可作为无头 Cordis Host 独立运行,不依赖桌面外壳或 Electron。
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
- ## 下载 AI-HUB 手机 App
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
- - [App Store](https://apps.apple.com/us/app/ai-hub/id6821681438)(若暂时无法打开,可能是 App 仍在等待苹果审核通过。)
12
- - [TestFlight 测试版](https://testflight.apple.com/join/4fn5PCMD)
13
-
14
- ## 功能
39
+ - [TestFlight 测试版](https://testflight.apple.com/join/4fn5PCMD)(比商店版先拿到新构建,正式版照常从 App Store 更新)
15
40
 
16
- - 通过 relay v2 隧道连接手机 App,并支持局域网代理。
17
- - 提供受限的 `/api/mobile/v1/*` 移动 API。
18
- - 支持会话创建、工作区归属与会话排序同步。
19
- - 支持设备事件 SSE 转发、自动归档和会话清理。
20
- - 提供本机配置接口与配对授权策略。
41
+ 装好 App 只有一半,另一半是这个插件。
21
42
 
22
- ## 安装与使用
43
+ ## 🚀 安装与更新
23
44
 
24
- ### 在 DSH 桌面端
45
+ ### 桌面端
25
46
 
26
- 在 DSH 的 **设置 → 插件** 中搜索并安装 `dsh-mobile-plugin`。安装后,从 **设置 → 移动端 → 打开配置页面** 配置连接与配对。插件市场负责检查和安装更新;通常更新后无需重启 DSH。
47
+ 在 DSH 的 **设置 → 插件** 里搜索并安装 `dsh-mobile-plugin`,然后打开 **设置 → 移动端 → 打开配置页面** 做配对。插件市场负责检查和安装更新,通常更新后不用重启 DSH。
27
48
 
28
49
  ### 无头 Host(Linux)
29
50
 
30
- 需要 Node.js、pnpm,以及可用的 DSH 插件宿主依赖。仓库提供 `host.js` 作为独立入口:
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 服务默认监听 `127.0.0.1:3080`,不对局域网直接开放。
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
- 移动 API 需要 Bearer token。设备事件流 `GET /api/mobile/v1/events/stream` 使用 SSE 转发宿主事件。创建会话时可传 `workspaceId` 或 `cwd`;前者优先,并将会话归入指定工作区。移动端排序也会同步到宿主工作区。
80
+ 配对策略默认 `code`,凭配对码放行;设成 `deny` 就拒绝所有新配对。
58
81
 
59
- 配对策略默认为 `code`(凭配对码放行);设为 `deny` 时拒绝所有新配对。自动归档只隐藏符合条件的闲置会话,不删除日志。`session_cleanup` 工具可归档会话,也可永久删除已归档会话;删除不可恢复,需要显式确认。删除是「排进待删队列」:会话立刻从列表里消失,日志与投影缓存等宿主下次启动时才从磁盘上删掉 —— 宿主没有「删除会话」的接口,进程活着的时候从它脚底下删文件会让它读到不存在的日志,那是未捕获异常,会带走整个宿主进程。
82
+ `session_cleanup` 工具能归档会话,也能永久删除归档过的会话,删除前要显式确认,删掉不可恢复。**删除是排队的**:会话立刻从列表里消失,日志和投影缓存等宿主下次启动才从磁盘上删 —— 宿主没有「删除会话」的接口,进程活着的时候从它脚底下删文件,它会打开一个已经不在的日志,那是未捕获异常,会带走整个宿主进程。
60
83
 
61
- ## 无头部署
84
+ ## 🖥️ 无头部署
62
85
 
63
- 无头服务器的启动器与插件本体分开管理:启动器由部署流程同步到 `/opt/dsh-mobile/`,插件本体安装在 profile 中:
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
- 宿主会优先挂载 profile 中安装的插件;未安装时才使用源码树版本。设备凭据库随 npm 包一同提供,不需要单独安装。仓库锁文件应使用 `pnpm install --frozen-lockfile`。
93
+ 宿主优先挂 profile 里装的那份,没装才用源码树版本。设备凭据库随 npm 包一起发,不用单独装。仓库锁文件用 `pnpm install --frozen-lockfile`。
71
94
 
72
- > 不要使用 `npm install --legacy-peer-deps` 安装插件;正式安装请使用 DSH 插件市场。
95
+ > 别用 `npm install --legacy-peer-deps` 装插件;正式安装走 DSH 插件市场。
73
96
 
74
- ## 本机调试 OTA
97
+ ## 🔄 本机调试 OTA
75
98
 
76
- 新版插件的管理面板提供「本机调试 OTA」卡片,可手动检查并安装中转发布的紧急完整包。入口仅限宿主本机访问,不会由手机或公网触发;安装后通过实现代切换生效,切换失败会恢复上一代。npm 仍是正式发布源;后续 npm 正式版本达到 OTA 版本时会接管该版本。首次发布带此功能的 npm 包需要重启一次 DSH,让稳定外壳加载 OTA 能力。
99
+ 新版插件的管理面板里有张「本机调试 OTA」卡片,可以手动检查并安装中转发布的紧急完整包。入口只认宿主本机的访问,手机和公网都触发不了;装完靠切换实现代生效,切换失败会退回上一代。正式发布源仍然是 npm,等 npm 上的版本号追到 OTA 版本时就自动接管。第一次发带这个功能的 npm 包需要重启一次 DSH,让稳定外壳把 OTA 能力加载进来。
77
100
 
78
- 发布一个紧急完整包:用仓库里的打包脚本,不要手工 `tar`。
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
- 脚本做四件事:文件清单问 `npm pack --dry-run --json` 要(**不碰系统 tar**,三个平台同一份逻辑)、往包里写一份 `lib/ota-manifest.json`(版本 + 每个文件的路径与字节数)、用客户端的真解包器自检、发布后再下载回来比 sha256。中转那边也记哈希,客户端装之前会核对——半个包、被中间层改过的包都装不上去。
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
- 修改 `React/` 面板源码后,先在该目录运行 `pnpm run build`,并将生成的 `lib/panel/index.html` 与源码一起提交。
122
+ 改过 `React/` 面板源码,先在该目录跑 `pnpm run build`,并把生成的 `lib/panel/index.html` 和源码一起提交。
100
123
 
101
- ## 仓库与发布
124
+ ## 📦 仓库与发布
102
125
 
103
- 本仓库是插件的公开发布仓库,仓库根目录就是 npm 包根目录。开发与 App、中转服务器位于私有 monorepo,因此公开仓库的提交历史从拆分时开始;发布时需将同一次改动同步到公开仓库。
126
+ 这里是插件的公开发布仓库,仓库根就是 npm 包根。开发和 App、中转服务器一起放在私有 monorepo 里,所以这个仓库的提交历史从拆分那天开始;发布时要把同一次改动同步过来。
104
127
 
105
- - npm 包的 `repository` 字段用于关联插件列表条目;修改后需再次发布,注册表信息才会更新。
106
- - 插件列表收录通过向 awesome-dsh-plugin 提交 `data/plugins/<owner>__<repo>.yml` 完成。描述中的数字和 API 名称应能由代码核实;列表要求 `description.en`、`category: remote`,包需声明 `dsh.bundle`,仓库需有 `dsh-plugin` topic。
107
- - 仓库创建满一天的检查会自动重跑;截图可通过 `screenshots.json` 提供,也可由市场从 README 查找。
108
- - GitHub 的 release 页放手机 App 的 APK(下载入口见开头那一节):资产名固定用 `ai-hub-android.apk`,并且发成正式版 —— 草稿与预发布都不会成为「最新」,README 里那条直连会随之断掉。插件自身走 npm 发布,不在本仓库发 release。
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. 运行 `node scripts/bump-version.mjs` 更新版本号。
114
- 2. 若修改了 `React/`,运行 `cd React && pnpm run build`。
115
- 3. 运行 `npm test`,并用 `npm pack --dry-run` 检查发布文件。
116
- 4. 运行 `npm publish`,按提示完成 npm 二次验证。
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
- 配对请求可携带设备信息,App 当前发送 `id`、`deviceId`、`name`、`platform`、`osVersion` 和 `appVersion`;局域网代理与中转会补充观察到的手机 IP。
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'
@@ -117,6 +117,29 @@ function listActive() {
117
117
  load();
118
118
  return records.filter((record) => !record.revokedAt && (!record.expiresAt || record.expiresAt > Date.now())).map(({ instanceId, deviceId, createdAt, expiresAt }) => ({ instanceId, deviceId, createdAt, ...(expiresAt ? { expiresAt } : {}) }));
119
119
  }
120
+ /** 一份镜像最多带这么多条摘要:长期反复配对不该把推送清单撑成累赘。 */
121
+ const MAX_ACTIVE_DIGESTS = 200;
122
+ /**
123
+ * 本实例签发过、仍然有效的凭据摘要,按签发时间从新到旧。
124
+ *
125
+ * 中转的离线镜像缓存要在桌面宿主上线之前判断「来读缓存的人是不是配对过的手机」,而它手上
126
+ * 只有手机发来的 `dshc_` 原文 —— 这里给出同一口径的 sha256(落盘存的本来就是这个摘要,
127
+ * 原文从未留存),它自己算一遍就能比对。吊销与过期在这里现读现滤,撤销一台设备后下一次
128
+ * 推送就少一条。
129
+ */
130
+ function activeDigests(instanceId) {
131
+ load();
132
+ const now = Date.now();
133
+ const live = [];
134
+ // 记录只在新签发时追加,所以倒着走就是 createdAt 倒序:超出上限时被截掉的是最老的,
135
+ // 同毫秒签发的几条也保持签发顺序而不是凭运气。
136
+ for (let i = records.length - 1; i >= 0 && live.length < MAX_ACTIVE_DIGESTS; i -= 1) {
137
+ const record = records[i];
138
+ if (record.instanceId !== instanceId || !isLive(record, now)) continue;
139
+ live.push({ hash: record.hash, ...(record.expiresAt ? { expiresAt: record.expiresAt } : {}) });
140
+ }
141
+ return live;
142
+ }
120
143
  function revokeDevice(instanceId, deviceId) {
121
144
  load();
122
145
  let count = 0;
@@ -132,4 +155,4 @@ function revokeAllByInstance(instanceId) {
132
155
  if (count) save();
133
156
  return count;
134
157
  }
135
- module.exports = { FILE, DEFAULT_TTL_MS, issue, find, lookup, isLive, isActiveDevice, revoke, revokeDevice, revokeAllByInstance, listActive };
158
+ module.exports = { FILE, DEFAULT_TTL_MS, issue, find, lookup, isLive, isActiveDevice, activeDigests, revoke, revokeDevice, revokeAllByInstance, listActive };
@@ -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
+ };
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }
@@ -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
+ };