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

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.
@@ -14,6 +14,8 @@ import { noteChannel, tryGitChannel } from './git-channel.js'
14
14
  import { retryPnpmOnce } from './install-diagnose.js'
15
15
  import { targetVersionOf, versionMissOf, versionMissText } from './install-version.js'
16
16
  import { tryPresetSourceChannel } from './preset-source.js'
17
+ // 自更新兜底(2026-11-03 加法,见 domain/self-update-guard.js 顶部事故记录):只在"装的包名 === 本插件自己"时接管
18
+ import { beginSelfUpdateGuard, selfUpdateFailureText, settleSelfUpdateGuard } from './self-update-guard.js'
17
19
 
18
20
  /** release 通道的候选预算(issue #3):release 通道现在按包名反查发布仓库,**每个候选**至少一次
19
21
  * `releases?per_page=10` 调用(第一梯队仓库没命中还要多试几个候选仓库),而候选可能有十几个
@@ -21,6 +23,41 @@ import { tryPresetSourceChannel } from './preset-source.js'
21
23
  * 只给前 N 个候选扫 release;排在后面的候选仍照走 curl/并行竞速(按包名施工,代价小)。 */
22
24
  const RELEASE_CHANNEL_BUDGET = 3
23
25
 
26
+ /**
27
+ * 单个候选的通道序列**外壳**(2026-11-03 加法):只给"本插件自己的自更新"多加一层备份/校验/自愈,
28
+ * 其余情况(所有别的包)**原样直通**——`beginSelfUpdateGuard` 不成立就 `return tryCandidateChannelsInner(...)`,
29
+ * 一个字节都不多走(tests/test-self-update-guard.mjs ⑤ 用"零备份 + 无 selfUpdate 字段 + 通道调用顺序不变"钉住)。
30
+ *
31
+ * 为什么包在整条序列外面,而不是包在某一次 pnpm 调用里面:真机事故(宿主读着插件自己的文件 ⇒ EPERM、
32
+ * Windows 上 pnpm 先删旧目录再解包、删到一半被打断)里,**残缺态是整条序列结束时才看得见的**,
33
+ * 而"哪条通道声称装上了"完全不可信(那一次 curl/pnpm 都回了成功)。所以:
34
+ * ① 进来先备份(复制,不打扰正在运行的目录);
35
+ * ② 出去无论成败都校验一遍(try/catch 之后仍然跑 —— 这就是"pnpm 报失败也要校验");
36
+ * ③ 校验不过 ⇒ 把这次尝试**改判成没装上**(installedName=null + 如实错误),
37
+ * 由 install-job.js 的自更新分支收口成可见失败(绝不报"更新成功")。
38
+ */
39
+ async function tryCandidateChannels(args) {
40
+ const inner = () => tryCandidateChannelsInner(args)
41
+ const guardDeps = args?.selfUpdateDeps ?? {}
42
+ const session = beginSelfUpdateGuard({
43
+ packageName: args?.name, profileDir: args?.profileDir,
44
+ targetVersion: targetVersionOf(args?.job, args?.name), deps: guardDeps,
45
+ })
46
+ if (session === null) return inner()
47
+ let outcome = null
48
+ let thrown = null
49
+ try {
50
+ outcome = await inner()
51
+ } catch (error) {
52
+ thrown = error
53
+ }
54
+ const selfUpdate = settleSelfUpdateGuard(session, { targetVersion: targetVersionOf(args?.job, args?.name), deps: guardDeps })
55
+ if (args?.job !== null && args?.job !== undefined && typeof args.job === 'object') args.job.selfUpdate = selfUpdate
56
+ if (thrown !== null) throw thrown
57
+ if (selfUpdate !== null && selfUpdate.verified === true) return outcome
58
+ return { installedName: null, lastError: new Error(selfUpdateFailureText(selfUpdate) ?? '自更新校验未通过(原因未知)') }
59
+ }
60
+
24
61
  /** 单个候选包的通道尝试序列(通道实现由 ch 注入;job.curlNote 等展示字段在此更新)。
25
62
  * 返回 { installedName, lastError }。三条守卫语义及理由(issue #3 要求逐条写清):
26
63
  *
@@ -37,7 +74,7 @@ const RELEASE_CHANNEL_BUDGET = 3
37
74
  * ③ git 通道:**保留 repoChannelAllowed**(它只 clone `job.repo`,候选是子包时 clone 根仓库装不出子包,
38
75
  * 属无意义尝试)**并保留 !expanded**(同一作业里对同一个 job.repo 反复 clone 纯属浪费时间,
39
76
  * 级联顺序也不该被破坏)。 */
40
- async function tryCandidateChannels({ job, ch, name, profileDir, registries, repoChannelAllowed, budget, baseUrl = null, expanded = false, deadline = null, expand = null, sourceDeps = {} }) {
77
+ async function tryCandidateChannelsInner({ job, ch, name, profileDir, registries, repoChannelAllowed, budget, baseUrl = null, expanded = false, deadline = null, expand = null, sourceDeps = {} }) {
41
78
  const installSpec = (typeof job?.version === 'string' && job.version !== '' && name === job.packageName)
42
79
  ? `${name}@${job.version}`
43
80
  : name // ★ 0.5.81:规格与身份分离 —— name 仍是身份;点名包+指定版本时规格才带 @version
@@ -16,6 +16,9 @@ import { RELEASE_CHANNEL_BUDGET, tryCandidateChannels } from './install-channels
16
16
  import { versionMissOf, versionMissText } from './install-version.js'
17
17
  import { settlePresetOutcome } from './preset-in-package.js'
18
18
  import { verifyInstalledEntry } from './install-verify.js'
19
+ // 自更新兜底(2026-11-03 加法,见 domain/self-update-guard.js 顶部):「自更新没落地」的那句话由它产出,
20
+ // 与通道层的 selfUpdate 报告同一份文案,别在作业里另写一套。
21
+ import { selfUpdateFailureText } from './self-update-guard.js'
19
22
  import { applyNpmNameHint, composeProbeFailureText, expandSubpackageCandidates, fetchRepoPackage, fetchRepoPackageEx, fetchSubdirPlugins, fetchSubpackageNames, npmNameHintForRepo, packageProbeErrorText, subpackageCandidates } from './market.js'
20
23
  import { decideNoRootManifest } from './subdir-detect.js'
21
24
  import { runSubdirInstallJob } from './subdir-install.js'
@@ -283,6 +286,20 @@ async function runInstallJob(job, ports, deps = {}) {
283
286
  // 预设收口(D-③ 源码装配成功 / D-⑥ 落地物里带预设):能按预设收口就直接 return —— 见 preset-in-package.js
284
287
  // (这两条路都**没有 node_modules 插件**,写补丁行/声明依赖会让补丁行指向无入口模块 → 启动崩溃)。
285
288
  if (await settlePresetOutcome({ job, installedName, profileDir, repo: job.repo, deps: sourceDeps }) === true) return
289
+ // ★ 2026-11-03 加法(真机两次事故:控制台自更新被打断 ⇒ 目录只剩 scripts/ 与 SECURITY.md):
290
+ // **自更新校验不过 ⇒ 如实失败,且不清场、不进 AI 兜底。** 那一刻盘上要么是"刚从备份恢复好的旧版"、
291
+ // 要么是"已改名挪开的残缺目录"——再让模型去"修"只会把刚恢复好的东西再动一遍;而清场
292
+ // (cleanupAttemptedCandidates)会把恢复回来的那一份也删掉,那才是真损失。
293
+ // 位置刻意放在版本闸分支**之前**:自更新的缺口(被打断/半棵树/没落地)比"镜像 latest 陈旧"更具体,
294
+ // 先说清是哪一种;失败原因、残缺目录位置与可直接复制运行的命令,全在 selfUpdate 里。
295
+ // 非自更新作业永远进不到这里(job.selfUpdate 只由 domain/self-update-guard.js 写入)。
296
+ if (installedName === null && job.selfUpdate != null && job.selfUpdate.verified !== true) {
297
+ job.status = 'failed'
298
+ job.stage = 'self-update-check'
299
+ job.error = selfUpdateFailureText(job.selfUpdate)
300
+ job.lastError = job.error
301
+ return
302
+ }
286
303
  // ★ 0.5.87(真机 2026-10-18 的根因收口):**点名了版本却没装上**⇒ 如实失败,且**不清场**。
287
304
  // 为什么不清场:下面的失败路径会调 cleanupAttemptedCandidates,它会 rmSync 掉
288
305
  // `node_modules/<包名>` —— 而那一刻盘上放着的是用户**现在还能用**的旧版本(本例 0.1.6-alpha.1)。
@@ -506,6 +506,10 @@ 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,
509
513
  // 抓清单那一轮的**通道身份** + 本地落地落点(0.5.47 加法,见 domain/manifest-probe.js):
510
514
  // 面板据此显示"清单经 npm 镜像 / 本地落地 / 直连读到的",本地命中时给出「从本地安装」的落点。
511
515
  probe: job.probe ?? null, localDir: job.localDir ?? null, localHint: job.localHint ?? null,
@@ -0,0 +1,577 @@
1
+ // L1 · domain —— self-update-guard.js(自更新兜底:更新前备份自身目录 → 更新后按同一判据校验 → 不过就自愈;2026-11-03 加法)
2
+ //
3
+ // ── 现场(真机两次,用户 2026-11-03 报告,已确认)──────────────────────────────────
4
+ // 用户用控制台自带的「更新」把控制台自己更新,pnpm 装包**中途被打断**(宿主正在读插件自己的文件 ⇒ EPERM,
5
+ // 本次还有外部杀进程干扰)。pnpm 的安装是"先删旧目录、再解包新目录",删到一半失败就留下半棵树:
6
+ // node_modules/@noob-stupid/dsh-plugin-console/ 只剩 scripts/ 与 SECURITY.md,package.json 没了
7
+ // ⇒ 宿主解析不出 profile bundle ⇒ 面板显示「异常 / 包不可用」⇒ 只能手工把 npm tarball 解回原位(一天两次)。
8
+ //
9
+ // ── 这不是"自更新逻辑有 bug" ───────────────────────────────────────────────────
10
+ // 用户此前长期正常更新。缺的是**中途被打断之后的自愈**:既有 gateVersion(0.5.87)只核对"落地版本是不是
11
+ // 目标版本",目录被删空时它读到的是"没装",既没有恢复动作、也没人替用户把旧版放回去。所以本模块是**加法**:
12
+ // 不改既有安装通道、不改版本闸、不改作业状态机,只在"被安装的包名 === 本插件自己"时多加一层保护。
13
+ //
14
+ // ── 六条硬规矩(改代码时别越线)────────────────────────────────────────────────
15
+ // ① **只对本插件自己生效**:`isSelfUpdate()` 不成立时 `beginSelfUpdateGuard()` 直接返回 null,
16
+ // 调用方走原路径、一个字节都不变(tests/test-self-update-guard.mjs ⑤ 钉住:别的包不备份、不出字段)。
17
+ // ② 只有"磁盘上本来就有**一份能解析的**自身副本"才算自更新(= 真的在更新,而不是首次安装)。
18
+ // 目录不存在 / 清单读不出来 ⇒ 不接管 ⇒ 首次安装、坏目录重装的既有行为原样保留。
19
+ // ③ 备份是**复制**不是移动(正在运行的目录不能被搬走,宿主还要继续读它);备份失败**不阻断**更新,
20
+ // 只如实记下来,等校验那一步再一起报。
21
+ // ④ 校验只认磁盘、不认"通道说成功":清单存在 + 能解析 + 包名是本插件 + 声明的入口文件在 + version === 期望
22
+ // (没点名版本时只要求"是一份完整的自身副本")。**同一判据**在自愈之后还要再跑一次。
23
+ // ⑤ 自愈:残缺目录**改名挪开**(`<dir>.broken-<ts>`,绝不删除 —— 用户可能还要从里面捞东西)→
24
+ // 从备份复制回来 → 用同一判据再校验。Windows 上改名会因占用失败(EPERM/EBUSY),此时降级为
25
+ // **就地覆盖复制**,并用 `isLockFailure` 把"占用类失败"与"普通异常"分开说。
26
+ // ⑥ 绝不静默:`verified !== true` 的每一次都带 `reason` 与**可直接复制运行**的 `manualCommand`
27
+ // (全绝对路径,见 manualRestoreCommand),由调用方写进作业错误,绝不报"更新成功"。
28
+ //
29
+ // 分层:L1 · domain(不认识 cordis,不联网;文件系统读写全部走 node:fs 与 infra/fsx.js 的既有件)。
30
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
31
+ import { basename, dirname, join } from 'node:path'
32
+ import { copyTree, isLockFailure } from '../infra/fsx.js'
33
+ import { pluginRoot, resolvePackageJson } from '../infra/paths.js'
34
+
35
+ /** 本插件包名的**兜底**常量(与 domain/selfupdate.js#CONSOLE_PACKAGE、routes/plugins.js 里的同名常量一致)。
36
+ * 为什么这里还要再写一份、而不是 import 过来:识别"我是谁"绝不能依赖**会消失的那个文件**——
37
+ * 事故现场正是自身清单一度不见;而 import selfupdate.js 还会让本模块与它成环(那边要 import 本模块)。
38
+ * 正常路径上一律优先读**正在运行的包根** package.json(见 readOwnName),常量只兜底。 */
39
+ const SELF_PACKAGE_FALLBACK = '@noob-stupid/dsh-plugin-console'
40
+
41
+ /** 备份目录后缀(与 `<pkgDir>` 同父目录,同卷 rename/copy 才成立)。 */
42
+ const PRE_UPDATE_SUFFIX = '.pre-update-'
43
+ /** 残缺目录后缀(**只改名,不删除**)。 */
44
+ const BROKEN_SUFFIX = '.broken-'
45
+ /** 备份最多留几份("别无限堆",见 pruneSelfBackups)。 */
46
+ const KEEP_BACKUPS = 1
47
+ /** 时间戳等宽(Date.now() 13 位)——名字的字典序即时间序,prune 靠这一条挑"最新的那份"。 */
48
+ const STAMP_WIDTH = 13
49
+
50
+ /** 时间戳(名字用;`deps.now` 是单测注入缝)。 */
51
+ function stampOf(now = Date.now()) {
52
+ const value = Number(now())
53
+ return String(Number.isFinite(value) ? value : 0).padStart(STAMP_WIDTH, '0')
54
+ }
55
+
56
+ /** 正在运行的这一份插件自己的 package.json 里的 name(读不到返回 null —— 事故现场就是读不到)。 */
57
+ function readOwnName(deps = {}) {
58
+ const read = typeof deps.readFile === 'function' ? deps.readFile : readFileSync
59
+ try {
60
+ const parsed = JSON.parse(read(join(pluginRoot(), 'package.json'), 'utf8'))
61
+ if (parsed !== null && typeof parsed === 'object' && typeof parsed.name === 'string' && parsed.name !== '') return parsed.name
62
+ } catch {}
63
+ return null
64
+ }
65
+
66
+ /** 本插件包名:注入 > 运行中清单 > 兜底常量。 */
67
+ function selfPackageNameOf(deps = {}) {
68
+ if (typeof deps.selfName === 'string' && deps.selfName !== '') return deps.selfName
69
+ return readOwnName(deps) ?? SELF_PACKAGE_FALLBACK
70
+ }
71
+
72
+ /** `pkgName` 是不是本插件自己(**纯函数**,判据的唯一入口)。 */
73
+ function isSelfUpdate(packageName, deps = {}) {
74
+ if (typeof packageName !== 'string' || packageName === '') return false
75
+ return packageName === selfPackageNameOf(deps)
76
+ }
77
+
78
+ /** 「这个目录是不是**真的装在 node_modules 里**的那一份」(自更新唯一允许动手的地方)。
79
+ * 为什么必须有这道闸:Node 的**包自引用**让"仓库根/开发检出"也能解析出同名清单 —— 真机实测过
80
+ * 一次测试夹具把 profile 建在仓库里面,`resolve()` 直接自指到仓库根,于是守卫把整棵源码树当成
81
+ * "安装副本"去做备份(几十万个文件、几分钟)。判据与 release-source.js#releaseInstallTarget 同源:
82
+ * `<…>/node_modules/<包名>` 或 `<…>/node_modules/@scope/<包名>`;其余(源码检出)一律不接管。 */
83
+ function looksInstalled(dir) {
84
+ const parent = basename(dirname(dir))
85
+ const grand = basename(dirname(dirname(dir)))
86
+ if (parent === 'node_modules') return true
87
+ return parent.startsWith('@') && grand === 'node_modules'
88
+ }
89
+
90
+ /** 被更新的那份**自身副本**在哪(绝对路径)。
91
+ * ① 调用方显式给了目录(测试/特例)就用它;
92
+ * ② `<profileDir>/node_modules/<包名>` —— pnpm / curl / 并行竞速三条通道的落点,事故现场就是它;
93
+ * ③ 解析得到的清单所在目录(宿主根层安装等布局);
94
+ * ④ 正在运行的包根(npm 全局安装、profile 里没有副本时)。
95
+ * 注:这里只负责"找路","敢不敢动手"由 beginSelfUpdateGuard 的 looksInstalled 闸决定。 */
96
+ function selfPackageDirOf({ profileDir = null, packageDir = null, deps = {} } = {}) {
97
+ if (typeof packageDir === 'string' && packageDir !== '') return packageDir
98
+ const name = selfPackageNameOf(deps)
99
+ if (typeof profileDir === 'string' && profileDir !== '') {
100
+ const profileCopy = join(profileDir, 'node_modules', ...name.split('/'))
101
+ if (existsSync(profileCopy) === true) return profileCopy
102
+ try {
103
+ const resolved = resolvePackageJson(name, profileDir)
104
+ if (typeof resolved === 'string' && resolved !== '') return dirname(resolved)
105
+ } catch {}
106
+ return profileCopy
107
+ }
108
+ return pluginRoot()
109
+ }
110
+
111
+ /** 读一份包清单(读不到返回 `{ parsed:null, version:null, error }`,永不抛)。 */
112
+ function readManifestOf(pkgDir, deps = {}) {
113
+ const read = typeof deps.readFile === 'function' ? deps.readFile : readFileSync
114
+ try {
115
+ const parsed = JSON.parse(read(join(String(pkgDir ?? ''), 'package.json'), 'utf8'))
116
+ const version = typeof parsed?.version === 'string' && parsed.version !== '' ? parsed.version : null
117
+ return { parsed, version, error: null }
118
+ } catch (error) {
119
+ return { parsed: null, version: null, error: error instanceof Error ? error.message : String(error) }
120
+ }
121
+ }
122
+
123
+ /** 「这段报错是不是占用/权限类失败」(判据的唯一真源是 infra/fsx.js#isLockFailure —— 别在这里另立一把尺子)。
124
+ * 占用类失败靠重试不会好,要做的是改名让开 / 事后恢复;说不清时按普通异常处理(不夸大)。 */
125
+ function occupiedReasonOf(error) {
126
+ const text = error instanceof Error ? error.message : String(error ?? '')
127
+ const code = error !== null && error !== undefined && typeof error.code === 'string' ? error.code : ''
128
+ return isLockFailure(`${code} ${text}`) === true
129
+ }
130
+
131
+ /**
132
+ * 自更新的**唯一判据**:`<pkgDir>/package.json` 存在 + 能解析 + 包名是本插件 + 声明的入口文件在 +
133
+ * version === `expectedVersion`。没给期望版本时只要求"是一份完整的自身副本"(版本号非空)——
134
+ * "按 latest 更新"的请求本来就没有目标版本。入口文件那一条是"半棵树"的第二道探针:
135
+ * pnpm 删到一半时也可能留下清单却丢了 lib/,这种状态宿主一样加载不起来。
136
+ * 只读、永不抛。返回 `{ ok, reason, version, packageName, path, occupied }`。
137
+ */
138
+ function verifySelfPackageDir(pkgDir, expectedVersion = null, deps = {}) {
139
+ const wanted = selfPackageNameOf(deps)
140
+ const dir = String(pkgDir ?? '')
141
+ const path = join(dir, 'package.json')
142
+ const out = { ok: false, reason: null, version: null, packageName: null, path, occupied: false }
143
+ const exists = typeof deps.exists === 'function' ? deps.exists : existsSync
144
+ if (exists(path) !== true) {
145
+ out.reason = '目录里没有 package.json(安装中途被打断的典型现场:pnpm 先删旧目录再解包,Windows 上删到一半失败就只剩几个文件)'
146
+ return out
147
+ }
148
+ const manifest = readManifestOf(dir, deps)
149
+ if (manifest.parsed === null) {
150
+ out.occupied = occupiedReasonOf(manifest.error)
151
+ out.reason = `package.json 读不出来/无法解析${out.occupied ? '(文件被占用)' : ''}:${manifest.error ?? '未知原因'}`
152
+ return out
153
+ }
154
+ out.packageName = typeof manifest.parsed.name === 'string' ? manifest.parsed.name : null
155
+ out.version = manifest.version
156
+ if (out.packageName !== wanted) {
157
+ out.reason = `package.json 里的包名是 ${out.packageName ?? '(空)'},不是本插件 ${wanted}`
158
+ return out
159
+ }
160
+ if (out.version === null) {
161
+ out.reason = 'package.json 里没有可用的 version 字段'
162
+ return out
163
+ }
164
+ const entry = typeof manifest.parsed.main === 'string' && manifest.parsed.main !== '' ? manifest.parsed.main : null
165
+ if (entry !== null && exists(join(dir, ...entry.split('/'))) !== true) {
166
+ out.reason = `声明的入口文件不在了(main = ${entry}):目录只剩半棵树`
167
+ return out
168
+ }
169
+ const expect = typeof expectedVersion === 'string' && expectedVersion !== '' ? expectedVersion : null
170
+ if (expect !== null && out.version !== expect) {
171
+ out.reason = `落地版本是 ${out.version},不是目标版本 ${expect}`
172
+ return out
173
+ }
174
+ out.ok = true
175
+ return out
176
+ }
177
+
178
+ /**
179
+ * 更新前备份自身目录:**复制**到 `<pkgDir>.pre-update-<时间戳>`(同父目录)。
180
+ * 复制而不是移动:宿主还在读这份目录,把它搬走等于把正在运行的服务从脚下抽掉。
181
+ * 失败**不阻断**更新(本函数只回结果),原因留给校验那一步一起如实上报。
182
+ * 返回 `{ ok, path, reason, previousVersion, occupied }`。
183
+ */
184
+ function backupSelfPackageDir(pkgDir, deps = {}) {
185
+ const dir = String(pkgDir ?? '')
186
+ const out = { ok: false, path: null, reason: null, previousVersion: null, occupied: false }
187
+ const exists = typeof deps.exists === 'function' ? deps.exists : existsSync
188
+ const copy = typeof deps.copy === 'function' ? deps.copy : copyTree
189
+ if (dir === '') {
190
+ out.reason = '没有给出插件目录'
191
+ return out
192
+ }
193
+ out.previousVersion = readManifestOf(dir, deps).version
194
+ if (exists(dir) !== true) {
195
+ out.reason = `插件目录不存在(${dir})`
196
+ return out
197
+ }
198
+ const target = join(dirname(dir), `${basename(dir)}${PRE_UPDATE_SUFFIX}${stampOf(deps.now ?? Date.now)}`)
199
+ try {
200
+ if (exists(target) === true) rmSync(target, { recursive: true, force: true })
201
+ copy(dir, target)
202
+ out.ok = true
203
+ out.path = target
204
+ return out
205
+ } catch (error) {
206
+ out.occupied = occupiedReasonOf(error)
207
+ out.reason = `备份失败${out.occupied ? '(目录被占用)' : ''}:${error instanceof Error ? error.message : String(error)}`
208
+ try { rmSync(target, { recursive: true, force: true }) } catch {}
209
+ return out
210
+ }
211
+ }
212
+
213
+ /**
214
+ * 自愈:**先让开残缺目录(改名,不删)→ 再从备份复制回来**。
215
+ * 改名挪不开(占用:目录里有 share=None 的句柄 / 是活进程的 cwd)时降级为**就地覆盖复制**——
216
+ * 宁可留着残渣也别把用户的插件晾在半空;用哪种方式、挪到哪、为什么,全部如实记在返回值里。
217
+ * 返回 `{ ok, mode:'renamed'|'in-place'|null, brokenPath, reason, occupied }`。
218
+ */
219
+ function restoreSelfPackageDir(pkgDir, backupPath, deps = {}) {
220
+ const dir = String(pkgDir ?? '')
221
+ const out = { ok: false, mode: null, brokenPath: null, reason: null, occupied: false }
222
+ const exists = typeof deps.exists === 'function' ? deps.exists : existsSync
223
+ const copy = typeof deps.copy === 'function' ? deps.copy : copyTree
224
+ const rename = typeof deps.rename === 'function' ? deps.rename : renameSync
225
+ const messageOf = (error) => (error instanceof Error ? error.message : String(error))
226
+ if (typeof backupPath !== 'string' || backupPath === '') {
227
+ out.reason = '没有可用备份'
228
+ return out
229
+ }
230
+ if (exists(backupPath) !== true) {
231
+ out.reason = `备份目录不存在(${backupPath})`
232
+ return out
233
+ }
234
+ if (exists(dir) === true) {
235
+ const broken = join(dirname(dir), `${basename(dir)}${BROKEN_SUFFIX}${stampOf(deps.now ?? Date.now)}`)
236
+ try {
237
+ rename(dir, broken)
238
+ out.mode = 'renamed'
239
+ out.brokenPath = broken
240
+ } catch (error) {
241
+ out.occupied = occupiedReasonOf(error)
242
+ try {
243
+ copy(backupPath, dir)
244
+ out.mode = 'in-place'
245
+ out.reason = `残缺目录改名挪不开${out.occupied ? '(目录被占用)' : ''}(${messageOf(error)}),已把备份内容就地覆盖回去`
246
+ } catch (copyError) {
247
+ out.occupied = out.occupied === true || occupiedReasonOf(copyError) === true
248
+ out.reason = `残缺目录改名挪不开(${messageOf(error)}),就地覆盖也失败:${messageOf(copyError)}`
249
+ return out
250
+ }
251
+ }
252
+ } else {
253
+ try { mkdirSync(dirname(dir), { recursive: true }) } catch {}
254
+ }
255
+ if (out.mode === 'renamed') {
256
+ try {
257
+ copy(backupPath, dir)
258
+ } catch (error) {
259
+ // 复制不可用时的最后一条腿:把备份**改名**到原位置(备份此后不再存在,但盘上有完整的一份)
260
+ try {
261
+ rename(backupPath, dir)
262
+ out.reason = `从备份复制失败(${messageOf(error)}),已改用改名把备份搬回原位`
263
+ } catch (renameError) {
264
+ out.occupied = occupiedReasonOf(error) === true || occupiedReasonOf(renameError) === true
265
+ out.reason = `从备份恢复失败:复制 ${messageOf(error)};改名 ${messageOf(renameError)}`
266
+ return out
267
+ }
268
+ }
269
+ }
270
+ out.ok = exists(join(dir, 'package.json')) === true
271
+ if (out.ok !== true && out.reason === null) out.reason = `恢复后仍看不到 ${join(dir, 'package.json')}`
272
+ return out
273
+ }
274
+
275
+ /** 备份只留最近 `keep` 份(名字里是等宽时间戳 ⇒ 字典序即时间序)。删不掉只记 note、绝不抛。 */
276
+ function pruneSelfBackups(pkgDir, { keep = KEEP_BACKUPS, deps = {} } = {}) {
277
+ const out = { removed: [], kept: [], note: null }
278
+ const dir = String(pkgDir ?? '')
279
+ const parent = dirname(dir)
280
+ const prefix = `${basename(dir)}${PRE_UPDATE_SUFFIX}`
281
+ const readdir = typeof deps.readdir === 'function' ? deps.readdir : readdirSync
282
+ let names = []
283
+ try { names = readdir(parent).filter((n) => typeof n === 'string' && n.startsWith(prefix)).sort() } catch { return out }
284
+ const keepCount = Number.isFinite(keep) && keep >= 0 ? Math.floor(keep) : KEEP_BACKUPS
285
+ for (const name of names.slice(0, Math.max(0, names.length - keepCount))) {
286
+ try {
287
+ rmSync(join(parent, name), { recursive: true, force: true })
288
+ out.removed.push(name)
289
+ } catch (error) {
290
+ out.note = `旧备份没删掉(${name}):${error instanceof Error ? error.message : String(error)}`
291
+ }
292
+ }
293
+ out.kept = names.slice(Math.max(0, names.length - keepCount))
294
+ return out
295
+ }
296
+
297
+ /** 去掉本次这一份备份(校验通过时用)。删不掉就留着交给 prune 收口,绝不抛。 */
298
+ function dropBackupOf(backupPath) {
299
+ if (typeof backupPath !== 'string' || backupPath === '') return { removed: false, note: null }
300
+ try {
301
+ rmSync(backupPath, { recursive: true, force: true })
302
+ return { removed: true, note: null }
303
+ } catch (error) {
304
+ return { removed: false, note: `本次备份没删掉(${backupPath}):${error instanceof Error ? error.message : String(error)}` }
305
+ }
306
+ }
307
+
308
+ /** registry tarball 地址(npm 约定:作用域在路径里,文件名不带作用域)。 */
309
+ function registryTarballUrlOf(name, version) {
310
+ const tail = String(name).split('/').pop()
311
+ return `https://registry.npmmirror.com/${name}/-/${tail}-${version}.tgz`
312
+ }
313
+
314
+ /**
315
+ * 失败时要给用户的那条**可直接复制运行**的命令(全绝对路径 —— 用户可能在任何目录下粘贴它)。
316
+ * ① 有备份 ⇒ 把备份整棵复制回来(Windows 用 robocopy:支持长路径,与本仓库回滚脚本同一手法);
317
+ * ② 没有备份 ⇒ 从 registry 取一份解到原位(**正是用户手工救过两次的那套动作**)。
318
+ * 落点全部选**已经存在**的目录(`<pkgDir>` 的父目录),所以粘贴即可跑,不必先建目录。
319
+ */
320
+ function manualRestoreCommand({ pkgDir, backupPath = null, version = null, deps = {} } = {}) {
321
+ const dir = String(pkgDir ?? '')
322
+ if (dir === '') return null
323
+ const win = (typeof deps.platform === 'string' ? deps.platform : process.platform) === 'win32'
324
+ if (typeof backupPath === 'string' && backupPath !== '') {
325
+ return win
326
+ ? `robocopy "${backupPath}" "${dir}" /E /NFL /NDL /NJH /NJS /R:1 /W:1`
327
+ : `cp -a "${backupPath}/." "${dir}/"`
328
+ }
329
+ const wanted = typeof version === 'string' && version !== '' ? version : 'latest'
330
+ const url = registryTarballUrlOf(selfPackageNameOf(deps), wanted)
331
+ const parent = dirname(dir)
332
+ const tgz = join(parent, `${basename(dir)}-rescue.tgz`)
333
+ const unpacked = join(parent, 'package')
334
+ return win
335
+ ? `curl.exe -sL -o "${tgz}" "${url}" ; tar -xzf "${tgz}" -C "${parent}" ; robocopy "${unpacked}" "${dir}" /E /NFL /NDL /NJH /NJS /R:1 /W:1`
336
+ : `curl -sL -o "${tgz}" "${url}" && tar -xzf "${tgz}" -C "${parent}" && cp -a "${unpacked}/." "${dir}/"`
337
+ }
338
+
339
+ /**
340
+ * 开始一次自更新的保护(**唯一入口**):不是本插件 / 磁盘上还没有可解析的自身副本 ⇒ 返回 null
341
+ * (调用方一个字节都不用走 —— 这就是"别的包行为不变"的实现方式)。
342
+ * 否则:先备份,返回后续 settle 要用的现场(含备份结果)。
343
+ */
344
+ function beginSelfUpdateGuard({ packageName = null, profileDir = null, packageDir = null, targetVersion = null, deps = {} } = {}) {
345
+ if (isSelfUpdate(packageName, deps) !== true) return null
346
+ const name = selfPackageNameOf(deps)
347
+ const pkgDir = selfPackageDirOf({ profileDir, packageDir, deps })
348
+ // 规矩②之前先过一道闸:只认**真的装在 node_modules 里**的那一份(源码检出/仓库根绝不动手,见 looksInstalled)
349
+ if (looksInstalled(pkgDir) !== true) return null
350
+ // 规矩②:只有"本来就有完整的一份"才算自更新(首次安装 / 坏目录重装 ⇒ 不接管,既有行为原样保留)
351
+ const before = verifySelfPackageDir(pkgDir, null, deps)
352
+ if (before.ok !== true) return null
353
+ const backup = backupSelfPackageDir(pkgDir, deps)
354
+ return {
355
+ packageName: name,
356
+ packageDir: pkgDir,
357
+ targetVersion: typeof targetVersion === 'string' && targetVersion !== '' ? targetVersion : null,
358
+ previousVersion: backup.previousVersion ?? before.version,
359
+ backupPath: backup.path,
360
+ backupReason: backup.reason,
361
+ backedUp: backup.ok === true,
362
+ deps,
363
+ }
364
+ }
365
+
366
+ /**
367
+ * 「每一步结束都过一遍校验」(**记录器**:只读盘、不写盘、不抛)。
368
+ * 自更新的三条通道(pnpm update / pnpm add / curl 手铺)各自收尾时调一次 ⇒ `selfUpdate.steps` 里能查到
369
+ * "第几步之后目录是好的、哪一步把它弄坏了"。刻意**不改既有步骤条件**(某一步成功也照走既有的下一步),
370
+ * 真正的自愈留到收尾统一做:**全部走完仍不过才动手**(见 settleSelfUpdateGuard)。session 为 null 时返回 null。
371
+ */
372
+ function noteGuardStep(session, step) {
373
+ if (session === null || session === undefined || typeof session !== 'object') return null
374
+ const io = session.deps ?? {}
375
+ const verdict = verifySelfPackageDir(session.packageDir, session.targetVersion ?? null, io)
376
+ const row = {
377
+ step: typeof step === 'string' && step !== '' ? step : 'unknown',
378
+ verified: verdict.ok === true,
379
+ version: verdict.version ?? null,
380
+ reason: verdict.reason ?? null,
381
+ }
382
+ session.steps = Array.isArray(session.steps) ? session.steps : []
383
+ session.steps.push(row)
384
+ return row
385
+ }
386
+
387
+ /**
388
+ * 收尾(**无论通道报成功还是报失败都要跑**):校验 → 不过就自愈(改名挪开 + 从备份恢复 + 同一判据复核)
389
+ * → 备份收口(最多留 KEEP_BACKUPS 份)→ 产出如实报告。session 为 null 时原样返回 null。
390
+ * 返回的报告就是作业里那个 `selfUpdate` 字段(只增字段,见 installJobView)。
391
+ */
392
+ function settleSelfUpdateGuard(session, { targetVersion = undefined, deps = {} } = {}) {
393
+ if (session === null || session === undefined || typeof session !== 'object') return null
394
+ const io = { ...(session.deps ?? {}), ...(deps ?? {}) }
395
+ const expected = targetVersion === undefined
396
+ ? session.targetVersion
397
+ : (typeof targetVersion === 'string' && targetVersion !== '' ? targetVersion : null)
398
+ const pkgDir = session.packageDir
399
+ const out = {
400
+ packageName: session.packageName ?? null,
401
+ packageDir: pkgDir ?? null,
402
+ targetVersion: expected ?? null,
403
+ previousVersion: session.previousVersion ?? null,
404
+ backedUp: session.backedUp === true,
405
+ backupPath: session.backupPath ?? null,
406
+ backupNote: session.backupReason ?? null,
407
+ verified: false,
408
+ healed: false,
409
+ restoredVersion: null,
410
+ brokenPath: null,
411
+ selfHeal: null,
412
+ reason: null,
413
+ manualCommand: null,
414
+ backupCleanup: null,
415
+ steps: Array.isArray(session.steps) ? session.steps : [],
416
+ }
417
+ const first = verifySelfPackageDir(pkgDir, expected, io)
418
+ out.verified = first.ok === true
419
+ out.restoredVersion = first.version ?? null
420
+ if (first.ok !== true) {
421
+ out.reason = first.reason
422
+ out.selfHeal = { attempted: true, restored: false, reason: null, mode: null, brokenPath: null, restoredVersion: null }
423
+ if (session.backedUp !== true) {
424
+ // 规矩③/⑥:备份没建起来 ⇒ 如实说"没有可用备份"(再给出用户可以自己跑的命令)
425
+ out.selfHeal.reason = `没有可用备份:${session.backupReason ?? '更新前的备份没能建立'}`
426
+ } else {
427
+ const restored = restoreSelfPackageDir(pkgDir, session.backupPath, io)
428
+ out.selfHeal.mode = restored.mode ?? null
429
+ out.selfHeal.brokenPath = restored.brokenPath ?? null
430
+ out.brokenPath = restored.brokenPath ?? null
431
+ if (restored.ok !== true) {
432
+ out.selfHeal.reason = restored.reason ?? '从备份恢复失败'
433
+ } else {
434
+ // 规矩④:**同一判据**再校验一次(期望值 = 备份里那一版 = 更新前的那一版)
435
+ const again = verifySelfPackageDir(pkgDir, out.previousVersion, io)
436
+ out.selfHeal.restored = again.ok === true
437
+ out.selfHeal.restoredVersion = again.version ?? null
438
+ out.selfHeal.reason = again.ok === true
439
+ ? `已从备份恢复成更新前的 v${out.previousVersion ?? '?'}`
440
+ : `已从备份恢复,但恢复后仍不过校验:${again.reason}`
441
+ out.healed = again.ok === true
442
+ if (again.ok === true) out.restoredVersion = again.version ?? null
443
+ }
444
+ }
445
+ }
446
+ // 备份收口(成败都收口到"最多 KEEP_BACKUPS 份"):通过 ⇒ 本次这份也删掉;没通过 ⇒ 留着它(那是唯一的旧版)
447
+ const dropped = out.verified === true ? dropBackupOf(out.backupPath) : null
448
+ const pruned = pruneSelfBackups(pkgDir, { keep: KEEP_BACKUPS, deps: io })
449
+ out.backupCleanup = {
450
+ removed: [...(dropped !== null && dropped.removed === true ? [out.backupPath] : []), ...pruned.removed],
451
+ kept: pruned.kept,
452
+ note: dropped?.note ?? pruned.note ?? null,
453
+ }
454
+ if (out.verified !== true) {
455
+ out.manualCommand = manualRestoreCommand({
456
+ pkgDir,
457
+ backupPath: out.backedUp === true ? out.backupPath : null,
458
+ version: expected ?? out.previousVersion,
459
+ deps: io,
460
+ })
461
+ }
462
+ return out
463
+ }
464
+
465
+ /**
466
+ * `/self-update` 通路的**收尾段**(2026-11-03 从 domain/selfupdate.js#selfUpdateToLatest **原样搬出**:
467
+ * 那边贴着 600 行架构棘轮,而这段"清单 spec 跟上 + note 组装 + 返回结构"本来就是自洽的一节)。
468
+ * 搬移只换了绑定来源 —— `CONSOLE_PACKAGE` / `isRegistryRange` / `label` 三个符号由调用方传进来
469
+ * (domain 层不许反向 import selfupdate.js:那会成环,见 selfupdate.js#defaultSetSpec 的同一顾虑),
470
+ * 正文逐字未改。
471
+ *
472
+ * 本轮只**加**自更新兜底的三件事(规矩⑥,绝不静默):
473
+ * · 返回结构多 `ok` / `reason` / `selfUpdate`(既有 9 个字段名与语义一个都没动);
474
+ * · 校验没过时把那段失败文案同时推进 `errors` 与 `note`(面板/接口都看得见);
475
+ * · `installedVersion` 在"已自愈"时改报**盘上真实那一版**(否则会留一个 null 让人以为目录还是空的)。
476
+ */
477
+ function finishSelfUpdate({ profileDir, packageName, latest, spec, installed, method, command, errors, lockVersion, lockUpdated, lockNote, registryRange, selfUpdate = null }) {
478
+ const CONSOLE_PACKAGE = packageName
479
+ const isRegistryRange = registryRange
480
+ const label = (e) => (e instanceof Error ? e.message : String(e))
481
+ const sourceSwitch = spec !== null && !isRegistryRange(spec) ? `安装来源已从 ${spec} 变为 registry 版本 ${latest}` : null
482
+
483
+ // ④ 清单 spec 也要跟上(2026-09-24 实测踩到:lock 写了、spec 还钉着 0.3.57,
484
+ // 于是下一次 pnpm 操作会把控制台「降级」回 0.3.57 —— 包管理器通道 ①② 通常会顺手改写 spec,
485
+ // 但手铺兜底不会,所以这里统一收口:装到哪版就把 spec 写成哪版(来源型 spec 除外,绝不丢来源)。
486
+ let specUpdated = null
487
+ if (installed === latest && (spec === null || isRegistryRange(spec))) {
488
+ try {
489
+ const manifestPath = join(profileDir, 'package.json')
490
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
491
+ const deps = { ...(manifest.dependencies ?? {}) }
492
+ if (deps[CONSOLE_PACKAGE] !== latest) {
493
+ deps[CONSOLE_PACKAGE] = latest
494
+ manifest.dependencies = deps
495
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
496
+ specUpdated = latest
497
+ }
498
+ } catch (error) {
499
+ errors.push(`清单 spec 同步失败:${label(error)}`)
500
+ }
501
+ } else if (installed === latest && spec !== null && !isRegistryRange(spec)) {
502
+ errors.push(`清单 spec 是来源型(${spec}),按约定保留不改写`)
503
+ }
504
+
505
+ const noteParts = [lockUpdated ? '已写入 pnpm-lock.yaml,重启服务后生效' : '已铺入文件但未写入 pnpm-lock.yaml:重启后可用,但会被 pnpm 还原']
506
+ if (sourceSwitch !== null) noteParts.push(sourceSwitch)
507
+ if (specUpdated !== null) noteParts.push(`清单 spec 已同步为 ${specUpdated}(防 pnpm 降级)`)
508
+
509
+ // ★ 加法:自更新校验的结论(只有"本插件自己 + 更新前就有完整副本"时 selfUpdate 才非空)
510
+ const verified = selfUpdate === null || selfUpdate === undefined ? true : selfUpdate.verified === true
511
+ const reason = verified === true ? null : selfUpdateFailureText(selfUpdate)
512
+ if (reason !== null) errors.push(reason)
513
+ if (reason !== null) noteParts.push(reason)
514
+ const installedVersion = verified !== true && typeof selfUpdate.restoredVersion === 'string' ? selfUpdate.restoredVersion : installed
515
+ return { method, spec: specUpdated ?? spec, installedVersion, lockVersion, lockUpdated, lockNote, command, note: noteParts.join(';'), errors, ok: verified, reason, selfUpdate: selfUpdate ?? null }
516
+ }
517
+
518
+ /**
519
+ * `/self-update` 通路的**响应视图**(2026-11-03 从 routes/plugins.js#routeSelfUpdate 的 sendJson 字面量**原样搬出**:
520
+ * 那个路由文件贴着 600 行架构棘轮,而"这条接口下发什么"本来就是自洽的一节 —— 与 install.js#installJobView
521
+ * 同一手法:view 归 domain,路由只管发送)。字段表达式逐字照搬,**只多了** selfUpdate / reason 两项。
522
+ *
523
+ * ★ 唯一一处取值修正:`updated`。旧代码恒写 `true`,于是"自更新没落地"时会下发 `{ok:false, updated:true}`
524
+ * 这种自相矛盾的响应(凡是按 updated 判断的消费方都会读成"已更新")。现在它就是 `ok` 的同一事实。
525
+ * 面板(lib/client.js:7376)只认 `data.ok === true`,所以这处修正不改变既有 UI 行为。
526
+ */
527
+ function selfUpdateView({ result, current = null, latest = null }) {
528
+ const ok = result?.ok !== false
529
+ return {
530
+ ok,
531
+ current,
532
+ latest,
533
+ updated: ok,
534
+ version: result.installedVersion ?? latest,
535
+ method: result.method,
536
+ spec: result.spec,
537
+ installedVersion: result.installedVersion,
538
+ lockVersion: result.lockVersion,
539
+ lockUpdated: result.lockUpdated,
540
+ lockNote: result.lockNote,
541
+ command: result.command,
542
+ note: result.note,
543
+ errors: result.errors,
544
+ selfUpdate: result.selfUpdate ?? null,
545
+ reason: result.reason ?? null,
546
+ }
547
+ }
548
+
549
+ /** 失败文案(作业 error / 面板提示共用同一份;通过校验时返回 null)。
550
+ * 一次说清四件事:没通过的原因、自愈有没有成、残缺目录留在哪、手动命令是什么。 */
551
+ function selfUpdateFailureText(selfUpdate) {
552
+ if (selfUpdate === null || selfUpdate === undefined || typeof selfUpdate !== 'object') return null
553
+ if (selfUpdate.verified === true) return null
554
+ const parts = [`自更新没有完成:${selfUpdate.reason ?? '校验未通过'}(插件目录 ${selfUpdate.packageDir ?? '未知'})。`]
555
+ if (selfUpdate.healed === true) {
556
+ parts.push(`已从更新前的备份自动恢复成 v${selfUpdate.restoredVersion ?? '?'},本次更新**没有生效**(这不是更新成功)。`)
557
+ } else if (selfUpdate.backedUp !== true) {
558
+ parts.push(`无法自动恢复:${selfUpdate.selfHeal?.reason ?? '更新前没能建立备份'}。`)
559
+ } else {
560
+ parts.push(`自动恢复也失败了:${selfUpdate.selfHeal?.reason ?? '原因未知'}。`)
561
+ }
562
+ if (typeof selfUpdate.brokenPath === 'string' && selfUpdate.brokenPath !== '') {
563
+ parts.push(`残缺目录已改名保留在 ${selfUpdate.brokenPath}(未删除,可自行核对)。`)
564
+ }
565
+ if (typeof selfUpdate.manualCommand === 'string' && selfUpdate.manualCommand !== '') {
566
+ parts.push(`要手动处理可直接复制运行:${selfUpdate.manualCommand}`)
567
+ }
568
+ return parts.join('')
569
+ }
570
+
571
+ export {
572
+ SELF_PACKAGE_FALLBACK, PRE_UPDATE_SUFFIX, BROKEN_SUFFIX, KEEP_BACKUPS,
573
+ selfPackageNameOf, isSelfUpdate, looksInstalled, selfPackageDirOf, verifySelfPackageDir,
574
+ backupSelfPackageDir, restoreSelfPackageDir, pruneSelfBackups, dropBackupOf,
575
+ manualRestoreCommand, registryTarballUrlOf,
576
+ beginSelfUpdateGuard, noteGuardStep, settleSelfUpdateGuard, finishSelfUpdate, selfUpdateView, selfUpdateFailureText,
577
+ }
@@ -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
+ // 自更新兜底(2026-11-03 加法,见 self-update-guard.js 顶部事故记录):
20
+ // begin/note/settle 是同一套判据与同一份 manualCommand;finishSelfUpdate 是本文件收尾段**原样搬出**的(腾行数用)。
21
+ import { beginSelfUpdateGuard, finishSelfUpdate, noteGuardStep, settleSelfUpdateGuard } from './self-update-guard.js'
19
22
  import { fetchJsonUrl } from '../infra/http.js'
20
23
  import { runPnpmWithFallback } from '../infra/exec.js'
21
24
 
@@ -111,12 +114,14 @@ function selfUpdateCommand(latest, profileName = '<你的profile>') {
111
114
  * 依赖以参数注入(便于单测替换):`runPnpm`(默认 runPnpmWithFallback)、`pnpmAdd`(默认 pnpmInstall)、
112
115
  * `curlManualInstall`(手铺文件兜底,由路由传入)。
113
116
  */
114
- async function selfUpdateToLatest({ profileDir, latest, registries, curlManualInstall, runPnpm = runPnpmWithFallback, pnpmAdd = pnpmInstall, profileName = null, execOpts = {} }) {
117
+ async function selfUpdateToLatest({ profileDir, latest, registries, curlManualInstall, runPnpm = runPnpmWithFallback, pnpmAdd = pnpmInstall, profileName = null, execOpts = {}, selfUpdateDeps = {} }) {
115
118
  const command = selfUpdateCommand(latest, profileName ?? '<你的profile>')
116
119
  const spec = profileSpec(profileDir, CONSOLE_PACKAGE)
117
120
  const errors = []
118
121
  let method = null
119
122
  const label = (e) => (e instanceof Error ? e.message : String(e))
123
+ // 自更新兜底(规矩:只对本插件生效 / 更新前就有完整副本 / 真的装在 node_modules 里,三条闸都在 begin 里)
124
+ const guard = beginSelfUpdateGuard({ packageName: CONSOLE_PACKAGE, profileDir, targetVersion: latest, deps: selfUpdateDeps })
120
125
 
121
126
  // ① spec 是版本范围 → pnpm update(spec 不变,lock 提到范围内最新)
122
127
  if (isRegistryRange(spec)) {
@@ -126,6 +131,7 @@ async function selfUpdateToLatest({ profileDir, latest, registries, curlManualIn
126
131
  } catch (error) {
127
132
  errors.push(`pnpm update 失败:${label(error)}`)
128
133
  }
134
+ noteGuardStep(guard, 'pnpm-update') // 这一步结束就校验一次(只记录,自愈留到收尾统一做)
129
135
  }
130
136
  // ② 还没到 latest(超出 range / git·file 来源)→ pnpm add <pkg>@<latest>(spec 与 lock 一起改写)
131
137
  if (readInstalledVersion(profileDir, CONSOLE_PACKAGE) !== latest) {
@@ -135,6 +141,7 @@ async function selfUpdateToLatest({ profileDir, latest, registries, curlManualIn
135
141
  } catch (error) {
136
142
  errors.push(`pnpm add 失败:${label(error)}`)
137
143
  }
144
+ noteGuardStep(guard, 'pnpm-add')
138
145
  }
139
146
 
140
147
  let installed = readInstalledVersion(profileDir, CONSOLE_PACKAGE)
@@ -153,40 +160,15 @@ async function selfUpdateToLatest({ profileDir, latest, registries, curlManualIn
153
160
  } catch (error) {
154
161
  errors.push(`手动铺文件失败:${label(error)}`)
155
162
  }
163
+ noteGuardStep(guard, 'manual-copy')
156
164
  if (!lockUpdated) {
157
165
  lockNote = `此更新未写入 pnpm-lock.yaml(lock 里仍是 ${lock ?? '未知'}):之后任何 pnpm 操作(开关插件、dsh plugin add/remove)都会把它还原成 lock 里的版本。要真正落地请执行:${command}`
158
166
  }
159
167
  }
160
168
 
161
- const sourceSwitch = spec !== null && !isRegistryRange(spec) ? `安装来源已从 ${spec} 变为 registry 版本 ${latest}` : null
162
-
163
- // ④ 清单 spec 也要跟上(2026-09-24 实测踩到:lock 写了、spec 还钉着 0.3.57,
164
- // 于是下一次 pnpm 操作会把控制台「降级」回 0.3.57 —— 包管理器通道 ①② 通常会顺手改写 spec,
165
- // 但手铺兜底不会,所以这里统一收口:装到哪版就把 spec 写成哪版(来源型 spec 除外,绝不丢来源)。
166
- let specUpdated = null
167
- if (installed === latest && (spec === null || isRegistryRange(spec))) {
168
- try {
169
- const manifestPath = join(profileDir, 'package.json')
170
- const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
171
- const deps = { ...(manifest.dependencies ?? {}) }
172
- if (deps[CONSOLE_PACKAGE] !== latest) {
173
- deps[CONSOLE_PACKAGE] = latest
174
- manifest.dependencies = deps
175
- writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
176
- specUpdated = latest
177
- }
178
- } catch (error) {
179
- errors.push(`清单 spec 同步失败:${label(error)}`)
180
- }
181
- } else if (installed === latest && spec !== null && !isRegistryRange(spec)) {
182
- errors.push(`清单 spec 是来源型(${spec}),按约定保留不改写`)
183
- }
184
-
185
- const noteParts = [lockUpdated ? '已写入 pnpm-lock.yaml,重启服务后生效' : '已铺入文件但未写入 pnpm-lock.yaml:重启后可用,但会被 pnpm 还原']
186
- if (sourceSwitch !== null) noteParts.push(sourceSwitch)
187
- if (specUpdated !== null) noteParts.push(`清单 spec 已同步为 ${specUpdated}(防 pnpm 降级)`)
188
-
189
- return { method, spec: specUpdated ?? spec, installedVersion: installed, lockVersion: lock, lockUpdated, lockNote, command, note: noteParts.join(';'), errors }
169
+ // 收尾:三步都走完才统一校验(全部走完仍不过才自愈),然后交给**原样搬出**的收尾段组装返回结构
170
+ const selfUpdate = settleSelfUpdateGuard(guard, { targetVersion: latest })
171
+ return finishSelfUpdate({ profileDir, packageName: CONSOLE_PACKAGE, latest, spec, installed, method, command, errors, lockVersion: lock, lockUpdated, lockNote, registryRange: isRegistryRange, selfUpdate })
190
172
  }
191
173
 
192
174
  /**
@@ -15,6 +15,8 @@ import { markPendingAdopted } from '../domain/quarantine.js'
15
15
  import { pendingRestartJobs, packageDirIn, needsPnpmRemove, revokePendingInstall } from '../domain/revoke.js'
16
16
  import { deriveEntryId, isProtectedModule, listEntries } from '../domain/runtime.js'
17
17
  import { selfUpdateToLatest } from '../domain/selfupdate.js'
18
+ // 自更新响应视图(2026-11-03 搬出:这个文件贴着 600 行棘轮;字段与取值逻辑一字未改,只多 selfUpdate/reason)
19
+ import { selfUpdateView } from '../domain/self-update-guard.js'
18
20
  import { orderedRegistries, readSources } from '../domain/sources.js'
19
21
  import { fetchJsonUrl } from '../infra/http.js'
20
22
  import { sendError, sendJson } from '../infra/httpd.js'
@@ -573,22 +575,9 @@ async function routeSelfUpdate(req, res, rc) {
573
575
  // 包管理器优先(见 domain/selfupdate.js 顶部注释):旧实现只把文件铺进 node_modules、不写
574
576
  // pnpm-lock.yaml → "看起来升级成功、下一次 pnpm 操作就被还原"(用户 2026-09-20 实测报告)。
575
577
  const result = await selfUpdateToLatest({ profileDir, latest, registries, curlManualInstall })
576
- sendJson(res, 200, {
577
- ok: true,
578
- current: selfVersion,
579
- latest,
580
- updated: true,
581
- version: result.installedVersion ?? latest,
582
- method: result.method,
583
- spec: result.spec,
584
- installedVersion: result.installedVersion,
585
- lockVersion: result.lockVersion,
586
- lockUpdated: result.lockUpdated,
587
- lockNote: result.lockNote,
588
- command: result.command,
589
- note: result.note,
590
- errors: result.errors,
591
- })
578
+ // 响应视图统一在 domain/self-update-guard.js#selfUpdateView(字段与取值原样搬出,只多 selfUpdate/reason;
579
+ // 顺便给这个文件腾出 600 行棘轮的余量)。失败时 ok 与 updated 同时为 false —— 绝不把"没落地"报成更新成功。
580
+ sendJson(res, 200, selfUpdateView({ result, current: selfVersion, latest }))
592
581
  } catch (error) {
593
582
  sendError(res, 500, `Hub 自动更新失败:${error instanceof Error ? error.message : String(error)}`)
594
583
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.100",
3
+ "version": "0.5.101",
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",