@kira905/dsh-butler-install 0.2.1 → 0.3.0
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 +120 -19
- package/bin/install-ui.mjs +133 -0
- package/install-ui.cmd +61 -0
- package/lib/cli-args.mjs +12 -6
- package/lib/dump.mjs +52 -4
- package/lib/exec.mjs +92 -13
- package/lib/explain-failure.mjs +117 -0
- package/lib/item-status.mjs +64 -0
- package/lib/run.mjs +107 -15
- package/lib/ui-install.mjs +541 -0
- package/lib/ui-page.mjs +514 -83
- package/lib/ui-probe.mjs +104 -0
- package/lib/ui-rollback.mjs +147 -0
- package/lib/ui-server.mjs +287 -35
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -30,33 +30,43 @@ node <dsh-butler-core 仓根>/selfcheck-replica.mjs --manifest suite-manifest.js
|
|
|
30
30
|
> (只跑它内置的那几项判据)。本仓的 `suite-manifest.json` 的字段是**为它将来接清单准备的**
|
|
31
31
|
> —— 字段含义见下文「清单字段」一节。别把它当成"已经能按清单跑了"。
|
|
32
32
|
|
|
33
|
-
## 想要个界面?`--ui`:挂在 DSH
|
|
33
|
+
## 想要个界面?`--ui`:挂在 DSH 外面的本地向导页(**能在页面上点着装**)
|
|
34
34
|
|
|
35
|
-
觉得命令行清单不好对?加 `--ui`
|
|
36
|
-
|
|
35
|
+
觉得命令行清单不好对?加 `--ui` 起一个本地网页:每一行写清**装没装 / 什么版本 / 期望区间**,
|
|
36
|
+
而且**能在页面上点着装** —— 点了才装,不点不装。
|
|
37
37
|
|
|
38
38
|
```powershell
|
|
39
|
-
#
|
|
39
|
+
# 起向导页(起来的时候什么都不装;点了【确认,开始】才装)
|
|
40
40
|
npx @kira905/dsh-butler-install --ui
|
|
41
41
|
|
|
42
42
|
# 换端口(默认 45080;被占会自动往后挑一个并打印实际地址)
|
|
43
43
|
npx @kira905/dsh-butler-install --ui --port 45090
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
打开它打印的地址(形如 `http://127.0.0.1:45080
|
|
46
|
+
打开它打印的地址(形如 `http://127.0.0.1:45080/`)就能看到清单表。页面上的动作:
|
|
47
47
|
|
|
48
48
|
| 按钮 | 干什么 | 不干什么 |
|
|
49
49
|
|---|---|---|
|
|
50
|
-
|
|
|
50
|
+
| **开始安装** | 算一遍"将对哪个 profile 做哪几件事",把 16 项、profile 名、回滚点位置逐条念给你看 | **那一步一条命令都不跑、一个文件都不写** |
|
|
51
|
+
| **确认,开始** | 真装。逐项显示 待装 / 安装中 / 成功 / 跳过 / 失败 | 不替你重启 DSH(装完要你自己重启,见下) |
|
|
52
|
+
| **取消** | 杀掉当前那条命令的进程树 | **不是干净撤回**:已经装好的项会留着;页面会明说"已取消,装了一半" |
|
|
53
|
+
| **卸载这次装的**(推荐) | 把这一轮装上去的真的卸掉:插件从 profile 摘掉、全局命令行包从全局前缀删掉 ⇒ 重查会真的变回"未装" | 不覆盖任何文件,所以你随后在侧边栏另装的插件不受影响 |
|
|
54
|
+
| **只恢复配置文件到装前** | 只把这些声明层还到开跑前那一份 | `node_modules` 实体与全局 CLI 包**还在**,所以重开仍显示"已装";期间有别的程序改过这些文件就**拒动** |
|
|
55
|
+
| **复制命令** | 把这一行放进剪贴板 —— 与 `--dry-run` 打印的是**同一行**(同一个函数产出) | 不替你执行 |
|
|
51
56
|
| **重查** | 重跑一遍探测、把最新读数写回同一张表 | 不改任何状态、不安装、不写文件 |
|
|
52
57
|
|
|
58
|
+
> 🔴 **装完第 ⑥ 步:要重启 DSH,插件才会出现在侧边栏。** 宿主只在启动时读一次组合列表,
|
|
59
|
+
> 不重启侧边栏不会多出任何东西 —— 这是最容易让人以为"装了没用、被骗了"的地方。
|
|
60
|
+
> 顺手那行「复制命令」给的是**单项**原语命令;要整批自己跑,用页面上那条
|
|
61
|
+
> **带 `--apply` 的整批命令**(不带 `--apply` 的那条只是演练,跑完什么都不装)。
|
|
62
|
+
|
|
53
63
|
### 为什么挂在 DSH 外面,而不是做成插件侧边栏页
|
|
54
64
|
|
|
55
65
|
这是设计前提,不是偷懒:
|
|
56
66
|
|
|
57
67
|
| | 插件侧边栏页(最初的想法) | 宿主外的本地页面(现在这样) |
|
|
58
68
|
|---|---|---|
|
|
59
|
-
| 要装插件 / 重启 DSH | 要(`dsh.bundle` + `cordis.patch.yml` + 重启) | **不要** |
|
|
69
|
+
| 要装插件 / 重启 DSH 才能**打开这个向导页** | 要(`dsh.bundle` + `cordis.patch.yml` + 重启) | **不要** |
|
|
60
70
|
| DSH 升级会不会崩 | 会(往页面里注入 client DOM,宿主一改就可能失效) | **不会**(它根本不进宿主) |
|
|
61
71
|
| 装 DSH 之前能不能用 | 不能 | **能** —— 还没装宿主时,向导正好可以告诉你"你得先装 DSH" |
|
|
62
72
|
| 成本 | 约 8h | **约 4h** |
|
|
@@ -65,10 +75,18 @@ npx @kira905/dsh-butler-install --ui --port 45090
|
|
|
65
75
|
### 安全边界(写死的,不是可选项)
|
|
66
76
|
|
|
67
77
|
- 🔴 **只绑 `127.0.0.1`**:同一局域网的别的机器连不上。没有 `0.0.0.0` 开关,也不读环境变量改它。
|
|
68
|
-
- 🔴
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
78
|
+
- 🔴 **写入口只有四条**,全是 `POST`:`/install/preview`(算一遍,不装)、`/install/start`(真装,必须带 `confirm:true`)、
|
|
79
|
+
`/install/cancel`、`/install/rollback`(撤回的两个动作)。除这四条之外的任何写方法一律 **405**,未知路径 404;
|
|
80
|
+
`Allow` 响应头按路径给(写路径 `POST`、读路径 `GET`),不再是一刀切的 `Allow: GET`。
|
|
81
|
+
- 🔴 **每个写入口都要过两道闸**:**安装令牌**(进程级,启动时生成一次、印在控制台、也内嵌在页面 meta 里;
|
|
82
|
+
只从页面读、绝不拼进 URL —— 拼进 URL 就落进历史记录与代理日志)**+ 同源校验**
|
|
83
|
+
(`Origin` 的 `host:port` 逐字等于 `server.address()` 的 `address:port`;端口是动态的,`--port 0` 也照样对)。
|
|
84
|
+
`Sec-Fetch-Site` **存在才校验**:Chrome 76+ / Safari 16.4+ 才有这个头,双击入口打开的是系统默认浏览器
|
|
85
|
+
(可能是 360 兼容模式),硬要求它等于给所有按钮静默 403。
|
|
86
|
+
- 🔴 **这条要如实说**:它**防不住本机、同一个 Windows 用户下的其他程序** —— 那些程序读得到这个页面、
|
|
87
|
+
也调得到这些接口。它是一个"给本机同一个人用的安装器",不是多用户隔离的服务,别当成后者。
|
|
88
|
+
- 双击两次 `.cmd` 不会起两个服务:新入口先探 `127.0.0.1:45080`,认(`GET /state` 里的 `service` 标记)
|
|
89
|
+
是自家服务就把浏览器指过去 ⇒ 两个标签看同一个服务,天然单实例。
|
|
72
90
|
- 端口被占:自动往后挑一个(最多 20 个),都不行就让系统分配,并**打印实际地址**。
|
|
73
91
|
|
|
74
92
|
### 「按顺序装」是什么意思
|
|
@@ -80,6 +98,59 @@ npx @kira905/dsh-butler-install --ui --port 45090
|
|
|
80
98
|
> 看到"必须按 1→2→3 才能成功"这种话,那是假的仪式。
|
|
81
99
|
|
|
82
100
|
|
|
101
|
+
## 不想碰命令?下载 exe 版(给完全不会命令行的人)
|
|
102
|
+
|
|
103
|
+
上面那套都要敲命令。**连 PowerShell 都不想开**的话,下载一个 exe 就行:
|
|
104
|
+
|
|
105
|
+
> **下载地址**:\[网盘链接 · 待填\](作者挂到网盘后填这里;文件名形如
|
|
106
|
+
> `dsh-butler-install-0.2.1-win-x64.exe`,约 89 MB)
|
|
107
|
+
|
|
108
|
+
用法就三步:
|
|
109
|
+
|
|
110
|
+
1. 下载 → **双击**。
|
|
111
|
+
2. 会弹出一个黑窗口,它先体检(`dsh` 在不在、`npm` 在不在),然后**自动打开浏览器** ——
|
|
112
|
+
浏览器里那个页面就是上面说的安装向导,点两下就装完了。
|
|
113
|
+
3. 装完直接关掉黑窗口(关窗口 = 关掉向导页;装到一半别关)。
|
|
114
|
+
|
|
115
|
+
### 🔴 第一次运行会被 Windows 拦一下(这里必须说清楚)
|
|
116
|
+
|
|
117
|
+
没有买代码签名证书(约 ¥1000+/年,暂时不买),所以 Windows SmartScreen 会弹:
|
|
118
|
+
|
|
119
|
+
> **Windows 已保护你的电脑**
|
|
120
|
+
|
|
121
|
+
十个人里有九个以为中病毒就关掉了。正确做法:
|
|
122
|
+
|
|
123
|
+
1. 点弹窗里的 **「更多信息」**;
|
|
124
|
+
2. 再点 **「仍要运行」**。
|
|
125
|
+
|
|
126
|
+
(这拦的是"没有数字签名的程序",不是"这个程序有毒"。看不放心就别装,用上面的 `npx` 命令版。)
|
|
127
|
+
|
|
128
|
+
### 这个 exe 的前提
|
|
129
|
+
|
|
130
|
+
- **这台电脑上已经装好了 DSH**(`dsh --version` 能打出版本号)。exe **不负责装 DSH**、也不装 Node;
|
|
131
|
+
缺了它会在黑窗口里用大字告诉你缺什么、怎么装,然后停住等你按任意键(不会一闪就没)。
|
|
132
|
+
- 只有 Windows x64 版。**自带 node 运行时**所以 89 MB —— 好处是你机器上 node 不在 PATH 也能起界面。
|
|
133
|
+
|
|
134
|
+
### exe 里到底跑了什么
|
|
135
|
+
|
|
136
|
+
它就是同一个安装向导:界面 HTML 内嵌在 exe 里、清单 `suite-manifest.json` 也内嵌在 exe 里,
|
|
137
|
+
所以**把 exe 单独拷到空目录也照样能起界面**(不读旁边任何文件)。命令行的行为也一模一样 —— 页面上
|
|
138
|
+
「复制命令」给你的,就是 CLI 真会跑的那一行。
|
|
139
|
+
|
|
140
|
+
### 自己造一个(仓里只留"怎么造",exe 不进仓)
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
npm install # esbuild + postject(devDependencies)
|
|
144
|
+
npm run build:exe # → dist/dsh-butler-install-<版本>-win-x64.exe
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
实现是三步(`scripts/build-exe.mjs`,改路线前先看每步的注释):esbuild 打单文件 CJS →
|
|
148
|
+
`node --experimental-sea-config` 生成 blob → 复制本机 `node.exe` + postject 注入。
|
|
149
|
+
`dist/` 在 `.gitignore` 里:exe 走网盘分发,进 git 只会把仓撑爆。
|
|
150
|
+
|
|
151
|
+
> 这一版**不改** npm 包的行为:`dsh-butler-install`、`--ui`、`--apply`、退出码、参数一个都没动。
|
|
152
|
+
> 会敲命令的人继续用上面那些;exe 只是同一套东西的"双击壳"。
|
|
153
|
+
|
|
83
154
|
## 它只做两件事,且只用两个原语
|
|
84
155
|
|
|
85
156
|
| 原语 | 用来装什么 |
|
|
@@ -92,7 +163,8 @@ npx @kira905/dsh-butler-install --ui --port 45090
|
|
|
92
163
|
- **不写** `dsh.profile.bundles` —— 那是宿主的活;本工具只调 `dsh plugin add`,让宿主自己登记。
|
|
93
164
|
- **不写** `cordis.patch.yml` —— 需要手工 insert 的包,本工具只告诉你"它要手工挂",不代劳。
|
|
94
165
|
- **不碰** `node_modules` —— 不自己下载、不解包、不建链接。代码里没有第二条装包路径。
|
|
95
|
-
-
|
|
166
|
+
- **不替你重启** DSH,不改别的仓,不帮你决定要不要装。
|
|
167
|
+
⚠️ 但**插件装完你确实要自己重启 DSH**,不然侧边栏不会出现它们 —— 这不是本工具偷懒,是宿主只在启动时读组合列表。
|
|
96
168
|
|
|
97
169
|
删掉本工具,一切照旧:它没有留下任何自己的运行时状态(只有回滚快照,见下)。
|
|
98
170
|
|
|
@@ -113,6 +185,31 @@ npx @kira905/dsh-butler-install --ui --port 45090
|
|
|
113
185
|
| `3` | 有步骤失败,但**已回滚干净** |
|
|
114
186
|
| `4` | 有步骤失败,且**回滚不干净**(要人看:报告里会指出哪份文件对不上、快照在哪) |
|
|
115
187
|
|
|
188
|
+
## 失败时那段英文是什么意思(人话归因)
|
|
189
|
+
|
|
190
|
+
失败时**原始报错仍然原样打印**(一个字都不改),人话归因**追加在它后面**:
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
❌ 安装命令退出码 1:…(原始 stderr 首行,原样)
|
|
194
|
+
💡 这不是包坏了:你的安全软件(360 / EDR)拦了 pnpm 往 store 里建符号链接。
|
|
195
|
+
…(为什么)
|
|
196
|
+
怎么办:① 在安全软件里给 pnpm / node 放行(或暂时退出) ② 换个不会被拦的位置做数据根(--home 指到别处) ③ 直接重试一次(这类拦截是间歇的)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
认得出的**环境障碍**(实现见 `lib/explain-failure.mjs`:纯函数,不跑命令、不读盘、不发网络):
|
|
200
|
+
|
|
201
|
+
| 归因 | 原始报错里长什么样 | 一句话 |
|
|
202
|
+
|---|---|---|
|
|
203
|
+
| `blocked-symlink` | `EPERM … symlink`(同一行)或栈里有 `registerProject` | 安全软件(360 / EDR)拦了 pnpm 建符号链接 —— **不是包坏了**;放行 / 换位置 / 重试 |
|
|
204
|
+
| `store-mismatch` | `ERR_PNPM_UNEXPECTED_STORE` | 这个 profile 的 `node_modules` 是用**另一处 pnpm store** 装的,两边对不上;用回原来那处 / 在那个 profile 重装 / 换干净数据根 |
|
|
205
|
+
| `unknown-pnpm` | 任何 `ERR_PNPM_<码>` | 确定是 pnpm 自己报的错,但这个码还没收录 ⇒ 把原码带走,去搜 / 贴给维护者 |
|
|
206
|
+
|
|
207
|
+
**认不出来就一个字都不加**(不猜)。归因只做展示:**不参与判据**,也不改退出码(`0/2/3/4` 口径不变)。
|
|
208
|
+
`--ui` 页面与 exe 走同一条路:页面进度里另有一条摘要行(`💡 …|怎么办:…`),失败项的详情里也带这一句。
|
|
209
|
+
|
|
210
|
+
退出码本身也写人话:Windows 上子进程被信号/异常结束时退出码是**无符号**的,
|
|
211
|
+
会看到 `退出码 4294963248(= -4048,0xFFFFF030)` —— 原始数值仍在最前面,括号里是等价写法。
|
|
212
|
+
|
|
116
213
|
## 回滚:两级,别只做一级
|
|
117
214
|
|
|
118
215
|
失败时默认**只退失败那一步**(前面已装好的几步保留,不用全重来):
|
|
@@ -190,7 +287,7 @@ npx -p @kira905/dsh-butler-install dsh-butler-install-probe-scan <某仓根或
|
|
|
190
287
|
| `--timeout` / `--budget` | 单步超时(默认 120s)/ 整批墙钟上限(默认 300s,超了就停下并回滚本批) |
|
|
191
288
|
| `--snapshot-dir <dir>` | 快照目录,默认 `<数据根>/butler-install/snapshots/<时间戳>` |
|
|
192
289
|
| `--json` | 只输出一份 JSON 汇总(人读的输出改走 stderr) |
|
|
193
|
-
| `--ui` |
|
|
290
|
+
| `--ui` | 起一个本地向导页,**能在页面上点着装**(点了才装、不点不装):只绑 `127.0.0.1`、写入口四条都得令牌 + 同源。装完要重启 DSH 才在侧边栏出现。见上文。用它时 `--profile` 可省(默认 `web`) |
|
|
194
291
|
| `--port <n>` | 向导页端口(只在 `--ui` 时有用),默认 `45080`;被占用会自动换一个并打印实际地址 |
|
|
195
292
|
|
|
196
293
|
## 它在 Windows 上怎么跑子进程
|
|
@@ -209,8 +306,11 @@ npx -p @kira905/dsh-butler-install dsh-butler-install-probe-scan <某仓根或
|
|
|
209
306
|
挂载判据记"跳过"。
|
|
210
307
|
- 不判"运行时会不会崩":`--dump-config` 只证明组合树里有这一行。真要验运行时,得自己起一次服务看日志。
|
|
211
308
|
- 不替你决定"要不要装某一件",也不做"装完自动起服务"。
|
|
212
|
-
- 🔴
|
|
213
|
-
|
|
309
|
+
- 🔴 向导页**能装,但它防不住本机同用户的其它程序**(见上文安全边界)。要更严的隔离,就别在本机跑第三方程序。
|
|
310
|
+
- 向导页的**取消不是干净撤回**:已装好的项留着,当前那一项可能只装了一半 —— 页面会直说,并给撤回入口。
|
|
311
|
+
- 向导页的**「只恢复配置文件到装前」只还声明层**:`node_modules` 实体与全局 CLI 包还在;期间有别的程序改过那几份文件就拒动。
|
|
312
|
+
- 向导页**不落自己的状态**:进度只在内存里。关掉窗口 = 这一批断了;重开页面按"现在装着什么"**现跑**给真实读数
|
|
313
|
+
(这比读一份可能过时的状态文件更准)。
|
|
214
314
|
|
|
215
315
|
## 夹具
|
|
216
316
|
|
|
@@ -222,10 +322,11 @@ npm test
|
|
|
222
322
|
不读也不写真实数据根。覆盖:清单校验(每条错误都带文件名 + 行号)· 版本区间三态 ·
|
|
223
323
|
探针三分支(含"标了 `help` 但该命令没实现 ⇒ 拒执行且不产生任何执行")· 组合树三条计数判据
|
|
224
324
|
(各自独立可触发)· 快照回放与核对 · 演练档零副作用(目录指纹必须一模一样)· 退出码 0/3/4 ·
|
|
225
|
-
步级与整批回滚的差别 · 卸载 · 向导页(监听地址只许 `127.0.0.1` /
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
325
|
+
步级与整批回滚的差别 · 卸载 · 向导页(监听地址只许 `127.0.0.1` / **写入口只有那四条、其余 405** /
|
|
326
|
+
令牌与同源两道闸 / 两步确认 / 进度轮询 / 撤回两条路与前置核对 / 页面数据与 `--dry-run` 同源 /
|
|
327
|
+
每请求零 spawn / 端口冲突自动换 / 不给 `--ui` 时输出逐字节不变)·
|
|
328
|
+
以及**变异测试(4 条)**:把监听地址改成 `0.0.0.0`、去掉令牌校验、把 `POST` 白名单放开到任意路径、
|
|
329
|
+
去掉同源校验 —— 每一条都必须让夹具**变红在指定判据上**(改坏了还绿 ⇒ 这条夹具是假的)。
|
|
229
330
|
|
|
230
331
|
### 清单漂移判据(`test/range-drift.mjs`)
|
|
231
332
|
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 双击入口(方案 §11.6 · B4:**第 0 步**)—— 先探、再起、然后自己开浏览器。
|
|
4
|
+
*
|
|
5
|
+
* 为什么"探测"能替代锁文件:
|
|
6
|
+
* 双击两次 `.cmd` = 两个进程。`listenLoopback()` 会很规矩地各自绑到 45080 / 45081,
|
|
7
|
+
* 于是两个页面都能点【开始安装】,同时对同一个 profile 跑 `dsh plugin add` + `npm i -g`。
|
|
8
|
+
* 与其落盘一把锁(还要处理陈旧锁、跨进程),不如先问一句"45080 上是不是已经有一个我们自己":
|
|
9
|
+
* 是 ⇒ **不再起第二个服务**,把浏览器指过去。双击两次 = 两个标签看同一个服务,天然单实例。
|
|
10
|
+
*
|
|
11
|
+
* 🔴 为什么不在核心 CLI 上加 `--open`:
|
|
12
|
+
* `lib/cli-args.mjs` 的纪律是"未知参数一律报错退出",加参数 = 改对外契约。
|
|
13
|
+
* 而且"谁开浏览器"是 shell 行为,不该塞进核心 CLI(`dsh-butler-install --ui` 仍是"起服务 + 打印地址")。
|
|
14
|
+
*
|
|
15
|
+
* 边界如实登记:用户显式 `--port <别的端口>` 时会探那个端口(不会去探 45080);
|
|
16
|
+
* `--port 0` 时无从探测 ⇒ 直接起一个新的(这就是"我想要第二个实例"的显式表达)。
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
import { parseArgs, HELP_TEXT, EXIT } from '../lib/cli-args.mjs';
|
|
23
|
+
import {
|
|
24
|
+
startUiServer,
|
|
25
|
+
uiBannerLines,
|
|
26
|
+
installExitHooks,
|
|
27
|
+
resolvePort,
|
|
28
|
+
} from '../lib/ui-server.mjs';
|
|
29
|
+
import { killAllRunningSync } from '../lib/exec.mjs';
|
|
30
|
+
// 探测与开浏览器住在 lib 层(2026-10-31 拆的):exe 入口要复用同一份实现,而它不能 import
|
|
31
|
+
// 本文件(本文件顶层有 `await runEntry()`,ESM 打不成单文件 CJS)。对外导出名与语义一个没变。
|
|
32
|
+
import { probeExistingUi, openBrowser } from '../lib/ui-probe.mjs';
|
|
33
|
+
|
|
34
|
+
export { probeExistingUi, openBrowser };
|
|
35
|
+
export { PROBE_TIMEOUT_MS } from '../lib/ui-probe.mjs';
|
|
36
|
+
|
|
37
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
|
|
39
|
+
function pkgVersion() {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8')).version;
|
|
42
|
+
} catch {
|
|
43
|
+
return 'unknown';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 入口本体(可注入:夹具要能在不起浏览器、也能不真起服务的前提下验"第二次只得到一个服务")。
|
|
49
|
+
*
|
|
50
|
+
* @param {object} [io]
|
|
51
|
+
* @param {string[]} [io.argv] 参数(缺省 process.argv.slice(2))
|
|
52
|
+
* @param {{write:(s:string)=>any}} [io.out]
|
|
53
|
+
* @param {(url:string) => boolean} [io.openBrowser]
|
|
54
|
+
* @param {Function} [io.startServer] 缺省 = lib/ui-server.mjs#startUiServer
|
|
55
|
+
* @param {boolean} [io.waitForShutdown] 缺省 true;夹具传 false(不然它会一直挂到 Ctrl+C)
|
|
56
|
+
* @returns {Promise<{exitCode:number, action:'help'|'version'|'error'|'reuse'|'start'|'closed', url?:string, port?:number}>}
|
|
57
|
+
*/
|
|
58
|
+
export async function runEntry({
|
|
59
|
+
argv = process.argv.slice(2),
|
|
60
|
+
out = process.stdout,
|
|
61
|
+
openBrowser: opener = openBrowser,
|
|
62
|
+
startServer = startUiServer,
|
|
63
|
+
waitForShutdown = true,
|
|
64
|
+
} = {}) {
|
|
65
|
+
// 本入口等价于 `--ui`:用户不用知道这个开关(但给了也不冲突)
|
|
66
|
+
const args = argv.includes('--ui') ? argv.slice() : ['--ui', ...argv];
|
|
67
|
+
const { options, errors } = parseArgs(args);
|
|
68
|
+
|
|
69
|
+
if (options.help) {
|
|
70
|
+
out.write(HELP_TEXT);
|
|
71
|
+
return { exitCode: EXIT.OK, action: 'help' };
|
|
72
|
+
}
|
|
73
|
+
if (options.version) {
|
|
74
|
+
out.write(`${pkgVersion()}\n`);
|
|
75
|
+
return { exitCode: EXIT.OK, action: 'version' };
|
|
76
|
+
}
|
|
77
|
+
if (errors.length) {
|
|
78
|
+
for (const e of errors) out.write(`✖ ${e}\n`);
|
|
79
|
+
out.write('用 --help 看可用参数。\n');
|
|
80
|
+
return { exitCode: EXIT.USAGE, action: 'error' };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const port = resolvePort(options.uiPort);
|
|
84
|
+
if (port > 0) {
|
|
85
|
+
const found = await probeExistingUi({ port });
|
|
86
|
+
if (found.ok) {
|
|
87
|
+
out.write(`已经有一个向导页在 ${found.url} 跑着了(profile=${found.profile || '?'})。\n`);
|
|
88
|
+
out.write(' 不再起第二个:两个标签看同一个服务,天然单实例(这样不会同时对同一个 profile 装两遍)。\n');
|
|
89
|
+
opener(found.url);
|
|
90
|
+
return { exitCode: EXIT.OK, action: 'reuse', url: found.url, port };
|
|
91
|
+
}
|
|
92
|
+
out.write(`端口 ${port} 上没有我们自己的向导页(${found.reason})⇒ 现在起一个。\n`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const started = await startServer({ options, port: options.uiPort });
|
|
96
|
+
if (!started.ok) {
|
|
97
|
+
for (const line of started.log) out.write(`${line}\n`);
|
|
98
|
+
return { exitCode: started.exitCode, action: 'error' };
|
|
99
|
+
}
|
|
100
|
+
if (started.portChanged) {
|
|
101
|
+
out.write(`⚠ 端口 ${started.requestedPort} 被占了,已改用 ${started.port}\n`);
|
|
102
|
+
}
|
|
103
|
+
for (const line of uiBannerLines(started)) out.write(`${line}\n`);
|
|
104
|
+
installExitHooks({ out });
|
|
105
|
+
opener(started.url);
|
|
106
|
+
if (!waitForShutdown) {
|
|
107
|
+
return { exitCode: EXIT.OK, action: 'start', url: started.url, port: started.port };
|
|
108
|
+
}
|
|
109
|
+
await new Promise((resolve) => {
|
|
110
|
+
process.once('SIGINT', () => resolve());
|
|
111
|
+
process.once('SIGTERM', () => resolve());
|
|
112
|
+
});
|
|
113
|
+
killAllRunningSync();
|
|
114
|
+
await started.close();
|
|
115
|
+
out.write('\n向导页已关。\n');
|
|
116
|
+
return { exitCode: EXIT.OK, action: 'closed', url: started.url, port: started.port };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 直接跑(不是被 import)时才算主程序 —— junction 下面 argv[1] 是逻辑路径、import.meta.url 是物理路径,两边都取 realpath 再比 */
|
|
120
|
+
function isMain() {
|
|
121
|
+
try {
|
|
122
|
+
const self = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
123
|
+
const arg = process.argv[1] ? fs.realpathSync(process.argv[1]) : '';
|
|
124
|
+
return Boolean(arg) && self === arg;
|
|
125
|
+
} catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (isMain()) {
|
|
131
|
+
const result = await runEntry();
|
|
132
|
+
process.exitCode = result.exitCode;
|
|
133
|
+
}
|
package/install-ui.cmd
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
@echo off
|
|
2
|
+
rem ============================================================================
|
|
3
|
+
rem DSH butler suite - double-click installer (web wizard)
|
|
4
|
+
rem ---------------------------------------------------------------------------
|
|
5
|
+
rem This file must stay PURE ASCII. A .cmd with CJK text gets parsed as GBK and
|
|
6
|
+
rem can break the command line. All Chinese copy lives on the web page.
|
|
7
|
+
rem
|
|
8
|
+
rem What it does (three things only):
|
|
9
|
+
rem 1) check node
|
|
10
|
+
rem 2) hand off to bin/install-ui.mjs (which probes for an already running
|
|
11
|
+
rem wizard, starts one if needed, and opens the browser by itself)
|
|
12
|
+
rem 3) keep the window open on error so the message can be read
|
|
13
|
+
rem
|
|
14
|
+
rem Why npx: the user only needs THIS file. No npm install, no clone.
|
|
15
|
+
rem ============================================================================
|
|
16
|
+
setlocal
|
|
17
|
+
title DSH butler suite - installer
|
|
18
|
+
echo.
|
|
19
|
+
echo ============================================
|
|
20
|
+
echo DSH butler suite - installer
|
|
21
|
+
echo ============================================
|
|
22
|
+
echo.
|
|
23
|
+
|
|
24
|
+
where node >nul 2>&1
|
|
25
|
+
if errorlevel 1 goto nonode
|
|
26
|
+
|
|
27
|
+
echo [1/2] node found. Checking the installer page...
|
|
28
|
+
echo [2/2] The browser opens by itself when the page is ready.
|
|
29
|
+
echo Keep this window open while installing (Ctrl+C stops it).
|
|
30
|
+
echo.
|
|
31
|
+
|
|
32
|
+
npx -y -p "@kira905/dsh-butler-install@^0.3.0" dsh-butler-install-ui %*
|
|
33
|
+
if errorlevel 1 goto failed
|
|
34
|
+
goto done
|
|
35
|
+
|
|
36
|
+
:nonode
|
|
37
|
+
echo [X] Node.js was not found.
|
|
38
|
+
echo.
|
|
39
|
+
echo The installer needs Node.js 22 or newer.
|
|
40
|
+
echo Download: https://nodejs.org/en/download
|
|
41
|
+
echo Install it, then double-click this file again.
|
|
42
|
+
echo.
|
|
43
|
+
pause
|
|
44
|
+
exit /b 1
|
|
45
|
+
|
|
46
|
+
:failed
|
|
47
|
+
echo.
|
|
48
|
+
echo [X] The installer stopped with an error (exit code %ERRORLEVEL%).
|
|
49
|
+
echo Read the messages above - they say which package or which port failed.
|
|
50
|
+
echo.
|
|
51
|
+
pause
|
|
52
|
+
exit /b 1
|
|
53
|
+
|
|
54
|
+
:done
|
|
55
|
+
echo.
|
|
56
|
+
echo The wizard is closed. If it was still installing, the log above shows
|
|
57
|
+
echo where it stopped. Nothing was changed silently.
|
|
58
|
+
echo.
|
|
59
|
+
pause
|
|
60
|
+
endlocal
|
|
61
|
+
exit /b 0
|
package/lib/cli-args.mjs
CHANGED
|
@@ -30,8 +30,12 @@ export const HELP_TEXT = `dsh-butler-install —— 大管家全家桶薄壳安
|
|
|
30
30
|
--timeout <秒> 单步超时,默认 120
|
|
31
31
|
--budget <秒> 整批墙钟上限,默认 300;超了停下并回滚本批
|
|
32
32
|
--snapshot-dir <dir> 快照目录,默认 <数据根>/butler-install/snapshots/<时间戳>
|
|
33
|
-
--ui
|
|
34
|
-
|
|
33
|
+
--ui 起一个本地向导页(挂在 DSH 外面:不用装插件、不用重启 DSH 就能开)。
|
|
34
|
+
**页面能装**:点【开始安装】只把"要做什么"算一遍,再点【确认,开始】才真装;
|
|
35
|
+
**不点那个确认,一条命令都不会跑**。
|
|
36
|
+
安全边界(写死的):只绑 127.0.0.1;每个写入口都要安装令牌 + 同源校验。
|
|
37
|
+
🔴 它防不住本机同一个 Windows 用户下的其它程序(那些程序读得到页面、也调得到接口)。
|
|
38
|
+
⚠️ 插件装完要**重启 DSH** 才会出现在侧边栏 —— 本工具不替你重启。
|
|
35
39
|
用它的时候 --profile 可省(默认 web)
|
|
36
40
|
--port <n> 向导页端口(只在 --ui 时有用),默认 45080;被占了会自动往后挑一个并打印实际地址
|
|
37
41
|
--json 只输出一份 JSON 汇总(人读的输出改走 stderr)
|
|
@@ -47,7 +51,9 @@ export const HELP_TEXT = `dsh-butler-install —— 大管家全家桶薄壳安
|
|
|
47
51
|
|
|
48
52
|
只用两个原语:\`dsh plugin --profile <p> add|remove …\` 与 \`npm i -g|npm rm -g …\`。
|
|
49
53
|
本工具**不写** dsh.profile.bundles、**不写** cordis.patch.yml、**不碰** node_modules —— 那些是宿主的事。
|
|
50
|
-
\`--ui\`
|
|
54
|
+
\`--ui\` 的写入口只有四条:POST /install/preview、/start、/cancel、/rollback。
|
|
55
|
+
每条都要安装令牌 + 同源校验;它跑的还是上面那两个原语,命令字符串与命令行模式由同一个函数生成
|
|
56
|
+
—— 页面不是第二套安装逻辑,只是按按钮的壳。
|
|
51
57
|
`;
|
|
52
58
|
|
|
53
59
|
const VALUE_FLAGS = new Set([
|
|
@@ -140,10 +146,10 @@ export function parseArgs(argv) {
|
|
|
140
146
|
|
|
141
147
|
if (options.help || options.version) return { options, errors };
|
|
142
148
|
|
|
143
|
-
// --ui =
|
|
149
|
+
// --ui = 向导页(装的动作在页面上点):它和"命令行真装"是两条路,混着给就报错
|
|
144
150
|
if (options.ui) {
|
|
145
|
-
if (options.apply) errors.push('--ui
|
|
146
|
-
if (options.uninstall) errors.push('--ui
|
|
151
|
+
if (options.apply) errors.push('--ui 是向导页(装的动作在页面上点【确认,开始】,不在命令行),不能和 --apply 一起用:要命令行真装请去掉 --ui');
|
|
152
|
+
if (options.uninstall) errors.push('--ui 是向导页(卸载用页面上的【卸载这次装的】),不能和 --uninstall 一起用');
|
|
147
153
|
if (!options.profile) options.profile = 'web';
|
|
148
154
|
} else if (options.uiPort !== null) {
|
|
149
155
|
errors.push('--port 只在 --ui 时有用(现在没给 --ui)');
|
package/lib/dump.mjs
CHANGED
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
* # == <bundle 名>, patched by <包名> ← 被 patch 的 bundle,别把这行算成"包里出现了"
|
|
11
11
|
* - id: <entry id>
|
|
12
12
|
* name: '<包名 或 内部模块名>'
|
|
13
|
+
*
|
|
14
|
+
* 条目的 name 有两种合法写法:包名本身(`@scope/pkg`),或**子包路径**
|
|
15
|
+
* (`@scope/pkg/内部模块`,实测 memory-toolkit 就是 `@kira905/dsh-memory-toolkit/dsh-memory-local`)。
|
|
16
|
+
* 后者曾被误判成"没挂上"(2026-10-31),匹配规则见 `mountMatches`。
|
|
13
17
|
*/
|
|
14
18
|
export function analyzeDump(text) {
|
|
15
19
|
const bundles = [];
|
|
@@ -48,19 +52,63 @@ export function analyzeDump(text) {
|
|
|
48
52
|
return { bundles, entries, idCounts, nameCounts };
|
|
49
53
|
}
|
|
50
54
|
|
|
51
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* 一个条目是否属于某个包的挂载。
|
|
57
|
+
*
|
|
58
|
+
* 两种合法形态:
|
|
59
|
+
* `name: '@scope/pkg'` ← 包自身当条目(老形态)
|
|
60
|
+
* `name: '@scope/pkg/内部模块'` ← 子包路径挂载(宿主认的就是这个,实测
|
|
61
|
+
* `@kira905/dsh-memory-toolkit/dsh-memory-local`)
|
|
62
|
+
* 前缀必须带 `/` 才算子路径:`@scope/dsh-foo` 不许命中包 `@scope/dsh-foo-bar`,
|
|
63
|
+
* 反过来 `@scope/dsh-foobar` 也不许命中包 `@scope/dsh-foo`。
|
|
64
|
+
*/
|
|
65
|
+
export function mountMatches(entryName, pkgName) {
|
|
66
|
+
if (!entryName || !pkgName) return false;
|
|
67
|
+
return entryName === pkgName || entryName.startsWith(`${pkgName}/`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 某个包在组合树里的计数判据。
|
|
72
|
+
*
|
|
73
|
+
* 为什么要数次数:同一份实现被挂两次时宿主**不报错**、只多打一行 —— 用户装完一路
|
|
74
|
+
* 绿灯,实际用的是哪一份谁也说不清。所以防线是"每个 name 在整棵树里恰好 1 次",
|
|
75
|
+
* 不是"出现过就算"。
|
|
76
|
+
*
|
|
77
|
+
* 为什么命中条目不是"恰好 1 条":一个包可以**有意识地**挂多个内部模块,那时命中
|
|
78
|
+
* 条目是 2 条以上,每条 name 各 1 次 —— 这是合法形态,不许判死;真该红的是同一个
|
|
79
|
+
* name 出现两次(见上一条)。
|
|
80
|
+
*/
|
|
52
81
|
export function checkMounted(analysis, pkgName) {
|
|
53
82
|
const bundleLines = analysis.bundles.filter((b) => b === pkgName).length;
|
|
54
|
-
const matching = analysis.entries.filter((e) => e.name
|
|
83
|
+
const matching = analysis.entries.filter((e) => mountMatches(e.name, pkgName));
|
|
84
|
+
const names = matching.map((e) => e.name);
|
|
85
|
+
const nameCounts = names.map((n) => analysis.nameCounts.get(n) || 0);
|
|
55
86
|
const ids = matching.map((e) => e.id);
|
|
56
87
|
const idCounts = ids.map((id) => analysis.idCounts.get(id) || 0);
|
|
57
88
|
const problems = [];
|
|
58
89
|
if (bundleLines !== 1) problems.push(`组合头 "# == ${pkgName}" 出现 ${bundleLines} 次(应为 1)(packages: ${analysis.bundles.filter((b)=>b.includes(pkgName)).join(' | ') || '无'})`);
|
|
59
|
-
if (matching.length
|
|
90
|
+
if (matching.length === 0) problems.push(`条目 name=${pkgName}(或子包路径 ${pkgName}/<内部模块>)出现 0 次(应至少 1 次)`);
|
|
91
|
+
const blamedNames = new Set();
|
|
92
|
+
for (let i = 0; i < names.length; i += 1) {
|
|
93
|
+
if (nameCounts[i] === 1 || blamedNames.has(names[i])) continue;
|
|
94
|
+
blamedNames.add(names[i]);
|
|
95
|
+
problems.push(`条目 name=${names[i]} 在整棵树里出现 ${nameCounts[i]} 次(应为 1)—— 同一份实现被挂两次时宿主不报错,只多打一行`);
|
|
96
|
+
}
|
|
60
97
|
for (let i = 0; i < ids.length; i += 1) {
|
|
61
98
|
if (idCounts[i] !== 1) problems.push(`条目 id=${ids[i]} 在整棵树里出现 ${idCounts[i]} 次(应为 1)—— 撞 id 时宿主不报错,只静默保留一份`);
|
|
62
99
|
}
|
|
63
|
-
return {
|
|
100
|
+
return {
|
|
101
|
+
ok: problems.length === 0,
|
|
102
|
+
bundleLines,
|
|
103
|
+
entryCount: matching.length,
|
|
104
|
+
// 兼容老读数:nameCount 一直表示"命中条目数",语义没变,只是门槛从 ===1 变成 >=1
|
|
105
|
+
nameCount: matching.length,
|
|
106
|
+
names,
|
|
107
|
+
nameCounts,
|
|
108
|
+
ids,
|
|
109
|
+
idCounts,
|
|
110
|
+
problems,
|
|
111
|
+
};
|
|
64
112
|
}
|
|
65
113
|
|
|
66
114
|
/** dump 的 stderr 里"某个 bundle 被跳过"的行(能归因时为安装器所用) */
|