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

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.
@@ -474,7 +474,7 @@ function settleSelfUpdateGuard(session, { targetVersion = undefined, deps = {} }
474
474
  * · 校验没过时把那段失败文案同时推进 `errors` 与 `note`(面板/接口都看得见);
475
475
  * · `installedVersion` 在"已自愈"时改报**盘上真实那一版**(否则会留一个 null 让人以为目录还是空的)。
476
476
  */
477
- function finishSelfUpdate({ profileDir, packageName, latest, spec, installed, method, command, errors, lockVersion, lockUpdated, lockNote, registryRange, selfUpdate = null, copy = null, tarball = null, lockPatch = null }) {
477
+ function finishSelfUpdate({ profileDir, packageName, latest, spec, installed, method, command, errors, lockVersion, lockUpdated, lockNote, registryRange, selfUpdate = null, copy = null, tarball = null, lockPatch = null, modeNote = null }) {
478
478
  const CONSOLE_PACKAGE = packageName
479
479
  const isRegistryRange = registryRange
480
480
  const label = (e) => (e instanceof Error ? e.message : String(e))
@@ -514,6 +514,8 @@ function finishSelfUpdate({ profileDir, packageName, latest, spec, installed, me
514
514
  else if (lockPatch.changed === true && lockPatch.verified === null) noteParts.push(`pnpm-lock.yaml 已补齐 ${latest} 的版本/完整性,但 pnpm 的只读校验**无法确认**(${lockPatch.verifyNote ?? '原因未知'})`)
515
515
  else if (lockPatch.reason !== null && lockPatch.reason !== undefined) noteParts.push(`pnpm-lock.yaml 未补齐:${lockPatch.reason}`)
516
516
  }
517
+ // ★ 2026-11-04 加法:本轮到底走的是哪条腿(manual-overlay = 不走包管理器)、lock 同步到什么程度,一句说清
518
+ if (typeof modeNote === 'string' && modeNote !== '') noteParts.push(modeNote)
517
519
 
518
520
  // ★ 加法:自更新校验的结论(只有"本插件自己 + 更新前就有完整副本"时 selfUpdate 才非空)
519
521
  const verified = selfUpdate === null || selfUpdate === undefined ? true : selfUpdate.verified === true
@@ -1,37 +1,36 @@
1
- // L1 · domain —— selfupdate-copy.js(自更新的**手铺兜底**:先备好 tarball → 只覆盖文件、不清空目录 → 补 lock)
1
+ // L1 · domain —— selfupdate-copy.js(自更新的**本体**:下载 tarball → 只覆盖文件、不清空目录 → 补 lock)
2
2
  //
3
3
  // ── 现场(真机 2026-11-04 实测采样,用户提供的原始时间线)─────────────────────────────────
4
- // 0.5.102 的自更新是「包管理器优先」:`pnpm update` → `pnpm add` → 才轮到手铺(见 domain/selfupdate.js 顶部)。
5
- // pnpm 一启动就**先清空目标目录**再重算整棵依赖树;这一步在 profile 里要跑几分钟
6
- // (`14:45:46 pkg=True files=160` → `14:45:56 pkg=False files=1`),于是:
7
- // · 撞 90s/180s 超时被杀 → 目录停在"空了"的状态,跑了一整夜都没轮到第三步;
4
+ // 0.5.103 的自更新是「包管理器优先」:`pnpm update` → `pnpm add` → 才轮到本模块的手铺(见 selfupdate.js 顶部)。
5
+ // pnpm 一启动就**先清空目标目录**再重算整棵依赖树;这一步在 profile 里要跑 3~9 分钟
6
+ // (`18:32:31 pkg=False files=1 bak=1 pnpm=1` → `18:43:48` 仍是空壳),于是:
7
+ // · 撞 90s/300s 超时被杀 → 目录停在"空了"的状态,跑了一整夜都没轮到第三步;
8
8
  // · 就算第三步轮到了,既有 curl 通道也是"先 rmSync 整目录再 copyTree"(见 install.js#curlManualInstall),
9
9
  // 宿主正读着这些文件时更容易被 Windows 拒绝。
10
- // ⇒ 用户要的:pnpm 卡住/失败时**立刻**手铺、并把 lock 一起补上(否则又会"更新成功、下次 pnpm 操作被还原")。
10
+ // ⇒ 用户拍板「自更新彻底不走 pnpm,改回下载 tarball + 覆盖解包」:本模块的三个函数从此是**主路径**,
11
+ // 不再有任何包管理器排在它们前面(补 lock 也一样,见 ③)。
11
12
  //
12
- // ── 本模块只做三件事(都不改既有通道的触发条件)──────────────────────────────────
13
- // ① prepareSelfUpdateTarball:走 pnpm **之前**先用既有 curl/元数据链路把目标版本 tarball 备到手
14
- // (失败**不影响**后续,只把原因记下来 —— 后面照样跑 pnpm 与既有 curl 通道);
15
- // ② overlaySelfUpdateTarball:用备好的 tarball **只覆盖文件、绝不清空目录**(copyTree 的语义,
13
+ // ── 本模块只做三件事 ─────────────────────────────────────────────────────────
14
+ // ① prepareSelfUpdateTarball:registry 元数据 → `dist.tarball` → curl 下载到临时目录(**唯一的取包动作**);
15
+ // ② overlaySelfUpdateTarball:用下载的 tarball **只覆盖文件、绝不清空目录**(copyTree 的语义,
16
16
  // 见 infra/fsx.js —— 它只 copyFileSync,不删任何东西),随后**读回** package.json 的版本与期望值比对;
17
17
  // ③ patchSelfUpdateLock:把 `pnpm-lock.yaml` 里该包的版本/完整性更新到目标版本(**唯一**写入口仍是
18
- // domain/lock-importer.js#writeLockRegistryVersion,本模块不另写一套 lock 解析),
19
- // 再用既有 `verifyLockReadOnly`(pnpm 只读用法)复核;复核明确不通过 ⇒ **把 lock 还原成改动前的字节**
20
- // 并如实上报(宁可报"没补上",也绝不留下 pnpm 会拒绝的 lock)。
18
+ // domain/lock-importer.js#writeLockRegistryVersion,本模块不另写一套 lock 解析;它自带"写后读回逐字核对"),
19
+ // 之后**默认不跑 pnpm**(见 patchSelfUpdateLock 里 verify 那一段);只读校验只在调用方显式注入时执行。
21
20
  //
22
21
  // ── 与既有实现的关系(复用,不另造一套)──────────────────────────────────────────
23
- // · 盒子校验 `verifyPackageBox`(install-box.js)、覆盖复制 `copyTree`(fsx.js)、
24
- // lock 写入/只读校验(lock-importer.js)、失败兜底与自愈(self-update-guard.js)全部是既有件;
22
+ // · 盒子校验 `verifyPackageBox`(install-box.js)、覆盖复制 `copyTree`(fsx.js)、临时目录删除核实
23
+ // `removeDirVerified`(fsx.js)、lock 写入(lock-importer.js)、失败兜底与自愈(self-update-guard.js)都是既有件;
25
24
  // · 本模块**不判断"更新成功与否"**:那由 selfupdate.js 的回读核实 + 自更新守卫说了算。
26
25
  //
27
26
  // ── 边界(别越线)──────────────────────────────────────────────────────────────
28
27
  // ① 只对**自更新**生效:由 selfupdate.js 在 `/self-update` 那条路里调用;装别的插件的通道一个字节都不经过这里。
29
- // ② 拿不到 tarball / 没有该版本的完整性摘要 ⇒ 什么都不做(返回 reason),调用方回落到 0.5.102 的既有行为。
28
+ // ② 拿不到 tarball / 没有该版本的完整性摘要 ⇒ 什么都不做(返回 reason),调用方回落到既有 curl 通道。
30
29
  // ③ 覆盖失败(EPERM/EBUSY 等)⇒ 如实返回 reason,**不重试、不删目录、不谎报**。
31
30
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
32
31
  import { join } from 'node:path'
33
32
  import { tmpdir } from 'node:os'
34
- import { verifyLockReadOnly, writeLockRegistryVersion } from './lock-importer.js'
33
+ import { writeLockRegistryVersion } from './lock-importer.js'
35
34
  import { verifyPackageBox } from './install-box.js'
36
35
  import { execFileAsync } from '../infra/exec.js'
37
36
  import { copyTree, removeDirVerified } from '../infra/fsx.js'
@@ -224,7 +223,7 @@ async function prepareSelfUpdateTarball({ packageName, version, registries = [],
224
223
  * (copyTree 一个都不删,这个数是给"没清空目录"作证的)。
225
224
  */
226
225
  async function overlaySelfUpdateTarball({ prepared = null, profileDir, packageName, expectedVersion = null, deps = {} } = {}) {
227
- const out = { ok: false, version: null, files: 0, preserved: 0, boxNote: null, reason: null }
226
+ const out = { ok: false, version: null, files: 0, preserved: 0, boxNote: null, links: null, reason: null }
228
227
  const label = (error) => (error instanceof Error ? error.message : String(error))
229
228
  if (prepared === null || prepared === undefined || prepared.ok !== true || typeof prepared.tgzPath !== 'string') {
230
229
  out.reason = `没有可用的预下载 tarball${prepared?.reason ? `(${prepared.reason})` : ''}`
@@ -245,6 +244,9 @@ async function overlaySelfUpdateTarball({ prepared = null, profileDir, packageNa
245
244
  const existing = filesUnder(target)
246
245
  let preserved = 0
247
246
  for (const rel of existing) if (!tarballFiles.has(rel)) preserved += 1
247
+ // ★ 覆盖之前先**断开硬链接**(2026-11-04 加法,见 breakHardLinksUnder:pnpm 的 node_modules 文件是指向
248
+ // store 的硬链接,copyFileSync 会顺着链接把 store 里那一版的内容改掉 —— 波及别的 profile)
249
+ out.links = breakHardLinksUnder(target, tarballFiles)
248
250
  // ★ 只覆盖:copyTree 只 copyFileSync(不删任何东西),目录缺就建目录 —— 见 infra/fsx.js
249
251
  copyTree(pkgDir, target)
250
252
  try {
@@ -271,6 +273,34 @@ async function overlaySelfUpdateTarball({ prepared = null, profileDir, packageNa
271
273
  }
272
274
  }
273
275
 
276
+ /**
277
+ * 覆盖之前**断开硬链接**(2026-11-04 加法,用户红线「不能影响别的功能」)。
278
+ * 现场事实:pnpm 把 store 里的文件**硬链接**进 `node_modules`,而 `copyFileSync` 是"打开目标文件、截断、写入"
279
+ * —— 它顺着硬链接**写回 store**:把 store 里 0.5.103 那份的内容换成 0.5.101 的(本机实测确认:nlink=2 时
280
+ * copyFileSync 会让另一份的内容一起变)。store 被改脏会波及**别的 profile**(同一 store 里那一版从此是坏的)。
281
+ * 所以只对"本来就在、且 nlink > 1"的目标文件做 **delete-then-copy**(删一个文件 ≠ 清空目录:
282
+ * 它本来就要被覆盖;tarball 里没有的文件一个都不动)。
283
+ * 删不掉(宿主正锁着该文件)⇒ 退回原地覆盖 = 与上一版行为逐字一致,**不新增失败模式**。
284
+ * 返回 `{ broken, failed }`:断了几个硬链接、几个没断开(如实上报,供调用方展示)。
285
+ */
286
+ function breakHardLinksUnder(root, rels) {
287
+ const out = { broken: 0, failed: 0 }
288
+ for (const rel of rels) {
289
+ const full = join(String(root), ...String(rel).split('/'))
290
+ // 目标里还没有这个文件(首次铺入 / 新版新增)⇒ 不需要断链,更不是失败
291
+ if (existsSync(full) !== true) continue
292
+ try {
293
+ const info = statSync(full)
294
+ if (info.isFile() !== true || info.nlink <= 1) continue
295
+ rmSync(full, { force: true })
296
+ out.broken += 1
297
+ } catch {
298
+ out.failed += 1
299
+ }
300
+ }
301
+ return out
302
+ }
303
+
274
304
  /** 读一份包目录里清单的版本(读不到返回 null)。 */
275
305
  function readVersionOf(dir) {
276
306
  try {
@@ -285,11 +315,14 @@ function readVersionOf(dir) {
285
315
  * 复用 lockfile-health.js 的既有解析器)传进来,两处各写一份必然分叉。 */
286
316
 
287
317
  /**
288
- * ③ 手铺成功之后**必须**把 lock 补到目标版本(不补就重演"下次 pnpm 操作把版本还原"的老毛病)。
289
- * 写入走 domain/lock-importer.js#writeLockRegistryVersion(唯一写入口,内部再复用 importer 规划器);
290
- * 复核走既有 `verifyLockReadOnly`(`pnpm install --lockfile-only --dry-run --frozen-lockfile`)。
291
- * 复核**明确不通过**(verified === false)⇒ 把 lock 还原成改动前的字节,并按未补上如实上报:
292
- * 宁可回到"会被还原"的老状态 + 一条可复制的命令,也绝不留下 pnpm 会拒绝的 lock。
318
+ * ③ 落地成功之后**必须**把 lock 补到目标版本(不补就重演"下次 pnpm 操作把版本还原"的老毛病)。
319
+ * 写入走 domain/lock-importer.js#writeLockRegistryVersion(唯一写入口,内部再复用 importer 规划器,
320
+ * 并且**写后读回逐字核对**)。
321
+ * ★ 2026-11-04 改动:pnpm 的只读解析校验(`pnpm install --lockfile-only --dry-run --frozen-lockfile`)
322
+ * 改成**显式注入才跑**(`deps.verifyLock`)—— 自更新这条路从此不启动任何 pnpm。不注入时 verified=null
323
+ * 并如实写明"未做解析层校验"(**绝不**把它当作通过)。注入时逐字沿用既有逻辑:复核**明确不通过**
324
+ * (verified === false)⇒ 把 lock 还原成改动前的字节,并按未补上如实上报:宁可回到"会被还原"的老状态
325
+ * + 一条可复制的命令,也绝不留下 pnpm 会拒绝的 lock。
293
326
  * 返回 `{ attempted, changed, unchanged, version, integrityWritten, renamed, verified, verifyNote, rolledBack, reason, note }`。
294
327
  */
295
328
  async function patchSelfUpdateLock({ profileDir, packageName, targetVersion, spec = null, integrity = null, section = 'dependencies', deps = {} } = {}) {
@@ -340,7 +373,17 @@ async function patchSelfUpdateLock({ profileDir, packageName, targetVersion, spe
340
373
  out.reason = written?.reason ?? 'lock 未写入(原因未给出)'
341
374
  return out
342
375
  }
343
- const verify = typeof deps.verifyLock === 'function' ? deps.verifyLock : verifyLockReadOnly
376
+ // ★ 2026-11-04 改动(用户拍板「自更新彻底不走 pnpm」):pnpm 的**只读解析校验**也改成"显式注入才跑"。
377
+ // 理由与自更新同一条:`pnpm install --lockfile-only --dry-run` 在这台机器上同样要几分钟(必被超时杀),
378
+ // 而自更新不能为了复核再启动一次包管理器。不注入 ⇒ `verified=null`(**无法确认**,不是"通过"),
379
+ // 并如实说明"未做解析层校验";写入器(writeLockRegistryVersion)本身已经做了**写后读回逐字核对**。
380
+ // 注入(既有测试 / 未来调用方)⇒ 逐字沿用原逻辑,含"明确不通过就把 lock 还原成改动前的字节"。
381
+ const verify = typeof deps.verifyLock === 'function' ? deps.verifyLock : null
382
+ if (verify === null) {
383
+ out.verified = null
384
+ out.verifyNote = '本轮不启动 pnpm(自更新不再走包管理器):lock 已定点写入并读回核对,但未做 pnpm 的解析层只读校验'
385
+ return out
386
+ }
344
387
  try {
345
388
  const verdict = await verify({ profileDir, registry: deps.registry ?? null, execOpts: { timeout: LOCK_VERIFY_TIMEOUT_MS }, ...(deps.verifyArgs ?? {}) })
346
389
  out.verified = verdict?.verified ?? null
@@ -380,6 +423,7 @@ export {
380
423
  LOCK_VERIFY_TIMEOUT_MS,
381
424
  META_TIMEOUT_MS,
382
425
  WORK_PREFIX,
426
+ breakHardLinksUnder,
383
427
  disposeSelfUpdateTarball,
384
428
  downloadToFile,
385
429
  encodePackageName,
@@ -1,20 +1,31 @@
1
- // L1 · domain —— selfupdate.js(控制台自更新:**包管理器优先**,保证升级真的写进 pnpm-lock.yaml)
1
+ // L1 · domain —— selfupdate.js(控制台自更新:**下载 tarball + 只覆盖解包**;自更新通路彻底不调用 pnpm)
2
2
  //
3
- // 缺陷背景(2026-09-20 用户实测报告,附完整时间线):
4
- // 旧的 `/self-update` 直接把 npm tarball 的文件铺进 profile 的 node_modules,**不碰 pnpm-lock.yaml**;
5
- // 而 profile 的依赖是 pnpm 按 lock 管理的(`dsh plugin` 本身就是 pnpm 的薄转发器)。于是只要发生
6
- // **任何一次 pnpm 操作**——开关任意插件(会改 `dsh.profile.bundles`)、`dsh plugin add/remove/install`——
7
- // pnpm 就按 lock 重装,把刚"升级"上去的版本**还原**回 lock 里钉住的旧版本。
8
- // 用户侧现象:UI 一直提示有新版(package.json 写 `^0.3.47` 允许 0.3.54),点更新显示成功、重启后还是旧版。
3
+ // 现场(2026-11-04 真机采样,8 秒粒度;用户原话「全面修复这个,不行就把这个插件更新改为之前的版本」):
4
+ // 18:31:26 pkg=True ver=0.5.103 files=165 bak=0
5
+ // 18:32:31 pkg=False files=1 bak=1 pnpm=1 ← 备份刚建好;pnpm 一启动就把目标目录清空了
6
+ // 18:32:39 pkg=False files=1 bak=0 ← 备份 8 秒后被收尾段删掉(它以为更新成功了)
7
+ // 18:37:30 / 18:38:11 / 18:41:25 pnpm 两轮,每轮 3~9 分钟,都被超时杀
8
+ // 18:43:48 仍是空壳(1 文件) ← 排在 pnpm 后面的"手铺兜底"一次都没轮到
9
+ // ⇒ 只要 pnpm 参与自更新,它就**先清空目标目录**;本机 pnpm 必超时被杀 ⇒ 目录一直空着。
9
10
  //
10
- // 现在的顺序:① spec 是版本范围 → `pnpm update <pkg>`(spec 不变、lock 提到范围内最新)
11
- // ② 仍不是 latest(超出范围 / spec 是 git·file 来源)→ `pnpm add <pkg>@<latest>`(spec 与 lock 同步改写)
12
- // ③ 回读核实 readInstalledVersion / lockVersion;都不匹配才回落手铺文件,且**必须**带 lockNote 警告。
11
+ // 现在的顺序(**自更新通路不再调用任何包管理器**;装别的插件的 /install 通道一个字节都没动):
12
+ // ① registry 元数据取目标版本 → curl 下载该版本 tarball
13
+ // ② **只覆盖、不清空**地解到 node_modules/<pkg>(复用 selfupdate-copy.js#overlaySelfUpdateTarball:
14
+ // infra/fsx.js#copyTree 只 copyFileSync,一个文件都不删)
15
+ // ③ 读回 package.json 核实版本 === 目标版本(不符/读不出 ⇒ 如实失败,才回落到既有 curl 通道)
16
+ // ④ 补 pnpm-lock.yaml(lock-importer.js#writeLockRegistryVersion:定点写 + 写后读回核对)
17
+ // ⑤ 既有守卫收尾(self-update-guard.js:更新前备份、校验不过就自愈、绝对路径 manualCommand)
18
+ // lock 这一层只做"定点写入 + 读回核对",**故意不跑** pnpm 的只读解析校验(`--lockfile-only --dry-run`
19
+ // 在这台机器上同样要几分钟):结果里 lockPatch.verified 恒为 null,note 明说"未做解析层校验"。
13
20
  import { readFileSync } from 'node:fs'
14
21
  import { join } from 'node:path'
15
22
  import { linkSpecFor, linkSpecIsIntact, materializePackageForLink, probeRegistryPackage, sameSourceSpec, suggestedPinAction } from './dep-source.js'
16
23
  import { canWriteImporterEntry, lockEntryKeyMatches, verifyLockReadOnly, writeLockImporterEntry } from './lock-importer.js'
17
24
  import { readManifestDeps } from './lockfile-health.js'
25
+ // ↓ pnpmInstall / runPnpmWithFallback **不是**自更新在用(自更新通路一次都不调用它们):
26
+ // 它们是 /install 通道(domain/install-channels.js / install-job.js)的包管理器入口,函数本身原样保留。
27
+ // 本文件只在 selfUpdateToLatestLocked 的签名里留着 `runPnpm` / `pnpmAdd` 两个**观测位**(默认绑真实现),
28
+ // 正文不调用 —— 测试据此注入计数桩、断言"自更新全程 0 次 pnpm"(变异:把那两步加回去 ⇒ 计数立刻 > 0)。
18
29
  import { pnpmInstall } from './install.js'
19
30
  // 同一个 profile + 包名 的安装互斥(2026-11-04 加法,见 domain/install-lock.js 顶部现场):
20
31
  // 「插件卡片的更新」与「市场页那颗自更新 pill」动的是**同一个** node_modules/<控制台> 目录 —— 必须串行。
@@ -23,11 +34,11 @@ import { withInstallLockOr } from './install-lock.js'
23
34
  // begin/note/settle 是同一套判据与同一份 manualCommand;finishSelfUpdate 是本文件收尾段**原样搬出**的(腾行数用)。
24
35
  import { beginSelfUpdateGuard, finishSelfUpdate, noteGuardStep, settleSelfUpdateGuard } from './self-update-guard.js'
25
36
  // 只读读数(spec 判定 / 已装版本 / lock 里钉住的版本)2026-11-04 搬进 selfupdate-read.js:
26
- // 本文件贴着 600 行架构棘轮,而本轮要给"pnpm 卡住/失败后立刻手铺 + 补 lock"腾出空间。
37
+ // 本文件贴着 600 行架构棘轮,而每轮都要在自更新路径上腾出空间(上一轮:手铺 + 补 lock;本轮:去掉 pnpm 两步)。
27
38
  // 名字与语义一个字未改;下面的 export 语句照旧把它们**原样 re-export**(外部调用方与测试不用改)。
28
39
  import { isRegistryRange, lockVersion, profileSpec, readInstalledVersion } from './selfupdate-read.js'
29
- // 手铺兜底(2026-11-04 加法,见 selfupdate-copy.js 顶部现场):pnpm 之前先把 tarball 备好 →
30
- // pnpm 超时/失败就**只覆盖文件、不清空目录**地铺进去 → 再把 lock 的版本/完整性补上。
40
+ // 手铺本体(2026-11-04 加法,见 selfupdate-copy.js 顶部现场):下载 tarball → **只覆盖文件、不清空目录**地
41
+ // 铺进去 → 再把 lock 的版本/完整性补上。本轮起它就是自更新的**主路径**(不再有"pnpm 失败才轮到它")。
31
42
  import { disposeSelfUpdateTarball, overlaySelfUpdateTarball, patchSelfUpdateLock, prepareSelfUpdateTarball } from './selfupdate-copy.js'
32
43
  import { fetchJsonUrl } from '../infra/http.js'
33
44
  import { runPnpmWithFallback } from '../infra/exec.js'
@@ -40,11 +51,11 @@ function selfUpdateCommand(latest, profileName = '<你的profile>') {
40
51
  }
41
52
 
42
53
  /**
43
- * 把控制台升到 `latest` 并**确保写进 lock**。
44
- * 依赖以参数注入(便于单测替换):`runPnpm`(默认 runPnpmWithFallback)、`pnpmAdd`(默认 pnpmInstall)、
45
- * `curlManualInstall`(手铺文件兜底,由路由传入)。
54
+ * 把控制台升到 `latest` 并**把落地的版本写进 lock**。
55
+ * 依赖以参数注入(便于单测替换):`runPnpm` / `pnpmAdd`(**自更新不再调用**,只作观测位)、
56
+ * `curlManualInstall`(tarball 覆盖失败时的既有兜底通道,由路由传入)。
46
57
  *
47
- * ★ 2026-11-04 加法(唯一的改动,见 domain/install-lock.js 顶部现场):整段操作过**同一个包互斥闸** ——
58
+ * ★ 2026-11-04 加法(见 domain/install-lock.js 顶部现场):整段操作过**同一个包互斥闸** ——
48
59
  * 这条路和插件卡片的「更新」(`/install` → domain/install-channels.js)动的是**同一个**
49
60
  * `node_modules/@noob-stupid/dsh-plugin-console`,真机事故就是两边同时装这个包。
50
61
  * 拿不到锁 ⇒ 如实返回"正在安装中"(ok=false + reason),**不装、不碰目录**(结构同 finishSelfUpdate)。
@@ -65,13 +76,14 @@ async function selfUpdateToLatest(args = {}) {
65
76
  /**
66
77
  * 上面那个互斥外壳里的正文。
67
78
  *
68
- * ★ 2026-11-04 加法(**三步的触发条件与顺序一个字未改**,只多了"预先备 tarball"和"手铺后补 lock"):
69
- * 现场:pnpm 一启动就清空目标目录,在 profile 里重算整棵依赖树要几分钟 → 撞 90s/180s 超时被杀 →
70
- * 目录停在"空了"的状态,手铺那一步(第③步)压根还没轮到;即使轮到,既有 curl 通道也是"先删再铺"。
71
- * 现在:⓪ 先用既有 curl/元数据链路把目标版本 tarball 备到手(失败不影响后续)→ ① ② 照旧 →
72
- * ③ 需要手铺时**先用备好的 tarball 只覆盖文件(绝不清空目录)**,不行才回落到既有 curl 通道 →
73
- * ③c 手铺成功但 lock 没跟上 ⇒ 立刻补 lock(版本 + 完整性)并复核;补不上就如实记 lockNote。
74
- * `copyDeps` 只是单测注入口(默认全是既有实现),路由侧一个字都不用改。
79
+ * ★ 2026-11-04 改法(用户拍板:「自更新彻底不走 pnpm」):
80
+ * 采样证明**只要 pnpm 参与自更新,它就先清空目标目录**,而本机 pnpm 要 3~9 分钟、必被超时杀
81
+ * ⇒ 目录一直空着,排在后面的手铺兜底永远轮不到。所以这条路起**不再调用 pnpm update / pnpm add**:
82
+ * ① 下载目标版本 tarball → ② 只覆盖、不清空地解到 node_modules/<pkg> → ③ 读回核实版本 === 目标版本
83
+ * → ④ 补 pnpm-lock.yaml(定点写 + 写后读回核对)→ ⑤ 守卫收尾(备份 / 自愈 / 绝对路径 manualCommand)。
84
+ * `runPnpm` / `pnpmAdd` 两个注入位**故意留着**(正文一次都不调用):它们是"自更新有没有偷偷跑起 pnpm"
85
+ * 的观测点 —— 测试注入计数桩断言 0 次;变异验证(把那两步加回去)会让那条断言立刻变红。
86
+ * `copyDeps` / `selfUpdateDeps` 只是单测注入口(默认全是既有实现),路由侧一个字都不用改。
75
87
  */
76
88
  async function selfUpdateToLatestLocked({ profileDir, latest, registries, curlManualInstall, runPnpm = runPnpmWithFallback, pnpmAdd = pnpmInstall, profileName = null, execOpts = {}, selfUpdateDeps = {}, copyDeps = {} }) {
77
89
  const command = selfUpdateCommand(latest, profileName ?? '<你的profile>')
@@ -82,81 +94,74 @@ async function selfUpdateToLatestLocked({ profileDir, latest, registries, curlMa
82
94
  // 自更新兜底(规矩:只对本插件生效 / 更新前就有完整副本 / 真的装在 node_modules 里,三条闸都在 begin 里)
83
95
  const guard = beginSelfUpdateGuard({ packageName: CONSOLE_PACKAGE, profileDir, targetVersion: latest, deps: selfUpdateDeps })
84
96
 
85
- // ⓪ 走 pnpm **之前**先把目标版本的 tarball 备到手(失败只记原因,绝不阻断后续:三步顺序不变)
97
+ // ① 目标版本的 tarball(自更新**唯一**的取包动作:元数据 → dist.tarball → curl 下载)
86
98
  const prepared = await prepareSelfUpdateTarball({ packageName: CONSOLE_PACKAGE, version: latest, registries, deps: copyDeps })
87
99
 
88
- // ① spec 是版本范围 → pnpm update(spec 不变,lock 提到范围内最新)
89
- if (isRegistryRange(spec)) {
90
- try {
91
- await runPnpm(['update', CONSOLE_PACKAGE], { execOpts: { cwd: profileDir, timeout: 300000, ...execOpts } })
92
- method = 'pnpm-update'
93
- } catch (error) {
94
- errors.push(`pnpm update 失败:${label(error)}`)
95
- }
96
- noteGuardStep(guard, 'pnpm-update') // 这一步结束就校验一次(只记录,自愈留到收尾统一做)
97
- }
98
- // ② 还没到 latest(超出 range / git·file 来源)→ pnpm add <pkg>@<latest>(spec 与 lock 一起改写)
99
- if (readInstalledVersion(profileDir, CONSOLE_PACKAGE) !== latest) {
100
- try {
101
- await pnpmAdd(profileDir, `${CONSOLE_PACKAGE}@${latest}`, registries?.[0])
102
- method = method === null ? 'pnpm-add' : `${method}+pnpm-add`
103
- } catch (error) {
104
- errors.push(`pnpm add 失败:${label(error)}`)
105
- }
106
- noteGuardStep(guard, 'pnpm-add')
107
- }
108
-
109
100
  let installed = readInstalledVersion(profileDir, CONSOLE_PACKAGE)
110
101
  let lock = lockVersion(profileDir, CONSOLE_PACKAGE)
111
102
  let lockUpdated = installed === latest && lock === latest
112
103
  let lockNote = null
113
104
  let copy = null
114
105
  let lockPatch = null
106
+ let modeNote = null
115
107
 
116
- // ③ 包管理器都没成 → 手铺文件兜底(触发条件与 0.5.102 一致:`!lockUpdated`)
117
- if (!lockUpdated) {
118
- // ③a 先用**已备好的 tarball 只覆盖文件、不清空目录**(pnpm 卡住/失败时那条"立刻手铺"的腿)
108
+ // ② **只覆盖、不清空目录**地解到 node_modules/<pkg>(copyTree 只 copyFileSync:宿主正读着的文件也能覆盖,
109
+ // 而且覆盖失败时目录不会先变空)。目录里已经是目标版本且没有 tarball ⇒ 什么都不做。
110
+ if (installed !== latest || prepared.ok === true) {
119
111
  const overlay = await overlaySelfUpdateTarball({ prepared, profileDir, packageName: CONSOLE_PACKAGE, expectedVersion: latest, deps: copyDeps })
112
+ noteGuardStep(guard, 'manual-overlay') // 覆盖之后立刻校验一次(记录器只读盘,自愈留到收尾统一做)
120
113
  if (overlay.ok === true) {
121
- method = method === null ? 'manual-overlay' : `${method}+manual-overlay`
114
+ method = 'manual-overlay'
122
115
  installed = overlay.version ?? readInstalledVersion(profileDir, CONSOLE_PACKAGE)
123
- copy = { channel: 'overlay', files: overlay.files, preserved: overlay.preserved, version: overlay.version ?? null, boxNote: overlay.boxNote ?? null, noClear: true, reason: null }
116
+ copy = { channel: 'overlay', files: overlay.files, preserved: overlay.preserved, version: overlay.version ?? null, boxNote: overlay.boxNote ?? null, links: overlay.links ?? null, noClear: true, reason: null }
124
117
  } else {
125
- // ③b 没 tarball / 覆盖失败 → **既有** curl 通道(先删再铺),行为与 0.5.102 逐字一致
118
+ // ②b 没 tarball / 覆盖失败 → **既有** curl 通道(先删再铺)。它同样不碰 pnpm,行为与 0.5.102 逐字一致。
126
119
  try {
127
120
  const info = await curlManualInstall(profileDir, CONSOLE_PACKAGE, registries, null, latest)
128
- method = method === null ? 'manual-copy' : `${method}+manual-copy`
121
+ method = 'manual-copy'
129
122
  installed = info?.version ?? readInstalledVersion(profileDir, CONSOLE_PACKAGE)
130
- copy = { channel: 'curl', files: null, preserved: null, version: info?.version ?? null, boxNote: info?.boxNote ?? null, noClear: false, reason: overlay.reason ?? null }
123
+ copy = { channel: 'curl', files: null, preserved: null, version: info?.version ?? null, boxNote: info?.boxNote ?? null, links: null, noClear: false, reason: overlay.reason ?? null }
131
124
  } catch (error) {
132
125
  errors.push(`手动铺文件失败:${label(error)}`)
133
- copy = { channel: null, files: null, preserved: null, version: null, boxNote: null, noClear: false, reason: overlay.reason ?? null }
126
+ copy = { channel: null, files: null, preserved: null, version: null, boxNote: null, links: null, noClear: false, reason: overlay.reason ?? null }
134
127
  }
128
+ noteGuardStep(guard, 'manual-copy')
135
129
  }
130
+ } else {
131
+ // 目录里本来就是目标版本(且 tarball 没备成)⇒ 如实说明"没改动任何文件",绝不假装更新过
132
+ method = 'already-current'
133
+ modeNote = `目录里的版本已是 ${latest},本轮没有改动任何文件`
134
+ }
135
+
136
+ lock = lockVersion(profileDir, CONSOLE_PACKAGE)
137
+ lockUpdated = installed === latest && lock === latest
138
+ // ③ 文件已落地但 lock 还钉在旧版本 ⇒ **必须补 lock**,否则下一次 pnpm 操作就把版本还原
139
+ if (installed === latest && !lockUpdated) {
140
+ lockPatch = await patchSelfUpdateLock({
141
+ profileDir, packageName: CONSOLE_PACKAGE, targetVersion: latest, spec,
142
+ integrity: prepared.ok === true ? prepared.integrity : null,
143
+ section: manifestSectionOf(profileDir, CONSOLE_PACKAGE), deps: copyDeps,
144
+ })
136
145
  lock = lockVersion(profileDir, CONSOLE_PACKAGE)
137
146
  lockUpdated = installed === latest && lock === latest
138
- // ③c 手铺成功(版本对了)但 lock 还钉在旧版本 ⇒ **必须补 lock**,否则下一次 pnpm 操作就把它还原
139
- if (installed === latest && !lockUpdated) {
140
- lockPatch = await patchSelfUpdateLock({
141
- profileDir, packageName: CONSOLE_PACKAGE, targetVersion: latest, spec,
142
- integrity: prepared.ok === true ? prepared.integrity : null,
143
- section: manifestSectionOf(profileDir, CONSOLE_PACKAGE), deps: copyDeps,
144
- })
145
- lock = lockVersion(profileDir, CONSOLE_PACKAGE)
146
- lockUpdated = installed === latest && lock === latest
147
- if (lockPatch.reason !== null) errors.push(`补 pnpm-lock.yaml 未成功:${lockPatch.reason}`)
148
- }
149
- noteGuardStep(guard, 'manual-copy')
150
- if (!lockUpdated) {
151
- lockNote = `此更新未写入 pnpm-lock.yaml(lock 里仍是 ${lock ?? '未知'}):之后任何 pnpm 操作(开关插件、dsh plugin add/remove)都会把它还原成 lock 里的版本。要真正落地请执行:${command}`
152
- if (lockPatch !== null && lockPatch.reason !== null) lockNote = `${lockNote}(补 lock 未成功:${lockPatch.reason})`
153
- }
147
+ if (lockPatch.reason !== null) errors.push(`补 pnpm-lock.yaml 未成功:${lockPatch.reason}`)
148
+ }
149
+ if (!lockUpdated) {
150
+ lockNote = `此更新未写入 pnpm-lock.yaml(lock 里仍是 ${lock ?? '未知'}):之后任何 pnpm 操作(开关插件、dsh plugin add/remove)都会把它还原成 lock 里的版本。要真正落地请执行:${command}`
151
+ if (lockPatch !== null && lockPatch.reason !== null) lockNote = `${lockNote}(补 lock 未成功:${lockPatch.reason})`
152
+ }
153
+
154
+ // ④ 如实说明本轮走的是哪条腿(**绝不静默**):manual-* 一律"没走包管理器",并交代 lock 的边界
155
+ if (copy !== null) {
156
+ modeNote = method === null
157
+ ? `本轮既没覆盖成功、也没走通既有兜底通道(全程**没有调用包管理器 pnpm**);lock 已尽力同步${lockUpdated === true ? '' : '但最终没同步(详见 lockNote)'}`
158
+ : `本轮走的是 ${method}(下载 tarball 后${copy.channel === 'overlay' ? '只覆盖文件、不清空目录' : '先清后铺'}),全程**没有调用包管理器 pnpm**;lock 已尽力同步${lockUpdated === true ? '' : '但最终没同步(详见 lockNote)'}——若 lock 最终没同步,下一次包管理器操作可能把版本还原`
154
159
  }
155
160
 
156
- // 临时工作目录(预下载的 tarball / 解包目录)收尾即删;删不掉要如实记下来(本机 %TEMP% 下 rmSync 会静默落空)
161
+ // 临时工作目录(下载的 tarball / 解包目录)收尾即删;删不掉要如实记下来(本机 %TEMP% 下 rmSync 会静默落空)
157
162
  const cleanup = disposeSelfUpdateTarball(prepared)
158
163
 
159
- // 收尾:三步都走完才统一校验(全部走完仍不过才自愈),然后交给**原样搬出**的收尾段组装返回结构
164
+ // 收尾:全部动作走完才统一校验(仍不过才自愈),然后交给**原样搬出**的收尾段组装返回结构
160
165
  const selfUpdate = settleSelfUpdateGuard(guard, { targetVersion: latest })
161
166
  const tarball = {
162
167
  ok: prepared.ok === true,
@@ -168,7 +173,7 @@ async function selfUpdateToLatestLocked({ profileDir, latest, registries, curlMa
168
173
  ms: prepared.ms ?? 0,
169
174
  cleanup: { removed: cleanup.removed === true, note: cleanup.note ?? null },
170
175
  }
171
- return finishSelfUpdate({ profileDir, packageName: CONSOLE_PACKAGE, latest, spec, installed, method, command, errors, lockVersion: lock, lockUpdated, lockNote, registryRange: isRegistryRange, selfUpdate, copy, tarball, lockPatch })
176
+ return finishSelfUpdate({ profileDir, packageName: CONSOLE_PACKAGE, latest, spec, installed, method, command, errors, lockVersion: lock, lockUpdated, lockNote, registryRange: isRegistryRange, selfUpdate, copy, tarball, lockPatch, modeNote })
172
177
  }
173
178
 
174
179
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.103",
3
+ "version": "0.5.105",
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",