@noob-stupid/dsh-plugin-console 0.5.100 → 0.5.102
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/server/domain/install-channels.js +84 -1
- package/lib/server/domain/install-cleanup.js +21 -4
- package/lib/server/domain/install-diagnose.js +10 -0
- package/lib/server/domain/install-job.js +43 -1
- package/lib/server/domain/install-lock.js +225 -0
- package/lib/server/domain/install-recovery.js +476 -0
- package/lib/server/domain/install.js +13 -0
- package/lib/server/domain/plugin-actions.js +47 -5
- package/lib/server/domain/self-update-guard.js +577 -0
- package/lib/server/domain/selfupdate.js +34 -30
- package/lib/server/domain/subdir-install.js +8 -1
- package/lib/server/routes/plugins.js +5 -16
- package/package.json +1 -1
|
@@ -0,0 +1,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
|
+
}
|