@noob-stupid/dsh-plugin-console 0.5.101 → 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.
@@ -12,10 +12,13 @@ import { cleanupStalePackageDir } from '../infra/fsx.js'
12
12
  import { alreadyInstalledTarget } from './install-verify.js'
13
13
  import { noteChannel, tryGitChannel } from './git-channel.js'
14
14
  import { retryPnpmOnce } from './install-diagnose.js'
15
+ import { INSTALL_LOCK_TIMEOUT, withInstallLock } from './install-lock.js'
15
16
  import { targetVersionOf, versionMissOf, versionMissText } from './install-version.js'
16
17
  import { tryPresetSourceChannel } from './preset-source.js'
17
18
  // 自更新兜底(2026-11-03 加法,见 domain/self-update-guard.js 顶部事故记录):只在"装的包名 === 本插件自己"时接管
18
19
  import { beginSelfUpdateGuard, selfUpdateFailureText, settleSelfUpdateGuard } from './self-update-guard.js'
20
+ // 通用"超时/中断把目录弄坏 ⇒ 复核 + 恢复"(2026-11-04 加法,见 domain/install-recovery.js 顶部):对**所有**包生效
21
+ import { withInstallRecovery } from './install-recovery.js'
19
22
 
20
23
  /** release 通道的候选预算(issue #3):release 通道现在按包名反查发布仓库,**每个候选**至少一次
21
24
  * `releases?per_page=10` 调用(第一梯队仓库没命中还要多试几个候选仓库),而候选可能有十几个
@@ -24,11 +27,17 @@ import { beginSelfUpdateGuard, selfUpdateFailureText, settleSelfUpdateGuard } fr
24
27
  const RELEASE_CHANNEL_BUDGET = 3
25
28
 
26
29
  /**
27
- * 单个候选的通道序列**外壳**(2026-11-03 加法):只给"本插件自己的自更新"多加一层备份/校验/自愈,
28
- * 其余情况(所有别的包)**原样直通**——`beginSelfUpdateGuard` 不成立就 `return tryCandidateChannelsInner(...)`,
29
- * 一个字节都不多走(tests/test-self-update-guard.mjs ⑤ 用"零备份 + 无 selfUpdate 字段 + 通道调用顺序不变"钉住)。
30
+ * 单个候选的通道序列**外壳**(2026-11-04 起有两个壳,顺序如下):
31
+ * ① 最外层 = **互斥**(domain/install-lock.js):同一个 `profile + 包名` 同一时刻只允许一个安装作业,
32
+ * 第二个排队等待(不同包照旧并行)。为什么放在最外层:整套通道(竞速 / 串行 pnpm / curl / release /
33
+ * git / 定向重试)动的都是**同一个** `node_modules/<包名>` 目录 —— 真机 2026-11-04 的事故就是
34
+ * 两个 pnpm 同时装同一个包,pnpm 把正式目录改名成 `_tmp_` 后进程被杀,正式目录直接空掉。
35
+ * ② 里层 = **自更新兜底**(2026-11-03 加法):只给"本插件自己的自更新"多加一层备份/校验/自愈,
36
+ * 其余情况(所有别的包)**原样直通** —— `beginSelfUpdateGuard` 不成立就走
37
+ * `withInstallRecovery(...)`(通用恢复,见下),自更新那条路一个字节都不多走
38
+ * (tests/test-self-update-guard.mjs ⑤ 用"零备份 + 无 selfUpdate 字段 + 通道调用顺序不变"钉住)。
30
39
  *
31
- * 为什么包在整条序列外面,而不是包在某一次 pnpm 调用里面:真机事故(宿主读着插件自己的文件 ⇒ EPERM、
40
+ * 为什么自更新兜底包在整条序列外面,而不是包在某一次 pnpm 调用里面:真机事故(宿主读着插件自己的文件 ⇒ EPERM、
32
41
  * Windows 上 pnpm 先删旧目录再解包、删到一半被打断)里,**残缺态是整条序列结束时才看得见的**,
33
42
  * 而"哪条通道声称装上了"完全不可信(那一次 curl/pnpm 都回了成功)。所以:
34
43
  * ① 进来先备份(复制,不打扰正在运行的目录);
@@ -37,13 +46,50 @@ const RELEASE_CHANNEL_BUDGET = 3
37
46
  * 由 install-job.js 的自更新分支收口成可见失败(绝不报"更新成功")。
38
47
  */
39
48
  async function tryCandidateChannels(args) {
49
+ const packageName = typeof args?.name === 'string' ? args.name : ''
50
+ const profileDir = typeof args?.profileDir === 'string' ? args.profileDir : ''
51
+ try {
52
+ return await withInstallLock({
53
+ profileDir, packageName, job: args?.job ?? null,
54
+ // 排队上限默认 10 分钟(domain/install-lock.js#INSTALL_LOCK_WAIT_MS);单测可传更小的值
55
+ ...(Number.isFinite(args?.lockWaitMs) ? { waitMs: args.lockWaitMs } : {}),
56
+ // 排队时留一行可读日志(面板的「通道备注」里看得到"在等谁")
57
+ onWait: ({ holder }) => {
58
+ if (args?.job !== null && args?.job !== undefined && typeof args.job === 'object') {
59
+ noteChannel(args.job, `同一个包正在被另一个作业安装${holder?.jobId ? `(${holder.jobId})` : ''}:本次排队等待,不会同时动 node_modules/${packageName}`)
60
+ }
61
+ },
62
+ }, async () => await runSelfUpdateGuarded(args))
63
+ } catch (error) {
64
+ if (error?.code !== INSTALL_LOCK_TIMEOUT) throw error
65
+ // 排队超时 ⇒ 如实"没装成"(不改既有通道语义:调用方本来就按 installedName=null 处理失败)
66
+ if (args?.job !== null && args?.job !== undefined && typeof args.job === 'object') {
67
+ args.job.installLock = { ...(args.job.installLock ?? {}), ...(error.holder ?? {}), timeout: true, reason: error.message }
68
+ }
69
+ return { installedName: null, lastError: error }
70
+ }
71
+ }
72
+
73
+ /** 自更新兜底那一层(原 tryCandidateChannels 的正文,逐字保留)—— 见上方 ② 的说明。 */
74
+ async function runSelfUpdateGuarded(args) {
40
75
  const inner = () => tryCandidateChannelsInner(args)
41
76
  const guardDeps = args?.selfUpdateDeps ?? {}
42
77
  const session = beginSelfUpdateGuard({
43
78
  packageName: args?.name, profileDir: args?.profileDir,
44
79
  targetVersion: targetVersionOf(args?.job, args?.name), deps: guardDeps,
45
80
  })
46
- if (session === null) return inner()
81
+ // ★ 2026-11-04 加法(见 domain/install-recovery.js 顶部现场):不是自更新 ⇒ 走**通用**恢复那一层 ——
82
+ // 对所有包生效(真机清空的不止控制台:用户的 dsh-workbuddy-connect 也被清空过)。
83
+ // 为什么二选一、不叠加:两层用的都是同一个备份后缀(`.pre-update-<ts>`),叠在一起会互相把对方
84
+ // 刚建好的那份 prune 掉;而自更新那条路的判据更强(还核对版本与入口文件),由它自己负责到底。
85
+ // `run(null)` = 没接管 ⇒ 用**原来的**通道对象跑(一个字节都不多走);接管时换成"被杀即复核"的代理。
86
+ if (session === null) {
87
+ const runWith = (channels) => tryCandidateChannelsInner(channels === null || channels === undefined ? args : { ...args, ch: channels })
88
+ return await withInstallRecovery({
89
+ job: args?.job ?? null, name: args?.name, profileDir: args?.profileDir,
90
+ targetVersion: targetVersionOf(args?.job, args?.name), ch: args?.ch ?? null, deps: args?.recoveryDeps ?? {},
91
+ }, runWith)
92
+ }
47
93
  let outcome = null
48
94
  let thrown = null
49
95
  try {
@@ -12,11 +12,19 @@ import { buildPnpmEnv, runPnpmWithFallback } from '../infra/exec.js'
12
12
  /** 安装失败清场:把本次尝试过的候选包目录与 pnpm `_tmp_` 半成品一起清掉,并**如实汇报**清了什么、什么没清掉
13
13
  * (2026-09-20 真装演练:聚合仓库跑 19 分钟后失败,node_modules 留着 `…_tmp_56272_2` 半成品,面板只说"安装失败")。
14
14
  * 2026-09-26(改错):删除一律走 disposeDir(本机 C 盘/`%TEMP%` 下 rmSync 会静默落空,不核实就会谎报干净);
15
- * 删不掉时它**改名降级成同父目录的 `.trash-<ts>`**(做法借自 2BingLing/dsh-market 的 `.bak-<ts>` + renameSync)。 */
16
- function cleanupAttemptedCandidates(profileDir, candidates) {
15
+ * 删不掉时它**改名降级成同父目录的 `.trash-<ts>`**(做法借自 2BingLing/dsh-market 的 `.bak-<ts>` + renameSync)。
16
+ *
17
+ * ★ 2026-11-04 加法(真机:超时之后正式目录被清空,用户面对的是"面板说失败、插件也真没了"):
18
+ * 第 3 个参数 `{ protect }` 列出**本次绝对不许清**的包名 —— 那些目录里放的是
19
+ * "domain/install-recovery.js 刚从更新前备份恢复回来的可用旧版本",清掉它才是真损失。
20
+ * 不传(老调用点、老测试)⇒ 一个字节都不变;被保护项单独汇报在 `protected` 里,不混进 cleaned。
21
+ */
22
+ function cleanupAttemptedCandidates(profileDir, candidates, { protect = [] } = {}) {
17
23
  const cleaned = []
18
24
  const failed = []
19
25
  const trashed = []
26
+ const protectedNames = []
27
+ const keep = new Set(Array.isArray(protect) ? protect.filter((n) => typeof n === 'string' && n !== '') : [])
20
28
  const dispose = (label, dir) => {
21
29
  const result = disposeDir(dir)
22
30
  if (result.trashed === true) trashed.push({ name: label, path: dir, trashPath: result.trashPath, note: disposeNote(result) })
@@ -25,6 +33,10 @@ function cleanupAttemptedCandidates(profileDir, candidates) {
25
33
  return 0
26
34
  }
27
35
  for (const name of candidates) {
36
+ if (keep.has(name)) {
37
+ protectedNames.push(name)
38
+ continue
39
+ }
28
40
  const dir = join(profileDir, 'node_modules', ...String(name).split('/'))
29
41
  const parent = dirname(dir)
30
42
  const base = basename(dir)
@@ -38,7 +50,7 @@ function cleanupAttemptedCandidates(profileDir, candidates) {
38
50
  } catch {}
39
51
  if (touched > 0) cleaned.push(name)
40
52
  }
41
- return { cleaned, failed, trashed }
53
+ return { cleaned, failed, trashed, protected: protectedNames }
42
54
  }
43
55
 
44
56
  /** 授权被拒/超时后的失败文案(纯函数,单测覆盖):说清为什么失败、清理了什么、什么没清掉。
@@ -52,13 +64,18 @@ function aiConsentFailureText(decision, leftovers, timeoutMs = AI_CONSENT_TIMEOU
52
64
  const cleaned = leftovers?.cleaned ?? []
53
65
  const failed = leftovers?.failed ?? []
54
66
  const trashed = leftovers?.trashed ?? []
67
+ const protectedList = Array.isArray(leftovers?.protected) ? leftovers.protected : []
55
68
  const noteOf = (item) => (typeof item?.note === 'string' && item.note !== '' ? `(${item.note})` : (item?.error != null ? `(${item.error})` : ''))
56
69
  const cleanedNote = cleaned.length > 0 ? `;已清理本次落盘残留:${cleaned.join('、')}` : ''
70
+ // 2026-11-04 加法:被保护的项要**说出来**(否则用户以为我们忘了清场)
71
+ const protectedNote = protectedList.length > 0
72
+ ? `;已保留 ${protectedList.join('、')} 不清场 —— 那是安装过程中被弄坏后、刚从备份恢复回来的旧版本,删掉它才是真损失`
73
+ : ''
57
74
  const trashedNote = trashed.length > 0
58
75
  ? `;有 ${trashed.length} 项正被占用、已改名降级为 .trash-*(${trashed.map((t) => (typeof t?.note === 'string' && t.note !== '' ? t.note : basename(String(t?.trashPath ?? '')))).join('、')})`
59
76
  : ''
60
77
  const failedNote = failed.length > 0 ? `;**有 ${failed.length} 项没能清理**:${failed.map((f) => `${f.path}${noteOf(f)}`).join('、')}` : ''
61
- return `${base}${cleanedNote}${trashedNote}${failedNote}`
78
+ return `${base}${cleanedNote}${protectedNote}${trashedNote}${failedNote}`
62
79
  }
63
80
 
64
81
  /** 用 corepack pnpm 移除包(与安装同一管理器与凭据抑制环境)。
@@ -92,6 +92,16 @@ const DIAGNOSIS_RULES = [
92
92
  hint: 'registry 上没有这个包/这个版本(包名拼错、子包未发布,或镜像未同步):核对包名与版本后重试;'
93
93
  + '若确认已发布,多半是镜像未同步完整 —— 换一个 registry 或稍后再试。',
94
94
  },
95
+ {
96
+ // 2026-11-04 加法:**同一个包正被另一个作业装着**(并发互斥闸拦下的那一次,见 domain/install-lock.js)。
97
+ // 必须排在 network-timeout 之前:这段文案里有"排队等待…超时"这种字样,落进网络分类会把用户带到
98
+ // "换源/稍后重试网络"的错误方向 —— 而真相是"等前一个作业跑完就能装,重试网络毫无意义"。
99
+ kind: 'package-locked',
100
+ re: /正在安装中|INSTALL_LOCK_TIMEOUT|排队等待/iu,
101
+ retry: null,
102
+ hint: '这个包正被另一个作业安装/更新:本控制台已保证**同一时刻只有一个作业动同一个包**,所以这次没有启动任何安装、'
103
+ + '也没有改动磁盘 —— 等那边结束后重试即可(不是网络问题,也不是包本身有问题)。',
104
+ },
95
105
  {
96
106
  kind: 'locked',
97
107
  re: /EPERM|EACCES|EBUSY|ERR_PNPM_EEXIST|operation not permitted|being used by another process/iu,
@@ -19,6 +19,8 @@ import { verifyInstalledEntry } from './install-verify.js'
19
19
  // 自更新兜底(2026-11-03 加法,见 domain/self-update-guard.js 顶部):「自更新没落地」的那句话由它产出,
20
20
  // 与通道层的 selfUpdate 报告同一份文案,别在作业里另写一套。
21
21
  import { selfUpdateFailureText } from './self-update-guard.js'
22
+ // 通用超时/中断恢复(2026-11-04 加法,见 domain/install-recovery.js 顶部):「安装把目录弄坏了」的那句话由它产出
23
+ import { installRecoveryFailureText } from './install-recovery.js'
22
24
  import { applyNpmNameHint, composeProbeFailureText, expandSubpackageCandidates, fetchRepoPackage, fetchRepoPackageEx, fetchSubdirPlugins, fetchSubpackageNames, npmNameHintForRepo, packageProbeErrorText, subpackageCandidates } from './market.js'
23
25
  import { decideNoRootManifest } from './subdir-detect.js'
24
26
  import { runSubdirInstallJob } from './subdir-install.js'
@@ -277,6 +279,9 @@ async function runInstallJob(job, ports, deps = {}) {
277
279
  const attempt = await tryCandidateChannels({
278
280
  job, ch, name, profileDir, registries, repoChannelAllowed, budget,
279
281
  baseUrl: ports.baseUrl ?? null, expanded, deadline, sourceDeps,
282
+ // 包互斥闸的排队上限(domain/install-lock.js#INSTALL_LOCK_WAIT_MS 是生产默认值;
283
+ // 这里只是**单测注入缝**,生产调用方不传 deps.installLockWaitMs ⇒ undefined ⇒ 默认值)
284
+ lockWaitMs: Number.isFinite(deps.installLockWaitMs) ? deps.installLockWaitMs : undefined,
280
285
  expand: (expanded || job.npmNameHint !== undefined) ? null : expandSubpackages,
281
286
  })
282
287
  installedName = attempt.installedName
@@ -323,6 +328,18 @@ async function runInstallJob(job, ports, deps = {}) {
323
328
  return
324
329
  }
325
330
  if (installedName === null) {
331
+ // ★ 2026-11-04 加法(真机:两个 pnpm 同时装同一个包 ⇒ 正式目录被清空):**拿不到包互斥锁** ⇒ 如实失败,
332
+ // 且**不进 AI 兜底**。理由与自更新/版本闸两条同源:这不是"哪一步坏了",而是"这个包正被另一个作业装着",
333
+ // 让模型去"修"只会去动一个正被别人写着的目录(必然 EPERM,还要等 10 分钟授权)。
334
+ // `job.installLock.timeout` 只由 domain/install-lock.js 写入 ⇒ 非并发场景一个字节都不受影响。
335
+ if (job.installLock !== null && job.installLock !== undefined && job.installLock.timeout === true) {
336
+ job.status = 'failed'
337
+ job.stage = 'install-lock'
338
+ job.error = job.installLock.reason ?? `这个包正在安装中(${job.packageName ?? job.candidateName ?? '未知包'}),本次排队等待超时,已放弃`
339
+ job.lastError = job.error
340
+ job.diagnosis = { kind: 'package-locked', hint: '同一个包同一时刻只允许一个安装作业:等前一个结束后重试即可(不是网络问题)。', retry: null, packages: [job.packageName ?? job.candidateName].filter((n) => typeof n === 'string' && n !== '') }
341
+ return
342
+ }
326
343
  if (job.diagnosis == null) job.diagnosis = classifyInstallFailure(lastError?.message) // 分类提示兜底(加法)
327
344
  // 本地 AI 兜底会调用模型 API、产生费用:挂起等待用户明确同意后再执行
328
345
  job.stage = 'ai-consent'
@@ -358,15 +375,23 @@ async function runInstallJob(job, ports, deps = {}) {
358
375
  job.status = 'failed'
359
376
  // 失败/取消时清场并如实汇报(见 cleanupAttemptedCandidates 注释):
360
377
  // 不能让用户面对"面板说失败、磁盘上却留了半个包和 _tmp_ 残留"的糊涂账。
361
- const leftovers = cleanupAttemptedCandidates(profileDir, candidates)
378
+ // ★ 2026-11-04 加法:**恢复回来的那份不许清**(domain/install-recovery.js 刚把更新前的备份放回原位,
379
+ // 那一刻盘上是用户**现在还能用**的旧版本;旧代码在这里把它一起删掉 —— 真机"正式目录被清空且再也回不来"
380
+ // 的最后一刀就在这一行)。保护名单只来自本作业自己的恢复报告,其余候选照旧清场。
381
+ const protect = job.installRecovery !== null && job.installRecovery !== undefined && job.installRecovery.healed === true
382
+ && typeof job.installRecovery.packageName === 'string' ? [job.installRecovery.packageName] : []
383
+ const leftovers = cleanupAttemptedCandidates(profileDir, candidates, { protect })
362
384
  job.leftovers = leftovers
363
385
  // ★ 失败路径 B 的文案(0.5.41 加法):**只有如实失败 + 原因**。取样/下载本身失败时磁盘上
364
386
  // 什么都没有 ⇒ 没有任何东西可挂 ⇒ 面板**绝不**出现"回落成功 / 已安装 / 已启用"之类说法。
365
387
  // 这里把最后一次的真实失败原因接在既有文案之后(不改写、不掩盖),并保留既有诊断分类(可重试动作
366
388
  // 由面板的「重试」按钮与 diagnosis.hint 承担)。
367
389
  const reason = typeof lastError?.message === 'string' && lastError.message !== '' ? lastError.message : null
390
+ // ★ 2026-11-04 加法:恢复/校验的结论单独接一句(绝不与"装上了"混在一起)
391
+ const recoveryNote = installRecoveryFailureText(job.installRecovery)
368
392
  job.error = aiConsentFailureText(decision, leftovers, aiConsentTimeoutMs)
369
393
  + (reason === null ? '' : `;最后一次失败原因:${reason}`)
394
+ + (recoveryNote === null ? '' : `;${recoveryNote}`)
370
395
  }
371
396
  return
372
397
  }
@@ -0,0 +1,225 @@
1
+ // L1 · domain —— install-lock.js(**同一个 profile + 包名** 的安装互斥;2026-11-04 加法)
2
+ // 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言;不认识 cordis ctx)
3
+ //
4
+ // ── 现场(真机 2026-11-04,用户提供的原始日志)────────────────────────────────────
5
+ // 一次「详情页更新控制台自己」失败,日志里 pnpm 先被 180s 超时杀树,随后:
6
+ // [WARN] Moving @noob-stupid/dsh-plugin-console_tmp_13796_2 to node_modules\.ignored\…
7
+ // [WARN] Moving dsh-workbuddy-connect_tmp_13796_3 to node_modules\.ignored\…
8
+ // 后果:正式目录被清空(只剩 1 个文件、package.json 不见),`node_modules/.ignored` 堆到 118 项 / 335 MB。
9
+ // 用户的判断(原文要点):「这是**两个 pnpm 安装同时装同一个包**造成的冲突 —— 并行竞速本身没问题,
10
+ // 要的是『如果下载一个的时候,就停止另一个相同的安装』」。
11
+ //
12
+ // ── 本模块只做一件事:包的**互斥**(粒度 = 包,不是全局)────────────────────────────
13
+ // · 同一个 `profileDir + 包名` 同一时刻只允许**一个**安装作业;第二个**排队等待**(FIFO,先到先得)。
14
+ // · 不同包**照旧并行** —— 用户明确要「支持同时下载多个」,把闸门做成全局串行是错的。
15
+ // · 等待有上限(默认真机作业预算 10 分钟):到点如实拒绝(`INSTALL_LOCK_TIMEOUT`),绝不放行去动目录。
16
+ // · **可重入**:同一个异步上下文里已经持有该包的锁时直接放行(否则"安装收口里再调一次 pnpm"这种
17
+ // 嵌套会自己把自己锁死;用 AsyncLocalStorage 认上下文,不靠猜)。
18
+ //
19
+ // ── 为什么队列实现要复用 enrich-throttle.js#makeLimiter ────────────────────────────
20
+ // 仓库里已经有一份经过真机验证的 FIFO 计数闸门(`makeLimiter(max)`:绝不丢任务、先到先得、能报
21
+ // active/queued/peak)。用户的原话是「先搜,能复用就复用,不要另造一套」——所以这里**只**复用它的队列,
22
+ // 不重写调度。本模块新增的只有三样它没有的东西:
23
+ // ① 以「包」为键(每个键一个 `max=1` 的 limiter ⇒ 天然就是该包的串行队列);
24
+ // ② **可取消的排队票**:等待超时后把票标记作废,轮到它时**不执行**(`makeLimiter` 本身不支持取消,
25
+ // 但我们不需要改它 —— 传入的 fn 自己看票);
26
+ // ③ **可重入**(同上,AsyncLocalStorage)。
27
+ //
28
+ // ── 语义边界(别越线)────────────────────────────────────────────────────────────
29
+ // · 本模块**不安装任何东西**,也不判断安装成败:它只保证"同一时刻只有一个人在动同一个目录"。
30
+ // · 拿不到锁 ⇒ 抛错(由调用方如实报成失败);**绝不**降级成"不拿锁直接装"。
31
+ // · 空包名 / 空 profileDir ⇒ 直接放行(既有调用路径不传这些参数时行为一个字都不变)。
32
+ import { AsyncLocalStorage } from 'node:async_hooks'
33
+ import { resolve } from 'node:path'
34
+ import { makeLimiter } from './enrich-throttle.js'
35
+
36
+ /** 排队等待上限(默认 10 分钟):到点就如实拒绝,绝不无限等。
37
+ * 取值理由:单次作业预算 8 分钟(domain/install-job.js#jobBudgetMs),最快的一轮 pnpm 失败约 90s;
38
+ * 10 分钟足以覆盖"前一个作业跑完一轮"的绝大多数情形,又不会让第二个作业挂到用户以为死了。 */
39
+ const INSTALL_LOCK_WAIT_MS = 10 * 60 * 1000
40
+ /** 拒绝时的错误码(调用方据此产出如实文案;不要靠匹配中文)。 */
41
+ const INSTALL_LOCK_TIMEOUT = 'INSTALL_LOCK_TIMEOUT'
42
+
43
+ /** 锁键:`<规范化的 profileDir>::<包名>`。Windows 路径不区分大小写 ⇒ 统一小写,否则同一个目录会有两把锁。 */
44
+ function installLockKey(profileDir, packageName) {
45
+ const name = typeof packageName === 'string' ? packageName.trim() : ''
46
+ if (name === '') return ''
47
+ const dir = typeof profileDir === 'string' && profileDir.trim() !== '' ? profileDir.trim() : '<unknown-profile>'
48
+ let normalized = ''
49
+ try { normalized = resolve(dir) } catch { normalized = dir }
50
+ if (process.platform === 'win32') normalized = normalized.toLowerCase()
51
+ return `${normalized}::${name.toLowerCase()}`
52
+ }
53
+
54
+ // ── 模块级状态(一个进程一份;`__resetInstallLocks()` 供单测还原)─────────────────────────
55
+ const states = new Map()
56
+ const heldStore = new AsyncLocalStorage()
57
+
58
+ function lockStateFor(key) {
59
+ let st = states.get(key)
60
+ if (st === undefined) {
61
+ st = { key, limiter: makeLimiter(1), holder: null, acquired: 0, waits: 0, timeouts: 0, peakQueued: 0, lastWaitMs: 0, seq: 0 }
62
+ states.set(key, st)
63
+ }
64
+ return st
65
+ }
66
+
67
+ /** 当前是否有人在装这个包(**不拿锁**,只读状态;给"要不要提示用户在排队"用)。 */
68
+ function installLockHolder(profileDir, packageName) {
69
+ const key = installLockKey(profileDir, packageName)
70
+ if (key === '') return null
71
+ const st = states.get(key)
72
+ if (st === undefined || st.holder === null) return null
73
+ const s = st.limiter.stats()
74
+ return { ...st.holder, active: s.active, queued: s.queued }
75
+ }
76
+
77
+ /** 闸门现状(诊断 / 单测断言"不同包确实并行、同一个包确实串行"用)。 */
78
+ function installLockStats() {
79
+ const items = []
80
+ for (const st of states.values()) {
81
+ const s = st.limiter.stats()
82
+ items.push({ key: st.key, holder: st.holder?.packageName ?? null, holderSince: st.holder?.since ?? null, active: s.active, queued: s.queued, peak: s.max, acquired: st.acquired, waits: st.waits, timeouts: st.timeouts, lastWaitMs: st.lastWaitMs, peakQueued: st.peakQueued })
83
+ }
84
+ return { locks: items.length, items }
85
+ }
86
+
87
+ /** 清空全部在途记录(单测 & 诊断用;已在跑的作业不会被取消 —— 它们退出时也不会再写回这张表)。 */
88
+ function __resetInstallLocks() {
89
+ states.clear()
90
+ }
91
+
92
+ /** 超时拒绝的错误对象(文案要能直接展示给用户:谁在装、等了多久、下一步怎么做)。 */
93
+ function installLockTimeoutError({ packageName, waitedMs, holder = null }) {
94
+ const seconds = Math.max(1, Math.round(waitedMs / 1000))
95
+ const who = typeof holder?.packageName === 'string'
96
+ ? `另一个作业(${holder.jobId ?? '未命名作业'})正在安装这个包,已进行 ${Math.max(1, Math.round((Date.now() - Number(holder.since ?? Date.now())) / 1000))}s`
97
+ : '另一个作业正在安装这个包'
98
+ const error = new Error(`这个包正在安装中:${who}。本次已排队等待 ${seconds}s 仍未轮到,已放弃(没有动任何文件)——请等它结束后重试。`)
99
+ error.code = INSTALL_LOCK_TIMEOUT
100
+ error.packageName = packageName
101
+ error.waitedMs = waitedMs
102
+ error.holder = holder
103
+ return error
104
+ }
105
+
106
+ /**
107
+ * 过闸执行一次"动 `node_modules/<包名>`"的动作(**唯一入口**)。
108
+ * @param {object} spec
109
+ * @param {string} [spec.profileDir] profile 目录(与另一个作业同一个 profile 才会互斥)
110
+ * @param {string} [spec.packageName] 包名(锁的粒度就是它;空字符串 ⇒ 不过闸、直接执行)
111
+ * @param {number} [spec.waitMs] 排队上限(默认 INSTALL_LOCK_WAIT_MS;传 0 = 有人占着就立刻拒绝)
112
+ * @param {object} [spec.job] 作业(只用于把"我在排队 / 我等了多久"写回去,便于面板显示)
113
+ * @param {(info:{queued:boolean,waitedMs:number,key:string})=>void} [spec.onWait] 排队回调(写日志用)
114
+ * @param {() => Promise<any>} fn 真正要做的活
115
+ * @returns {Promise<any>} fn 的返回值;拿不到锁 ⇒ 抛 INSTALL_LOCK_TIMEOUT
116
+ */
117
+ async function withInstallLock(spec, fn, deps = {}) {
118
+ const packageName = typeof spec?.packageName === 'string' ? spec.packageName.trim() : ''
119
+ const key = installLockKey(spec?.profileDir, packageName)
120
+ if (key === '' || typeof fn !== 'function') return await fn()
121
+ // 可重入:同一个异步上下文里已经持有这个包 ⇒ 直接执行(防"安装收口里再调一次"把自己锁死)
122
+ const held = heldStore.getStore()
123
+ if (held !== undefined && held !== null && held.has(key) === true) return await fn({ queued: false, waitedMs: 0, key, reentrant: true })
124
+ const st = lockStateFor(key)
125
+ const now = typeof deps.now === 'function' ? deps.now : Date.now
126
+ const waitMs = Number.isFinite(spec?.waitMs) && spec.waitMs >= 0 ? spec.waitMs : INSTALL_LOCK_WAIT_MS
127
+ const startedAt = now()
128
+ // 「前面还有人」的判据要有两条:`holder` 是人可读的当前持有者,`limiter.active` 是队列的**权威**计数
129
+ // (同一个 tick 里两个调用同时进来时 holder 还是 null,只有 limiter 已经把它算进去了)。
130
+ const queued = st.holder !== null || st.limiter.stats().active > 0
131
+ const ticket = { cancelled: false }
132
+ if (queued) {
133
+ st.waits += 1
134
+ const depth = st.limiter.stats().queued + 1
135
+ if (depth > st.peakQueued) st.peakQueued = depth
136
+ const holder = st.holder === null ? null : { ...st.holder }
137
+ if (spec?.job !== null && spec?.job !== undefined && typeof spec.job === 'object') {
138
+ // 只增字段:面板/诊断据此显示"在排队等谁",不改任何既有作业字段的语义
139
+ spec.job.installLock = { key, packageName, queued: true, since: startedAt, waitedFor: holder?.packageName ?? null, holderJobId: holder?.jobId ?? null, waitMs }
140
+ }
141
+ if (typeof spec?.onWait === 'function') {
142
+ try { spec.onWait({ queued: true, waitedMs: 0, key, holder }) } catch {}
143
+ }
144
+ }
145
+ // 排队(max=1 的 FIFO;票作废后轮到它时**不执行**,只把队列让给下一位)
146
+ // `queueTimer` 在**拿到锁的那一刻**就清掉 —— 排队上限只约束"排队",绝不能把**已经轮到自己、
147
+ // 正在装**的那一次作业从后面掐断(真实形状:等 9 分钟才轮到,再装 8 分钟,旧写法会在第 10 分钟误杀)。
148
+ let queueTimer = null
149
+ const task = st.limiter.run(async () => {
150
+ if (ticket.cancelled === true) return { skipped: true }
151
+ if (queueTimer !== null) { clearTimeout(queueTimer); queueTimer = null }
152
+ const waitedMs = now() - startedAt
153
+ st.holder = { key, packageName, since: now(), jobId: spec?.job?.id ?? null, serial: (st.seq += 1) }
154
+ st.acquired += 1
155
+ st.lastWaitMs = waitedMs
156
+ if (spec?.job !== null && spec?.job !== undefined && typeof spec.job === 'object') {
157
+ spec.job.installLock = { key, packageName, queued: queued, waitedMs, acquiredAt: now() }
158
+ }
159
+ try {
160
+ return await heldStore.run(new Set([...(heldStore.getStore() ?? []), key]), () => fn({ queued, waitedMs, key, reentrant: false }))
161
+ } finally {
162
+ st.holder = null
163
+ }
164
+ })
165
+ // `waitMs === 0` = 明确拒绝策略(有人占着就立刻失败,不排队);否则立刻放行或排队
166
+ if (waitMs === 0) {
167
+ if (queued === true) {
168
+ st.timeouts += 1
169
+ ticket.cancelled = true
170
+ void task.catch(() => {})
171
+ throw installLockTimeoutError({ packageName, waitedMs: 0, holder: st.holder === null ? null : { ...st.holder } })
172
+ }
173
+ return await task
174
+ }
175
+ if (queued !== true) return await task
176
+ // 有上限地等:到点把票作废并如实拒绝(**不会**降级成"不拿锁直接装")
177
+ const guard = new Promise((resolveGuard, rejectGuard) => {
178
+ queueTimer = setTimeout(() => {
179
+ queueTimer = null
180
+ ticket.cancelled = true
181
+ const waitedMs = now() - startedAt
182
+ st.timeouts += 1
183
+ rejectGuard(installLockTimeoutError({ packageName, waitedMs, holder: st.holder === null ? null : { ...st.holder } }))
184
+ }, waitMs)
185
+ })
186
+ try {
187
+ return await Promise.race([task, guard])
188
+ } finally {
189
+ if (queueTimer !== null) { clearTimeout(queueTimer); queueTimer = null }
190
+ // 票作废后那个 limiter 任务仍会用 { skipped: true } 落地 —— 这里接住它,别留下未处理的 rejection
191
+ if (ticket.cancelled === true) void task.catch(() => {})
192
+ }
193
+ }
194
+
195
+ /** 失败文案(作业 error / 面板提示共用一份;非锁超时返回 null):拿不到锁时**如实失败**,绝不报成功。 */
196
+ function installLockFailureText(job) {
197
+ const lock = job !== null && job !== undefined && typeof job === 'object' ? job.installLock : null
198
+ if (lock === null || lock === undefined) return null
199
+ if (lock.timeout !== true) return null
200
+ return lock.reason ?? `这个包正在安装中(${lock.packageName ?? '未知包'}),本次排队等待超时,已放弃`
201
+ }
202
+
203
+ /** `withInstallLock` 的"拿不到锁就返回一份**如实**的结果"版本(只吞 `INSTALL_LOCK_TIMEOUT`,其余照抛)。
204
+ * `onTimeout(error)` 的返回值原样交给调用方 —— 调用方据此产出"没装成 + 原因",绝不假装成功。 */
205
+ async function withInstallLockOr(spec, fn, onTimeout, deps = {}) {
206
+ try {
207
+ return await withInstallLock(spec, fn, deps)
208
+ } catch (error) {
209
+ if (error?.code !== INSTALL_LOCK_TIMEOUT || typeof onTimeout !== 'function') throw error
210
+ return await onTimeout(error)
211
+ }
212
+ }
213
+
214
+ export {
215
+ INSTALL_LOCK_WAIT_MS,
216
+ INSTALL_LOCK_TIMEOUT,
217
+ installLockKey,
218
+ installLockHolder,
219
+ installLockStats,
220
+ installLockTimeoutError,
221
+ installLockFailureText,
222
+ withInstallLock,
223
+ withInstallLockOr,
224
+ __resetInstallLocks,
225
+ }
@@ -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,
@@ -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
- const run = await runAddWithReleaseAgeRetry({ profileDir, spec: record.spec, registry, runAdd, timeoutMs: ACTION_ADD_TIMEOUT_MS })
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
- const run = await runAddWithReleaseAgeRetry({ profileDir, spec: pinned.spec, registry, runAdd, timeoutMs })
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: manifests.pinned
448
- ? (lockSynced ? `${parsed.packageName} 已钉住:清单与 pnpm-lock.yaml 都指向 link:${pinned.dir}` : `${parsed.packageName} 的清单已钉住,lock 未对齐`)
449
- : `${parsed.packageName} 的清单写回未生效(实际是 ${after ?? '(无该条目)'})`,
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
 
@@ -16,6 +16,9 @@ import { linkSpecFor, linkSpecIsIntact, materializePackageForLink, probeRegistry
16
16
  import { canWriteImporterEntry, lockEntryKeyMatches, verifyLockReadOnly, writeLockImporterEntry } from './lock-importer.js'
17
17
  import { readManifestDeps } from './lockfile-health.js'
18
18
  import { pnpmInstall } from './install.js'
19
+ // 同一个 profile + 包名 的安装互斥(2026-11-04 加法,见 domain/install-lock.js 顶部现场):
20
+ // 「插件卡片的更新」与「市场页那颗自更新 pill」动的是**同一个** node_modules/<控制台> 目录 —— 必须串行。
21
+ import { withInstallLockOr } from './install-lock.js'
19
22
  // 自更新兜底(2026-11-03 加法,见 self-update-guard.js 顶部事故记录):
20
23
  // begin/note/settle 是同一套判据与同一份 manualCommand;finishSelfUpdate 是本文件收尾段**原样搬出**的(腾行数用)。
21
24
  import { beginSelfUpdateGuard, finishSelfUpdate, noteGuardStep, settleSelfUpdateGuard } from './self-update-guard.js'
@@ -113,8 +116,27 @@ function selfUpdateCommand(latest, profileName = '<你的profile>') {
113
116
  * 把控制台升到 `latest` 并**确保写进 lock**。
114
117
  * 依赖以参数注入(便于单测替换):`runPnpm`(默认 runPnpmWithFallback)、`pnpmAdd`(默认 pnpmInstall)、
115
118
  * `curlManualInstall`(手铺文件兜底,由路由传入)。
119
+ *
120
+ * ★ 2026-11-04 加法(唯一的改动,见 domain/install-lock.js 顶部现场):整段操作过**同一个包互斥闸** ——
121
+ * 这条路和插件卡片的「更新」(`/install` → domain/install-channels.js)动的是**同一个**
122
+ * `node_modules/@noob-stupid/dsh-plugin-console`,真机事故就是两边同时装这个包。
123
+ * 拿不到锁 ⇒ 如实返回"正在安装中"(ok=false + reason),**不装、不碰目录**(结构同 finishSelfUpdate)。
116
124
  */
117
- async function selfUpdateToLatest({ profileDir, latest, registries, curlManualInstall, runPnpm = runPnpmWithFallback, pnpmAdd = pnpmInstall, profileName = null, execOpts = {}, selfUpdateDeps = {} }) {
125
+ async function selfUpdateToLatest(args = {}) {
126
+ return await withInstallLockOr(
127
+ { profileDir: args?.profileDir, packageName: CONSOLE_PACKAGE },
128
+ async () => await selfUpdateToLatestLocked(args),
129
+ (error) => ({
130
+ method: null, spec: profileSpec(args?.profileDir ?? '', CONSOLE_PACKAGE), installedVersion: readInstalledVersion(args?.profileDir ?? '', CONSOLE_PACKAGE),
131
+ lockVersion: lockVersion(args?.profileDir ?? '', CONSOLE_PACKAGE), lockUpdated: false, lockNote: null,
132
+ command: selfUpdateCommand(args?.latest, args?.profileName ?? '<你的profile>'), note: error.message, errors: [error.message],
133
+ ok: false, reason: error.message, selfUpdate: null,
134
+ }),
135
+ )
136
+ }
137
+
138
+ /** 上面那个互斥外壳里的正文(原来的函数体,逐字未改)。 */
139
+ async function selfUpdateToLatestLocked({ profileDir, latest, registries, curlManualInstall, runPnpm = runPnpmWithFallback, pnpmAdd = pnpmInstall, profileName = null, execOpts = {}, selfUpdateDeps = {} }) {
118
140
  const command = selfUpdateCommand(latest, profileName ?? '<你的profile>')
119
141
  const spec = profileSpec(profileDir, CONSOLE_PACKAGE)
120
142
  const errors = []
@@ -32,6 +32,7 @@ import { PLUGIN_SRC_DIR, linkSpecFor, sameSourceSpec } from './dep-source.js'
32
32
  import { noteChannel } from './git-channel.js'
33
33
  import { addBundleToManifest, detectBundleOnly } from './install.js'
34
34
  import { finalizeInstallPersistence } from './install-finalize.js'
35
+ import { INSTALL_LOCK_TIMEOUT, withInstallLock } from './install-lock.js'
35
36
  import { alreadyInstalledTarget, verifyInstalledEntry } from './install-verify.js'
36
37
  import { ensureLinkSelfSufficiency } from './link-self-sufficiency.js'
37
38
  import { setProfileDependency } from './manifest.js'
@@ -313,9 +314,15 @@ async function runSubdirInstallJobInner(job, ports, deps = {}) {
313
314
  job.subdirSource = { dir, from: acquired.dir, reused: acquired.reused === true, placed: placed.dir, version: recheck.version }
314
315
  // ⑥ 本地目录来源 = 既有安装通道:`pnpm add link:<目录>` 建起 node_modules 里的那条链接
315
316
  // (link: 是本地目录的正常形态,见 source-spec.js 判据 C;依赖由下一步的自足性补齐)
317
+ // ★ 2026-11-04:过**同一个包互斥闸**(domain/install-lock.js)—— 它动的也是 node_modules/<包名>
316
318
  try {
317
- await channels.pnpmInstall(profileDir, spec, undefined)
319
+ await withInstallLock({ profileDir, packageName, job }, async () => await channels.pnpmInstall(profileDir, spec, undefined))
318
320
  } catch (error) {
321
+ // 排队等不到(INSTALL_LOCK_TIMEOUT)与 pnpm 真失败都是"没装上",但文案必须分开(不把排队说成安装失败)
322
+ if (error?.code === INSTALL_LOCK_TIMEOUT) {
323
+ job.installLock = { ...(job.installLock ?? {}), timeout: true, reason: error.message }
324
+ return failSubdirInstall(job, error.message)
325
+ }
319
326
  return failSubdirInstall(job, `从子目录 ${dir}/ 安装失败(本地目录来源 ${spec}):${error instanceof Error ? error.message : String(error)}。`
320
327
  + `源码已取到本地(${placed.dir}),可重试;重试会复用已取到的源码。`)
321
328
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.101",
3
+ "version": "0.5.102",
4
4
  "description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
5
5
  "repository": {
6
6
  "type": "git",