@noob-stupid/dsh-plugin-console 0.5.100 → 0.5.102
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/server/domain/install-channels.js +84 -1
- package/lib/server/domain/install-cleanup.js +21 -4
- package/lib/server/domain/install-diagnose.js +10 -0
- package/lib/server/domain/install-job.js +43 -1
- package/lib/server/domain/install-lock.js +225 -0
- package/lib/server/domain/install-recovery.js +476 -0
- package/lib/server/domain/install.js +13 -0
- package/lib/server/domain/plugin-actions.js +47 -5
- package/lib/server/domain/self-update-guard.js +577 -0
- package/lib/server/domain/selfupdate.js +34 -30
- package/lib/server/domain/subdir-install.js +8 -1
- package/lib/server/routes/plugins.js +5 -16
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -506,6 +506,19 @@ function installJobView(job) {
|
|
|
506
506
|
persisted: job.persisted ?? null, persistNote: job.persistNote ?? null, persist: job.persist ?? null,
|
|
507
507
|
// 「其实已经装上了」的核实结果(0.5.47 加法,见 install-verify.js#installedEvidence):失败作业由路由在下发前**现查**三处覆盖。
|
|
508
508
|
installedEvidence: job.installedEvidence ?? null,
|
|
509
|
+
// 自更新保护结果(2026-11-03 加法,见 domain/self-update-guard.js 顶部事故记录):只对"本插件自己"的
|
|
510
|
+
// 更新会出现,**非自更新作业恒 null**(既有契约一字不改)。`verified:false` ⇒ 作业必为 failed 且 job.error
|
|
511
|
+
// 已写清原因/残缺目录/手动命令;`healed:true` ⇒ 已从备份恢复成更新前的版本(那不是"更新成功")。
|
|
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,
|
|
509
522
|
// 抓清单那一轮的**通道身份** + 本地落地落点(0.5.47 加法,见 domain/manifest-probe.js):
|
|
510
523
|
// 面板据此显示"清单经 npm 镜像 / 本地落地 / 直连读到的",本地命中时给出「从本地安装」的落点。
|
|
511
524
|
probe: job.probe ?? null, localDir: job.localDir ?? null, localHint: job.localHint ?? null,
|
|
@@ -39,6 +39,10 @@ import { join } from 'node:path'
|
|
|
39
39
|
import { applyAllowBuilds, pendingApprovedBuilds, readWorkspaceApprovals } from './allow-builds.js'
|
|
40
40
|
import { NPM_NAME_RE, sameSourceSpec, suggestedAllowBuildsAction } from './dep-source.js'
|
|
41
41
|
import { classifyInstallFailure, ignoredBuildsFrom } from './install-diagnose.js'
|
|
42
|
+
// 同一个 profile + 包名 的安装互斥(2026-11-04 加法,见 domain/install-lock.js 顶部现场):
|
|
43
|
+
// 本模块的两个动作(pin-dependency / real-install)都会跑 `pnpm add`,动的就是 `node_modules/<包名>`
|
|
44
|
+
// 那一份目录 —— 与安装作业(domain/install-channels.js)必须串行,否则就是真机事故的重演。
|
|
45
|
+
import { withInstallLockOr } from './install-lock.js'
|
|
42
46
|
import { runLockfileRepair } from './lockfile-health.js'
|
|
43
47
|
import { pinProfileDependency, specOf } from './manifest.js'
|
|
44
48
|
import { ensurePersisted } from './persist.js'
|
|
@@ -129,6 +133,19 @@ function isReleaseAgeBlock(message) {
|
|
|
129
133
|
return /ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|minimumReleaseAge/iu.test(String(message ?? ''))
|
|
130
134
|
}
|
|
131
135
|
|
|
136
|
+
/**
|
|
137
|
+
* 过**包互斥闸**跑一次 pnpm(2026-11-04 加法):动作会把 `node_modules/<包名>` 重写一遍,与安装作业
|
|
138
|
+
* 动的是同一份目录 ⇒ 必须排队。拿不到锁 ⇒ 返回一份**如实失败**的结果(`locked: true` + reason),
|
|
139
|
+
* 绝不降级成"不拿锁照样装",也绝不报成功。
|
|
140
|
+
*/
|
|
141
|
+
async function runAddUnderLock({ profileDir, packageName, timeoutMs, run }) {
|
|
142
|
+
return await withInstallLockOr(
|
|
143
|
+
{ profileDir, packageName },
|
|
144
|
+
async () => ({ locked: false, result: await run() }),
|
|
145
|
+
(error) => ({ locked: true, reason: error.message, result: { ok: false, exitCode: null, stdout: '', stderr: '', relaxedReleaseAge: false, error, argv: [] } }),
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
132
149
|
/** 跑一次 pnpm(原样),必要时**只对这一条命令**放宽供应链闸并如实标记。
|
|
133
150
|
* 返回 { ok, exitCode, stdout, stderr, relaxedReleaseAge, error }。
|
|
134
151
|
*
|
|
@@ -186,6 +203,7 @@ async function runSuggestedAction({ body, profileDir, registries = [], deps = {}
|
|
|
186
203
|
registry, exitCode: null, command: null, stdout: '', stderr: '', relaxedReleaseAge: false,
|
|
187
204
|
manifest: null, lock: null, notes: [], reason: null, hint: null, detail: null,
|
|
188
205
|
kind: parsed.action, ignoredBuilds: [], nextAction: null,
|
|
206
|
+
locked: false, // 2026-11-04 加法:true = 这个包正被别的作业装着,本次**没有**启动 pnpm(如实失败)
|
|
189
207
|
}
|
|
190
208
|
if (parsed.action === 'allow-builds') {
|
|
191
209
|
const applyAllow = typeof deps.applyAllow === 'function' ? deps.applyAllow : applyAllowBuilds
|
|
@@ -326,7 +344,19 @@ async function runSuggestedAction({ body, profileDir, registries = [], deps = {}
|
|
|
326
344
|
}
|
|
327
345
|
}
|
|
328
346
|
const before = await specOf(profileDir, parsed.packageName)
|
|
329
|
-
|
|
347
|
+
// 过包互斥闸(见上方 runAddUnderLock):拿不到锁 ⇒ 如实失败,连 pnpm 都不启动
|
|
348
|
+
const runGuard = await runAddUnderLock({
|
|
349
|
+
profileDir, packageName: parsed.packageName, timeoutMs: ACTION_ADD_TIMEOUT_MS,
|
|
350
|
+
run: async () => await runAddWithReleaseAgeRetry({ profileDir, spec: record.spec, registry, runAdd, timeoutMs: ACTION_ADD_TIMEOUT_MS }),
|
|
351
|
+
})
|
|
352
|
+
const run = runGuard.result
|
|
353
|
+
if (runGuard.locked === true) {
|
|
354
|
+
return {
|
|
355
|
+
...base, ok: false, status: 200, locked: true, error: runGuard.reason, reason: runGuard.reason,
|
|
356
|
+
command: `pnpm ${pnpmAddArgs(record.spec, registry).join(' ')}`,
|
|
357
|
+
notes: ['这个包正在被另一个作业安装/更新:本次没有启动 pnpm,也没有动任何文件(排到队尾再试即可)。'],
|
|
358
|
+
}
|
|
359
|
+
}
|
|
330
360
|
const after = await specOf(profileDir, parsed.packageName)
|
|
331
361
|
const entry = lockVersion(profileDir, parsed.packageName)
|
|
332
362
|
const failure = run.ok === true ? null : classifyInstallFailure(`${run.stderr}\n${String(run.error?.message ?? '')}`)
|
|
@@ -391,7 +421,12 @@ async function runSuggestedAction({ body, profileDir, registries = [], deps = {}
|
|
|
391
421
|
const timeoutMs = Number.isFinite(deps.timeoutMs) && deps.timeoutMs > 0
|
|
392
422
|
? deps.timeoutMs
|
|
393
423
|
: (pendingBuilds.pending === true ? ACTION_BUILD_TIMEOUT_MS : ACTION_ADD_TIMEOUT_MS)
|
|
394
|
-
|
|
424
|
+
// 过包互斥闸(见上方 runAddUnderLock):pin 的这一步会重铺 node_modules 里那一份 —— 必须与安装作业串行
|
|
425
|
+
const runGuard = await runAddUnderLock({
|
|
426
|
+
profileDir, packageName: parsed.packageName, timeoutMs,
|
|
427
|
+
run: async () => await runAddWithReleaseAgeRetry({ profileDir, spec: pinned.spec, registry, runAdd, timeoutMs }),
|
|
428
|
+
})
|
|
429
|
+
const run = runGuard.result
|
|
395
430
|
const after = await specOf(profileDir, parsed.packageName)
|
|
396
431
|
const entry = lockVersion(profileDir, parsed.packageName)
|
|
397
432
|
const lockSynced = typeof entry === 'string' && entry.startsWith('link:')
|
|
@@ -405,6 +440,10 @@ async function runSuggestedAction({ body, profileDir, registries = [], deps = {}
|
|
|
405
440
|
const ignoredBuilds = ignored === true ? ignoredBuildsFrom(run.stderr) : []
|
|
406
441
|
const nextAction = ignored === true ? suggestedAllowBuildsAction({ packages: ignoredBuilds, profile: parsed.profile, reason: 'pnpm 因构建脚本未获批准而报错' }) : null
|
|
407
442
|
const notes = []
|
|
443
|
+
if (runGuard.locked === true) {
|
|
444
|
+
// 拿不到包互斥锁:pnpm 一步都没启动。清单可能已经写成 link:(pin 的第一步),要把这半件事如实说清
|
|
445
|
+
notes.push('这个包正在被另一个作业安装/更新:本次**没有启动 pnpm**,也没有动 node_modules 里那一份(排到队尾再试即可)。')
|
|
446
|
+
}
|
|
408
447
|
if (run.relaxedReleaseAge) notes.push('本机 pnpm 的 24h 供应链闸(minimumReleaseAge)拦下了这次 lock 校验,已**仅对这一条命令**追加 --config.minimumReleaseAge=0 重试一次(有安全代价,未写进任何配置文件)。')
|
|
409
448
|
if (pendingBuilds.pending === true) {
|
|
410
449
|
notes.push(`本 profile 已有放行项但还没构建过:本次 pnpm 那一步会真的执行构建脚本(本机实测约 19 分钟),`
|
|
@@ -434,6 +473,7 @@ async function runSuggestedAction({ body, profileDir, registries = [], deps = {}
|
|
|
434
473
|
// 只有"清单真的是 link: **且** lock 里也有这条 link"才算成功;只成一半时 partial=true 并如实说明
|
|
435
474
|
ok: manifests.pinned && lockSynced,
|
|
436
475
|
partial: manifests.pinned && !lockSynced,
|
|
476
|
+
locked: runGuard.locked === true,
|
|
437
477
|
exitCode: run.exitCode,
|
|
438
478
|
command: `pnpm ${argv.join(' ')}`,
|
|
439
479
|
stdout: run.stdout,
|
|
@@ -444,9 +484,11 @@ async function runSuggestedAction({ body, profileDir, registries = [], deps = {}
|
|
|
444
484
|
lock: { entry: entry ?? null, synced: lockSynced },
|
|
445
485
|
notes,
|
|
446
486
|
failureKind: failure?.kind ?? null,
|
|
447
|
-
reason:
|
|
448
|
-
?
|
|
449
|
-
:
|
|
487
|
+
reason: runGuard.locked === true
|
|
488
|
+
? runGuard.reason
|
|
489
|
+
: (manifests.pinned
|
|
490
|
+
? (lockSynced ? `${parsed.packageName} 已钉住:清单与 pnpm-lock.yaml 都指向 link:${pinned.dir}` : `${parsed.packageName} 的清单已钉住,lock 未对齐`)
|
|
491
|
+
: `${parsed.packageName} 的清单写回未生效(实际是 ${after ?? '(无该条目)'})`),
|
|
450
492
|
}
|
|
451
493
|
}
|
|
452
494
|
|