@kira905/dsh-butler-install 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -1
- package/bin/dsh-butler-install.mjs +4 -0
- package/lib/cli-args.mjs +27 -2
- package/lib/plan.mjs +354 -0
- package/lib/run.mjs +33 -224
- package/lib/ui-page.mjs +382 -0
- package/lib/ui-server.mjs +285 -0
- package/package.json +2 -2
- package/suite-manifest.json +6 -6
package/README.md
CHANGED
|
@@ -30,6 +30,56 @@ node <dsh-butler-core 仓根>/selfcheck-replica.mjs --manifest suite-manifest.js
|
|
|
30
30
|
> (只跑它内置的那几项判据)。本仓的 `suite-manifest.json` 的字段是**为它将来接清单准备的**
|
|
31
31
|
> —— 字段含义见下文「清单字段」一节。别把它当成"已经能按清单跑了"。
|
|
32
32
|
|
|
33
|
+
## 想要个界面?`--ui`:挂在 DSH 外面的只读向导页
|
|
34
|
+
|
|
35
|
+
觉得命令行清单不好对?加 `--ui` 起一个本地网页,每一行写清**装没装 / 什么版本 / 期望区间**,
|
|
36
|
+
按钮只帮你**复制命令**:
|
|
37
|
+
|
|
38
|
+
```powershell
|
|
39
|
+
# 起向导页(不会装任何东西、不会重启你的 DSH)
|
|
40
|
+
npx @kira905/dsh-butler-install --ui
|
|
41
|
+
|
|
42
|
+
# 换端口(默认 45080;被占会自动往后挑一个并打印实际地址)
|
|
43
|
+
npx @kira905/dsh-butler-install --ui --port 45090
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
打开它打印的地址(形如 `http://127.0.0.1:45080/`)就能看到清单表。页面上只有两个动作:
|
|
47
|
+
|
|
48
|
+
| 按钮 | 干什么 | 不干什么 |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| **复制命令** | 把这件要跑的**那一行**放进剪贴板 —— 与 `--dry-run` 打印的是**同一行**(同一个函数产出) | 不替你执行;你仍然要在终端里自己跑 |
|
|
51
|
+
| **重查** | 重跑一遍探测、把最新读数写回同一张表 | 不改任何状态、不安装、不写文件 |
|
|
52
|
+
|
|
53
|
+
### 为什么挂在 DSH 外面,而不是做成插件侧边栏页
|
|
54
|
+
|
|
55
|
+
这是设计前提,不是偷懒:
|
|
56
|
+
|
|
57
|
+
| | 插件侧边栏页(最初的想法) | 宿主外的本地页面(现在这样) |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| 要装插件 / 重启 DSH | 要(`dsh.bundle` + `cordis.patch.yml` + 重启) | **不要** |
|
|
60
|
+
| DSH 升级会不会崩 | 会(往页面里注入 client DOM,宿主一改就可能失效) | **不会**(它根本不进宿主) |
|
|
61
|
+
| 装 DSH 之前能不能用 | 不能 | **能** —— 还没装宿主时,向导正好可以告诉你"你得先装 DSH" |
|
|
62
|
+
| 成本 | 约 8h | **约 4h** |
|
|
63
|
+
| 代价 | 写入口的风险面大 | 要监听一个本地端口(所以**只许 `127.0.0.1`**) |
|
|
64
|
+
|
|
65
|
+
### 安全边界(写死的,不是可选项)
|
|
66
|
+
|
|
67
|
+
- 🔴 **只绑 `127.0.0.1`**:同一局域网的别的机器连不上。没有 `0.0.0.0` 开关,也不读环境变量改它。
|
|
68
|
+
- 🔴 **只有两个 `GET`**:`GET /`(页面)与 `GET /state`(与 `--dry-run` 同源的 JSON)。
|
|
69
|
+
任何 `POST` / `PUT` / `DELETE` / `PATCH` / `OPTIONS` / `HEAD` 一律 **405** + `Allow: GET`,其余路径 404。
|
|
70
|
+
- 🔴 **服务端一条命令都不执行**:没有写入口,页面也不会替你跑 `dsh` / `npm`。
|
|
71
|
+
启动时只做一次只读的 `npm config get prefix` 来定位全局包落点,之后**每个请求零 spawn**。
|
|
72
|
+
- 端口被占:自动往后挑一个(最多 20 个),都不行就让系统分配,并**打印实际地址**。
|
|
73
|
+
|
|
74
|
+
### 「按顺序装」是什么意思
|
|
75
|
+
|
|
76
|
+
页面会给你**建议顺序 + 为什么这么建议**,但请看清它的含义:
|
|
77
|
+
|
|
78
|
+
> **组件之间没有代码依赖,谁先装谁后装都能装上。** 真正有先后的只有三格:
|
|
79
|
+
> Node(≥22)→ `dsh` 主包 → profile。下面的"顺序"是**给人看的排查路径**,不是成功的前提。
|
|
80
|
+
> 看到"必须按 1→2→3 才能成功"这种话,那是假的仪式。
|
|
81
|
+
|
|
82
|
+
|
|
33
83
|
## 它只做两件事,且只用两个原语
|
|
34
84
|
|
|
35
85
|
| 原语 | 用来装什么 |
|
|
@@ -140,6 +190,8 @@ npx -p @kira905/dsh-butler-install dsh-butler-install-probe-scan <某仓根或
|
|
|
140
190
|
| `--timeout` / `--budget` | 单步超时(默认 120s)/ 整批墙钟上限(默认 300s,超了就停下并回滚本批) |
|
|
141
191
|
| `--snapshot-dir <dir>` | 快照目录,默认 `<数据根>/butler-install/snapshots/<时间戳>` |
|
|
142
192
|
| `--json` | 只输出一份 JSON 汇总(人读的输出改走 stderr) |
|
|
193
|
+
| `--ui` | 起一个**只读**的本地向导页(`127.0.0.1`、只有 `GET`、零执行),见上文。用它时 `--profile` 可省(默认 `web`) |
|
|
194
|
+
| `--port <n>` | 向导页端口(只在 `--ui` 时有用),默认 `45080`;被占用会自动换一个并打印实际地址 |
|
|
143
195
|
|
|
144
196
|
## 它在 Windows 上怎么跑子进程
|
|
145
197
|
|
|
@@ -157,6 +209,8 @@ npx -p @kira905/dsh-butler-install dsh-butler-install-probe-scan <某仓根或
|
|
|
157
209
|
挂载判据记"跳过"。
|
|
158
210
|
- 不判"运行时会不会崩":`--dump-config` 只证明组合树里有这一行。真要验运行时,得自己起一次服务看日志。
|
|
159
211
|
- 不替你决定"要不要装某一件",也不做"装完自动起服务"。
|
|
212
|
+
- 🔴 向导页**故意不能"点一下就装"**:那需要开一个能替你执行命令的写入口,风险面比省下的两次复制大得多。
|
|
213
|
+
页面给的是命令,装的动作永远在终端里、由你按下回车。
|
|
160
214
|
|
|
161
215
|
## 夹具
|
|
162
216
|
|
|
@@ -168,7 +222,26 @@ npm test
|
|
|
168
222
|
不读也不写真实数据根。覆盖:清单校验(每条错误都带文件名 + 行号)· 版本区间三态 ·
|
|
169
223
|
探针三分支(含"标了 `help` 但该命令没实现 ⇒ 拒执行且不产生任何执行")· 组合树三条计数判据
|
|
170
224
|
(各自独立可触发)· 快照回放与核对 · 演练档零副作用(目录指纹必须一模一样)· 退出码 0/3/4 ·
|
|
171
|
-
步级与整批回滚的差别 ·
|
|
225
|
+
步级与整批回滚的差别 · 卸载 · 向导页(监听地址只许 `127.0.0.1` / 除两个 `GET` 外全 405 / 页面数据与
|
|
226
|
+
`--dry-run` 同源 / 每请求零 spawn / 端口冲突自动换 / 不给 `--ui` 时输出逐字节不变)·
|
|
227
|
+
以及**变异测试**:把监听地址改成 `0.0.0.0`、或把路由表放开非 `GET`、或让命令字符串不带期望区间,
|
|
228
|
+
夹具必须**变红**(改坏了还绿 ⇒ 这条夹具是假的)。
|
|
229
|
+
|
|
230
|
+
### 清单漂移判据(`test/range-drift.mjs`)
|
|
231
|
+
|
|
232
|
+
清单是单一真相,但真相会烂:写清单那天按当天的 latest 填区间,之后某个包跨了 0.x 的小版本
|
|
233
|
+
(0 阶段里这就是大版本),caret 的上界就把新版挡在门外 —— 照清单装一遍拿到旧一套,而且装后自检
|
|
234
|
+
**照样绿**(它读同一份清单)。所以这条判据对着清单外面的世界核,而不是自己核自己:
|
|
235
|
+
|
|
236
|
+
```powershell
|
|
237
|
+
node test/range-drift.mjs # npm view,真网络
|
|
238
|
+
node test/range-drift.mjs --offline test/fixtures/npm-latest-snapshot.json # 读快照,零网络
|
|
239
|
+
node test/range-drift.mjs --json --manifest <别的清单>
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
退出码:`0` 全部接受 · `3` 有漂移(照清单装会拿到旧版)· `4` 取数失败/无法判定(**绝不当通过**)·
|
|
243
|
+
`2` 用法错或清单读不进来。`test/run-all.mjs` 里那一格走仓内快照、零网络;快照的 `capturedAt`
|
|
244
|
+
是这份数据"什么时候是真的"的凭据,刷新清单区间时记得一起刷。
|
|
172
245
|
|
|
173
246
|
## 许可
|
|
174
247
|
|
|
@@ -9,6 +9,7 @@ import path from 'node:path';
|
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import { parseArgs, HELP_TEXT, EXIT } from '../lib/cli-args.mjs';
|
|
11
11
|
import { runSuite } from '../lib/run.mjs';
|
|
12
|
+
import { runUiCli } from '../lib/ui-server.mjs';
|
|
12
13
|
|
|
13
14
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
14
15
|
|
|
@@ -33,6 +34,9 @@ if (options.help) {
|
|
|
33
34
|
for (const e of errors) process.stderr.write(`✖ ${e}\n`);
|
|
34
35
|
process.stderr.write('用 --help 看可用参数。\n');
|
|
35
36
|
process.exitCode = EXIT.USAGE;
|
|
37
|
+
} else if (options.ui) {
|
|
38
|
+
// 向导页:只读、只绑 127.0.0.1、只有 GET。起完就挂着,Ctrl+C 结束(不留后台服务)
|
|
39
|
+
process.exitCode = await runUiCli(options);
|
|
36
40
|
} else {
|
|
37
41
|
const { exitCode, summary } = await runSuite(options);
|
|
38
42
|
if (options.json) {
|
package/lib/cli-args.mjs
CHANGED
|
@@ -30,6 +30,10 @@ 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 起一个**只读**的本地向导页(挂在 DSH 外面:不用装插件、不用重启)。
|
|
34
|
+
只绑 127.0.0.1、只有 GET、服务端一条命令都不执行;页面只给你"复制命令 + 重查"。
|
|
35
|
+
用它的时候 --profile 可省(默认 web)
|
|
36
|
+
--port <n> 向导页端口(只在 --ui 时有用),默认 45080;被占了会自动往后挑一个并打印实际地址
|
|
33
37
|
--json 只输出一份 JSON 汇总(人读的输出改走 stderr)
|
|
34
38
|
--dry-run 显式写"演练"(与默认行为相同,便于脚本表达意图)
|
|
35
39
|
-h, --help 看这份说明
|
|
@@ -43,6 +47,7 @@ export const HELP_TEXT = `dsh-butler-install —— 大管家全家桶薄壳安
|
|
|
43
47
|
|
|
44
48
|
只用两个原语:\`dsh plugin --profile <p> add|remove …\` 与 \`npm i -g|npm rm -g …\`。
|
|
45
49
|
本工具**不写** dsh.profile.bundles、**不写** cordis.patch.yml、**不碰** node_modules —— 那些是宿主的事。
|
|
50
|
+
\`--ui\` 是例外中的例外:它连装都不装,只念一遍"该在终端里跑什么",然后一直只读地重查。
|
|
46
51
|
`;
|
|
47
52
|
|
|
48
53
|
const VALUE_FLAGS = new Set([
|
|
@@ -55,9 +60,11 @@ const VALUE_FLAGS = new Set([
|
|
|
55
60
|
'--timeout',
|
|
56
61
|
'--budget',
|
|
57
62
|
'--snapshot-dir',
|
|
63
|
+
'--port',
|
|
58
64
|
]);
|
|
59
65
|
|
|
60
|
-
const BOOL_FLAGS = new Set(['--apply', '--upgrade', '--uninstall', '--json', '--dry-run', '--help', '-h', '--version', '-V', '--rollback']);
|
|
66
|
+
const BOOL_FLAGS = new Set(['--apply', '--upgrade', '--uninstall', '--json', '--dry-run', '--help', '-h', '--version', '-V', '--rollback', '--ui']);
|
|
67
|
+
|
|
61
68
|
|
|
62
69
|
export function parseArgs(argv) {
|
|
63
70
|
const options = {
|
|
@@ -75,6 +82,8 @@ export function parseArgs(argv) {
|
|
|
75
82
|
budgetMs: 300000,
|
|
76
83
|
snapshotDir: null,
|
|
77
84
|
json: false,
|
|
85
|
+
ui: false,
|
|
86
|
+
uiPort: null,
|
|
78
87
|
help: false,
|
|
79
88
|
version: false,
|
|
80
89
|
};
|
|
@@ -116,6 +125,7 @@ export function parseArgs(argv) {
|
|
|
116
125
|
if (flag === '--upgrade') options.upgrade = true;
|
|
117
126
|
if (flag === '--uninstall') options.uninstall = true;
|
|
118
127
|
if (flag === '--json') options.json = true;
|
|
128
|
+
if (flag === '--ui') options.ui = true;
|
|
119
129
|
if (flag === '--help' || flag === '-h') options.help = true;
|
|
120
130
|
if (flag === '--version' || flag === '-V') options.version = true;
|
|
121
131
|
continue;
|
|
@@ -128,7 +138,18 @@ export function parseArgs(argv) {
|
|
|
128
138
|
errors.push(`多余的位置参数:${token}`);
|
|
129
139
|
}
|
|
130
140
|
|
|
131
|
-
if (
|
|
141
|
+
if (options.help || options.version) return { options, errors };
|
|
142
|
+
|
|
143
|
+
// --ui = 只读向导页。它和"真装"是两码事,混着给就报错(免得有人以为页面会自己装)
|
|
144
|
+
if (options.ui) {
|
|
145
|
+
if (options.apply) errors.push('--ui 是只读向导页(它一条命令都不执行),不能和 --apply 一起用:要真装请去掉 --ui');
|
|
146
|
+
if (options.uninstall) errors.push('--ui 是只读向导页(它一条命令都不执行),不能和 --uninstall 一起用');
|
|
147
|
+
if (!options.profile) options.profile = 'web';
|
|
148
|
+
} else if (options.uiPort !== null) {
|
|
149
|
+
errors.push('--port 只在 --ui 时有用(现在没给 --ui)');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!options.profile) errors.push('缺 --profile;用法:dsh-butler-install --profile <name> [--apply]');
|
|
132
153
|
return { options, errors };
|
|
133
154
|
}
|
|
134
155
|
|
|
@@ -148,6 +169,10 @@ function applyValue(options, flag, value, errors) {
|
|
|
148
169
|
options.home = value;
|
|
149
170
|
} else if (flag === '--snapshot-dir') {
|
|
150
171
|
options.snapshotDir = value;
|
|
172
|
+
} else if (flag === '--port') {
|
|
173
|
+
const n = Number(value);
|
|
174
|
+
if (!Number.isInteger(n) || n < 0 || n > 65535) errors.push(`--port 要 0–65535 的整数(0 = 让系统挑;现在读到 ${JSON.stringify(value)})`);
|
|
175
|
+
else options.uiPort = n;
|
|
151
176
|
} else if (flag === '--timeout') {
|
|
152
177
|
const n = Number(value);
|
|
153
178
|
if (!Number.isFinite(n) || n <= 0) errors.push(`--timeout 要正数秒(现在读到 ${JSON.stringify(value)})`);
|
package/lib/plan.mjs
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 计划层 = 「清单 → 选中 → 现状 → 每项要跑的命令」的**唯一**出产地。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有这一层(2026-10-30 加 --ui 时抽出来的):
|
|
5
|
+
* 向导页与 `--dry-run` 必须说同一句话。只要"页面自己拼一行命令"存在,两者迟早漂移,
|
|
6
|
+
* 而漂移的后果是用户复制到的那行**不是**安装器真正会跑的那行 —— 这类假一致比没有界面更坏。
|
|
7
|
+
* 所以命令字符串只在这里生成一次(`formatCommandLine` + `installStepFor`),
|
|
8
|
+
* 文本演练(lib/run.mjs)与 JSON(GET /state)都只做"渲染",不做"拼接"。
|
|
9
|
+
*
|
|
10
|
+
* 本层是**纯读**:读清单、读 profile/全局前缀里的文件、扫 PATH。唯一可能起进程的是
|
|
11
|
+
* `npm config get prefix`(见 lib/state.mjs,只读且失败可回退);`--ui` 会在启动时解析一次
|
|
12
|
+
* 再传进来(见 options.npmResolved),于是**每个请求零 spawn**。
|
|
13
|
+
*/
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { loadManifest, formatManifestError, describeItem } from './manifest.mjs';
|
|
17
|
+
import { satisfies, parseRange, describeRange } from './semver.mjs';
|
|
18
|
+
import {
|
|
19
|
+
resolveHome,
|
|
20
|
+
readProfile,
|
|
21
|
+
profileInstalledVersion,
|
|
22
|
+
globalInstalledVersion,
|
|
23
|
+
resolveNpmPrefix,
|
|
24
|
+
findExistingBin,
|
|
25
|
+
findOnPath,
|
|
26
|
+
readInstalledDshDecl,
|
|
27
|
+
} from './state.mjs';
|
|
28
|
+
import { formatCommandLine } from './exec.mjs';
|
|
29
|
+
import { EXIT } from './cli-args.mjs';
|
|
30
|
+
|
|
31
|
+
const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
32
|
+
export const DEFAULT_MANIFEST = path.join(PKG_ROOT, 'suite-manifest.json');
|
|
33
|
+
|
|
34
|
+
export function shortName(name) {
|
|
35
|
+
const parts = String(name).split('/');
|
|
36
|
+
return parts[parts.length - 1];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 按 --only / --skip 选条目;组名、包名、短名、别名都认 */
|
|
40
|
+
export function selectItems(manifest, { only = [], skip = [] } = {}) {
|
|
41
|
+
const items = manifest.items;
|
|
42
|
+
const errors = [];
|
|
43
|
+
const groups = [...new Set(items.map((i) => i.group))];
|
|
44
|
+
const resolve = (token) => items.filter((i) => (
|
|
45
|
+
i.name === token
|
|
46
|
+
|| shortName(i.name) === token
|
|
47
|
+
|| i.group === token
|
|
48
|
+
|| (Array.isArray(i.aliases) && i.aliases.includes(token))
|
|
49
|
+
));
|
|
50
|
+
|
|
51
|
+
let selected = items.slice();
|
|
52
|
+
if (only.length) {
|
|
53
|
+
const picked = new Set();
|
|
54
|
+
for (const token of only) {
|
|
55
|
+
const hits = resolve(token);
|
|
56
|
+
if (!hits.length) {
|
|
57
|
+
errors.push(`--only 里的 ${JSON.stringify(token)} 没匹配到任何条目(可选组:${groups.join(' / ')};也可以直接写包名或别名)`);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
for (const h of hits) picked.add(h.name);
|
|
61
|
+
}
|
|
62
|
+
selected = items.filter((i) => picked.has(i.name));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const dropped = new Set();
|
|
66
|
+
for (const token of skip) {
|
|
67
|
+
const hits = resolve(token);
|
|
68
|
+
if (!hits.length) errors.push(`--skip 里的 ${JSON.stringify(token)} 没匹配到任何条目`);
|
|
69
|
+
for (const h of hits) dropped.add(h.name);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { items: selected.filter((i) => !dropped.has(i.name)), errors, groups };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function installStepFor(item, { profile, npmPrefix }) {
|
|
76
|
+
// 装的时候把**清单里的期望区间**一起点名(`<包>@^x.y.z`)。
|
|
77
|
+
// 为什么必须带上:不带区间时拿到的是注册表 `latest` 标签指的那个版本,而它未必在
|
|
78
|
+
// 我们期望的区间里(实测踩过:latest 标签指着的版本与最高版本号不是一回事)。
|
|
79
|
+
// 带区间 = 让解析器只能落进期望区间,落不进去就报错,而不是"装了个别的版本还报成功"。
|
|
80
|
+
const spec = `${item.name}@${item.versionRange}`;
|
|
81
|
+
if (item.type === 'plugin') {
|
|
82
|
+
return { file: 'dsh', args: ['plugin', '--profile', profile, 'add', spec], env: {} };
|
|
83
|
+
}
|
|
84
|
+
return { file: 'npm', args: ['i', '-g', spec], env: npmPrefix ? { npm_config_prefix: npmPrefix } : {} };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function uninstallStepFor(item, { profile, npmPrefix }) {
|
|
88
|
+
if (item.type === 'plugin') {
|
|
89
|
+
return { file: 'dsh', args: ['plugin', '--profile', profile, 'remove', item.name], env: {} };
|
|
90
|
+
}
|
|
91
|
+
return { file: 'npm', args: ['rm', '-g', item.name], env: npmPrefix ? { npm_config_prefix: npmPrefix } : {} };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 判定某一项"现在是否已满足",纯读文件 */
|
|
95
|
+
export function assessItem(item, ctx, profile, { upgrade = false } = {}) {
|
|
96
|
+
const mount = item.mount || (item.type === 'plugin' ? 'auto' : 'none');
|
|
97
|
+
if (item.type === 'plugin') {
|
|
98
|
+
const version = profileInstalledVersion(ctx.home, ctx.profile, item.name);
|
|
99
|
+
const depDeclared = Object.prototype.hasOwnProperty.call(profile.deps || {}, item.name);
|
|
100
|
+
const bundleRegistered = (profile.bundles || []).includes(item.name);
|
|
101
|
+
const range = version ? satisfies(item.versionRange, version) : 'no';
|
|
102
|
+
const decl = readInstalledDshDecl(ctx.home, ctx.profile, item.name);
|
|
103
|
+
// 该不该进组合列表:**以包实体的声明为准**。本版没声明 bundle 的包,
|
|
104
|
+
// 不登记也是正确状态(登记反而是空的),所以这里不拿"没登记"判它未满足。
|
|
105
|
+
const mountOk = mount !== 'auto' || bundleRegistered || !decl.hasBundle;
|
|
106
|
+
const satisfied = Boolean(version) && depDeclared && mountOk && (range === 'yes' || range === 'unknown');
|
|
107
|
+
return {
|
|
108
|
+
item,
|
|
109
|
+
type: 'plugin',
|
|
110
|
+
mount,
|
|
111
|
+
version,
|
|
112
|
+
depDeclared,
|
|
113
|
+
bundleRegistered,
|
|
114
|
+
range,
|
|
115
|
+
satisfied,
|
|
116
|
+
action: satisfied && !upgrade ? 'skip' : 'install',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const version = globalInstalledVersion(ctx.npmPrefix, item.name);
|
|
120
|
+
const binFile = item.probe && item.probe.cmd
|
|
121
|
+
? findExistingBin({ cmd: item.probe.cmd, home: ctx.home, profile: ctx.profile, npmPrefix: ctx.npmPrefix })
|
|
122
|
+
: null;
|
|
123
|
+
const range = version ? satisfies(item.versionRange, version) : 'no';
|
|
124
|
+
const satisfied = Boolean(version) && Boolean(binFile) && (range === 'yes' || range === 'unknown');
|
|
125
|
+
return {
|
|
126
|
+
item,
|
|
127
|
+
type: 'cli',
|
|
128
|
+
mount: 'none',
|
|
129
|
+
version,
|
|
130
|
+
binFile,
|
|
131
|
+
range,
|
|
132
|
+
satisfied,
|
|
133
|
+
action: satisfied && !upgrade ? 'skip' : 'install',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 每一项装完拿什么判据(演练档与向导页共用同一句话) */
|
|
138
|
+
export function criteriaText(item) {
|
|
139
|
+
const parts = [];
|
|
140
|
+
const kind = (item.probe && item.probe.kind) || 'existence';
|
|
141
|
+
if (kind === 'existence') {
|
|
142
|
+
parts.push(item.type === 'plugin'
|
|
143
|
+
? `profile 的 node_modules/${item.name}/package.json 存在(只查文件,不执行)`
|
|
144
|
+
: `命令文件 ${item.probe.cmd} 在 .bin 或全局前缀下存在(只查文件,不执行)`);
|
|
145
|
+
} else if (kind === 'help') {
|
|
146
|
+
parts.push(`先静态核 ${item.probe.cmd} 源码里确有 --help 分支,再跑 ${item.probe.cmd} --help`);
|
|
147
|
+
} else {
|
|
148
|
+
parts.push(`跑 ${item.probe.cmd} ${(item.probe.argv || []).join(' ')}(清单声明只读:${item.probe.readonlyBecause})`);
|
|
149
|
+
}
|
|
150
|
+
parts.push(`实体版本落在 ${describeRange(parseRange(item.versionRange))}`);
|
|
151
|
+
if (item.type === 'plugin' && (item.mount || 'auto') === 'auto') {
|
|
152
|
+
parts.push('dump-config 里组合头恰好 1 行、条目 name 恰好 1 条、该 id 在整棵树里恰好 1 次');
|
|
153
|
+
}
|
|
154
|
+
if (item.type === 'plugin' && item.mount === 'manual') {
|
|
155
|
+
parts.push('挂载不判(本包按设计不进组合列表)');
|
|
156
|
+
}
|
|
157
|
+
return parts.join(' · ');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 「建议顺序」= 阅读/排查顺序,**不是**技术依赖(方案 §5.3 的硬口径)。
|
|
162
|
+
* 组名 → 为什么建议排在这。没写到的组用兜底理由,绝不编一个假依赖出来。
|
|
163
|
+
*/
|
|
164
|
+
const GROUP_ORDER_WHY = {
|
|
165
|
+
核心: '先装面板:后面装的东西对不对,靠它看。',
|
|
166
|
+
界面: '界面增强,彼此之间没有先后,装完刷新页面就能看到。',
|
|
167
|
+
记忆: '它要占"记忆"这个位置,与官方记忆插件二选一;建议单独一步,装完自己确认组合列表里没有官方那份。',
|
|
168
|
+
CLI: '纯命令行工具,可以最后一批装。装完用"命令文件在不在"验,不跑 --help —— 这批命令里有不少会把未知参数当普通参数、照常干活。',
|
|
169
|
+
体检: '体检 / 急救类命令,最后装也行;平时用不到它们,不影响前几类。',
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const ORDER_FALLBACK_WHY = '与其它组之间没有代码依赖,什么时候装都行;这里只是建议顺序。';
|
|
173
|
+
|
|
174
|
+
export const ORDER_NOTES = [
|
|
175
|
+
'真正的技术顺序只有三格:Node ≥22 → dsh 主包 → profile(首次 `dsh plugin` 会自动建)→ 各组件。',
|
|
176
|
+
'组件之间没有代码依赖:谁先谁后都能装上。下面给的是**建议顺序 + 为什么这么建议** —— 换句话说,这里的"顺序"是给人看的排查路径,不是成功的前提。',
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
function buildOrder(groups, items) {
|
|
180
|
+
const out = [];
|
|
181
|
+
for (const group of groups) {
|
|
182
|
+
const inGroup = items.filter((i) => i.group === group);
|
|
183
|
+
if (!inGroup.length) continue;
|
|
184
|
+
out.push({
|
|
185
|
+
step: out.length + 1,
|
|
186
|
+
group,
|
|
187
|
+
why: GROUP_ORDER_WHY[group] || ORDER_FALLBACK_WHY,
|
|
188
|
+
items: inGroup.map((i) => i.name),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 唯一入口:产出「这次会做什么」的全部数据(文本演练与 /state 共用)。
|
|
196
|
+
*
|
|
197
|
+
* @param {object} options 与 CLI 选项同形(至少 profile / manifest / only / skip / upgrade / home / npmPrefix)
|
|
198
|
+
* @param {object} [pre] `{ npm }`:已解析好的全局前缀,传了就不再 spawn(--ui 用它保证每请求零 spawn)
|
|
199
|
+
* @returns {Promise<{ok:boolean, exitCode:number, log:string[], summary:object, plan?:Array, state?:object}>}
|
|
200
|
+
*/
|
|
201
|
+
export async function buildSuiteState(options, pre = {}) {
|
|
202
|
+
const log = [];
|
|
203
|
+
const say = (text = '') => log.push(text);
|
|
204
|
+
const summary = {
|
|
205
|
+
mode: options.uninstall ? 'uninstall' : options.apply ? 'apply' : 'dry-run',
|
|
206
|
+
profile: options.profile,
|
|
207
|
+
home: null,
|
|
208
|
+
npmPrefix: null,
|
|
209
|
+
manifest: null,
|
|
210
|
+
items: [],
|
|
211
|
+
warnings: [],
|
|
212
|
+
excluded: [],
|
|
213
|
+
rollback: null,
|
|
214
|
+
exitCode: null,
|
|
215
|
+
};
|
|
216
|
+
const fail = (exitCode, errors) => ({ ok: false, exitCode, log, summary, errors: errors || log.slice() });
|
|
217
|
+
|
|
218
|
+
// ── 1. 清单 ────────────────────────────────────────────────
|
|
219
|
+
const manifestPath = options.manifest || DEFAULT_MANIFEST;
|
|
220
|
+
let manifest;
|
|
221
|
+
try {
|
|
222
|
+
manifest = loadManifest(manifestPath);
|
|
223
|
+
} catch (err) {
|
|
224
|
+
say(formatManifestError(err, path.basename(manifestPath)));
|
|
225
|
+
summary.exitCode = EXIT.USAGE;
|
|
226
|
+
return fail(EXIT.USAGE);
|
|
227
|
+
}
|
|
228
|
+
summary.manifest = manifest.path;
|
|
229
|
+
summary.excluded = manifest.excluded.map((e) => ({ id: e.id, reason: e.reason }));
|
|
230
|
+
|
|
231
|
+
// ── 2. 选条目 ──────────────────────────────────────────────
|
|
232
|
+
const selection = selectItems(manifest, options);
|
|
233
|
+
if (selection.errors.length) {
|
|
234
|
+
for (const e of selection.errors) say(`✖ ${e}`);
|
|
235
|
+
summary.exitCode = EXIT.USAGE;
|
|
236
|
+
return fail(EXIT.USAGE);
|
|
237
|
+
}
|
|
238
|
+
if (!selection.items.length) {
|
|
239
|
+
say('✖ 选完之后没有任何条目可做(检查 --only / --skip)');
|
|
240
|
+
summary.exitCode = EXIT.USAGE;
|
|
241
|
+
return fail(EXIT.USAGE);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── 3. 上下文 ──────────────────────────────────────────────
|
|
245
|
+
const home = resolveHome(options.home);
|
|
246
|
+
const profile = readProfile(home, options.profile);
|
|
247
|
+
const npm = pre.npm || await resolveNpmPrefix(options.npmPrefix);
|
|
248
|
+
const ctx = { home, profile: options.profile, npmPrefix: npm.prefix };
|
|
249
|
+
summary.home = home;
|
|
250
|
+
summary.npmPrefix = npm.prefix;
|
|
251
|
+
|
|
252
|
+
say(`清单:${manifest.path}`);
|
|
253
|
+
say(`共 ${manifest.items.length} 项,本次选中 ${selection.items.length} 项(组:${[...new Set(selection.items.map((i) => i.group))].join(' / ')})`);
|
|
254
|
+
say(`数据根:${home}`);
|
|
255
|
+
say(`profile:${options.profile}(${profile.exists ? '已存在' : '尚不存在,真装时由宿主自动创建'})`);
|
|
256
|
+
say(`全局包落点:${npm.prefix}(来源:${npm.source})`);
|
|
257
|
+
if (manifest.excluded.length) {
|
|
258
|
+
for (const entry of manifest.excluded) {
|
|
259
|
+
say(`⏭ 不可安装项 ${entry.id}:${entry.reason}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
say('');
|
|
263
|
+
|
|
264
|
+
// 前置:dsh 与 npm 两条原语都得在(只查文件存在,不执行)
|
|
265
|
+
const dshPath = findOnPath('dsh') || findExistingBin({ cmd: 'dsh', home, profile: options.profile, npmPrefix: npm.prefix });
|
|
266
|
+
const npmPath = findOnPath('npm');
|
|
267
|
+
const preflight = [];
|
|
268
|
+
if (!dshPath) preflight.push('找不到 dsh 命令(PATH 与全局前缀两处都查过)⇒ 插件类无从安装,也无法核对挂载');
|
|
269
|
+
if (!npmPath) preflight.push('找不到 npm 命令 ⇒ 命令行类无从安装');
|
|
270
|
+
|
|
271
|
+
// ── 4. 计划 ────────────────────────────────────────────────
|
|
272
|
+
const plan = selection.items.map((item) => assessItem(item, ctx, profile, { upgrade: options.upgrade }));
|
|
273
|
+
// 命令字符串只在这里生成一次:演练文案与 /state 都是它的渲染,不许别处再拼一遍
|
|
274
|
+
for (const entry of plan) {
|
|
275
|
+
const installStep = installStepFor(entry.item, { profile: options.profile, npmPrefix: npm.prefix });
|
|
276
|
+
const uninstallStep = uninstallStepFor(entry.item, { profile: options.profile, npmPrefix: npm.prefix });
|
|
277
|
+
entry.command = formatCommandLine(installStep.file, installStep.args);
|
|
278
|
+
entry.commandUninstall = formatCommandLine(uninstallStep.file, uninstallStep.args);
|
|
279
|
+
entry.criteria = criteriaText(entry.item);
|
|
280
|
+
}
|
|
281
|
+
summary.items = plan.map((p) => ({
|
|
282
|
+
name: p.item.name,
|
|
283
|
+
type: p.item.type,
|
|
284
|
+
group: p.item.group,
|
|
285
|
+
mount: p.mount,
|
|
286
|
+
installedVersion: p.version,
|
|
287
|
+
inRange: p.range,
|
|
288
|
+
satisfied: p.satisfied,
|
|
289
|
+
action: p.action,
|
|
290
|
+
}));
|
|
291
|
+
|
|
292
|
+
const toInstall = plan.filter((p) => p.action === 'install');
|
|
293
|
+
const skipped = plan.filter((p) => p.action === 'skip');
|
|
294
|
+
|
|
295
|
+
if (!options.uninstall) {
|
|
296
|
+
say(`建议顺序(组件之间没有代码依赖,"顺序"只是排查建议):先装面板 → 界面增强 → 记忆 → 命令行工具一批。`);
|
|
297
|
+
say('');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── 5. 给渲染层用的结构化快照(/state 就是它,加字段只加在这里) ─────
|
|
301
|
+
const items = plan.map((entry, index) => {
|
|
302
|
+
const d = describeItem(entry.item);
|
|
303
|
+
return {
|
|
304
|
+
step: index + 1,
|
|
305
|
+
name: d.name,
|
|
306
|
+
shortName: shortName(d.name),
|
|
307
|
+
type: d.type,
|
|
308
|
+
group: d.group,
|
|
309
|
+
mount: d.mount,
|
|
310
|
+
versionRange: entry.item.versionRange,
|
|
311
|
+
rangeText: d.rangeText,
|
|
312
|
+
aliases: Array.isArray(entry.item.aliases) ? entry.item.aliases : [],
|
|
313
|
+
note: typeof entry.item.note === 'string' ? entry.item.note : '',
|
|
314
|
+
installed: Boolean(entry.version),
|
|
315
|
+
installedVersion: entry.version,
|
|
316
|
+
inRange: entry.range,
|
|
317
|
+
satisfied: entry.satisfied,
|
|
318
|
+
action: entry.action,
|
|
319
|
+
probeKind: d.probeKind,
|
|
320
|
+
// 已满足的项 CLI 什么都不跑(不加 --upgrade 时)⇒ 命令就是 null,页面也就复制不到假命令
|
|
321
|
+
command: entry.action === 'install' ? entry.command : null,
|
|
322
|
+
commandUninstall: entry.commandUninstall,
|
|
323
|
+
criteria: entry.action === 'install' ? entry.criteria : null,
|
|
324
|
+
};
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const state = {
|
|
328
|
+
ok: true,
|
|
329
|
+
exitCode: EXIT.OK,
|
|
330
|
+
generatedAt: new Date().toISOString(),
|
|
331
|
+
mode: summary.mode,
|
|
332
|
+
suite: manifest.meta && manifest.meta.suite ? manifest.meta.suite : null,
|
|
333
|
+
manifest: manifest.path,
|
|
334
|
+
home,
|
|
335
|
+
profile: options.profile,
|
|
336
|
+
profileExists: profile.exists,
|
|
337
|
+
npmPrefix: npm.prefix,
|
|
338
|
+
npmPrefixSource: npm.source,
|
|
339
|
+
upgrade: Boolean(options.upgrade),
|
|
340
|
+
counts: {
|
|
341
|
+
total: manifest.items.length,
|
|
342
|
+
selected: selection.items.length,
|
|
343
|
+
install: toInstall.length,
|
|
344
|
+
skip: skipped.length,
|
|
345
|
+
},
|
|
346
|
+
preflight,
|
|
347
|
+
excluded: summary.excluded.map((e) => ({ ...e })),
|
|
348
|
+
order: buildOrder([...new Set(plan.map((p) => p.item.group))], plan.map((p) => p.item)),
|
|
349
|
+
orderNotes: ORDER_NOTES.slice(),
|
|
350
|
+
items,
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
return { ok: true, exitCode: EXIT.OK, log, summary, plan, state, ctx, profile, preflight, npm, toInstall, skipped };
|
|
354
|
+
}
|