@fengye_nobug/dsh-qq-bot 0.2.5 → 0.2.7
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/CHANGELOG.md +10 -0
- package/README.md +29 -25
- package/client/client.js +3 -3
- package/cordis.patch.yml +6 -3
- package/index.js +2 -0
- package/package.json +1 -1
- package/updates.js +602 -98
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
3
|
+
## v0.2.7 — 2026-10-31
|
|
4
|
+
|
|
5
|
+
- **一键更新装完会自己重启 dsh-web**:安装成功后按面板原来的办法重启,不再把「请自己重启」当成正常结果。Linux 上如果是 systemd 拉起来的,会读本进程的 cgroup,大约 3 秒后只重启认出来的那个服务。服务在用户实例里时用 `systemctl --user`,不会重启整个登录。以前没把 cgroup 读进来,服务里一点更新就直接说面板没有重启。`systemd-run` 没权限或失败时,改为按当前的 node 命令行再拉起一次。两条都做不到,才用白话请你自己重启 dsh-web,并提醒刷新页面、登录口令可能会变。安装超过三分钟、但磁盘上已经是要装的版本时,同样会改包名并重启。进程还在,或没有确认装上时,仍然不会重启。
|
|
6
|
+
- **检查和安装的提示改成短句**:设置卡片不再把「优先从 npm…三分钟…」那段长说明当作主要提示。检查按钮旁边写「只检查,不安装。」有更新按钮时另写「装完会重启面板。请刷新;登录口令可能会变。不改 NapCat。」检查结果是「有新版本 x.y.z(现在是 a.b.c),在 npm 上。这里只检查,还没安装。」或「在 GitHub 上」。安装成功是「已经从 npm 装上 vX.Y.Z,面板即将重启。」或「已经从 GitHub 装上 vX.Y.Z,面板即将重启。」自动重启失败时才请自己重启,并写明这次是从 npm 还是从 GitHub 装上的。
|
|
7
|
+
|
|
8
|
+
## v0.2.6 — 2026-10-30
|
|
9
|
+
|
|
10
|
+
- **一键更新之后,Cordis 按实际装上的包名挂 `qq-bridge`**:npm 包是 `@fengye_nobug/dsh-qq-bot`。v0.2.5 自带的 `cordis.patch.yml` 仍写 `name: dsh-qq-bot`。Cordis 按这个名字去加载,无作用域包并不存在,插件 id `qq-bridge` 不会挂上,`/api/qq-bridge/settings` 就是 HTTP 404,设置页显示本机接口不可用。现在插件自带的补丁把 `name` 写成 `@fengye_nobug/dsh-qq-bot`。安装成功后,还会看 profile 的 `cordis.patch.yml`、`$DSH_HOME/cordis.patch.yml`(这份文件里有 `qq-bridge` 才改),以及已经装进 profile 的插件补丁。这三处里 `qq-bridge` 的 `name` 如果还是 `dsh-qq-bot` 或 `dsh-qq-bridge`,就改成磁盘上 `package.json` 的包名。别的插件不动,`config` 里的设置不动。界面显示名和消息来源仍是 `dsh-qq-bot`,Cordis id 仍是 `qq-bridge`。从 0.2.5 点「更新」时,跑的仍是 0.2.5 里的安装程序,profile 里手写的旧 `name` 要先改成 `@fengye_nobug/dsh-qq-bot` 再重启;这一版自己跑一键更新时会改。
|
|
11
|
+
- **一键更新卡住时会停掉安装进程**:`dsh plugin add` 或它拉起的 `pnpm add` 超过三分钟没有结束,会停掉这一组进程,并用白话说明,不带路径和堆栈。profile 里的 `.plugin-manager/run.json` 只在记录的进程已经不在、或记录读不懂时删掉;进程还在就不删,也不会再开第二个安装。超时之后如果磁盘上已经是这次要装的版本,仍把 `qq-bridge` 的 `name` 改成实际包名,但不会自动重启,需要自己重启 dsh-web,设置页才会出来。没有确认装上时,可以再点一次「更新」。
|
|
12
|
+
|
|
3
13
|
## v0.2.5 — 2026-10-30
|
|
4
14
|
|
|
5
15
|
- **设置卡片可以一键更新**:有新正式版时出现「更新到 vX.Y.Z」。点下去才安装,并重启面板。优先执行 `dsh plugin --profile web add @fengye_nobug/dsh-qq-bot@版本`,走本机 npm / pnpm 已经配置的 registry。插件不写死镜像地址。registry 里找不到这个版本时,改从 GitHub 下载官方源码包(先 github.com,连不上再试同一标签的 codeload.github.com),不走第三方镜像。v0.2.3 及更早标签里的 `package.json` 仍是无作用域名,这种源码包仍然能装。装完提示刷新页面,登录口令可能会变。现有设置还在,不改 NapCat。已经是最新时没有这个按钮。不会安装更旧的版本,也不装草稿和预发布。连不上、安装失败、没能重启,都用白话说明。
|
package/README.md
CHANGED
|
@@ -8,22 +8,22 @@
|
|
|
8
8
|
|
|
9
9
|
本仓库 fork 自 [sj244/dsh-qq-bridge](https://github.com/sj244/dsh-qq-bridge)。上游许可证为 MIT,版权归 sj244;本 fork 的版权声明见 [`LICENSE`](./LICENSE)。安装和更新都走 `dsh plugin`,不要在服务器上打补丁脚本。
|
|
10
10
|
|
|
11
|
-
**Cordis 插件 id 仍是 `qq-bridge`**(`cordis.patch.yml` 里的 `id`)。npm 包名是 **`@fengye_nobug/dsh-qq-bot
|
|
11
|
+
**Cordis 插件 id 仍是 `qq-bridge`**(`cordis.patch.yml` 里的 `id`)。npm 包名是 **`@fengye_nobug/dsh-qq-bot`**。`cordis.patch.yml` 的 `name` 必须写成这个包名,Cordis 靠它加载模块。界面显示名仍是 **dsh-qq-bot**,消息来源仍是 `plugin:dsh-qq-bot`。无作用域的 `dsh-qq-bot` 发不出去:npm 认为它和已有的 `dsh-qqbot` 太像。已有 profile 里按 `id: qq-bridge` 写的覆盖继续生效;如果那一行还写着 `name: dsh-qq-bot`,要改成 `@fengye_nobug/dsh-qq-bot`,否则插件挂不上,设置接口是 HTTP 404。设置命名空间、状态文件和 `/api/qq-bridge/...` 也沿用这个 id,避免升级时对不上。
|
|
12
12
|
|
|
13
13
|
**想装:** 优先用 npm 上已经发出的版本。连不上 npm,或 registry 里还没有这一版时,再用已经发布的 GitHub tag,不要盯着 main。
|
|
14
14
|
|
|
15
15
|
```sh
|
|
16
|
-
dsh plugin --profile web add @fengye_nobug/dsh-qq-bot@0.2.
|
|
16
|
+
dsh plugin --profile web add @fengye_nobug/dsh-qq-bot@0.2.5
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
```sh
|
|
20
|
-
dsh plugin --profile web add github:yunfengnobug/dsh-qq-bot#v0.2.
|
|
20
|
+
dsh plugin --profile web add github:yunfengnobug/dsh-qq-bot#v0.2.5
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
在 GitHub 上 Watch → Custom → **Releases**,比盯 main 稳。环境、第一次怎么开白名单、怎么升级、和官方插件为什么不能一起挂,写在[安装](#安装)。
|
|
24
24
|
|
|
25
25
|
- 设计:[`DESIGN.md`](./DESIGN.md) | 里程碑与真机实录:[`HANDOVER.md`](./HANDOVER.md) | 变更:[`CHANGELOG.md`](./CHANGELOG.md)
|
|
26
|
-
- 源码版本 **0.2.
|
|
26
|
+
- 源码版本 **0.2.7**(一键更新装完会自己重启 dsh-web;Cordis 的包名会改成实际装上的 `@fengye_nobug/dsh-qq-bot`)。已经发布的是 **v0.2.5**,npm 上对应 `@fengye_nobug/dsh-qq-bot@0.2.5`。源码这一版还没打 tag,也还没 `npm publish`。v0.2.5 自带补丁里的 `name` 仍是无作用域的 `dsh-qq-bot`,装完若设置页是 HTTP 404,先把 `qq-bridge` 的 `name` 改成 `@fengye_nobug/dsh-qq-bot` 再重启。见 [`CHANGELOG.md`](./CHANGELOG.md)。
|
|
27
27
|
- **M1–M8 全部完成**,已在**真实 NapCat + 真实 QQ** 上端到端跑通(Windows 真机)
|
|
28
28
|
|
|
29
29
|
## 目录
|
|
@@ -49,7 +49,7 @@ dsh plugin --profile web add github:yunfengnobug/dsh-qq-bot#v0.2.3
|
|
|
49
49
|
也认 `[表情182]` 和 `[CQ:face,id=182]`。详见[出站](#出站三种模式--来源闸门)。
|
|
50
50
|
- **有设置卡片**:名称是白话(哪些群可以叫醒、群里发出去的是哪一段、忙的时候怎么插话等),
|
|
51
51
|
配置键仍是英文。在 `设置 → 插件 → 插件配置` 里修改,保存即生效。
|
|
52
|
-
- **可以一键更新**:先点「检查更新」。有新正式版时再点「更新到 vX.Y.Z
|
|
52
|
+
- **可以一键更新**:先点「检查更新」。有新正式版时再点「更新到 vX.Y.Z」,面板会装上并自己重启 dsh-web。优先从 npm 安装 `@fengye_nobug/dsh-qq-bot@版本`;这一版还没发到 npm 时,改从 GitHub 下载。装完把 Cordis 里 `qq-bridge` 的 `name` 改成实际装上的包名。超过三分钟没有结束就停掉卡住的安装进程;要装的版本已经在磁盘上时仍改包名,并自动重启。只有自动重启没有安排上,才提示自己重启。启动时检查不会自动安装。
|
|
53
53
|
- **NapCat 自助托管**:插件可以按需下载、校验、解包、写配置、启停 NapCat。
|
|
54
54
|
|
|
55
55
|
**不做的三件事**:
|
|
@@ -60,16 +60,16 @@ dsh plugin --profile web add github:yunfengnobug/dsh-qq-bot#v0.2.3
|
|
|
60
60
|
|
|
61
61
|
## 安装
|
|
62
62
|
|
|
63
|
-
日常安装用 npm 上已经发出的 **`@fengye_nobug/dsh-qq-bot@0.2.
|
|
63
|
+
日常安装用 npm 上已经发出的 **`@fengye_nobug/dsh-qq-bot@0.2.5`**。无作用域的 `dsh-qq-bot@版本` 装不上:npm 不接受这个名字,它和已有的 `dsh-qqbot` 太像。GitHub [Releases](https://github.com/yunfengnobug/dsh-qq-bot/releases) 上已经发出的是 **v0.2.5**。仓库源码如果已经是 0.2.7,那是还没打 tag、也还没发到 npm 的版本,日常不要装 main。某一版还没出现在 registry 里时,一键更新会改从 GitHub 下载。v0.2.5 的补丁仍把 Cordis 的 `name` 写成 `dsh-qq-bot`。装上这一版后,一键更新会在安装成功时把 profile、home(写了 `qq-bridge` 才改)和插件补丁里的 `name` 改成磁盘上的包名。
|
|
64
64
|
|
|
65
65
|
```sh
|
|
66
|
-
dsh plugin --profile web add @fengye_nobug/dsh-qq-bot@0.2.
|
|
66
|
+
dsh plugin --profile web add @fengye_nobug/dsh-qq-bot@0.2.5
|
|
67
67
|
```
|
|
68
68
|
|
|
69
69
|
GitHub 备用:
|
|
70
70
|
|
|
71
71
|
```sh
|
|
72
|
-
dsh plugin --profile web add github:yunfengnobug/dsh-qq-bot#v0.2.
|
|
72
|
+
dsh plugin --profile web add github:yunfengnobug/dsh-qq-bot#v0.2.5
|
|
73
73
|
```
|
|
74
74
|
|
|
75
75
|
不写 tag 会装 main 上最新一次提交。主分支随时在改,日常别用这条:
|
|
@@ -98,16 +98,20 @@ dsh plugin --profile web add github:yunfengnobug/dsh-qq-bot
|
|
|
98
98
|
|
|
99
99
|
四条路。装完都要让 dsh-web 重新加载插件:
|
|
100
100
|
|
|
101
|
-
1. 设置卡片里先点「检查更新」。有新正式版时出现「更新到 vX.Y.Z」。点下去优先执行 `dsh plugin --profile web add @fengye_nobug/dsh-qq-bot@X.Y.Z`,走这台机器上 pnpm 已经配置的 registry
|
|
102
|
-
2. 自己执行同一条命令。npm 上已经发出的是 `@fengye_nobug/dsh-qq-bot@0.2.
|
|
101
|
+
1. 设置卡片里先点「检查更新」。有新正式版时出现「更新到 vX.Y.Z」。点下去优先执行 `dsh plugin --profile web add @fengye_nobug/dsh-qq-bot@X.Y.Z`,走这台机器上 pnpm 已经配置的 registry,然后重启面板。装完会把 profile、`$DSH_HOME/cordis.patch.yml`(写了 `qq-bridge` 才改)和插件补丁里 `qq-bridge` 的 `name` 改成磁盘上的包名,`config` 不动。设置还在,不改 NapCat。重启后刷新页面;登录口令可能会变。
|
|
102
|
+
2. 自己执行同一条命令。npm 上已经发出的是 `@fengye_nobug/dsh-qq-bot@0.2.5`。自己装不会自动改 profile 里的旧 `name`。
|
|
103
103
|
3. 指定 GitHub tag:`dsh plugin --profile web add github:yunfengnobug/dsh-qq-bot#vX.Y.Z`
|
|
104
104
|
4. 卸掉再装,然后重启。
|
|
105
105
|
|
|
106
|
-
无作用域名字发不出去。第 1 步如果发现 registry 里没有 `@fengye_nobug/dsh-qq-bot` 的这一版,会改从 GitHub 下载该标签的官方源码包:先 `github.com`,连不上再试同一标签的 `codeload.github.com`,然后交给 `dsh plugin add`。v0.2.
|
|
106
|
+
无作用域名字发不出去。第 1 步如果发现 registry 里没有 `@fengye_nobug/dsh-qq-bot` 的这一版,会改从 GitHub 下载该标签的官方源码包:先 `github.com`,连不上再试同一标签的 `codeload.github.com`,然后交给 `dsh plugin add`。v0.2.4 及更早标签里的 `package.json` 仍是无作用域名,这种源码包仍然能装,Cordis 的 `name` 也保持 `dsh-qq-bot`。v0.2.5 的 `package.json` 已是作用域包,但补丁里的 `name` 还是旧的,所以会 404。插件不写死第三方镜像。registry 里已经有比当前新的正式版时,检查和安装不再访问 GitHub。
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
从 0.2.5 点「更新」时,执行的还是那一版里的安装程序,不会改 profile,也不会自己重启。若设置页变成 HTTP 404,把 profile 和插件目录里 `qq-bridge` 的 `name` 改成 `@fengye_nobug/dsh-qq-bot`,再重启 dsh-web。这一版(0.2.7)自己跑一键更新时会改包名,并在装完后自动重启。
|
|
109
109
|
|
|
110
|
-
|
|
110
|
+
「检查更新」和「启动时检查一次」只询问,不会安装。已经是最新正式版时不出现更新按钮。不会装比当前更旧的版本,也不装草稿和预发布。连不上、安装失败、面板没能重启,都用白话说明。安装已经成功但自动重启没有安排上时,新版本在磁盘上,需要自己重启 dsh-web。包名已经改好的话,重启后设置页会回来。
|
|
111
|
+
|
|
112
|
+
安装超过三分钟没有结束时,会停掉卡住的 dsh 和它带起来的 pnpm,并用白话说明。profile 里的 `.plugin-manager/run.json` 只在记录的进程已经不在、或记录读不懂时删掉;进程还在就不会再开一次安装。超时后如果要装的版本已经在磁盘上,包名仍会改成实际安装的包,然后自动重启 dsh-web。没有确认装上时,可以再点一次「更新」,这时不会重启。
|
|
113
|
+
|
|
114
|
+
在 Linux 上,进程由 systemd 拉起并且能认出具体服务时,大约 3 秒后只重启那个服务。服务挂在用户实例(`user@`)下面时,用 `systemctl --user`,不会重启整个登录。认不出单独服务,或 `systemd-run` 没有权限、执行失败时,改为按当前的 node 命令行再拉起一次。两条都做不到,才提示自己重启 dsh-web。没有被 systemd 拉起时,响应发出后按当前命令行再拉起一次。这次更新不改 NapCat 的配置,也不去停它。插件自己拉起的 NapCat 会随 dsh-web 退出而结束;已经在外面跑着的 NapCat 继续运行。
|
|
111
115
|
|
|
112
116
|
想让 npm 走镜像,把 npm / pnpm 的 registry 配成你自己的镜像。插件只在没读到配置时用 `https://registry.npmjs.org/`。
|
|
113
117
|
|
|
@@ -119,12 +123,12 @@ dsh plugin --profile web add github:yunfengnobug/dsh-qq-bot
|
|
|
119
123
|
|
|
120
124
|
```sh
|
|
121
125
|
dsh plugin --profile web remove dsh-qq-bridge
|
|
122
|
-
dsh plugin --profile web add @fengye_nobug/dsh-qq-bot@0.2.
|
|
126
|
+
dsh plugin --profile web add @fengye_nobug/dsh-qq-bot@0.2.5
|
|
123
127
|
```
|
|
124
128
|
|
|
125
|
-
连不上 npm 时,把上面第二条换成 `dsh plugin --profile web add github:yunfengnobug/dsh-qq-bot#v0.2.
|
|
129
|
+
连不上 npm 时,把上面第二条换成 `dsh plugin --profile web add github:yunfengnobug/dsh-qq-bot#v0.2.5`。
|
|
126
130
|
|
|
127
|
-
然后重启 dsh-web。profile 里原来按 `id: qq-bridge`
|
|
131
|
+
然后重启 dsh-web。profile 里原来按 `id: qq-bridge` 写的覆盖可以留着。若其中有 `name`,写成 `@fengye_nobug/dsh-qq-bot`,不要留着 `dsh-qq-bot` 或 `dsh-qq-bridge`。`list` 里如果包名不是 `dsh-qq-bridge`,卸你看到的那一个官方包,不要卸别的插件。
|
|
128
132
|
|
|
129
133
|
### 开发期
|
|
130
134
|
|
|
@@ -158,18 +162,18 @@ Release 不附带密钥、日志或本机文件。源码压缩包让 GitHub 自
|
|
|
158
162
|
|
|
159
163
|
## 用 npm 安装
|
|
160
164
|
|
|
161
|
-
`dsh plugin` 是 pnpm 的包装。安装和一键更新都用作用域包名。npm 上已经发出的是 0.2.
|
|
165
|
+
`dsh plugin` 是 pnpm 的包装。安装和一键更新都用作用域包名。npm 上已经发出的是 0.2.5:
|
|
162
166
|
|
|
163
167
|
```sh
|
|
164
|
-
dsh plugin --profile web add @fengye_nobug/dsh-qq-bot@0.2.
|
|
168
|
+
dsh plugin --profile web add @fengye_nobug/dsh-qq-bot@0.2.5
|
|
165
169
|
```
|
|
166
170
|
|
|
167
171
|
无作用域的 `dsh-qq-bot` 发不出去,npm 认为它和已有的 `dsh-qqbot` 太像。不要写成 `dsh plugin add dsh-qq-bot@版本`。
|
|
168
172
|
|
|
169
|
-
registry 里还没有要装的那一版时(源码 0.2.
|
|
173
|
+
registry 里还没有要装的那一版时(源码 0.2.7 尚未发布),改用 GitHub:
|
|
170
174
|
|
|
171
175
|
```sh
|
|
172
|
-
dsh plugin --profile web add github:yunfengnobug/dsh-qq-bot#v0.2.
|
|
176
|
+
dsh plugin --profile web add github:yunfengnobug/dsh-qq-bot#v0.2.5
|
|
173
177
|
```
|
|
174
178
|
|
|
175
179
|
一键更新的顺利路径是 npm,不再要求打开 github.com。某台机器上的 `dsh plugin --help` 如果只认 `github:`,就继续用 Release tag。想用镜像,配置这台机器的 npm / pnpm registry,不要等插件内置一个镜像地址。
|
|
@@ -193,7 +197,7 @@ dsh plugin --profile web add github:yunfengnobug/dsh-qq-bot#v0.2.3
|
|
|
193
197
|
2. 把本仓库的 [`docs/awesome-dsh-plugin-entry.yml`](./docs/awesome-dsh-plugin-entry.yml) 复制成对方仓库的 `data/plugins/yunfengnobug__dsh-qq-bot.yml`,提一个 PR。不要改上游那条。一个 PR 最多 3 条。对方的 README 由脚本生成,不要手改。
|
|
194
198
|
3. 描述只写插件实际做的事。`repository` 必须指回本仓库(`package.json` 里已经是了)。yml 里不要手写 `npm:`,市场自己从 registry 认。
|
|
195
199
|
|
|
196
|
-
npm 上已经有 `@fengye_nobug/dsh-qq-bot`(目前是 0.2.
|
|
200
|
+
npm 上已经有 `@fengye_nobug/dsh-qq-bot`(目前是 0.2.5)。无作用域名发不出去。发到 npm 之后,市场才能显示下载量;不发也能收录。
|
|
197
201
|
|
|
198
202
|
用着有问题,到 [Issues](https://github.com/yunfengnobug/dsh-qq-bot/issues/new/choose) 选模板:**连不上**、**不回**、**发不出**。不要在 Issue 里贴真实 QQ 号、群号、token 或服务器地址。
|
|
199
203
|
|
|
@@ -471,9 +475,9 @@ QQ 的“拍一拍”在 OneBot 里走的是 **notice 事件**,不是消息段
|
|
|
471
475
|
> 宿主 `requestRejection()` 返回空、`undefined` 或 `0` **不算**已登录。已登录只认宿主明确给出的结果:`isAuthenticated`、浏览器会话,或 `admit()` 交回的身份。没有这个结果的外网请求是 403。来源是别的网站、和 `Host` 对不上的请求也是 403,就算带着 cookie 也不行。接口只写 schema 白名单里的键。
|
|
472
476
|
> 判断面板监听器是不是 HTTPS 时,只通过 `ctx.get('webServer')` 读协议,不读 `ctx.webServer`。插件没有把 `webServer` 放进 inject。直接读这个属性会抛错,宿主会把这次请求收成**没有正文的 HTTP 400**,卡片诊断就显示本机接口不可用,即使浏览器里已经登录。读不到这项服务、或读取失败时,按 HTTP 继续做登录判定。
|
|
473
477
|
|
|
474
|
-
卡片上方显示**当前版本**,并有
|
|
478
|
+
卡片上方显示**当前版本**,并有 **检查更新**。按钮旁边只写「只检查,不安装。」接口是 `GET/POST /api/qq-bridge/update`,准入和设置接口相同。GET 只读当前版本,不去 npm,也不去 GitHub。POST 不带 `action` 才对比版本:先问 npm registry 里的 `@fengye_nobug/dsh-qq-bot` 正式版。有比当前新的版本时,卡片上写短句「有新版本 x.y.z(现在是 a.b.c),在 npm 上。这里只检查,还没安装。」并且不再访问 GitHub。npm 上还没有更新、但 GitHub Release 上有更新时,改问 GitHub,短句里写「在 GitHub 上」。安装命令和说明摘要仍在接口字段里,不铺在这句主提示上。没有新版、连不上、被限流,都用白话说明。检查本身不会下载或安装。
|
|
475
479
|
|
|
476
|
-
有新版时出现 **更新到 vX.Y.Z
|
|
480
|
+
有新版时出现 **更新到 vX.Y.Z**。旁边只加一句「装完会重启面板。请刷新;登录口令可能会变。不改 NapCat。」点下去才发送 `POST { "action": "apply", "version": "x.y.z" }`。优先 `dsh plugin --profile web add @fengye_nobug/dsh-qq-bot@x.y.z`。registry 里没有这个版本时,改从 GitHub 的官方源码包装上。安装成功后,把 profile、`$DSH_HOME/cordis.patch.yml`(有 `qq-bridge` 这一行才改)和已安装插件补丁里的 `name` 改成磁盘上 `package.json` 的包名,然后重启面板。只改我们自己的旧包名(`dsh-qq-bot`、`dsh-qq-bridge`),不改 `config`,也不改别的插件。成功时主提示是「已经从 npm 装上 vX.Y.Z,面板即将重启。」或「已经从 GitHub 装上 vX.Y.Z,面板即将重启。」进行中显示「正在更新…」。已经是最新正式版时没有这个按钮。超过三分钟还没装完时停掉这次的 dsh 和 pnpm。`.plugin-manager/run.json` 里的进程还在就不删,也不再开第二个安装;进程已经不在或记录读不懂才删。超时后如果磁盘上已经是要装的版本,仍改包名,并自动重启 dsh-web。自动重启没有安排上时,才提示自己重启,并写明这次是从 npm 还是从 GitHub 装上的。
|
|
477
481
|
|
|
478
482
|
「启动时检查一次」默认关。打开并保存后,下次启动 dsh-web 才问一次,结果只写在日志里,不会安装。
|
|
479
483
|
|
|
@@ -526,7 +530,7 @@ QQ 的“拍一拍”在 OneBot 里走的是 **notice 事件**,不是消息段
|
|
|
526
530
|
| `downloadProxy` | `''` | 下载时走的代理;空 = 先看 `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY`。NapCat 下载,以及一键更新改从 GitHub 下载时,都用它。从 npm 安装时走 pnpm 自己的 registry,不读这个框。界面上不显示代理地址 |
|
|
527
531
|
| `onebotPort` | `3001` | OneBot 端口。自己另外部署的 NapCat 要和这边一致 |
|
|
528
532
|
| `qqNumber` | `''` | 快速登录用的 QQ 号(需先手动登录过一次) |
|
|
529
|
-
| `checkUpdateOnStart` | `false` |
|
|
533
|
+
| `checkUpdateOnStart` | `false` | 启动时检查一次。默认关。打开后,下次启动只检查有没有新版本,写在日志里,不自动安装。要装用卡片上的「更新到 vX.Y.Z」 |
|
|
530
534
|
|
|
531
535
|
这些值优先从设置界面 / `settings.yaml` 取,未设置时回落到 `cordis.patch.yml` 里的安装配置(composition base)。
|
|
532
536
|
|
|
@@ -624,7 +628,7 @@ QQ 侧没有人能点击“同意”,因此:
|
|
|
624
628
|
|
|
625
629
|
改用法、设置或运行行为时,同一次提交里更新本文件。约定见 [`AGENTS.md`](./AGENTS.md)。
|
|
626
630
|
|
|
627
|
-
5 个套件都是**零依赖**(测试自己造 zip、自己起一个最小 OneBot WS 服务器;检查更新不连真 npm、也不连真 GitHub),共 **
|
|
631
|
+
5 个套件都是**零依赖**(测试自己造 zip、自己起一个最小 OneBot WS 服务器;检查更新不连真 npm、也不连真 GitHub),共 **812 条断言**:
|
|
628
632
|
|
|
629
633
|
```powershell
|
|
630
634
|
node test-smoke.mjs # 策略分支 + 出站闸门 + 描述缓存命中 + 表情包工具(含 mface)+ 系统表情分段 + 设置卡片字段 + 检查更新和一键安装接口(假 ctx,纯离线)
|
package/client/client.js
CHANGED
|
@@ -38,7 +38,7 @@ window.__ModuleLoader__.load({
|
|
|
38
38
|
key: 'checkUpdateOnStart',
|
|
39
39
|
label: '启动时检查一次',
|
|
40
40
|
kind: 'boolean',
|
|
41
|
-
hint: '
|
|
41
|
+
hint: '默认关。打开并保存后,下次启动只检查有没有新版本,写在日志里,不会自动安装。',
|
|
42
42
|
},
|
|
43
43
|
{
|
|
44
44
|
key: 'sessionScope',
|
|
@@ -860,9 +860,9 @@ window.__ModuleLoader__.load({
|
|
|
860
860
|
)
|
|
861
861
|
: null,
|
|
862
862
|
),
|
|
863
|
-
h('div', { style: S.hint }, '
|
|
863
|
+
h('div', { style: S.hint }, '只检查,不安装。'),
|
|
864
864
|
updateOffer
|
|
865
|
-
? h('div', { style: S.hint }, '
|
|
865
|
+
? h('div', { style: S.hint }, '装完会重启面板。请刷新;登录口令可能会变。不改 NapCat。')
|
|
866
866
|
: null,
|
|
867
867
|
updateNote
|
|
868
868
|
? h('div', { style: { ...(updateIsError ? S.err : S.note), whiteSpace: 'pre-wrap' } }, updateNote)
|
package/cordis.patch.yml
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
# dsh-qq-bot bundle patch: one insert row carrying the whole plugin config.
|
|
2
|
-
# Cordis 插件 id 仍是 qq-bridge
|
|
2
|
+
# Cordis 插件 id 仍是 qq-bridge(与上游兼容)。界面显示名仍是 dsh-qq-bot。
|
|
3
|
+
# name 是 Node 用来加载插件的包名,必须等于实际安装的 npm 包。
|
|
4
|
+
# 无作用域的 dsh-qq-bot 发不出去;写成那个名字时 Cordis 解析不到模块,
|
|
5
|
+
# qq-bridge 不会挂上,/api/qq-bridge/settings 就是 HTTP 404。
|
|
3
6
|
# 用户在 profile 的 cordis.patch.yml 里按 id 覆盖这一行;patch 会替换整份 config,
|
|
4
|
-
#
|
|
7
|
+
# 所以要保留的键请全部重述。覆盖里如果写了 name,也要写成同一个包名。
|
|
5
8
|
- insert:
|
|
6
9
|
- id: qq-bridge
|
|
7
|
-
name: dsh-qq-bot
|
|
10
|
+
name: '@fengye_nobug/dsh-qq-bot'
|
|
8
11
|
config:
|
|
9
12
|
# 固定目标会话:QQ 的一切都注入这一个会话(保留其上下文)。
|
|
10
13
|
# 留空表示不驱动任何会话。
|
package/index.js
CHANGED
|
@@ -3971,6 +3971,8 @@ export function apply(ctx, config) {
|
|
|
3971
3971
|
version: typeof payload.version === 'string' ? payload.version : '',
|
|
3972
3972
|
force: payload.force === true,
|
|
3973
3973
|
proxy: proxyFor(readSettings()),
|
|
3974
|
+
home: process.env.DSH_HOME || join(homedir(), '.dsh'),
|
|
3975
|
+
profile: 'web',
|
|
3974
3976
|
})
|
|
3975
3977
|
} catch {
|
|
3976
3978
|
return sendJson(res, 200, { ok: false, stage: 'install', error: '更新没有完成。稍后再试。' })
|
package/package.json
CHANGED
package/updates.js
CHANGED
|
@@ -7,11 +7,19 @@
|
|
|
7
7
|
// 插件不写死第三方镜像。包还没发到 npm,或 registry 里没有这个版本时,才改从 GitHub 下载:
|
|
8
8
|
// 先要 github.com 上的官方源码包,连不上再试同一标签的 codeload。只跟随仍指向同一标签的
|
|
9
9
|
// github.com、api.github.com 或 codeload.github.com。不用 git 协议。
|
|
10
|
+
// 安装成功后,若 profile、home 或插件自己的 cordis.patch.yml 里 qq-bridge 的 name
|
|
11
|
+
// 仍是旧的无作用域名,会改成磁盘上 package.json 里的包名。不改 config,不改设置,不改 NapCat。
|
|
12
|
+
// 安装超过三分钟还没结束时,停掉这次拉起的进程组,并清掉已经退出的安装记录。
|
|
13
|
+
// 超时后若磁盘上已是目标版本,仍改包名,并安排重启。
|
|
14
|
+
// 重启会读本进程的 cgroup:认得出具体服务就用 systemd-run 只重启那个服务
|
|
15
|
+
// (挂在 user@ 下面的用 systemctl --user)。systemd-run 失败,或只看见用户会话、
|
|
16
|
+
// 认不出单独服务时,不重启整个登录,改为按当前命令行再拉起一次。
|
|
17
|
+
// 两条都做不到,才提示自己重启。
|
|
10
18
|
|
|
11
19
|
import { spawn } from 'node:child_process'
|
|
12
|
-
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
20
|
+
import { chmodSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
13
21
|
import { tmpdir } from 'node:os'
|
|
14
|
-
import { join } from 'node:path'
|
|
22
|
+
import { isAbsolute, join } from 'node:path'
|
|
15
23
|
import { gunzipSync, gzipSync } from 'node:zlib'
|
|
16
24
|
|
|
17
25
|
export const UPDATE_REPO = 'yunfengnobug/dsh-qq-bot'
|
|
@@ -27,6 +35,8 @@ const MAX_TARBALL_BYTES = 20 * 1024 * 1024
|
|
|
27
35
|
const MAX_UNPACKED_BYTES = 40 * 1024 * 1024
|
|
28
36
|
const MAX_TAR_ENTRIES = 4000
|
|
29
37
|
const INSTALL_TIMEOUT_MS = 180000
|
|
38
|
+
/** 先劝退,再强制停。给子进程一点时间自己退出。 */
|
|
39
|
+
const KILL_GRACE_MS = 1500
|
|
30
40
|
const DOWNLOAD_TIMEOUT_MS = 60000
|
|
31
41
|
/** 先把 HTTP 响应发出去,再重启,避免面板还没回答就被掐掉。 */
|
|
32
42
|
const RESTART_DELAY_MS = 2500
|
|
@@ -231,7 +241,7 @@ export function assessUpdate({ current, releases, repo = UPDATE_REPO, channel =
|
|
|
231
241
|
kind: 'no-release',
|
|
232
242
|
current: parsedCurrent.raw,
|
|
233
243
|
updateAvailable: false,
|
|
234
|
-
message: 'GitHub 上还没有可用的正式版,没法对比新版本。',
|
|
244
|
+
message: channel === 'npm' ? 'npm 上还没有可用的正式版,没法对比新版本。' : 'GitHub 上还没有可用的正式版,没法对比新版本。',
|
|
235
245
|
}
|
|
236
246
|
}
|
|
237
247
|
stable.sort((left, right) => compareSemver(right.parsed, left.parsed) ?? 0)
|
|
@@ -253,21 +263,21 @@ export function assessUpdate({ current, releases, repo = UPDATE_REPO, channel =
|
|
|
253
263
|
url,
|
|
254
264
|
}
|
|
255
265
|
if (compared > 0) {
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
return { ...base, kind: 'update', updateAvailable: true, message: lines.join('\n') }
|
|
266
|
+
const where = useNpm ? 'npm' : 'GitHub'
|
|
267
|
+
return {
|
|
268
|
+
...base,
|
|
269
|
+
kind: 'update',
|
|
270
|
+
updateAvailable: true,
|
|
271
|
+
message: `有新版本 ${latest.parsed.raw}(现在是 ${parsedCurrent.raw}),在 ${where} 上。这里只检查,还没安装。`,
|
|
272
|
+
}
|
|
264
273
|
}
|
|
265
274
|
if (compared < 0) {
|
|
275
|
+
const where = useNpm ? 'npm' : 'GitHub'
|
|
266
276
|
return {
|
|
267
277
|
...base,
|
|
268
278
|
kind: 'ahead',
|
|
269
279
|
updateAvailable: false,
|
|
270
|
-
message: `你现在的 ${parsedCurrent.raw} 比
|
|
280
|
+
message: `你现在的 ${parsedCurrent.raw} 比 ${where} 上最新正式版 ${latest.parsed.raw} 还新,不用往回装。`,
|
|
271
281
|
}
|
|
272
282
|
}
|
|
273
283
|
return {
|
|
@@ -433,19 +443,6 @@ export async function queryNpmVersions(input = {}) {
|
|
|
433
443
|
return { ok: false, code: 'network' }
|
|
434
444
|
}
|
|
435
445
|
|
|
436
|
-
function noteGithubFallback(assessed) {
|
|
437
|
-
if (!assessed || assessed.updateAvailable !== true) return assessed
|
|
438
|
-
const message = String(assessed.message ?? '')
|
|
439
|
-
if (message.includes('还没发到 npm') || !message.includes('也可以在设置卡片里点')) return assessed
|
|
440
|
-
return {
|
|
441
|
-
...assessed,
|
|
442
|
-
message: message.replace(
|
|
443
|
-
'也可以在设置卡片里点',
|
|
444
|
-
'这一版还没发到 npm,一键更新会改从 GitHub 下载。\n也可以在设置卡片里点',
|
|
445
|
-
),
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
446
|
function lookupFailure(npmCode, githubError, applying) {
|
|
450
447
|
const raw = String(githubError ?? '')
|
|
451
448
|
if (npmCode === 'network' && /连不上 GitHub/.test(raw)) {
|
|
@@ -484,7 +481,7 @@ async function findUpdate(input) {
|
|
|
484
481
|
if (wanted !== '') return { ok: false, error: '这次检查到的正式版里没有这个版本,没有安装。' }
|
|
485
482
|
return { ok: true, channel: 'npm', releases, assessed }
|
|
486
483
|
}
|
|
487
|
-
const ghAssessed =
|
|
484
|
+
const ghAssessed = assessUpdate({ current: input.current, releases: gh.releases, repo, channel: 'github' })
|
|
488
485
|
if (ghAssessed.ok === false) return { ok: false, error: ghAssessed.message }
|
|
489
486
|
const npmLatest = assessed.latest || '0.0.0'
|
|
490
487
|
const githubNewer = ghAssessed.updateAvailable === true && (compareSemver(ghAssessed.latest, npmLatest) ?? 0) > 0
|
|
@@ -500,7 +497,7 @@ async function findUpdate(input) {
|
|
|
500
497
|
}
|
|
501
498
|
const gh = await queryGithubReleases(input.fetchImpl, { repo, timeoutMs: input.timeoutMs })
|
|
502
499
|
if (!gh.ok) return { ok: false, error: lookupFailure(npm.code, gh.error, input.applying === true) }
|
|
503
|
-
const assessed =
|
|
500
|
+
const assessed = assessUpdate({ current: input.current, releases: gh.releases, repo, channel: 'github' })
|
|
504
501
|
if (assessed.ok === false) return { ok: false, error: assessed.message }
|
|
505
502
|
return { ok: true, channel: 'github', releases: gh.releases, assessed }
|
|
506
503
|
}
|
|
@@ -613,18 +610,29 @@ function downloadError(code) {
|
|
|
613
610
|
return '连不上 GitHub,这次没有把新版本下下来。稍后再试。'
|
|
614
611
|
}
|
|
615
612
|
|
|
616
|
-
function installError(code, command) {
|
|
613
|
+
function installError(code, command, extra = {}) {
|
|
617
614
|
const hint = command ? `也可以自己执行:${command}` : '也可以按卡片上的安装命令自己装。'
|
|
618
615
|
if (code === 'ENOENT') return `没找到 dsh 命令,没有安装。${hint}`
|
|
619
|
-
if (code === '
|
|
616
|
+
if (code === 'busy') {
|
|
617
|
+
const pid = Number(extra.pid)
|
|
618
|
+
const which = Number.isSafeInteger(pid) && pid > 1 ? `(进程 ${pid})` : ''
|
|
619
|
+
return `已经有一个安装进程还在跑${which},这次没有再启动。等它结束再点「更新」。如果确认它已经卡死,先停掉那个进程,再点一次。`
|
|
620
|
+
}
|
|
621
|
+
if (code === 'timeout' && extra.leftover) {
|
|
622
|
+
return `安装超时,卡住的安装进程还没停干净。请先停掉 dsh 和 pnpm,再点一次「更新」。${hint}`
|
|
623
|
+
}
|
|
624
|
+
if (code === 'timeout') {
|
|
625
|
+
return `安装超时,已经停掉卡住的安装进程,并清掉这次留下的安装记录。没有确认装上。可以再点一次「更新」。${hint}`
|
|
626
|
+
}
|
|
620
627
|
if (code === 'wrong-name') return '下到的安装包不是这个插件,没有安装。'
|
|
621
628
|
if (code === 'wrong-version') return '下到的版本和发布标签对不上,没有安装。'
|
|
622
629
|
if (code === 'bad-package') return '下到的安装包读不懂,没有安装。'
|
|
623
630
|
return `安装没有成功。${hint}`
|
|
624
631
|
}
|
|
625
632
|
|
|
626
|
-
function restartError() {
|
|
627
|
-
|
|
633
|
+
function restartError(channel) {
|
|
634
|
+
const via = channel === 'npm' ? '从 npm ' : channel === 'github' ? '从 GitHub ' : ''
|
|
635
|
+
return `新版本已经${via}装上,但没能自动重启面板。请自己重启 dsh-web,然后刷新页面。登录口令可能会变。`
|
|
628
636
|
}
|
|
629
637
|
|
|
630
638
|
function headerValue(response, name) {
|
|
@@ -1167,16 +1175,147 @@ export function dshPluginAddSpec(spec) {
|
|
|
1167
1175
|
return { command: 'dsh', args: pluginArgs, shell: false }
|
|
1168
1176
|
}
|
|
1169
1177
|
|
|
1178
|
+
function defaultAlive(pid) {
|
|
1179
|
+
try {
|
|
1180
|
+
process.kill(pid, 0)
|
|
1181
|
+
return true
|
|
1182
|
+
} catch (error) {
|
|
1183
|
+
return error?.code === 'EPERM'
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function defaultKill(target, signal, child) {
|
|
1188
|
+
if (typeof target === 'number' && target < 0) {
|
|
1189
|
+
process.kill(target, signal)
|
|
1190
|
+
return
|
|
1191
|
+
}
|
|
1192
|
+
if (child && typeof child.kill === 'function') {
|
|
1193
|
+
child.kill(signal)
|
|
1194
|
+
return
|
|
1195
|
+
}
|
|
1196
|
+
process.kill(target, signal)
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function defaultTaskkill(pid) {
|
|
1200
|
+
const killer = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' })
|
|
1201
|
+
killer.on?.('error', () => {})
|
|
1202
|
+
killer.unref?.()
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* 停掉安装进程,以及它在 POSIX 上拉起来的进程组。
|
|
1207
|
+
* 测试要传入 kill,避免对一个碰巧存在的 pid 发信号。
|
|
1208
|
+
* @param {{ pid?: number, kill?: (signal: string) => void }} child
|
|
1209
|
+
* @param {{ platform?: string, graceMs?: number, kill?: (target: number, signal: string) => void, taskkill?: (pid: number) => void, group?: boolean }} [options]
|
|
1210
|
+
*/
|
|
1211
|
+
export function stopProcessTree(child, options = {}) {
|
|
1212
|
+
const pid = Number(child?.pid)
|
|
1213
|
+
const known = Number.isSafeInteger(pid) && pid > 1
|
|
1214
|
+
const platform = options.platform || process.platform
|
|
1215
|
+
const graceMs = options.graceMs ?? KILL_GRACE_MS
|
|
1216
|
+
const group = options.group !== false && platform !== 'win32'
|
|
1217
|
+
const kill = options.kill || ((target, signal) => defaultKill(target, signal, child))
|
|
1218
|
+
const targets = []
|
|
1219
|
+
if (known && group) targets.push(-pid)
|
|
1220
|
+
if (known) targets.push(pid)
|
|
1221
|
+
const send = (signal) => {
|
|
1222
|
+
if (platform === 'win32' && signal === 'SIGKILL') {
|
|
1223
|
+
if (!known) return
|
|
1224
|
+
const taskkill = options.taskkill || defaultTaskkill
|
|
1225
|
+
try { taskkill(pid) } catch { /* 停不掉时由调用方再看进程还在不在 */ }
|
|
1226
|
+
return
|
|
1227
|
+
}
|
|
1228
|
+
for (const target of targets) {
|
|
1229
|
+
try { kill(target, signal) } catch { /* 进程已经退出 */ }
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
send('SIGTERM')
|
|
1233
|
+
const timer = setTimeout(() => send('SIGKILL'), graceMs)
|
|
1234
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
1235
|
+
return {
|
|
1236
|
+
pid: known ? pid : 0,
|
|
1237
|
+
stop() { clearTimeout(timer) },
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/** DSH 把正在跑的 pnpm 记在 profile 的这个文件里。 */
|
|
1242
|
+
export function runRecordPath(profileDir) {
|
|
1243
|
+
return join(profileDir, '.plugin-manager', 'run.json')
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/** 只认 `{ pid, grouped }`。pid 必须是大于 1 的整数,避免把 1 号进程当成安装进程。 */
|
|
1247
|
+
export function parseRunRecord(text) {
|
|
1248
|
+
let value
|
|
1249
|
+
try {
|
|
1250
|
+
value = JSON.parse(String(text ?? ''))
|
|
1251
|
+
} catch {
|
|
1252
|
+
return null
|
|
1253
|
+
}
|
|
1254
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
1255
|
+
const pid = value.pid
|
|
1256
|
+
if (!Number.isSafeInteger(pid) || pid <= 1 || typeof value.grouped !== 'boolean') return null
|
|
1257
|
+
return { pid, grouped: value.grouped }
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* 安装记录里的进程已经不在,或记录本身读不懂时,把文件删掉。
|
|
1262
|
+
* 进程还活着就不删,免得下一次安装和还在写目录的 pnpm 抢在一起。
|
|
1263
|
+
* 测试要传入 alive。默认探测会向真实 pid 发 0 信号。
|
|
1264
|
+
* @param {string} profileDir
|
|
1265
|
+
* @param {{ alive?: (pid: number) => boolean }} [options]
|
|
1266
|
+
*/
|
|
1267
|
+
export function clearStaleRunRecord(profileDir, options = {}) {
|
|
1268
|
+
const path = runRecordPath(profileDir)
|
|
1269
|
+
let text
|
|
1270
|
+
try {
|
|
1271
|
+
text = readFileSync(path, 'utf8')
|
|
1272
|
+
} catch (error) {
|
|
1273
|
+
if (error?.code === 'ENOENT') return { removed: false, alive: false, missing: true }
|
|
1274
|
+
throw error
|
|
1275
|
+
}
|
|
1276
|
+
const record = parseRunRecord(text)
|
|
1277
|
+
if (!record) {
|
|
1278
|
+
try {
|
|
1279
|
+
rmSync(path, { force: true })
|
|
1280
|
+
} catch {
|
|
1281
|
+
return { removed: false, invalid: true, alive: false }
|
|
1282
|
+
}
|
|
1283
|
+
return { removed: true, invalid: true, alive: false }
|
|
1284
|
+
}
|
|
1285
|
+
const probe = options.alive || defaultAlive
|
|
1286
|
+
let living = false
|
|
1287
|
+
try {
|
|
1288
|
+
living = probe(record.pid) === true
|
|
1289
|
+
} catch {
|
|
1290
|
+
// 看不出来就当成还在跑,避免删掉记录后再开一个安装。
|
|
1291
|
+
living = true
|
|
1292
|
+
}
|
|
1293
|
+
if (living) return { removed: false, alive: true, pid: record.pid, grouped: record.grouped }
|
|
1294
|
+
try {
|
|
1295
|
+
rmSync(path, { force: true })
|
|
1296
|
+
} catch {
|
|
1297
|
+
return { removed: false, alive: false, pid: record.pid }
|
|
1298
|
+
}
|
|
1299
|
+
return { removed: true, alive: false, pid: record.pid }
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1170
1302
|
function runChild(spawnImpl, command, args, options = {}) {
|
|
1171
1303
|
return new Promise((resolve) => {
|
|
1172
1304
|
let settled = false
|
|
1173
1305
|
let timer
|
|
1306
|
+
let graceTimer
|
|
1307
|
+
let stopper = null
|
|
1308
|
+
let timedOut = false
|
|
1174
1309
|
const finish = (value) => {
|
|
1175
1310
|
if (settled) return
|
|
1176
1311
|
settled = true
|
|
1177
1312
|
if (timer) clearTimeout(timer)
|
|
1313
|
+
if (graceTimer) clearTimeout(graceTimer)
|
|
1314
|
+
stopper?.stop()
|
|
1178
1315
|
resolve(value)
|
|
1179
1316
|
}
|
|
1317
|
+
const platform = options.platform || process.platform
|
|
1318
|
+
const detached = options.detached === true && platform !== 'win32'
|
|
1180
1319
|
let child
|
|
1181
1320
|
try {
|
|
1182
1321
|
child = spawnImpl(command, args, {
|
|
@@ -1185,6 +1324,7 @@ function runChild(spawnImpl, command, args, options = {}) {
|
|
|
1185
1324
|
windowsHide: true,
|
|
1186
1325
|
shell: false,
|
|
1187
1326
|
stdio: options.stdio || ['ignore', 'pipe', 'pipe'],
|
|
1327
|
+
detached,
|
|
1188
1328
|
})
|
|
1189
1329
|
} catch (error) {
|
|
1190
1330
|
finish({ ok: false, code: error?.code === 'ENOENT' ? 'ENOENT' : 'error' })
|
|
@@ -1204,29 +1344,120 @@ function runChild(spawnImpl, command, args, options = {}) {
|
|
|
1204
1344
|
if (stderr.length >= 4000) return
|
|
1205
1345
|
stderr += chunk.toString().slice(0, 4000 - stderr.length)
|
|
1206
1346
|
})
|
|
1207
|
-
child.on('error', (error) =>
|
|
1347
|
+
child.on('error', (error) => {
|
|
1348
|
+
if (timedOut) return
|
|
1349
|
+
finish({ ok: false, code: error?.code === 'ENOENT' ? 'ENOENT' : 'error', stdout, stderr })
|
|
1350
|
+
})
|
|
1351
|
+
const graceMs = options.killGraceMs ?? KILL_GRACE_MS
|
|
1208
1352
|
timer = setTimeout(() => {
|
|
1209
|
-
|
|
1210
|
-
|
|
1353
|
+
timedOut = true
|
|
1354
|
+
stopper = stopProcessTree(child, {
|
|
1355
|
+
platform,
|
|
1356
|
+
graceMs,
|
|
1357
|
+
kill: options.kill,
|
|
1358
|
+
taskkill: options.taskkill,
|
|
1359
|
+
group: detached,
|
|
1360
|
+
})
|
|
1361
|
+
if (settled) {
|
|
1362
|
+
stopper.stop()
|
|
1363
|
+
return
|
|
1364
|
+
}
|
|
1365
|
+
graceTimer = setTimeout(() => {
|
|
1366
|
+
const pid = Number(child.pid)
|
|
1367
|
+
const known = Number.isSafeInteger(pid) && pid > 1
|
|
1368
|
+
let leftover = false
|
|
1369
|
+
if (known) {
|
|
1370
|
+
const probe = options.alive || defaultAlive
|
|
1371
|
+
try { leftover = probe(pid) === true } catch { leftover = true }
|
|
1372
|
+
}
|
|
1373
|
+
finish({ ok: false, code: 'timeout', leftover, pid: known ? pid : 0, stdout, stderr })
|
|
1374
|
+
}, graceMs + 50)
|
|
1211
1375
|
}, options.timeoutMs ?? INSTALL_TIMEOUT_MS)
|
|
1212
|
-
child.on('close', (code) =>
|
|
1376
|
+
child.on('close', (code) => {
|
|
1377
|
+
if (timedOut && code === 0) {
|
|
1378
|
+
finish({ ok: true, code: 0, stdout, stderr })
|
|
1379
|
+
return
|
|
1380
|
+
}
|
|
1381
|
+
if (timedOut) {
|
|
1382
|
+
finish({ ok: false, code: 'timeout', leftover: false, stdout, stderr, pid: Number.isSafeInteger(Number(child.pid)) ? Number(child.pid) : 0 })
|
|
1383
|
+
return
|
|
1384
|
+
}
|
|
1385
|
+
finish({ ok: code === 0, code: code === 0 ? 0 : 'exit', stdout, stderr })
|
|
1386
|
+
})
|
|
1213
1387
|
})
|
|
1214
1388
|
}
|
|
1215
1389
|
|
|
1216
|
-
|
|
1390
|
+
/**
|
|
1391
|
+
* 执行 `dsh plugin add`。开始前清掉已经退出的安装记录;记录里的进程还在就不再开一次。
|
|
1392
|
+
* 超时后停掉进程组。记录里如果是另一个还活着的 pid,也停掉,但活着的记录文件留着。
|
|
1393
|
+
* @param {{ packageSpec?: string, tarballPath?: string, platform?: string, profile?: string, profileDir?: string, cwd?: string, env?: NodeJS.ProcessEnv, spawnImpl?: typeof spawn, timeoutMs?: number, killGraceMs?: number, kill?: (target: number, signal: string) => void, alive?: (pid: number) => boolean, taskkill?: (pid: number) => void }} spec
|
|
1394
|
+
*/
|
|
1395
|
+
export async function runPluginAdd(spec) {
|
|
1396
|
+
const profileDir = typeof spec.profileDir === 'string' ? spec.profileDir : ''
|
|
1397
|
+
const platform = spec.platform || process.platform
|
|
1398
|
+
if (profileDir !== '') {
|
|
1399
|
+
let stale = null
|
|
1400
|
+
try {
|
|
1401
|
+
stale = clearStaleRunRecord(profileDir, { alive: spec.alive })
|
|
1402
|
+
} catch {
|
|
1403
|
+
stale = null
|
|
1404
|
+
}
|
|
1405
|
+
if (stale?.alive) return { ok: false, code: 'busy', pid: stale.pid }
|
|
1406
|
+
}
|
|
1217
1407
|
const planned = dshPluginAddSpec(spec)
|
|
1218
1408
|
const ran = await runChild(spec.spawnImpl || spawn, planned.command, planned.args, {
|
|
1219
1409
|
cwd: spec.cwd || process.cwd(),
|
|
1220
1410
|
env: spec.env || process.env,
|
|
1221
1411
|
timeoutMs: spec.timeoutMs ?? INSTALL_TIMEOUT_MS,
|
|
1222
1412
|
capture: true,
|
|
1413
|
+
detached: platform !== 'win32',
|
|
1414
|
+
platform,
|
|
1415
|
+
kill: spec.kill,
|
|
1416
|
+
alive: spec.alive,
|
|
1417
|
+
taskkill: spec.taskkill,
|
|
1418
|
+
killGraceMs: spec.killGraceMs,
|
|
1223
1419
|
})
|
|
1224
1420
|
if (ran.ok) return { ok: true, code: 0 }
|
|
1421
|
+
if (ran.code === 'timeout') {
|
|
1422
|
+
let leftover = ran.leftover === true
|
|
1423
|
+
let pid = ran.pid
|
|
1424
|
+
if (profileDir !== '') {
|
|
1425
|
+
try {
|
|
1426
|
+
const record = parseRunRecord(readFileSync(runRecordPath(profileDir), 'utf8'))
|
|
1427
|
+
if (record && record.pid !== ran.pid) {
|
|
1428
|
+
// 记录里的 grouped 为真时,这个 pid 才是进程组负责人。CLI 安装记的是 false,只停这个进程。
|
|
1429
|
+
stopProcessTree({ pid: record.pid }, {
|
|
1430
|
+
platform,
|
|
1431
|
+
graceMs: 0,
|
|
1432
|
+
kill: spec.kill,
|
|
1433
|
+
taskkill: spec.taskkill,
|
|
1434
|
+
group: record.grouped === true,
|
|
1435
|
+
})
|
|
1436
|
+
}
|
|
1437
|
+
} catch {
|
|
1438
|
+
// 没有留下记录,或记录读不懂。下面再按进程是否还在决定删不删。
|
|
1439
|
+
}
|
|
1440
|
+
try {
|
|
1441
|
+
const again = clearStaleRunRecord(profileDir, { alive: spec.alive })
|
|
1442
|
+
if (again.alive) {
|
|
1443
|
+
leftover = true
|
|
1444
|
+
pid = again.pid
|
|
1445
|
+
}
|
|
1446
|
+
} catch {
|
|
1447
|
+
// 清不掉就按超时说明,不把路径写进给用户的句子。
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
return { ok: false, code: 'timeout', leftover, pid }
|
|
1451
|
+
}
|
|
1452
|
+
if (ran.code === 'ENOENT') return { ok: false, code: 'ENOENT' }
|
|
1225
1453
|
if (spec.packageSpec && isRegistryMiss(ran.stderr)) return { ok: false, code: 'unpublished' }
|
|
1226
|
-
if (ran.code === 'ENOENT' || ran.code === 'timeout') return { ok: false, code: ran.code }
|
|
1227
1454
|
return { ok: false, code: 'error' }
|
|
1228
1455
|
}
|
|
1229
1456
|
|
|
1457
|
+
function defaultRunCommand(spec) {
|
|
1458
|
+
return runPluginAdd(spec)
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1230
1461
|
function readOwnCgroup() {
|
|
1231
1462
|
try {
|
|
1232
1463
|
return readFileSync('/proc/self/cgroup', 'utf8')
|
|
@@ -1294,46 +1525,75 @@ setTimeout(() => {
|
|
|
1294
1525
|
}, delay)
|
|
1295
1526
|
`.trim()
|
|
1296
1527
|
|
|
1528
|
+
/** 具体服务挂在 user@UID.service 下面时,重启要走用户实例,不能重启整个登录会话。 */
|
|
1529
|
+
function systemdUserManager(text) {
|
|
1530
|
+
return /\/user@\d+\.service(?:\/|$)/.test(String(text ?? ''))
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
function systemdRunArgs(unit, user) {
|
|
1534
|
+
const scope = user ? ['--user'] : []
|
|
1535
|
+
return [
|
|
1536
|
+
...scope,
|
|
1537
|
+
'--quiet',
|
|
1538
|
+
'--collect',
|
|
1539
|
+
'--on-active=3s',
|
|
1540
|
+
'--timer-property=AccuracySec=1s',
|
|
1541
|
+
'--',
|
|
1542
|
+
'systemctl',
|
|
1543
|
+
...scope,
|
|
1544
|
+
'restart',
|
|
1545
|
+
unit,
|
|
1546
|
+
]
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
function cgroupText(input) {
|
|
1550
|
+
if (input.cgroup != null) return String(input.cgroup)
|
|
1551
|
+
const read = typeof input.readCgroup === 'function' ? input.readCgroup : readOwnCgroup
|
|
1552
|
+
try {
|
|
1553
|
+
return String(read() ?? '')
|
|
1554
|
+
} catch {
|
|
1555
|
+
return ''
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
function reexecPlan(input) {
|
|
1560
|
+
return {
|
|
1561
|
+
mode: 'reexec',
|
|
1562
|
+
execPath: input.execPath || process.execPath,
|
|
1563
|
+
argv: Array.isArray(input.argv) ? input.argv : process.argv.slice(1),
|
|
1564
|
+
pid: input.pid || process.pid,
|
|
1565
|
+
cwd: input.cwd || process.cwd(),
|
|
1566
|
+
delayMs: RESTART_DELAY_MS,
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1297
1570
|
/**
|
|
1298
1571
|
* 重启计划。systemd 里只重启 cgroup 里那个具体服务,用 systemd-run 把动作放到服务外面。
|
|
1299
|
-
*
|
|
1572
|
+
* 没传入 cgroup 时读本进程的 /proc/self/cgroup。用户服务加 --user。
|
|
1573
|
+
* 认不出单独服务时计划是 manual:不重启整个登录会话。其余情况按当前进程的命令行再拉起一次。
|
|
1300
1574
|
*/
|
|
1301
1575
|
export function planPanelRestart(input = {}) {
|
|
1302
1576
|
const platform = input.platform || process.platform
|
|
1303
1577
|
const env = input.env || process.env
|
|
1304
1578
|
const invocation = platform !== 'win32' && Boolean(env && (env.INVOCATION_ID || env.NOTIFY_SOCKET))
|
|
1305
|
-
const
|
|
1306
|
-
|
|
1579
|
+
const cgroup = invocation ? cgroupText(input) : ''
|
|
1580
|
+
const unit = invocation ? systemdUnitFromCgroup(cgroup) : null
|
|
1307
1581
|
if (unit) {
|
|
1582
|
+
const user = systemdUserManager(cgroup)
|
|
1308
1583
|
return {
|
|
1309
1584
|
mode: 'systemd',
|
|
1310
1585
|
command: 'systemd-run',
|
|
1311
|
-
args:
|
|
1586
|
+
args: systemdRunArgs(unit, user),
|
|
1312
1587
|
unit,
|
|
1588
|
+
scope: user ? 'user' : 'system',
|
|
1313
1589
|
}
|
|
1314
1590
|
}
|
|
1315
|
-
return {
|
|
1316
|
-
|
|
1317
|
-
execPath: input.execPath || process.execPath,
|
|
1318
|
-
argv: Array.isArray(input.argv) ? input.argv : process.argv.slice(1),
|
|
1319
|
-
pid: input.pid || process.pid,
|
|
1320
|
-
cwd: input.cwd || process.cwd(),
|
|
1321
|
-
delayMs: RESTART_DELAY_MS,
|
|
1322
|
-
}
|
|
1591
|
+
if (invocation) return { mode: 'manual' }
|
|
1592
|
+
return reexecPlan(input)
|
|
1323
1593
|
}
|
|
1324
1594
|
|
|
1325
|
-
|
|
1326
|
-
* @param {{ plan?: ReturnType<typeof planPanelRestart>, spawnImpl?: typeof spawn, env?: NodeJS.ProcessEnv, platform?: string, cgroup?: string, execPath?: string, argv?: string[], pid?: number, cwd?: string }} input
|
|
1327
|
-
*/
|
|
1328
|
-
export async function schedulePanelRestart(input = {}) {
|
|
1329
|
-
const plan = input.plan || planPanelRestart(input)
|
|
1330
|
-
if (plan.mode === 'manual') return { ok: false, mode: 'manual', error: restartError() }
|
|
1595
|
+
async function spawnReexec(plan, input) {
|
|
1331
1596
|
const spawnImpl = input.spawnImpl || spawn
|
|
1332
|
-
if (plan.mode === 'systemd') {
|
|
1333
|
-
const ran = await runChild(spawnImpl, plan.command, plan.args, { timeoutMs: 20000, stdio: 'ignore' })
|
|
1334
|
-
if (!ran.ok) return { ok: false, mode: 'systemd', error: restartError() }
|
|
1335
|
-
return { ok: true, mode: 'systemd' }
|
|
1336
|
-
}
|
|
1337
1597
|
const payload = JSON.stringify({
|
|
1338
1598
|
pid: plan.pid,
|
|
1339
1599
|
cwd: plan.cwd,
|
|
@@ -1363,11 +1623,208 @@ export async function schedulePanelRestart(input = {}) {
|
|
|
1363
1623
|
return { ok: true, mode: 'reexec' }
|
|
1364
1624
|
}
|
|
1365
1625
|
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1626
|
+
/**
|
|
1627
|
+
* 先按计划重启。systemd-run 失败,或认不出单独服务时,改为重启当前进程,不重启整个登录。
|
|
1628
|
+
* 两条都做不到才返回失败。
|
|
1629
|
+
* @param {{ plan?: ReturnType<typeof planPanelRestart>, spawnImpl?: typeof spawn, env?: NodeJS.ProcessEnv, platform?: string, cgroup?: string, readCgroup?: () => string, execPath?: string, argv?: string[], pid?: number, cwd?: string }} input
|
|
1630
|
+
*/
|
|
1631
|
+
export async function schedulePanelRestart(input = {}) {
|
|
1632
|
+
const plan = input.plan || planPanelRestart(input)
|
|
1633
|
+
const spawnImpl = input.spawnImpl || spawn
|
|
1634
|
+
if (plan.mode === 'systemd') {
|
|
1635
|
+
const ran = await runChild(spawnImpl, plan.command, plan.args, { timeoutMs: 20000, stdio: 'ignore' })
|
|
1636
|
+
if (ran.ok) return { ok: true, mode: 'systemd', unit: plan.unit, scope: plan.scope }
|
|
1637
|
+
const fallback = await spawnReexec(reexecPlan(input), { ...input, spawnImpl })
|
|
1638
|
+
if (fallback.ok) return fallback
|
|
1639
|
+
return { ok: false, mode: 'manual', error: restartError() }
|
|
1640
|
+
}
|
|
1641
|
+
if (plan.mode === 'manual') {
|
|
1642
|
+
const fallback = await spawnReexec(reexecPlan(input), { ...input, spawnImpl })
|
|
1643
|
+
if (fallback.ok) return fallback
|
|
1644
|
+
return { ok: false, mode: 'manual', error: restartError() }
|
|
1645
|
+
}
|
|
1646
|
+
return spawnReexec(plan, { ...input, spawnImpl })
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
const CORDIS_ID = 'qq-bridge'
|
|
1650
|
+
|
|
1651
|
+
function isWritablePackageName(name) {
|
|
1652
|
+
return name === PACKAGE_NAME || name === LEGACY_UNSCOPED_NAME || name === 'dsh-qq-bridge'
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
/** profile 目录。profile 名只接受 dsh 自己的那一档,避免把 `..` 拼进路径。 */
|
|
1656
|
+
export function profileDirectory(home, profile = 'web') {
|
|
1657
|
+
const root = String(home ?? '')
|
|
1658
|
+
if (root === '' || root.includes('\0')) return ''
|
|
1659
|
+
const name = String(profile || 'web')
|
|
1660
|
+
if (!/^[a-z][a-z0-9_-]{0,31}$/.test(name)) return ''
|
|
1661
|
+
return join(root, 'profiles', name)
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
function profileDirOf(input) {
|
|
1665
|
+
if (input && typeof input.profileDir === 'string' && input.profileDir !== '') return input.profileDir
|
|
1666
|
+
if (input && typeof input.home === 'string' && input.home !== '') return profileDirectory(input.home, input.profile || 'web')
|
|
1667
|
+
return ''
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
function bundlePatchPath(dir, manifest) {
|
|
1671
|
+
const raw = manifest?.dsh?.bundle?.patch
|
|
1672
|
+
const rel = typeof raw === 'string' && raw !== '' ? raw : 'cordis.patch.yml'
|
|
1673
|
+
if (isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) return ''
|
|
1674
|
+
return join(dir, rel)
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
function readInstalledManifest(dir) {
|
|
1678
|
+
let manifest
|
|
1679
|
+
try {
|
|
1680
|
+
manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
|
|
1681
|
+
} catch {
|
|
1682
|
+
return null
|
|
1683
|
+
}
|
|
1684
|
+
if (!manifest || typeof manifest !== 'object' || !isWritablePackageName(manifest.name)) return null
|
|
1685
|
+
return {
|
|
1686
|
+
dir,
|
|
1687
|
+
name: manifest.name,
|
|
1688
|
+
version: String(manifest.version ?? ''),
|
|
1689
|
+
patchFile: bundlePatchPath(dir, manifest),
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
/**
|
|
1694
|
+
* 只改 `qq-bridge` 这一行的 `name`。config 和别的插件保持原样,包括注释和空行。
|
|
1695
|
+
* 只认我们自己的三个包名;别人写的路径或别的包不动。
|
|
1696
|
+
* @param {string} text
|
|
1697
|
+
* @param {string} packageName
|
|
1698
|
+
* @param {string} [pluginId]
|
|
1699
|
+
*/
|
|
1700
|
+
export function rewriteCordisPackageName(text, packageName, pluginId = CORDIS_ID) {
|
|
1701
|
+
const source = String(text ?? '')
|
|
1702
|
+
if (!isWritablePackageName(packageName) || !/^[A-Za-z0-9_-]+$/.test(pluginId)) return { text: source, changed: false }
|
|
1703
|
+
const newline = source.includes('\r\n') ? '\r\n' : '\n'
|
|
1704
|
+
const endsWithNewline = /\n$/.test(source)
|
|
1705
|
+
const lines = source.split(/\r?\n/)
|
|
1706
|
+
if (endsWithNewline && lines[lines.length - 1] === '') lines.pop()
|
|
1707
|
+
let changed = false
|
|
1708
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1709
|
+
const idMatch = lines[i].match(/^(\s*)(?:-\s+)?id:\s*(['"]?)([A-Za-z0-9_-]+)\2\s*(#.*)?$/)
|
|
1710
|
+
if (!idMatch || idMatch[3] !== pluginId) continue
|
|
1711
|
+
const entryIndent = idMatch[1].length
|
|
1712
|
+
let keyIndent = -1
|
|
1713
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
1714
|
+
const line = lines[j]
|
|
1715
|
+
if (line.includes('\t')) break
|
|
1716
|
+
if (line.trim() === '' || /^\s*#/.test(line)) continue
|
|
1717
|
+
const indent = line.match(/^ */)?.[0].length ?? 0
|
|
1718
|
+
if (indent <= entryIndent) break
|
|
1719
|
+
if (keyIndent < 0) keyIndent = indent
|
|
1720
|
+
if (indent !== keyIndent) continue
|
|
1721
|
+
if (!/^\s*name:/.test(line)) continue
|
|
1722
|
+
const nameMatch = line.match(/^(\s*)name:\s*(['"]?)([^'"\s#]+)\2\s*(#.*)?$/)
|
|
1723
|
+
if (!nameMatch) break
|
|
1724
|
+
const current = nameMatch[3]
|
|
1725
|
+
if (isWritablePackageName(current) && current !== packageName) {
|
|
1726
|
+
const comment = nameMatch[4] ? ` ${nameMatch[4]}` : ''
|
|
1727
|
+
lines[j] = `${nameMatch[1]}name: '${packageName}'${comment}`
|
|
1728
|
+
changed = true
|
|
1729
|
+
}
|
|
1730
|
+
break
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
if (!changed) return { text: source, changed: false }
|
|
1734
|
+
let next = lines.join(newline)
|
|
1735
|
+
if (endsWithNewline) next += newline
|
|
1736
|
+
return { text: next, changed: true }
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
function writeTextAtomic(file, text) {
|
|
1740
|
+
const tmp = `${file}.tmp-${process.pid}`
|
|
1741
|
+
writeFileSync(tmp, text)
|
|
1742
|
+
try {
|
|
1743
|
+
chmodSync(tmp, statSync(file).mode)
|
|
1744
|
+
} catch {
|
|
1745
|
+
// 读不到原权限就按这次写入的默认权限留下。
|
|
1746
|
+
}
|
|
1747
|
+
try {
|
|
1748
|
+
renameSync(tmp, file)
|
|
1749
|
+
} catch (error) {
|
|
1750
|
+
try { rmSync(tmp, { force: true }) } catch { /* 临时文件清不掉时,仍把写入失败抛回去 */ }
|
|
1751
|
+
throw error
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
/**
|
|
1756
|
+
* 把 profile、home(有这份文件才改)和已安装插件自己的补丁里,
|
|
1757
|
+
* `qq-bridge` 的 `name` 改成磁盘上 package.json 的包名。
|
|
1758
|
+
* 传入 expectedVersion 时,只有版本对得上才改,避免把半截安装写成错误的包名。
|
|
1759
|
+
* @param {{ profileDir?: string, home?: string, expectedVersion?: string }} input
|
|
1760
|
+
*/
|
|
1761
|
+
export function alignCordisNames(input = {}) {
|
|
1762
|
+
const profileDir = String(input.profileDir ?? '')
|
|
1763
|
+
if (profileDir === '') return { ok: false, reason: 'no-profile', changed: [], name: '' }
|
|
1764
|
+
const scoped = readInstalledManifest(join(profileDir, 'node_modules', '@fengye_nobug', 'dsh-qq-bot'))
|
|
1765
|
+
const legacy = readInstalledManifest(join(profileDir, 'node_modules', 'dsh-qq-bot'))
|
|
1766
|
+
const candidates = [scoped, legacy].filter(Boolean)
|
|
1767
|
+
let installed = null
|
|
1768
|
+
if (input.expectedVersion) {
|
|
1769
|
+
installed = candidates.find((item) => compareSemver(item.version, input.expectedVersion) === 0) || null
|
|
1770
|
+
if (!installed) return { ok: false, reason: 'version', changed: [], name: '' }
|
|
1771
|
+
} else {
|
|
1772
|
+
installed = scoped || legacy
|
|
1773
|
+
}
|
|
1774
|
+
if (!installed) return { ok: false, reason: 'missing', changed: [], name: '' }
|
|
1775
|
+
const files = []
|
|
1776
|
+
if (installed.patchFile) files.push(installed.patchFile)
|
|
1777
|
+
files.push(join(profileDir, 'cordis.patch.yml'))
|
|
1778
|
+
if (typeof input.home === 'string' && input.home !== '') files.push(join(input.home, 'cordis.patch.yml'))
|
|
1779
|
+
const changed = []
|
|
1780
|
+
let failed = false
|
|
1781
|
+
for (const file of files) {
|
|
1782
|
+
let text
|
|
1783
|
+
try {
|
|
1784
|
+
text = readFileSync(file, 'utf8')
|
|
1785
|
+
} catch {
|
|
1786
|
+
continue
|
|
1787
|
+
}
|
|
1788
|
+
const next = rewriteCordisPackageName(text, installed.name)
|
|
1789
|
+
if (!next.changed) continue
|
|
1790
|
+
try {
|
|
1791
|
+
writeTextAtomic(file, next.text)
|
|
1792
|
+
changed.push(file)
|
|
1793
|
+
} catch {
|
|
1794
|
+
failed = true
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
return {
|
|
1798
|
+
ok: !failed,
|
|
1799
|
+
failed,
|
|
1800
|
+
reason: failed ? 'write' : 'ok',
|
|
1801
|
+
changed,
|
|
1802
|
+
name: installed.name,
|
|
1803
|
+
version: installed.version,
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
function alignNote(input, expectedVersion) {
|
|
1808
|
+
const profileDir = profileDirOf(input)
|
|
1809
|
+
if (profileDir === '') return ''
|
|
1810
|
+
try {
|
|
1811
|
+
const result = alignCordisNames({
|
|
1812
|
+
profileDir,
|
|
1813
|
+
home: typeof input?.home === 'string' ? input.home : '',
|
|
1814
|
+
expectedVersion,
|
|
1815
|
+
})
|
|
1816
|
+
if (result.failed) return '没能改 Cordis 的包名。若重启后设置页打不开,把 qq-bridge 的 name 改成实际安装的包名再重启。'
|
|
1817
|
+
if (result.changed.length > 0) return `Cordis 里 qq-bridge 的包名已经改成实际安装的 ${result.name}。`
|
|
1818
|
+
return ''
|
|
1819
|
+
} catch {
|
|
1820
|
+
return '没能改 Cordis 的包名。若重启后设置页打不开,把 qq-bridge 的 name 改成实际安装的包名再重启。'
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
function successMessage(tag, channel, note = '') {
|
|
1825
|
+
const via = channel === 'npm' ? 'npm' : 'GitHub'
|
|
1826
|
+
const base = `已经从 ${via} 装上 ${tag},面板即将重启。`
|
|
1827
|
+
return note ? `${base}${note}` : base
|
|
1371
1828
|
}
|
|
1372
1829
|
|
|
1373
1830
|
function shownInstall(channel, version, repo) {
|
|
@@ -1375,6 +1832,59 @@ function shownInstall(channel, version, repo) {
|
|
|
1375
1832
|
return { install: installSpec(version, repo), command: installCommand(version, repo) }
|
|
1376
1833
|
}
|
|
1377
1834
|
|
|
1835
|
+
function installRunSpec(input, extra) {
|
|
1836
|
+
return {
|
|
1837
|
+
...extra,
|
|
1838
|
+
platform: input.platform,
|
|
1839
|
+
profile: input.profile || 'web',
|
|
1840
|
+
profileDir: profileDirOf(input),
|
|
1841
|
+
spawnImpl: input.spawnImpl,
|
|
1842
|
+
timeoutMs: input.timeoutMs,
|
|
1843
|
+
killGraceMs: input.killGraceMs,
|
|
1844
|
+
kill: input.kill,
|
|
1845
|
+
alive: input.alive,
|
|
1846
|
+
taskkill: input.taskkill,
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
/** 磁盘上的已安装包是不是这次要装的版本。只读 package.json,不改文件。 */
|
|
1851
|
+
function installedMatches(input, version) {
|
|
1852
|
+
const profileDir = profileDirOf(input)
|
|
1853
|
+
if (profileDir === '' || !version) return false
|
|
1854
|
+
const scoped = readInstalledManifest(join(profileDir, 'node_modules', '@fengye_nobug', 'dsh-qq-bot'))
|
|
1855
|
+
const legacy = readInstalledManifest(join(profileDir, 'node_modules', 'dsh-qq-bot'))
|
|
1856
|
+
return [scoped, legacy].some((item) => item && compareSemver(item.version, version) === 0)
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
/**
|
|
1860
|
+
* 安装命令没返回成功时:进程已经停干净,并且磁盘上已经是目标版本,就改包名并安排重启。
|
|
1861
|
+
* 进程还在,或版本对不上,不重启。
|
|
1862
|
+
*/
|
|
1863
|
+
async function failInstall(input, decision, ran, command, install, channel) {
|
|
1864
|
+
const code = ran?.code
|
|
1865
|
+
const leftover = ran?.leftover === true
|
|
1866
|
+
const via = channel === 'npm' || channel === 'github' ? channel : decision.channel
|
|
1867
|
+
if (!leftover && (code === 'timeout' || code === 'exit' || code === 'error') && installedMatches(input, decision.version)) {
|
|
1868
|
+
return finishApply(
|
|
1869
|
+
input,
|
|
1870
|
+
{ ...decision, command: command || decision.command, install: install || decision.install },
|
|
1871
|
+
via === 'npm' ? 'npm' : 'github',
|
|
1872
|
+
)
|
|
1873
|
+
}
|
|
1874
|
+
const message = installError(code, command, { leftover, pid: ran?.pid })
|
|
1875
|
+
return {
|
|
1876
|
+
ok: false,
|
|
1877
|
+
stage: 'install',
|
|
1878
|
+
current: decision.current,
|
|
1879
|
+
error: message,
|
|
1880
|
+
message,
|
|
1881
|
+
tag: decision.tag,
|
|
1882
|
+
version: decision.version,
|
|
1883
|
+
command,
|
|
1884
|
+
install: install || decision.install,
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1378
1888
|
/**
|
|
1379
1889
|
* 下载并安装一次检查允许的版本,然后安排重启。
|
|
1380
1890
|
* 调用方要自己做登录校验。这里不读设置文件,也不碰 NapCat。
|
|
@@ -1405,36 +1915,26 @@ export async function applyPluginUpdate(input = {}) {
|
|
|
1405
1915
|
updateAvailable: false,
|
|
1406
1916
|
}
|
|
1407
1917
|
}
|
|
1918
|
+
const profileDir = profileDirOf(input)
|
|
1919
|
+
if (profileDir !== '') {
|
|
1920
|
+
try {
|
|
1921
|
+
const stale = clearStaleRunRecord(profileDir, { alive: input.alive })
|
|
1922
|
+
if (stale.alive) return failInstall(input, decision, { ok: false, code: 'busy', pid: stale.pid }, decision.command, decision.install)
|
|
1923
|
+
} catch {
|
|
1924
|
+
// 记录读不出来就继续,安装命令自己会再看一遍。
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1408
1927
|
let channel = decision.channel
|
|
1409
1928
|
if (channel === 'npm') {
|
|
1410
1929
|
let ran
|
|
1411
1930
|
try {
|
|
1412
1931
|
const run = input.runCommand || defaultRunCommand
|
|
1413
|
-
ran = await run({
|
|
1414
|
-
packageSpec: decision.install,
|
|
1415
|
-
platform: input.platform,
|
|
1416
|
-
profile: 'web',
|
|
1417
|
-
spawnImpl: input.spawnImpl,
|
|
1418
|
-
timeoutMs: input.timeoutMs,
|
|
1419
|
-
})
|
|
1932
|
+
ran = await run(installRunSpec(input, { packageSpec: decision.install }))
|
|
1420
1933
|
} catch {
|
|
1421
1934
|
ran = { ok: false, code: 'error' }
|
|
1422
1935
|
}
|
|
1423
1936
|
if (ran?.ok === true) return finishApply(input, decision, 'npm')
|
|
1424
|
-
if (ran?.code !== 'unpublished')
|
|
1425
|
-
const message = installError(ran?.code, decision.command)
|
|
1426
|
-
return {
|
|
1427
|
-
ok: false,
|
|
1428
|
-
stage: 'install',
|
|
1429
|
-
current: decision.current,
|
|
1430
|
-
error: message,
|
|
1431
|
-
message,
|
|
1432
|
-
tag: decision.tag,
|
|
1433
|
-
version: decision.version,
|
|
1434
|
-
command: decision.command,
|
|
1435
|
-
install: decision.install,
|
|
1436
|
-
}
|
|
1437
|
-
}
|
|
1937
|
+
if (ran?.code !== 'unpublished') return failInstall(input, decision, ran, decision.command, decision.install, 'npm')
|
|
1438
1938
|
channel = 'github'
|
|
1439
1939
|
}
|
|
1440
1940
|
const shown = shownInstall(channel, decision.version, repo)
|
|
@@ -1469,35 +1969,39 @@ export async function applyPluginUpdate(input = {}) {
|
|
|
1469
1969
|
try {
|
|
1470
1970
|
writeFileSync(tarballPath, packed)
|
|
1471
1971
|
const run = input.runCommand || defaultRunCommand
|
|
1472
|
-
ran = await run({
|
|
1473
|
-
tarballPath,
|
|
1474
|
-
platform: input.platform,
|
|
1475
|
-
profile: 'web',
|
|
1476
|
-
spawnImpl: input.spawnImpl,
|
|
1477
|
-
timeoutMs: input.timeoutMs,
|
|
1478
|
-
})
|
|
1972
|
+
ran = await run(installRunSpec(input, { tarballPath }))
|
|
1479
1973
|
} catch {
|
|
1480
1974
|
ran = { ok: false, code: 'error' }
|
|
1481
1975
|
} finally {
|
|
1482
1976
|
rmSync(dir, { recursive: true, force: true })
|
|
1483
1977
|
}
|
|
1484
1978
|
if (!ran || ran.ok !== true) {
|
|
1485
|
-
|
|
1486
|
-
return { ok: false, stage: 'install', current: decision.current, error: message, message, tag: decision.tag, version: decision.version, command: shown.command, install: shown.install }
|
|
1979
|
+
return failInstall(input, { ...decision, install: shown.install, command: shown.command }, ran, shown.command, shown.install, 'github')
|
|
1487
1980
|
}
|
|
1488
1981
|
return finishApply(input, { ...decision, install: shown.install, command: shown.command }, 'github')
|
|
1489
1982
|
}
|
|
1490
1983
|
|
|
1491
1984
|
async function finishApply(input, decision, channel) {
|
|
1985
|
+
const note = alignNote(input)
|
|
1492
1986
|
let restart
|
|
1493
1987
|
try {
|
|
1494
1988
|
const schedule = input.scheduleRestart || schedulePanelRestart
|
|
1495
|
-
restart = await schedule({
|
|
1989
|
+
restart = await schedule({
|
|
1990
|
+
spawnImpl: input.spawnImpl,
|
|
1991
|
+
platform: input.platform,
|
|
1992
|
+
env: input.env,
|
|
1993
|
+
cgroup: input.cgroup,
|
|
1994
|
+
readCgroup: input.readCgroup,
|
|
1995
|
+
execPath: input.execPath,
|
|
1996
|
+
argv: input.argv,
|
|
1997
|
+
pid: input.pid,
|
|
1998
|
+
cwd: input.cwd,
|
|
1999
|
+
})
|
|
1496
2000
|
} catch {
|
|
1497
2001
|
restart = { ok: false, error: restartError() }
|
|
1498
2002
|
}
|
|
1499
2003
|
if (!restart || restart.ok !== true) {
|
|
1500
|
-
const message =
|
|
2004
|
+
const message = `${restartError(channel)}${note}`
|
|
1501
2005
|
return {
|
|
1502
2006
|
ok: false,
|
|
1503
2007
|
stage: 'restart',
|
|
@@ -1512,7 +2016,7 @@ async function finishApply(input, decision, channel) {
|
|
|
1512
2016
|
message,
|
|
1513
2017
|
}
|
|
1514
2018
|
}
|
|
1515
|
-
const message = successMessage(decision.tag, channel)
|
|
2019
|
+
const message = successMessage(decision.tag, channel, note)
|
|
1516
2020
|
return {
|
|
1517
2021
|
ok: true,
|
|
1518
2022
|
applied: true,
|