@noob-stupid/dsh-plugin-console 0.5.101 → 0.5.103

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.
@@ -0,0 +1,476 @@
1
+ // L1 · domain —— install-recovery.js(安装过程把**已有副本**弄坏之后的复核与恢复;2026-11-04 加法)
2
+ // 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言;不认识 cordis ctx)
3
+ //
4
+ // ── 现场(真机 2026-11-04,用户提供的原始日志)────────────────────────────────────
5
+ // 一次「详情页更新控制台自己」失败:pnpm 被 180s 超时**杀整棵进程树**(`已终止整棵进程树 pid=25340`),
6
+ // 同一个进程还在把 `@noob-stupid/dsh-plugin-console_tmp_13796_2` 之类的目录往 `.ignored` 里搬。
7
+ // 杀掉之后没人收尾 ⇒ pnpm 那一套"先把正式目录改名成 `_tmp_`、再解包新内容"停在中途:
8
+ // node_modules/@noob-stupid/dsh-plugin-console/ 只剩 1 个文件,package.json 没了
9
+ // ⇒ 宿主解析不出这个包 ⇒ 面板显示「异常 / 包不可用」。同一次里 `.ignored` 已经堆到 **118 项 / 335 MB**,
10
+ // 其中 `dsh-workbuddy-connect_tmp_*` 六十多个 —— 说明这个失败模式**长期存在**,只是以前没人收口。
11
+ //
12
+ // ── 本模块补的是什么(用户原话:「杀进程后立刻复核目标目录,发现 package.json 缺失或空壳就从备份恢复;
13
+ // 恢复不了就如实报,绝不允许报成功」)────────────────────────────────────────────
14
+ // ① **杀进程前**先把"这个目录里原来是什么"记下来(有没有清单、版本是多少);
15
+ // ② 通道**每次**抛"被杀/超时/占用"类错误之后**立刻**复核 + 恢复(不等整条通道序列走完 —— 否则后面
16
+ // 每条通道都在一个空目录上继续施工,越走越远);
17
+ // ③ 整条序列结束时(成功、失败、抛异常都算)再复核一次:结构坏了 ⇒ 先试**更新前的备份**,
18
+ // 没有再试 **pnpm 的暂存目录**(`<pkg>_tmp_*`,那里面通常是一份完整副本 ⇒ 这正是"从暂存目录恢复");
19
+ // ④ 恢复不了 ⇒ 报告里带**绝对路径** + 可直接复制运行的修复命令,并且**绝不报成功**
20
+ // (调用方据此把这次尝试改判成"没装上")。
21
+ //
22
+ // ── 与既有实现的关系(复用,不另造一套)────────────────────────────────────────────
23
+ // · 备份/恢复/校验/手动命令**全部复用** domain/self-update-guard.js 的现成实现:
24
+ // `backupSelfPackageDir` / `restoreSelfPackageDir` / `pruneSelfBackups` / `dropBackupOf` /
25
+ // `manualRestoreCommand` / `verifySelfPackageDir`。那套判据本来是给"控制台自己"写的,
26
+ // 但它的判据函数都接受一个包名(通过既有注入缝 `deps.selfName`),所以这里**不改它一个字节**,
27
+ // 只是把包名传进去 —— 于是同一套备份命名(`.pre-update-<ts>`)、同一套"改名挪开不删"、
28
+ // 同一份手动命令,对本模块覆盖的所有包都成立。
29
+ // · 本模块**只管"目录被弄坏之后的复核与恢复"**:不判断安装成功与否、不改版本闸、不改任何通道。
30
+ //
31
+ // ── 什么时候**不**接管(边界写死在这里,别越线)──────────────────────────────────
32
+ // ① 包名/目录算不出来 ⇒ 不接管(既有行为一个字节都不变)。
33
+ // ② 目标目录不在 `node_modules` 里(源码检出 / 开发树)⇒ 不接管(与 self-update-guard 的 looksInstalled 同源)。
34
+ // ③ 目标目录本身是**链接**(`link:` / junction 安装)⇒ **不接管**:那是用户自己的源码树,把它恢复成"真目录"
35
+ // 会让"改了源码不生效"变成常态。宁可不动手,也要在报告里说清为什么不动手。
36
+ // ④ 安装**之前**目录就已经不是一份完整副本(首次安装、本来就坏着)⇒ 不接管:
37
+ // 没有"原来那版"可回滚,接管只会把既有失败路径改一遍。
38
+ // ⑤ 备份成败都不阻断安装:备份失败只记原因(真机 EPERM 时连读都读不出来),恢复永远排在校验之后。
39
+ import { existsSync, lstatSync, readdirSync, statSync } from 'node:fs'
40
+ import { basename, dirname, join } from 'node:path'
41
+ import { compareSemverText, parseSemverText } from '../infra/semver.js'
42
+ import { alreadyInstalledTarget } from './install-verify.js'
43
+ import {
44
+ PRE_UPDATE_SUFFIX, backupSelfPackageDir, dropBackupOf, looksInstalled, manualRestoreCommand,
45
+ pruneSelfBackups, restoreSelfPackageDir, verifySelfPackageDir,
46
+ } from './self-update-guard.js'
47
+
48
+ /** 备份的体积上限(默认 256 MB):超了就**跳过备份并如实记下原因**(一次更新不该顺带拷几百 MB)。
49
+ * 真机量级参考:本机 desktop profile 里的控制台 2.7 MB / 161 个文件,普通插件都在个位数 MB。 */
50
+ const MAX_BACKUP_BYTES = 256 * 1024 * 1024
51
+ /** pnpm 的暂存目录前缀:`<包目录名>_tmp_<pid>_<序号>`(真机日志里就是这个形状)。 */
52
+ const STAGING_MARK = '_tmp_'
53
+ /** 备份最多留几份(与 self-update-guard 的 KEEP_BACKUPS 同口径:这里只收口"本模块造的"那一族)。 */
54
+ const KEEP_BACKUPS = 1
55
+
56
+ /** `<profileDir>/node_modules/<包名>`(作用域包按 `/` 分段)。 */
57
+ function packageDirOf(profileDir, packageName) {
58
+ const name = typeof packageName === 'string' ? packageName.trim() : ''
59
+ const dir = typeof profileDir === 'string' ? profileDir.trim() : ''
60
+ if (name === '' || dir === '') return ''
61
+ return join(dir, 'node_modules', ...name.split('/'))
62
+ }
63
+
64
+ /** 目录是不是**链接**(junction 在 lstat 上也表现为符号链接)—— 见边界③。只读,失败按"不是链接"处理。 */
65
+ function isLinkedDir(dir) {
66
+ try { return lstatSync(dir).isSymbolicLink() === true } catch { return false }
67
+ }
68
+
69
+ /** 目录体积(有上限地递归统计:一旦超过 `limit` 立刻返回,不把一次更新变成一次全盘扫描)。 */
70
+ function dirBytes(dir, { limit = MAX_BACKUP_BYTES, readdir = readdirSync, stat = statSync } = {}) {
71
+ let total = 0
72
+ const walk = (current) => {
73
+ let entries = []
74
+ try { entries = readdir(current, { withFileTypes: true }) } catch { return }
75
+ for (const entry of entries) {
76
+ if (total > limit) return
77
+ const full = join(current, entry.name)
78
+ if (entry.isDirectory()) { walk(full); continue }
79
+ if (!entry.isFile()) continue
80
+ try { total += stat(full).size } catch {}
81
+ }
82
+ }
83
+ try { if (existsSync(dir)) walk(dir) } catch {}
84
+ return total
85
+ }
86
+
87
+ /** 一次"这个目录现在是什么"的只读快照(结构判据复用 verifySelfPackageDir,包名走既有注入缝)。 */
88
+ function inspectPackageDir(packageDir, packageName, deps = {}) {
89
+ const dir = String(packageDir ?? '')
90
+ const name = typeof packageName === 'string' ? packageName.trim() : ''
91
+ const exists = typeof deps.exists === 'function' ? deps.exists : existsSync
92
+ const out = { path: join(dir, 'package.json'), exists: exists(dir) === true, linked: false, ok: false, hasManifest: false, version: null, packageName: null, reason: null }
93
+ if (dir === '' || name === '') { out.reason = '包名或目录为空'; return out }
94
+ out.linked = isLinkedDir(dir)
95
+ if (exists(dir) !== true) { out.reason = '目录不存在(安装过程把它删掉/搬走了)'; return out }
96
+ const verdict = verifySelfPackageDir(dir, null, { ...deps, selfName: name })
97
+ out.ok = verdict.ok === true
98
+ out.version = verdict.version ?? null
99
+ out.packageName = verdict.packageName ?? null
100
+ out.reason = verdict.reason ?? null
101
+ out.hasManifest = exists(out.path) === true
102
+ return out
103
+ }
104
+
105
+ /** 判断"这个错误是不是'进程被杀/目录被占用'那一类"(只认明确的证据,宁窄勿宽)。 */
106
+ function looksInterrupted(error) {
107
+ if (error === null || error === undefined) return false
108
+ if (error.timedOut === true || error.killed === true) return true
109
+ const text = `${error.code ?? ''} ${error.message ?? ''}`
110
+ return /已终止整棵进程树|SIGTERM|SIGKILL|EPERM|EACCES|EBUSY|ENOTEMPTY|operation not permitted|being used by another process|maxBuffer/iu.test(text)
111
+ }
112
+
113
+ /** 版本比较(只用来**记录**"有没有退回",不参与任何成功判定;解析不了就返回 null)。 */
114
+ function versionDirection(before, after) {
115
+ const a = parseSemverText(before ?? '')
116
+ const b = parseSemverText(after ?? '')
117
+ if (a === null || b === null) return null
118
+ const diff = compareSemverText(b, a)
119
+ return diff < 0 ? 'regressed' : (diff > 0 ? 'advanced' : 'same')
120
+ }
121
+
122
+ /**
123
+ * 找 pnpm 留下的**可用暂存副本**:`node_modules/<父>/<包目录名>_tmp_*` 里含同一包名清单的那些,
124
+ * 按版本取最高的一个(都没有版本号就按名字排序取最后一个)。返回绝对路径或 null。
125
+ * 为什么值得一试:真机日志里 pnpm 正是把正式目录改名成 `_tmp_<pid>_<n>` 再搬去 `.ignored`,
126
+ * 所以**正式目录空掉时,完整的那一份很可能就在这几个 `_tmp_` 里**。
127
+ */
128
+ function findStagingDir(profileDir, packageName, deps = {}) {
129
+ const dir = packageDirOf(profileDir, packageName)
130
+ if (dir === '') return null
131
+ const parent = dirname(dir)
132
+ const base = basename(dir)
133
+ const readdir = typeof deps.readdir === 'function' ? deps.readdir : readdirSync
134
+ const name = typeof packageName === 'string' ? packageName.trim() : ''
135
+ let names = []
136
+ try { names = readdir(parent).filter((entry) => typeof entry === 'string' && entry.startsWith(`${base}${STAGING_MARK}`)).sort() } catch { return null }
137
+ const usable = []
138
+ for (const entry of names) {
139
+ const candidate = join(parent, entry)
140
+ const verdict = verifySelfPackageDir(candidate, null, { ...deps, selfName: name })
141
+ if (verdict.ok === true) usable.push({ path: candidate, version: verdict.version ?? null })
142
+ }
143
+ if (usable.length === 0) return null
144
+ usable.sort((x, y) => {
145
+ const a = parseSemverText(x.version ?? '')
146
+ const b = parseSemverText(y.version ?? '')
147
+ if (a !== null && b !== null) return compareSemverText(b, a)
148
+ return String(x.path) < String(y.path) ? 1 : -1
149
+ })
150
+ return usable[0]
151
+ }
152
+
153
+ /** 备份一族(`<pkg>.pre-update-*`)里最新的一份(本模块只在"本次备份没建成功"时用它兜底)。返回绝对路径或 null。 */
154
+ function findLatestBackup(packageDir) {
155
+ const dir = String(packageDir ?? '')
156
+ if (dir === '') return null
157
+ const parent = dirname(dir)
158
+ const prefix = `${basename(dir)}${PRE_UPDATE_SUFFIX}`
159
+ try {
160
+ const names = readdirSync(parent).filter((entry) => typeof entry === 'string' && entry.startsWith(prefix)).sort()
161
+ return names.length === 0 ? null : join(parent, names[names.length - 1])
162
+ } catch { return null }
163
+ }
164
+
165
+ /**
166
+ * 开始一次"更新已有副本"的保护(**唯一入口**)。不满足边界 ①–④ 时返回 null:
167
+ * 调用方据此走原路径,一个字节都不多走(这就是"别的行为不变"的实现方式)。
168
+ * 返回的 session 只装现场事实与备份结果,**不动任何文件**(除了复制出一份备份)。
169
+ */
170
+ function beginInstallRecovery({ packageName = '', profileDir = '', packageDir = null, targetVersion = null, job = null, deps = {} } = {}) {
171
+ const name = typeof packageName === 'string' ? packageName.trim() : ''
172
+ if (name === '') return null
173
+ const dir = typeof packageDir === 'string' && packageDir !== '' ? packageDir : packageDirOf(profileDir, name)
174
+ if (dir === '') return null
175
+ // 边界⑤:这一次通道**根本不会动目录**(包已在盘上、且不是更新请求 ⇒ install-verify 的 alreadyInstalledTarget
176
+ // 会直接短路"跳过重复下载")⇒ 不备份、不接管:省掉一次整目录复制,语义与那条既有判据一致(不新造判据)。
177
+ if (job !== null && job !== undefined && typeof profileDir === 'string' && profileDir !== ''
178
+ && alreadyInstalledTarget(profileDir, name, job) !== null) return null
179
+ // 边界②:只认"真的装在 node_modules 里"的那一份
180
+ const installedShaped = typeof deps.looksInstalled === 'function' ? deps.looksInstalled(dir) === true : looksInstalled(dir) === true
181
+ if (installedShaped !== true) return null
182
+ // 边界③:链接安装(link:/junction)不动手
183
+ if (isLinkedDir(dir) === true) return null
184
+ // 边界④:安装前就必须是一份完整的副本(首次安装 / 本来就坏着 ⇒ 不接管)
185
+ const before = inspectPackageDir(dir, name, deps)
186
+ if (before.ok !== true) return null
187
+ // 备份(复制;失败不阻断,只如实记)
188
+ const limit = Number.isFinite(deps.maxBackupBytes) && deps.maxBackupBytes > 0 ? deps.maxBackupBytes : MAX_BACKUP_BYTES
189
+ const bytes = typeof deps.sizeOf === 'function' ? Number(deps.sizeOf(dir)) || 0 : dirBytes(dir, { limit })
190
+ let backup = { ok: false, path: null, reason: null, previousVersion: before.version, occupied: false }
191
+ let backupSkipped = false
192
+ if (bytes > limit) {
193
+ backupSkipped = true
194
+ backup.reason = `目录体积约 ${Math.round(bytes / (1024 * 1024))} MB,超过备份上限 ${Math.round(limit / (1024 * 1024))} MB:本次**没有**建备份(恢复时会退而使用 pnpm 暂存目录)`
195
+ } else {
196
+ try {
197
+ const result = backupSelfPackageDir(dir, deps)
198
+ if (result !== null && typeof result === 'object') backup = result
199
+ else backup.reason = '备份实现没有返回结果'
200
+ } catch (error) {
201
+ backup.reason = `备份抛错:${error instanceof Error ? error.message : String(error)}`
202
+ }
203
+ }
204
+ return {
205
+ packageName: name,
206
+ packageDir: dir,
207
+ profileDir: typeof profileDir === 'string' ? profileDir : '',
208
+ targetVersion: typeof targetVersion === 'string' && targetVersion !== '' ? targetVersion : null,
209
+ before,
210
+ previousVersion: backup.previousVersion ?? before.version,
211
+ backupPath: backup.path,
212
+ backedUp: backup.ok === true,
213
+ backupReason: backup.reason,
214
+ backupSkipped,
215
+ deps,
216
+ }
217
+ }
218
+
219
+ /** 恢复现场的"流水"(每次复核/恢复都记一条,供报告与面板显示;不参与判定)。
220
+ * `pendingHeal`:最近一次恢复之后**还没有**任何通道成功过 —— 也就是说盘上那一份是"恢复回来的旧版",
221
+ * 不能算这次安装的成果。后面有通道成功写入时它会被清掉(见 wrapChannelsWithRecovery)。 */
222
+ function makeRecoveryState(session) {
223
+ return { session, timeline: [], healCount: 0, savedFrom: null, savedVersion: null, brokenPaths: [], pendingHeal: false }
224
+ }
225
+
226
+ /**
227
+ * 立刻复核 + 必要时恢复("杀进程后立刻复核"的那一步;幂等,可以反复调)。
228
+ * 先试**更新前的备份**,再试 **pnpm 暂存目录**;都不行就如实返回原因(绝不假装恢复了)。
229
+ */
230
+ function healPackageDir(state, { trigger = 'manual', deps = {} } = {}) {
231
+ const session = state.session
232
+ const io = { ...(session.deps ?? {}), ...(deps ?? {}) }
233
+ const dir = session.packageDir
234
+ const name = session.packageName
235
+ const current = inspectPackageDir(dir, name, io)
236
+ const row = { at: typeof io.now === 'function' ? io.now() : Date.now(), trigger, ok: current.ok === true, version: current.version ?? null, restored: false, restoredFrom: null, restoredVersion: null, brokenPath: null, reason: null }
237
+ if (current.ok === true) {
238
+ row.reason = '目录当前是完整的,无需恢复'
239
+ state.timeline.push(row)
240
+ return row
241
+ }
242
+ // ① 更新前的备份(本次这一份优先;本次没建成才用同目录里最新的那一份)
243
+ const backupPath = session.backedUp === true && typeof session.backupPath === 'string' && existsSync(session.backupPath) ? session.backupPath : findLatestBackup(dir)
244
+ if (backupPath !== null) {
245
+ const restored = restoreSelfPackageDir(dir, backupPath, io)
246
+ const again = inspectPackageDir(dir, name, io)
247
+ if (restored.ok === true && again.ok === true) {
248
+ row.restored = true
249
+ row.restoredFrom = 'backup'
250
+ row.restoredVersion = again.version ?? null
251
+ row.brokenPath = restored.brokenPath ?? null
252
+ row.version = again.version ?? null
253
+ row.ok = true
254
+ row.reason = `已从备份恢复(${backupPath})`
255
+ state.healCount += 1
256
+ state.pendingHeal = true
257
+ state.savedFrom = 'backup'
258
+ state.savedVersion = again.version ?? null
259
+ if (typeof restored.brokenPath === 'string') state.brokenPaths.push(restored.brokenPath)
260
+ state.timeline.push(row)
261
+ return row
262
+ }
263
+ row.reason = `从备份恢复失败:${restored.reason ?? '恢复后仍不过校验'}${restored.ok === true ? `(${again.reason ?? '结构不完整'})` : ''}`
264
+ } else {
265
+ row.reason = session.backupSkipped === true ? `本次没有建备份(${session.backupReason ?? '体积超上限'})` : `没有可用备份${session.backupReason ? `(${session.backupReason})` : ''}`
266
+ }
267
+ // ② pnpm 暂存目录(`<pkg>_tmp_*`):正式目录空掉时,完整的那一份常常就在这里
268
+ const staging = session.profileDir === '' ? null : findStagingDir(session.profileDir, name, io)
269
+ if (staging !== null) {
270
+ const restored = restoreSelfPackageDir(dir, staging.path, io)
271
+ const again = inspectPackageDir(dir, name, io)
272
+ if (restored.ok === true && again.ok === true) {
273
+ row.restored = true
274
+ row.restoredFrom = 'staging'
275
+ row.restoredVersion = again.version ?? null
276
+ row.brokenPath = restored.brokenPath ?? null
277
+ row.version = again.version ?? null
278
+ row.ok = true
279
+ row.reason = `已从 pnpm 暂存目录恢复(${staging.path}${staging.version ? `,v${staging.version}` : ''})`
280
+ state.healCount += 1
281
+ state.pendingHeal = true
282
+ state.savedFrom = 'staging'
283
+ state.savedVersion = again.version ?? null
284
+ if (typeof restored.brokenPath === 'string') state.brokenPaths.push(restored.brokenPath)
285
+ state.timeline.push(row)
286
+ return row
287
+ }
288
+ row.reason = `${row.reason};从暂存目录也恢复失败:${restored.reason ?? '恢复后仍不过校验'}`
289
+ }
290
+ state.timeline.push(row)
291
+ return row
292
+ }
293
+
294
+ /** 出错之后**立刻**复核的那一次(通道层每次"被杀/超时/占用"失败都会调它;幂等)。 */
295
+ function noteInterrupted(state, { trigger = 'channel', error = null, deps = {} } = {}) {
296
+ if (state === null || state === undefined) return null
297
+ if (looksInterrupted(error) !== true) return null
298
+ return healPackageDir(state, { trigger, deps })
299
+ }
300
+
301
+ /**
302
+ * 收尾(**成功、失败、抛异常三条路都要跑**):复核 → 坏了就恢复(备份 → 暂存目录)→ 同一判据再复核 →
303
+ * 备份收口(最多留 1 份)→ 产出如实报告。报告就是作业上的 `installRecovery` 字段。
304
+ */
305
+ function settleInstallRecovery(state, { deps = {} } = {}) {
306
+ if (state === null || state === undefined) return null
307
+ const session = state.session
308
+ const io = { ...(session.deps ?? {}), ...(deps ?? {}) }
309
+ const after0 = inspectPackageDir(session.packageDir, session.packageName, io)
310
+ let heal = null
311
+ if (after0.ok !== true) heal = healPackageDir(state, { trigger: 'settle', deps: io })
312
+ const after = inspectPackageDir(session.packageDir, session.packageName, io)
313
+ const verified = after.ok === true
314
+ const healed = state.healCount > 0
315
+ // `landed` = 这次安装**真的落地了**:目录完整 **且** 盘上那份不是"恢复回来的旧版"。
316
+ // 只看 verified 不够(真机现场:恢复完目录当然完整,但那一份是**更新前**的旧版);
317
+ // 只看 healed 也不够("pnpm 超时被杀 → 恢复旧版 → curl 后来真的装上了"是成功,不能报失败)——
318
+ // 两者之间的判据就是 `pendingHeal`:恢复之后有没有通道成功写入过。
319
+ const landed = verified === true && state.pendingHeal !== true
320
+ // `problem` = 到底坏在哪(如实描述现场);`reason` = 收尾时的结论(含恢复动作的结果)
321
+ const problem = after0.ok !== true
322
+ ? (after0.reason ?? '目录里没有可读的 package.json')
323
+ : (healed === true ? '目录在安装过程中被清空过(恢复动作发生在通道失败的那一刻,见 timeline)' : null)
324
+ const reason = landed === true
325
+ ? null
326
+ : (problem ?? heal?.reason ?? '安装过程把目录弄坏了(目录已不可用)')
327
+ const backupPath = session.backedUp === true ? session.backupPath : null
328
+ // 备份收口(与 self-update-guard 同一口径):这次更新**落地了**才删本次备份;失败/恢复过就留着(那是唯一旧版)
329
+ const dropped = landed === true ? dropBackupOf(backupPath) : null
330
+ const pruned = pruneSelfBackups(session.packageDir, { keep: KEEP_BACKUPS, deps: io })
331
+ const report = {
332
+ packageName: session.packageName,
333
+ packageDir: session.packageDir,
334
+ targetVersion: session.targetVersion ?? null,
335
+ previousVersion: session.previousVersion ?? null,
336
+ before: { ok: session.before.ok === true, version: session.before.version ?? null, hasManifest: session.before.hasManifest === true },
337
+ after: { ok: after.ok === true, version: after.version ?? null, hasManifest: after.hasManifest === true },
338
+ backedUp: session.backedUp === true,
339
+ backupPath,
340
+ backupNote: session.backupReason ?? null,
341
+ backupSkipped: session.backupSkipped === true,
342
+ verified,
343
+ healed,
344
+ landed,
345
+ restoredFrom: state.savedFrom,
346
+ restoredVersion: state.savedVersion ?? after.version ?? null,
347
+ brokenPath: state.brokenPaths.length > 0 ? state.brokenPaths[state.brokenPaths.length - 1] : null,
348
+ versionDirection: versionDirection(session.previousVersion, after.version ?? null),
349
+ problem,
350
+ reason,
351
+ selfHeal: {
352
+ attempted: heal !== null,
353
+ restored: heal?.restored === true,
354
+ reason: heal?.reason ?? null,
355
+ mode: heal?.restoredFrom ?? null,
356
+ },
357
+ timeline: state.timeline,
358
+ backupCleanup: { removed: [...(dropped !== null && dropped.removed === true ? [backupPath] : []), ...pruned.removed], kept: pruned.kept, note: dropped?.note ?? pruned.note ?? null },
359
+ manualCommand: null,
360
+ }
361
+ if (verified !== true) {
362
+ report.manualCommand = manualRestoreCommand({
363
+ pkgDir: session.packageDir,
364
+ backupPath,
365
+ version: session.targetVersion ?? session.previousVersion,
366
+ // 包名走既有注入缝(self-update-guard 的 selfName),这样命令里取的 tarball 是**这个包**的
367
+ deps: { ...io, selfName: session.packageName },
368
+ })
369
+ }
370
+ return report
371
+ }
372
+
373
+ /**
374
+ * 通道代理:每次"被杀/超时/占用"类失败 ⇒ **立刻**复核 + 恢复,再把原错误抛回去
375
+ * (既有语义不变:通道失败照旧被上层吞成"没装成")。
376
+ * 另外记一件事:恢复之后**只要还有通道成功写入**,就说明盘上那份不再是"恢复回来的旧版" ⇒
377
+ * 清掉 `pendingHeal`(否则"pnpm 超时被杀 → 恢复旧版 → curl 后来装成功"会被误判成失败)。
378
+ */
379
+ function wrapChannelsWithRecovery(ch, state, deps = {}) {
380
+ if (ch === null || ch === undefined || typeof ch !== 'object') return ch
381
+ const wrapped = { ...ch }
382
+ for (const key of ['raceInstallChannels', 'pnpmInstall', 'curlManualInstall', 'githubReleaseInstall']) {
383
+ if (typeof ch[key] !== 'function') continue
384
+ wrapped[key] = async (...callArgs) => {
385
+ let out = null
386
+ try {
387
+ out = await ch[key](...callArgs)
388
+ } catch (error) {
389
+ try { noteInterrupted(state, { trigger: `channel:${key}`, error, deps }) } catch {}
390
+ throw error
391
+ }
392
+ // "通道成功"的判据:竞速通道返回非空才算有赢家;其余通道能正常返回就算写成功了
393
+ const succeeded = key === 'raceInstallChannels' ? (out !== null && out !== undefined) : true
394
+ if (succeeded === true) state.pendingHeal = false
395
+ return out
396
+ }
397
+ }
398
+ return wrapped
399
+ }
400
+
401
+ /**
402
+ * 接线外壳(被 domain/install-channels.js#runSelfUpdateGuarded 调用):
403
+ * · 不接管(session=null)⇒ `run(null)`,用**原来的**通道对象,一个字节都不多走;
404
+ * · 接管 ⇒ 通道换成带"被杀即复核"的代理,收尾统一 settle,并把报告挂到 `job.installRecovery`。
405
+ * 返回值的语义与不改时**一样**({ installedName, lastError, source }),唯一的例外是
406
+ * "通道说装上了、但目录其实已经坏了" —— 那一条改判成没装上(绝不报成功)。
407
+ */
408
+ async function withInstallRecovery({ job = null, name = '', profileDir = '', targetVersion = null, ch = null, deps = {} } = {}, run) {
409
+ const session = beginInstallRecovery({ packageName: name, profileDir, targetVersion, job, deps })
410
+ if (session === null) return await run(null)
411
+ const state = makeRecoveryState(session)
412
+ const guarded = wrapChannelsWithRecovery(ch, state, deps)
413
+ let outcome = null
414
+ let thrown = null
415
+ try {
416
+ outcome = await run(guarded)
417
+ } catch (error) {
418
+ thrown = error
419
+ }
420
+ const report = settleInstallRecovery(state, { deps })
421
+ if (job !== null && job !== undefined && typeof job === 'object') job.installRecovery = report
422
+ if (thrown !== null) throw thrown
423
+ // 通道说"装上了"不算数:**目录完整 且 不是靠恢复旧版救回来的**才算这次真的落地。
424
+ // (只看 verified 会把"恢复成旧版后的完整目录"当成更新成功 —— 那正是真机谎报的形状。)
425
+ if (outcome !== null && outcome !== undefined && outcome.installedName !== null && outcome.installedName !== undefined && report.landed !== true) {
426
+ return { installedName: null, lastError: new Error(installRecoveryFailureText(report) ?? '安装没有落地(目录已不可用)') }
427
+ }
428
+ return outcome
429
+ }
430
+
431
+ /**
432
+ * 失败文案(作业 error / 面板提示共用一份;**这次真的落地了**才返回 null)。
433
+ * 一次说清四件事:没通过的原因、自愈有没有成、残缺目录留在哪、手动命令是什么。
434
+ */
435
+ function installRecoveryFailureText(report) {
436
+ if (report === null || report === undefined || typeof report !== 'object') return null
437
+ if (report.landed === true) return null
438
+ const parts = [`安装没有落地:安装过程把 ${report.packageDir ?? '插件目录'} 弄坏了(${report.problem ?? report.reason ?? '目录里没有可读的 package.json'})。`]
439
+ if (report.healed === true) {
440
+ const from = report.restoredFrom === 'staging' ? 'pnpm 暂存目录' : '更新前的备份'
441
+ parts.push(`已从${from}自动恢复成 v${report.restoredVersion ?? '?'},本次更新**没有生效**(这不是更新成功)。`)
442
+ } else if (report.backedUp !== true) {
443
+ parts.push(`无法自动恢复:${report.selfHeal?.reason ?? report.backupNote ?? '更新前没能建立备份'}。`)
444
+ } else {
445
+ parts.push(`自动恢复也失败了:${report.selfHeal?.reason ?? '原因未知'}。`)
446
+ }
447
+ if (typeof report.brokenPath === 'string' && report.brokenPath !== '') {
448
+ parts.push(`残缺目录已改名保留在 ${report.brokenPath}(未删除,可自行核对)。`)
449
+ }
450
+ if (typeof report.manualCommand === 'string' && report.manualCommand !== '') {
451
+ parts.push(`要手动处理可直接复制运行:${report.manualCommand}`)
452
+ }
453
+ return parts.join('')
454
+ }
455
+
456
+ export {
457
+ MAX_BACKUP_BYTES,
458
+ STAGING_MARK,
459
+ KEEP_BACKUPS,
460
+ packageDirOf,
461
+ isLinkedDir,
462
+ dirBytes,
463
+ inspectPackageDir,
464
+ looksInterrupted,
465
+ versionDirection,
466
+ findStagingDir,
467
+ findLatestBackup,
468
+ beginInstallRecovery,
469
+ makeRecoveryState,
470
+ healPackageDir,
471
+ noteInterrupted,
472
+ settleInstallRecovery,
473
+ wrapChannelsWithRecovery,
474
+ withInstallRecovery,
475
+ installRecoveryFailureText,
476
+ }
@@ -510,6 +510,15 @@ function installJobView(job) {
510
510
  // 更新会出现,**非自更新作业恒 null**(既有契约一字不改)。`verified:false` ⇒ 作业必为 failed 且 job.error
511
511
  // 已写清原因/残缺目录/手动命令;`healed:true` ⇒ 已从备份恢复成更新前的版本(那不是"更新成功")。
512
512
  selfUpdate: job.selfUpdate ?? null,
513
+ // 同一个包互斥的结果(2026-11-04 加法,见 domain/install-lock.js 顶部现场):`queued:true` ⇒ 本次在排队
514
+ // 等前一个作业(有 `waitedMs` 就能看出等了多久);`timeout:true` ⇒ 排队超时、本次**没装**(job.error 已写清)。
515
+ // 非并发场景恒 null(既有契约一字不改)。
516
+ installLock: job.installLock ?? null,
517
+ // 通用"超时/中断把目录弄坏 ⇒ 复核 + 恢复"的结论(2026-11-04 加法,见 domain/install-recovery.js 顶部):
518
+ // `verified:false` ⇒ 这次尝试被改判成"没装上"(job.error 已写清原因/残缺目录/手动命令);
519
+ // `healed:true` ⇒ 已从更新前的备份(或 pnpm 暂存目录)恢复成旧版 —— **那不是更新成功**。
520
+ // 只在"安装前目录里就有一份完整副本"时出现(首次安装 / link: 安装恒 null,既有契约一字不改)。
521
+ installRecovery: job.installRecovery ?? null,
513
522
  // 抓清单那一轮的**通道身份** + 本地落地落点(0.5.47 加法,见 domain/manifest-probe.js):
514
523
  // 面板据此显示"清单经 npm 镜像 / 本地落地 / 直连读到的",本地命中时给出「从本地安装」的落点。
515
524
  probe: job.probe ?? null, localDir: job.localDir ?? null, localHint: job.localHint ?? null,