@kira905/dsh-butler-install 0.2.0 → 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 +136 -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/suite-manifest.json +6 -6
package/lib/exec.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* 打印出来的命令行与真正执行的命令行是同一个函数生成的,报告里不会出现"说要跑 A、
|
|
10
10
|
* 实际跑了 B"。
|
|
11
11
|
*/
|
|
12
|
-
import { spawn } from 'node:child_process';
|
|
12
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
13
13
|
|
|
14
14
|
const SAFE_ARG = /^[A-Za-z0-9@/._:+=,~^()-]+$/;
|
|
15
15
|
|
|
@@ -40,12 +40,66 @@ function killTree(pid) {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** 当前还活着的子进程 pid(只记我们自己 spawn 的;进程退出时要靠它把树杀干净) */
|
|
44
|
+
const liveChildren = new Set();
|
|
45
|
+
|
|
46
|
+
export function runningChildPids() {
|
|
47
|
+
return [...liveChildren];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 退出钩子专用:把还在跑的子进程树**同步**杀一遍,返回杀了几棵 */
|
|
51
|
+
export function killAllRunningSync() {
|
|
52
|
+
let killed = 0;
|
|
53
|
+
for (const pid of liveChildren) {
|
|
54
|
+
if (killTreeSync(pid)) killed += 1;
|
|
55
|
+
}
|
|
56
|
+
liveChildren.clear();
|
|
57
|
+
return killed;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 同步版杀进程树 —— **只给退出钩子用**。
|
|
62
|
+
*
|
|
63
|
+
* 为什么非要有这一份:`killTree()` 走的是**异步** `spawn`,而 Node 的 `process.on('exit')`
|
|
64
|
+
* 里只能跑同步代码。于是"Ctrl+C 不留孤儿 npm"如果用异步版,会有两种假象之一:
|
|
65
|
+
* 要么钩子跑完异步 spawn 还没被执行(进程已经没了),要么子进程被杀了一半。
|
|
66
|
+
* 实测结论:退出钩子必须同步 —— 这里用 `spawnSync('taskkill', …)`。
|
|
67
|
+
*
|
|
68
|
+
* ⚠️ `/T` 在这里是对的:它只针对**我们自己起的**那棵子进程树。
|
|
69
|
+
* 与"杀 dsh 不能带 /T(会连坐守护鱼)"是两件不同的事,别把结论搬过去。
|
|
70
|
+
*/
|
|
71
|
+
export function killTreeSync(pid) {
|
|
72
|
+
if (!pid) return false;
|
|
73
|
+
try {
|
|
74
|
+
if (process.platform === 'win32') {
|
|
75
|
+
const res = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
76
|
+
return res && res.status === 0;
|
|
77
|
+
}
|
|
78
|
+
try { process.kill(-pid, 'SIGKILL'); } catch { process.kill(pid, 'SIGKILL'); }
|
|
79
|
+
return true;
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 取消之后最多再等这么久就把它当成"已停"(子进程被杀但不发 close 的兜底) */
|
|
86
|
+
const ABORT_SETTLE_MS = 3000;
|
|
87
|
+
|
|
43
88
|
/**
|
|
44
|
-
* @
|
|
89
|
+
* @param {object} [options]
|
|
90
|
+
* @param {number} [options.timeoutMs] 单步超时(到点按超时处理,走 killTree)
|
|
91
|
+
* @param {AbortSignal} [options.signal] 外部取消。**不传 ⇒ 行为与加这个参数之前一模一样**
|
|
92
|
+
* @returns {Promise<{code:number|null, stdout:string, stderr:string, timedOut:boolean, aborted:boolean, cmdline:string, error?:string}>}
|
|
45
93
|
*/
|
|
46
94
|
export function spawnCommand(file, args, options = {}) {
|
|
47
|
-
const { timeoutMs = 120000, env = {}, cwd, onLine, quiet = false } = options;
|
|
95
|
+
const { timeoutMs = 120000, env = {}, cwd, onLine, quiet = false, signal } = options;
|
|
48
96
|
const cmdline = formatCommandLine(file, args);
|
|
97
|
+
|
|
98
|
+
// 已经取消过了就别起进程:起一个立刻杀一个既慢又会给用户看一个假的"正在装"
|
|
99
|
+
if (signal && signal.aborted) {
|
|
100
|
+
return Promise.resolve({ code: null, stdout: '', stderr: '', timedOut: false, aborted: true, cmdline, error: 'aborted-before-start' });
|
|
101
|
+
}
|
|
102
|
+
|
|
49
103
|
return new Promise((resolve) => {
|
|
50
104
|
let child;
|
|
51
105
|
try {
|
|
@@ -56,20 +110,51 @@ export function spawnCommand(file, args, options = {}) {
|
|
|
56
110
|
env: { ...process.env, ...env },
|
|
57
111
|
});
|
|
58
112
|
} catch (err) {
|
|
59
|
-
resolve({ code: null, stdout: '', stderr: '', timedOut: false, cmdline, error: String(err && err.message ? err.message : err) });
|
|
113
|
+
resolve({ code: null, stdout: '', stderr: '', timedOut: false, aborted: false, cmdline, error: String(err && err.message ? err.message : err) });
|
|
60
114
|
return;
|
|
61
115
|
}
|
|
62
116
|
|
|
63
117
|
let stdout = '';
|
|
64
118
|
let stderr = '';
|
|
65
119
|
let timedOut = false;
|
|
120
|
+
let aborted = false;
|
|
66
121
|
let settled = false;
|
|
122
|
+
let abortSettleTimer = null;
|
|
123
|
+
let timer = null;
|
|
124
|
+
// 登记在册:进程退出时(Ctrl+C / 关 .cmd 窗口)要靠这份名册把子进程树同步杀掉,
|
|
125
|
+
// 否则页面停了、npm 还在改 profile(方案 R2)
|
|
126
|
+
if (child.pid) liveChildren.add(child.pid);
|
|
67
127
|
|
|
68
|
-
const
|
|
128
|
+
const finish = (payload) => {
|
|
129
|
+
if (settled) return;
|
|
130
|
+
settled = true;
|
|
131
|
+
if (child.pid) liveChildren.delete(child.pid);
|
|
132
|
+
if (timer) clearTimeout(timer);
|
|
133
|
+
if (abortSettleTimer) clearTimeout(abortSettleTimer);
|
|
134
|
+
if (signal) signal.removeEventListener('abort', onAbort);
|
|
135
|
+
resolve(payload);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
timer = setTimeout(() => {
|
|
69
139
|
timedOut = true;
|
|
70
140
|
killTree(child.pid);
|
|
71
141
|
}, timeoutMs);
|
|
72
142
|
|
|
143
|
+
// 取消与超时走**同一个** killTree 分支:都是"这条命令不能算成功",只是原因不同
|
|
144
|
+
const onAbort = () => {
|
|
145
|
+
if (settled) return;
|
|
146
|
+
aborted = true;
|
|
147
|
+
killTree(child.pid);
|
|
148
|
+
// 子进程被杀之后 close 一般很快就来;万一来不了,也不能让"取消中…"永远挂着
|
|
149
|
+
abortSettleTimer = setTimeout(() => {
|
|
150
|
+
finish({ code: null, stdout, stderr, timedOut, aborted, cmdline });
|
|
151
|
+
}, ABORT_SETTLE_MS);
|
|
152
|
+
};
|
|
153
|
+
if (signal) {
|
|
154
|
+
if (signal.aborted) onAbort();
|
|
155
|
+
else signal.addEventListener('abort', onAbort, { once: true });
|
|
156
|
+
}
|
|
157
|
+
|
|
73
158
|
const pump = (stream, into) => {
|
|
74
159
|
let buffer = '';
|
|
75
160
|
stream.on('data', (chunk) => {
|
|
@@ -90,17 +175,11 @@ export function spawnCommand(file, args, options = {}) {
|
|
|
90
175
|
if (child.stderr) pump(child.stderr, 'err');
|
|
91
176
|
|
|
92
177
|
child.on('error', (err) => {
|
|
93
|
-
|
|
94
|
-
settled = true;
|
|
95
|
-
clearTimeout(timer);
|
|
96
|
-
resolve({ code: null, stdout, stderr, timedOut, cmdline, error: String(err && err.message ? err.message : err) });
|
|
178
|
+
finish({ code: null, stdout, stderr, timedOut, aborted, cmdline, error: String(err && err.message ? err.message : err) });
|
|
97
179
|
});
|
|
98
180
|
|
|
99
181
|
child.on('close', (code) => {
|
|
100
|
-
|
|
101
|
-
settled = true;
|
|
102
|
-
clearTimeout(timer);
|
|
103
|
-
resolve({ code, stdout, stderr, timedOut, cmdline });
|
|
182
|
+
finish({ code, stdout, stderr, timedOut, aborted, cmdline });
|
|
104
183
|
});
|
|
105
184
|
});
|
|
106
185
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 把「环境障碍」翻成人话 —— 只给展示用,**不参与任何判据**。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有这一层:真装验收里撞到过两类拦路虎,安装器原来只甩一段英文原文,
|
|
5
|
+
* 用户看不出"这到底是包坏了、网络坏了、还是我自己的机器拦了它"。
|
|
6
|
+
*
|
|
7
|
+
* 三条硬边界(改这个文件之前先读一遍):
|
|
8
|
+
* 1. **纯函数**:不 spawn 子进程、不读盘、不发网络请求、不打日志。传什么进来看什么。
|
|
9
|
+
* 2. 认不出就返回 `null` —— 宁可不说,也不许瞎猜。猜错比不说更糟:
|
|
10
|
+
* 用户会照着错的"怎么办"去改机器。
|
|
11
|
+
* 3. 只做**追加**:调用方必须先原样打印原始 stderr,归因永远排在它后面。
|
|
12
|
+
* 本文件不认识退出码语义,也不允许谁拿 `kind` 当判据(0/2/3/4 一个不动)。
|
|
13
|
+
*
|
|
14
|
+
* 两类签名的真实出处(都来自真跑,不是编的):
|
|
15
|
+
* - `blocked-symlink`:Windows 上 360 / EDR 拦 pnpm 建符号链接,pnpm 栈里落在 registerProject
|
|
16
|
+
* - `store-mismatch`:pnpm store 与已有 node_modules 不是同一处(不同盘 / 换过数据根)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** 认得出来的码:`ERR_PNPM_UNEXPECTED_STORE` 最精确,先认它 */
|
|
20
|
+
const CODE_STORE_MISMATCH = 'ERR_PNPM_UNEXPECTED_STORE';
|
|
21
|
+
/** pnpm 所有自己的错误码都是这个前缀(认得出"是 pnpm 的错",但具体含义未必收录) */
|
|
22
|
+
const ANY_PNPM_CODE = /\bERR_PNPM_[A-Z0-9_]+\b/;
|
|
23
|
+
|
|
24
|
+
function splitLines(text) {
|
|
25
|
+
return String(text || '').split(/\r?\n/);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 解释一次 pnpm(或经 pnpm 的 `dsh plugin`)失败。
|
|
30
|
+
*
|
|
31
|
+
* @param {{stderr?: string, stdout?: string}} [input] 两个流任一命中都算
|
|
32
|
+
* @returns {{kind: string, code: string|null, title: string, why: string, fix: string}|null}
|
|
33
|
+
*/
|
|
34
|
+
export function explainPnpmFailure({ stderr, stdout } = {}) {
|
|
35
|
+
const errText = String(stderr || '');
|
|
36
|
+
const outText = String(stdout || '');
|
|
37
|
+
const text = `${errText}\n${outText}`;
|
|
38
|
+
if (!text.trim()) return null;
|
|
39
|
+
const lines = splitLines(text);
|
|
40
|
+
|
|
41
|
+
// ① store 与已有 node_modules 不是同一处:码是本工具最精确的指纹,先认它。
|
|
42
|
+
// 先于 blocked-symlink 判:两者万一同时出现,"要你用回原来那处 store"这句更可执行。
|
|
43
|
+
if (text.includes(CODE_STORE_MISMATCH)) {
|
|
44
|
+
return {
|
|
45
|
+
kind: 'store-mismatch',
|
|
46
|
+
code: CODE_STORE_MISMATCH,
|
|
47
|
+
title: '这个 profile 的 node_modules 是用另一处 pnpm store 装的,pnpm 现在想用新的那处,两边对不上。',
|
|
48
|
+
why: 'pnpm 在 node_modules 里记着"我是从哪个 store 链过来的";发现要用的 store 不是同一条就停手,'
|
|
49
|
+
+ '免得把已装依赖的链接链断掉。这条不是包坏了,也不是网络问题。',
|
|
50
|
+
fix: '① 让 pnpm 用回原来那处(pnpm config set store-dir <上面报的那个 store>)'
|
|
51
|
+
+ ' ② 或者就在那个 profile 里重装依赖(pnpm install,让 pnpm 按新 store 重链一遍)'
|
|
52
|
+
+ ' ③ 换一个干净的数据根(--home 指到一个空目录)',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ② 安全软件拦符号链接。判据两条,任一命中:
|
|
57
|
+
// a) 某一行里同时出现 EPERM 与 symlink(pnpm 的原样报错就长这样)
|
|
58
|
+
// b) 栈里出现 registerProject(pnpm「给这个 project 登记 store 链接」那一步被拦)
|
|
59
|
+
const epermSymlink = lines.some((l) => /\bEPERM\b/.test(l) && /symlink/i.test(l));
|
|
60
|
+
const registerProject = /registerProject/.test(text);
|
|
61
|
+
if (epermSymlink || registerProject) {
|
|
62
|
+
return {
|
|
63
|
+
kind: 'blocked-symlink',
|
|
64
|
+
code: epermSymlink ? 'EPERM' : null,
|
|
65
|
+
title: '这不是包坏了:你的安全软件(360 / EDR)拦了 pnpm 往 store 里建符号链接。',
|
|
66
|
+
why: 'pnpm 靠"store 里存一份真文件、各 profile 只放符号链接"来省空间;建链接这一步会被安全软件当成可疑动作拦掉。'
|
|
67
|
+
+ '这类拦截是间歇出现的 —— 同一条命令重试一次可能就过了。',
|
|
68
|
+
fix: '① 在安全软件里给 pnpm / node 放行(或暂时退出)'
|
|
69
|
+
+ ' ② 换个不会被拦的位置做数据根(--home 指到别处)'
|
|
70
|
+
+ ' ③ 直接重试一次(这类拦截是间歇的)',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ③ 认得出是 pnpm 自己的错,但这条码没收录 ⇒ 给原码 + 让用户把原文带走。
|
|
75
|
+
// 注意:这里**不**猜原因 —— 只说"是 pnpm 这一层报的,怎么修得看这个码"。
|
|
76
|
+
const anyCode = text.match(ANY_PNPM_CODE);
|
|
77
|
+
if (anyCode) {
|
|
78
|
+
const code = anyCode[0];
|
|
79
|
+
return {
|
|
80
|
+
kind: 'unknown-pnpm',
|
|
81
|
+
code,
|
|
82
|
+
title: `pnpm 自己报的错(${code}),这条码本工具还没收录。`,
|
|
83
|
+
why: '能确定是 pnpm 这一层的错(不是本工具的判据失败,也不是网络偶发),但具体怎么修得看这个码的含义。',
|
|
84
|
+
fix: `把上面那段原始输出整段贴给维护者,或者去 pnpm 文档 / 搜索引擎里搜「${code}」。`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 认不出 —— 别硬编。
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 退出码写人话:Windows 上子进程被信号/异常结束时,`res.code` 是**无符号**值,
|
|
94
|
+
* 日志里就出现过 `退出码 4294963248`(其实是 -4048 / 0xFFFFF030 那一类)。人读不出来。
|
|
95
|
+
*
|
|
96
|
+
* 规则:原始数值**一律保留**在最前面(可解析性不能丢),能解释成负数/十六进制时追加在括号里。
|
|
97
|
+
* 常规退码(0~65535)原样返回 —— 不减一个字。
|
|
98
|
+
*
|
|
99
|
+
* @param {number|string|null|undefined} code
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
export function formatExitCode(code) {
|
|
103
|
+
if (code === null || code === undefined) return '未知';
|
|
104
|
+
const n = Number(code);
|
|
105
|
+
if (!Number.isFinite(n)) return String(code);
|
|
106
|
+
if (!Number.isInteger(n)) return String(code);
|
|
107
|
+
if (n === 0) return '0';
|
|
108
|
+
|
|
109
|
+
const hex = (v) => `0x${(v >>> 0).toString(16).toUpperCase().padStart(8, '0')}`;
|
|
110
|
+
// 负数:补码形态最能对上文档里的 0xFFFFF030 那种写法
|
|
111
|
+
if (n < 0) return `${n}(${hex(n)})`;
|
|
112
|
+
// 2^31 以上:极可能是无符号包装过的负数(Windows 的 CRASH / 异常退出码都在这段)
|
|
113
|
+
if (n > 0x7fffffff) return `${n}(= ${n - 0x100000000},${hex(n)})`;
|
|
114
|
+
// 65535 以上但没溢出符号位:罕见的自定义码,给出十六进制方便对照
|
|
115
|
+
if (n > 0xffff) return `${n}(${hex(n)})`;
|
|
116
|
+
return String(n);
|
|
117
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 安装状态的两套徽标 —— **全仓只有这一份实现**。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独抽出来(方案 §11.6 · S1):
|
|
5
|
+
* `ui-page.mjs` 里原来有两份 `statusOf()`:一份给服务端首屏渲染、一份塞在页面脚本里。
|
|
6
|
+
* 加"安装中 / 成功 / 失败"这三个运行态时,两份必然漂移 —— 首屏说"已装"、重查后说"未装",
|
|
7
|
+
* 用户会以为安装器瞎报。
|
|
8
|
+
*
|
|
9
|
+
* 所以现在的规矩是:
|
|
10
|
+
* - 服务端用 `statusOf()` 渲染首屏,**并且**把结果作为 `item.status` 一起下发(`decorateState`);
|
|
11
|
+
* - 页面脚本**不再自己算**,只读 `item.status` / `item.label` 这两个字段。
|
|
12
|
+
* ⇒ 想改文案、想加状态,来这里改一处,两处自动一致。
|
|
13
|
+
*
|
|
14
|
+
* 两套徽标是不同的东西,别混:
|
|
15
|
+
* - `statusOf(item)` = 「这一项**现在装着没有**」(未装 / 已装 / 版本不在区间…)
|
|
16
|
+
* - `runLabel(status)` = 「这一轮**跑到它时怎么样**」(待装 / 安装中 / 成功 / 跳过 / 失败 / 已取消)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** 运行态徽标(进度区与清单表共用) */
|
|
20
|
+
export const RUN_LABELS = {
|
|
21
|
+
pending: { cls: 'badge-wait', text: '待装' },
|
|
22
|
+
running: { cls: 'badge-run', text: '安装中' },
|
|
23
|
+
ok: { cls: 'badge-ok', text: '成功' },
|
|
24
|
+
skip: { cls: 'badge-skip', text: '跳过' },
|
|
25
|
+
fail: { cls: 'badge-bad', text: '失败' },
|
|
26
|
+
cancelled: { cls: 'badge-warn', text: '已取消' },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function runLabel(status) {
|
|
30
|
+
return RUN_LABELS[status] || RUN_LABELS.pending;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const RUN_HINTS = {
|
|
34
|
+
pending: '还没轮到它',
|
|
35
|
+
running: '正在跑这一步的命令',
|
|
36
|
+
ok: '当场判据过了(不是"命令退出码 0")',
|
|
37
|
+
skip: '已经满足,这一轮不动它',
|
|
38
|
+
fail: '这一步没成(看下面那行 detail)',
|
|
39
|
+
cancelled: '取消时正跑到它',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 「现在装着没有」的徽标。
|
|
44
|
+
* `item.run` 存在时优先(同一行既可能"已装"、也可能"这一轮刚把它装成/装坏")。
|
|
45
|
+
*/
|
|
46
|
+
export function statusOf(item) {
|
|
47
|
+
if (!item || typeof item !== 'object') return { cls: 'badge-bad', text: '未装', hint: '读数缺失' };
|
|
48
|
+
if (item.run && RUN_LABELS[item.run]) {
|
|
49
|
+
return { cls: RUN_LABELS[item.run].cls, text: RUN_LABELS[item.run].text, hint: RUN_HINTS[item.run] || '' };
|
|
50
|
+
}
|
|
51
|
+
if (!item.installed) return { cls: 'badge-bad', text: '未装', hint: '这一项可以装(点【开始安装】)' };
|
|
52
|
+
if (item.inRange === 'unknown') return { cls: 'badge-warn', text: '已装', hint: '版本带预发布后缀,区间不判' };
|
|
53
|
+
if (item.inRange !== 'yes') return { cls: 'badge-warn', text: '已装', hint: '版本不在期望区间内' };
|
|
54
|
+
return { cls: 'badge-ok', text: '已装', hint: item.action === 'skip' ? '这一轮会跳过' : '这一轮会重跑' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 把 `statusOf` 的结果塞进 state 再下发 —— 页面脚本只读不算(这就是"同源") */
|
|
58
|
+
export function decorateState(state) {
|
|
59
|
+
if (!state || !Array.isArray(state.items)) return state;
|
|
60
|
+
return {
|
|
61
|
+
...state,
|
|
62
|
+
items: state.items.map((item) => ({ ...item, status: statusOf(item) })),
|
|
63
|
+
};
|
|
64
|
+
}
|
package/lib/run.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { runProbe } from './probe.mjs';
|
|
|
22
22
|
import { analyzeDump, checkMounted, skippedBundlesFromStderr } from './dump.mjs';
|
|
23
23
|
import { takeSnapshot, restoreSnapshot, verifySnapshotRestored, SNAPSHOT_WARNING } from './snapshot.mjs';
|
|
24
24
|
import { spawnCommand, formatCommandLine } from './exec.mjs';
|
|
25
|
+
import { explainPnpmFailure, formatExitCode } from './explain-failure.mjs';
|
|
25
26
|
import { EXIT } from './cli-args.mjs';
|
|
26
27
|
import {
|
|
27
28
|
DEFAULT_MANIFEST,
|
|
@@ -41,6 +42,30 @@ function firstLine(text) {
|
|
|
41
42
|
return String(text || '').trim().split(/\r?\n/)[0] || '';
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
/**
|
|
46
|
+
* 失败之后追加的「人话环境归因」—— **只展示,不参与判据**。
|
|
47
|
+
*
|
|
48
|
+
* 三条纪律(改这里之前先读一遍):
|
|
49
|
+
* 1. 原始报错已经在上一行**原样**打过了(`❌ …:<stderr 首行>`),这里只**追加**,
|
|
50
|
+
* 绝不替换、绝不截断,也不改退出码语义(0/2/3/4 一个不动)。
|
|
51
|
+
* 2. 终端这三行缩进 4 格 = 与子命令原始输出同一层,不冒充摘要行(`--ui` 的进度事件
|
|
52
|
+
* 只收摘要行,见 runSuite 里 say 的注释)。
|
|
53
|
+
* 3. 但页面(`--ui` / exe)也得看得见 —— 所以另外发**一条** log 事件(标题 + 怎么办)。
|
|
54
|
+
* 页面 lines 只收摘要行,这一条不再缩进;两处各显示各自的一份,不重复也不丢。
|
|
55
|
+
* 认不出(`explainPnpmFailure` 返回 null)就一个字都不加 —— 宁可不说,不瞎猜。
|
|
56
|
+
*/
|
|
57
|
+
function printFailureHint(say, emit, res) {
|
|
58
|
+
if (!res || res.timedOut || res.aborted) return null; // 超时/取消不是"环境障碍",别乱归因
|
|
59
|
+
const hint = explainPnpmFailure({ stderr: res.stderr, stdout: res.stdout });
|
|
60
|
+
if (!hint) return null;
|
|
61
|
+
say(` 💡 ${hint.title}`);
|
|
62
|
+
say(` ${hint.why}`);
|
|
63
|
+
say(` 怎么办:${hint.fix}`);
|
|
64
|
+
// 页面 lines 用的那一份:一句话带上"怎么办"(页面是窄栏,别把三段都塞进去)
|
|
65
|
+
emit({ kind: 'log', detail: `💡 ${hint.title} | 怎么办:${hint.fix}` });
|
|
66
|
+
return hint;
|
|
67
|
+
}
|
|
68
|
+
|
|
44
69
|
function stamp() {
|
|
45
70
|
return new Date().toISOString().replace(/[-:]/g, '').replace(/\..*$/, '');
|
|
46
71
|
}
|
|
@@ -140,7 +165,7 @@ export async function verifyItem(item, ctx) {
|
|
|
140
165
|
label: '挂载',
|
|
141
166
|
status: mounted.ok ? 'pass' : 'fail',
|
|
142
167
|
detail: mounted.ok
|
|
143
|
-
? `组合头 1 行 · 条目
|
|
168
|
+
? `组合头 1 行 · 条目 ${mounted.entryCount} 条(${mounted.names.join(' | ')}) · 该 id 计数 ${mounted.idCounts.join(',') || 'n/a'}`
|
|
144
169
|
: mounted.problems.join(';'),
|
|
145
170
|
});
|
|
146
171
|
if (!mounted.ok && dump.skipped.length) {
|
|
@@ -162,14 +187,54 @@ function printChecks(say, checks) {
|
|
|
162
187
|
|
|
163
188
|
/**
|
|
164
189
|
* 主入口。返回 {exitCode, summary}。
|
|
190
|
+
*
|
|
191
|
+
* 第二个参数 `hooks` 是**纯增量**(2026-10-31 为 --ui 的逐项进度加的):
|
|
192
|
+
* - `hooks.onEvent(event)`:进度回调,事件形如
|
|
193
|
+
* { kind:'step-start'|'step-done'|'log'|'batch-done', index, total, item, ok, detail }
|
|
194
|
+
* - `hooks.signal`(AbortSignal):取消信号,直接透给 spawnCommand(页面上的【取消】靠它)
|
|
195
|
+
* 🔴 不传 hooks(或传了个空对象)⇒ 输出与行为**与加这个参数之前逐字节相同**;
|
|
196
|
+
* 回调自己抛错也不算安装失败(安装结果只认判据)。
|
|
197
|
+
* 🔴 进度事件只带**摘要行**:每行命令的原始输出在这里都缩进 4 格,那是 MB 级的 npm 输出,
|
|
198
|
+
* 不进内存进度对象、更不进页面。别改成 monkey-patch process.stdout.write —— 那会污染
|
|
199
|
+
* 整个向导页进程的输出(.cmd 窗口 + 多请求互相穿插)。
|
|
165
200
|
*/
|
|
166
|
-
export async function runSuite(options) {
|
|
201
|
+
export async function runSuite(options, hooks = {}) {
|
|
202
|
+
const hooksObj = hooks && typeof hooks === 'object' ? hooks : {};
|
|
203
|
+
const signal = hooksObj.signal;
|
|
204
|
+
const emit = (event) => {
|
|
205
|
+
if (typeof hooksObj.onEvent !== 'function') return;
|
|
206
|
+
try {
|
|
207
|
+
hooksObj.onEvent(event);
|
|
208
|
+
} catch {
|
|
209
|
+
/* 进度回调炸了不能带崩安装:安装结果只认判据,不认回调 */
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
167
213
|
const lines = [];
|
|
214
|
+
let failure = null;
|
|
168
215
|
const say = (text = '') => {
|
|
169
216
|
lines.push(text);
|
|
170
217
|
const stream = options.json ? process.stderr : process.stdout;
|
|
171
218
|
stream.write(`${text}\n`);
|
|
219
|
+
// 摘要行 = 没缩进 4 格的那些;缩进 4 格的是子命令的原始输出(见下面 onLine: (l) => say(` ${l}`))
|
|
220
|
+
if (typeof hooksObj.onEvent === 'function' && !text.startsWith(' ')) {
|
|
221
|
+
emit({ kind: 'log', detail: text.length > 500 ? `${text.slice(0, 500)}…` : text });
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
const finish = (result) => {
|
|
225
|
+
const installed = Array.isArray(result.summary && result.summary.installedThisRun) ? result.summary.installedThisRun : [];
|
|
226
|
+
emit({
|
|
227
|
+
kind: 'batch-done',
|
|
228
|
+
ok: result.exitCode === EXIT.OK,
|
|
229
|
+
exitCode: result.exitCode,
|
|
230
|
+
installed: installed.slice(),
|
|
231
|
+
failed: failure && failure.entry ? failure.entry.item.name : null,
|
|
232
|
+
detail: result.exitCode === EXIT.OK ? '全绿' : `退出码 ${result.exitCode}`,
|
|
233
|
+
});
|
|
234
|
+
return result;
|
|
172
235
|
};
|
|
236
|
+
// 所有出口都过这一层:/install/progress 靠 batch-done 判"这一批结束了没有"
|
|
237
|
+
const ret = (exitCode) => finish({ exitCode, summary, text: lines.join('\n') });
|
|
173
238
|
// 清单 → 选中 → 现状 → 每项要跑的命令:全部在 lib/plan.mjs 里算一次。
|
|
174
239
|
// 这里只负责"印人话";`--ui` 的 GET /state 是同一份数据的另一种渲染 —— 两处必然一致。
|
|
175
240
|
const built = await buildSuiteState(options);
|
|
@@ -183,7 +248,7 @@ export async function runSuite(options) {
|
|
|
183
248
|
|
|
184
249
|
if (!built.ok) {
|
|
185
250
|
summary.exitCode = built.exitCode;
|
|
186
|
-
return
|
|
251
|
+
return ret(built.exitCode);
|
|
187
252
|
}
|
|
188
253
|
|
|
189
254
|
const { plan, preflight } = built;
|
|
@@ -217,33 +282,38 @@ export async function runSuite(options) {
|
|
|
217
282
|
if (preflight.length) {
|
|
218
283
|
for (const p of preflight) warn(`前置条件不满足:${p}`);
|
|
219
284
|
summary.exitCode = EXIT.USAGE;
|
|
220
|
-
return
|
|
285
|
+
return ret(EXIT.USAGE);
|
|
221
286
|
}
|
|
222
287
|
summary.exitCode = EXIT.OK;
|
|
223
|
-
return
|
|
288
|
+
return ret(EXIT.OK);
|
|
224
289
|
}
|
|
225
290
|
|
|
226
291
|
if (preflight.length) {
|
|
227
292
|
for (const p of preflight) say(`✖ ${p}`);
|
|
228
293
|
summary.exitCode = EXIT.USAGE;
|
|
229
|
-
return
|
|
294
|
+
return ret(EXIT.USAGE);
|
|
230
295
|
}
|
|
231
296
|
|
|
232
297
|
// ── 5b. 卸载 ──────────────────────────────────────────────
|
|
233
298
|
if (options.uninstall) {
|
|
234
299
|
let failed = 0;
|
|
235
|
-
for (const entry of plan) {
|
|
300
|
+
for (const [index, entry] of plan.entries()) {
|
|
236
301
|
const step = uninstallStepFor(entry.item, { profile: options.profile, npmPrefix: npm.prefix });
|
|
237
302
|
say(`\n[${entry.item.group}] ${entry.item.name}`);
|
|
238
303
|
say(` $ ${formatCommandLine(step.file, step.args)}`);
|
|
304
|
+
emit({ kind: 'step-start', index: index + 1, total: plan.length, item: entry.item.name, action: 'uninstall' });
|
|
239
305
|
const res = await spawnCommand(step.file, step.args, {
|
|
240
306
|
timeoutMs: options.timeoutMs,
|
|
241
307
|
env: execEnv(ctx, step.env),
|
|
242
308
|
onLine: (l) => say(` ${l}`),
|
|
309
|
+
signal,
|
|
243
310
|
});
|
|
244
311
|
if (res.timedOut || res.code !== 0) {
|
|
245
|
-
say(` ❌ 卸载命令退出码 ${res.timedOut ? '超时' : res.code}`);
|
|
312
|
+
say(` ❌ 卸载命令退出码 ${res.timedOut ? '超时' : formatExitCode(res.code)}`);
|
|
313
|
+
const hint = printFailureHint(say, emit, res);
|
|
314
|
+
const detail = `卸载命令${res.timedOut ? '超时' : `退出码 ${formatExitCode(res.code)}`}`;
|
|
246
315
|
failed += 1;
|
|
316
|
+
emit({ kind: 'step-done', index: index + 1, total: plan.length, item: entry.item.name, ok: false, detail: hint ? `${detail}|${hint.title}` : detail });
|
|
247
317
|
continue;
|
|
248
318
|
}
|
|
249
319
|
invalidateDump();
|
|
@@ -251,11 +321,12 @@ export async function runSuite(options) {
|
|
|
251
321
|
const gone = after.version === null;
|
|
252
322
|
say(` ${gone ? '✅' : '❌'} 卸载核对:${gone ? '实体已不在' : `实体仍在(${after.version})`}`);
|
|
253
323
|
if (!gone) failed += 1;
|
|
324
|
+
emit({ kind: 'step-done', index: index + 1, total: plan.length, item: entry.item.name, ok: gone, detail: gone ? '实体已不在' : `实体仍在(${after.version})` });
|
|
254
325
|
}
|
|
255
326
|
summary.exitCode = failed ? EXIT.ROLLED_BACK : EXIT.OK;
|
|
256
327
|
say(`\n卸载结束:${plan.length - failed} 项已卸、${failed} 项没卸干净。`);
|
|
257
328
|
if (failed) say('(卸载没有"回滚"这一说:它看的是现在装着什么,不是这一轮做了什么。)');
|
|
258
|
-
return
|
|
329
|
+
return ret(summary.exitCode);
|
|
259
330
|
}
|
|
260
331
|
|
|
261
332
|
// ── 6. 快照(真跑前唯一一次写盘,且只写快照目录)────────────────
|
|
@@ -269,7 +340,7 @@ export async function runSuite(options) {
|
|
|
269
340
|
} catch (err) {
|
|
270
341
|
say(`✖ 快照写不出来,按规矩停手(不带着"回滚不了"的批次往下跑):${err.message}`);
|
|
271
342
|
summary.exitCode = EXIT.UNCLEAN;
|
|
272
|
-
return
|
|
343
|
+
return ret(EXIT.UNCLEAN);
|
|
273
344
|
}
|
|
274
345
|
say(`快照:${snapshotRoot}(开跑前一份 pre-run,之后每装成功一步再拍一份)`);
|
|
275
346
|
say(` ${SNAPSHOT_WARNING}`);
|
|
@@ -278,17 +349,20 @@ export async function runSuite(options) {
|
|
|
278
349
|
// ── 7. 逐步执行 ────────────────────────────────────────────
|
|
279
350
|
const runStart = Date.now();
|
|
280
351
|
const installedThisRun = [];
|
|
281
|
-
|
|
352
|
+
// (failure 在函数顶部声明:batch-done 事件要读它,早退的出口也得能读)
|
|
282
353
|
|
|
283
354
|
for (let i = 0; i < plan.length; i += 1) {
|
|
284
355
|
const entry = plan[i];
|
|
285
356
|
entry.stepIndex = i;
|
|
357
|
+
emit({ kind: 'step-start', index: i + 1, total: plan.length, item: entry.item.name, action: entry.action });
|
|
286
358
|
if (entry.action === 'skip') {
|
|
287
359
|
say(`[${i + 1}/${plan.length}] ⏭ ${entry.item.name} 已满足(${entry.version}),跳过`);
|
|
360
|
+
emit({ kind: 'step-done', index: i + 1, total: plan.length, item: entry.item.name, ok: true, detail: `已满足(${entry.version}),跳过` });
|
|
288
361
|
continue;
|
|
289
362
|
}
|
|
290
363
|
if (Date.now() - runStart > options.budgetMs) {
|
|
291
364
|
failure = { reason: 'budget', entry, message: `整批已跑 ${Math.round((Date.now() - runStart) / 1000)}s,超过墙钟上限 ${Math.round(options.budgetMs / 1000)}s` };
|
|
365
|
+
emit({ kind: 'step-done', index: i + 1, total: plan.length, item: entry.item.name, ok: false, detail: '没开始:整批墙钟超时' });
|
|
292
366
|
break;
|
|
293
367
|
}
|
|
294
368
|
const step = installStepFor(entry.item, { profile: options.profile, npmPrefix: npm.prefix });
|
|
@@ -298,6 +372,7 @@ export async function runSuite(options) {
|
|
|
298
372
|
timeoutMs: options.timeoutMs,
|
|
299
373
|
env: execEnv(ctx, step.env),
|
|
300
374
|
onLine: (l) => say(` ${l}`),
|
|
375
|
+
signal,
|
|
301
376
|
});
|
|
302
377
|
entry.command = formatCommandLine(step.file, step.args);
|
|
303
378
|
entry.rc = res.code;
|
|
@@ -305,10 +380,23 @@ export async function runSuite(options) {
|
|
|
305
380
|
failure = {
|
|
306
381
|
reason: 'cmd',
|
|
307
382
|
entry,
|
|
308
|
-
message:
|
|
383
|
+
message: res.aborted
|
|
384
|
+
? '这条命令被取消了(前端点了【取消】⇒ AbortSignal ⇒ 进程树被杀);这一步可能只装了半截'
|
|
385
|
+
: `安装命令${res.timedOut ? '超时' : `退出码 ${formatExitCode(res.code)}`}:${firstLine(res.stderr) || '(无输出)'}`,
|
|
309
386
|
attempted: true,
|
|
387
|
+
aborted: Boolean(res.aborted),
|
|
388
|
+
hint: null, // 纯展示用的环境归因(见 printFailureHint),**不是判据**
|
|
310
389
|
};
|
|
311
390
|
say(` ❌ ${failure.message}`);
|
|
391
|
+
failure.hint = printFailureHint(say, emit, res);
|
|
392
|
+
emit({
|
|
393
|
+
kind: 'step-done',
|
|
394
|
+
index: i + 1,
|
|
395
|
+
total: plan.length,
|
|
396
|
+
item: entry.item.name,
|
|
397
|
+
ok: false,
|
|
398
|
+
detail: failure.hint ? `${failure.message}|${failure.hint.title}` : failure.message,
|
|
399
|
+
});
|
|
312
400
|
break;
|
|
313
401
|
}
|
|
314
402
|
invalidateDump();
|
|
@@ -317,6 +405,7 @@ export async function runSuite(options) {
|
|
|
317
405
|
printChecks(say, verdict.checks);
|
|
318
406
|
if (!verdict.ok) {
|
|
319
407
|
failure = { reason: 'criteria', entry, message: '命令成功了,但判据没过(这类"装完不生效"正是本工具要抓的)', attempted: true };
|
|
408
|
+
emit({ kind: 'step-done', index: i + 1, total: plan.length, item: entry.item.name, ok: false, detail: failure.message });
|
|
320
409
|
break;
|
|
321
410
|
}
|
|
322
411
|
// 记下装完之后的真实版本(汇总里要显示"实际装成了什么",不是"装之前是什么")
|
|
@@ -324,6 +413,7 @@ export async function runSuite(options) {
|
|
|
324
413
|
? profileInstalledVersion(home, options.profile, entry.item.name)
|
|
325
414
|
: globalInstalledVersion(npm.prefix, entry.item.name);
|
|
326
415
|
installedThisRun.push(entry);
|
|
416
|
+
emit({ kind: 'step-done', index: i + 1, total: plan.length, item: entry.item.name, ok: true, detail: `装好:实体 ${entry.version || 'n/a'}` });
|
|
327
417
|
// 每成功一步拍一份:步级回滚要回到"失败那一步之前",而不是回到整批开跑之前
|
|
328
418
|
const afterDir = path.join(snapshotRoot, `after-${String(i + 1).padStart(2, '0')}`);
|
|
329
419
|
try {
|
|
@@ -367,7 +457,7 @@ export async function runSuite(options) {
|
|
|
367
457
|
if (!failure) {
|
|
368
458
|
summary.exitCode = EXIT.OK;
|
|
369
459
|
printSummary(say, plan, null);
|
|
370
|
-
return
|
|
460
|
+
return ret(EXIT.OK);
|
|
371
461
|
}
|
|
372
462
|
|
|
373
463
|
// ── 9. 回滚 ───────────────────────────────────────────────
|
|
@@ -402,13 +492,15 @@ export async function runSuite(options) {
|
|
|
402
492
|
}
|
|
403
493
|
const step = uninstallStepFor(entry.item, { profile: options.profile, npmPrefix: npm.prefix });
|
|
404
494
|
say(` $ ${formatCommandLine(step.file, step.args)}`);
|
|
495
|
+
// 回滚里的卸载同样要能被取消打断:用户点了【取消】之后不该继续干 2 分钟的活
|
|
405
496
|
const res = await spawnCommand(step.file, step.args, {
|
|
406
497
|
timeoutMs: options.timeoutMs,
|
|
407
498
|
env: execEnv(ctx, step.env),
|
|
408
499
|
onLine: (l) => say(` ${l}`),
|
|
500
|
+
signal,
|
|
409
501
|
});
|
|
410
502
|
actions.push({ name: entry.item.name, command: formatCommandLine(step.file, step.args), rc: res.code, timedOut: res.timedOut });
|
|
411
|
-
if (res.timedOut || res.code !== 0) say(` ❌ 卸不掉(${res.timedOut ? '超时' : `rc=${res.code}`})—— 这一项留下了残留`);
|
|
503
|
+
if (res.timedOut || res.code !== 0) say(` ❌ 卸不掉(${res.timedOut ? '超时' : `rc=${formatExitCode(res.code)}`})—— 这一项留下了残留`);
|
|
412
504
|
}
|
|
413
505
|
if (!undo.length) say(' (没有跑过任何安装命令,无需卸载)');
|
|
414
506
|
|
|
@@ -454,7 +546,7 @@ export async function runSuite(options) {
|
|
|
454
546
|
summary.rollback = { mode, clean, snapshotDirUsed: rollbackDir, actions, verify, stillDeclared, residue };
|
|
455
547
|
summary.exitCode = clean ? EXIT.ROLLED_BACK : EXIT.UNCLEAN;
|
|
456
548
|
printSummary(say, plan, failure);
|
|
457
|
-
return
|
|
549
|
+
return ret(summary.exitCode);
|
|
458
550
|
}
|
|
459
551
|
|
|
460
552
|
function printSummary(say, plan, failure) {
|