@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/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
- * @returns {Promise<{code:number|null, stdout:string, stderr:string, timedOut:boolean, cmdline:string, error?:string}>}
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 timer = setTimeout(() => {
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
- if (settled) return;
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
- if (settled) return;
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 行 · 条目 1 条 · 该 id 计数 ${mounted.idCounts.join(',') || 'n/a'}`
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 { exitCode: built.exitCode, summary, text: lines.join('\n') };
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 { exitCode: EXIT.USAGE, summary, text: lines.join('\n') };
285
+ return ret(EXIT.USAGE);
221
286
  }
222
287
  summary.exitCode = EXIT.OK;
223
- return { exitCode: EXIT.OK, summary, text: lines.join('\n') };
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 { exitCode: EXIT.USAGE, summary, text: lines.join('\n') };
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 { exitCode: summary.exitCode, summary, text: lines.join('\n') };
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 { exitCode: EXIT.UNCLEAN, summary, text: lines.join('\n') };
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
- let failure = null;
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: `安装命令${res.timedOut ? '超时' : `退出码 ${res.code}`}:${firstLine(res.stderr) || '(无输出)'}`,
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 { exitCode: EXIT.OK, summary, text: lines.join('\n') };
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 { exitCode: summary.exitCode, summary, text: lines.join('\n') };
549
+ return ret(summary.exitCode);
458
550
  }
459
551
 
460
552
  function printSummary(say, plan, failure) {