@noob-stupid/dsh-plugin-console 0.5.101 → 0.5.103
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 +51 -5
- 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 +26 -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 +9 -0
- package/lib/server/domain/lock-importer.js +165 -0
- package/lib/server/domain/plugin-actions.js +47 -5
- package/lib/server/domain/self-update-guard.js +15 -2
- package/lib/server/domain/selfupdate-copy.js +393 -0
- package/lib/server/domain/selfupdate-read.js +95 -0
- package/lib/server/domain/selfupdate.js +90 -90
- package/lib/server/domain/subdir-install.js +8 -1
- package/package.json +1 -1
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
// L1 · domain —— selfupdate-copy.js(自更新的**手铺兜底**:先备好 tarball → 只覆盖文件、不清空目录 → 补 lock)
|
|
2
|
+
//
|
|
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 超时被杀 → 目录停在"空了"的状态,跑了一整夜都没轮到第三步;
|
|
8
|
+
// · 就算第三步轮到了,既有 curl 通道也是"先 rmSync 整目录再 copyTree"(见 install.js#curlManualInstall),
|
|
9
|
+
// 宿主正读着这些文件时更容易被 Windows 拒绝。
|
|
10
|
+
// ⇒ 用户要的:pnpm 卡住/失败时**立刻**手铺、并把 lock 一起补上(否则又会"更新成功、下次 pnpm 操作被还原")。
|
|
11
|
+
//
|
|
12
|
+
// ── 本模块只做三件事(都不改既有通道的触发条件)──────────────────────────────────
|
|
13
|
+
// ① prepareSelfUpdateTarball:走 pnpm **之前**先用既有 curl/元数据链路把目标版本 tarball 备到手
|
|
14
|
+
// (失败**不影响**后续,只把原因记下来 —— 后面照样跑 pnpm 与既有 curl 通道);
|
|
15
|
+
// ② overlaySelfUpdateTarball:用备好的 tarball **只覆盖文件、绝不清空目录**(copyTree 的语义,
|
|
16
|
+
// 见 infra/fsx.js —— 它只 copyFileSync,不删任何东西),随后**读回** package.json 的版本与期望值比对;
|
|
17
|
+
// ③ patchSelfUpdateLock:把 `pnpm-lock.yaml` 里该包的版本/完整性更新到目标版本(**唯一**写入口仍是
|
|
18
|
+
// domain/lock-importer.js#writeLockRegistryVersion,本模块不另写一套 lock 解析),
|
|
19
|
+
// 再用既有 `verifyLockReadOnly`(pnpm 只读用法)复核;复核明确不通过 ⇒ **把 lock 还原成改动前的字节**
|
|
20
|
+
// 并如实上报(宁可报"没补上",也绝不留下 pnpm 会拒绝的 lock)。
|
|
21
|
+
//
|
|
22
|
+
// ── 与既有实现的关系(复用,不另造一套)──────────────────────────────────────────
|
|
23
|
+
// · 盒子校验 `verifyPackageBox`(install-box.js)、覆盖复制 `copyTree`(fsx.js)、
|
|
24
|
+
// lock 写入/只读校验(lock-importer.js)、失败兜底与自愈(self-update-guard.js)全部是既有件;
|
|
25
|
+
// · 本模块**不判断"更新成功与否"**:那由 selfupdate.js 的回读核实 + 自更新守卫说了算。
|
|
26
|
+
//
|
|
27
|
+
// ── 边界(别越线)──────────────────────────────────────────────────────────────
|
|
28
|
+
// ① 只对**自更新**生效:由 selfupdate.js 在 `/self-update` 那条路里调用;装别的插件的通道一个字节都不经过这里。
|
|
29
|
+
// ② 拿不到 tarball / 没有该版本的完整性摘要 ⇒ 什么都不做(返回 reason),调用方回落到 0.5.102 的既有行为。
|
|
30
|
+
// ③ 覆盖失败(EPERM/EBUSY 等)⇒ 如实返回 reason,**不重试、不删目录、不谎报**。
|
|
31
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
32
|
+
import { join } from 'node:path'
|
|
33
|
+
import { tmpdir } from 'node:os'
|
|
34
|
+
import { verifyLockReadOnly, writeLockRegistryVersion } from './lock-importer.js'
|
|
35
|
+
import { verifyPackageBox } from './install-box.js'
|
|
36
|
+
import { execFileAsync } from '../infra/exec.js'
|
|
37
|
+
import { copyTree, removeDirVerified } from '../infra/fsx.js'
|
|
38
|
+
import { fetchJsonUrl } from '../infra/http.js'
|
|
39
|
+
|
|
40
|
+
/** registry 元数据的超时(与既有 curl 通道同量级;拿不到就是拿不到,不许拖住自更新)。 */
|
|
41
|
+
const META_TIMEOUT_MS = 15000
|
|
42
|
+
/** tarball 下载超时(既有 curl 通道是 70s;这里 60s 封顶)。 */
|
|
43
|
+
const DOWNLOAD_TIMEOUT_MS = 60000
|
|
44
|
+
/** 解包超时(既有 curl 通道 30s)。 */
|
|
45
|
+
const EXTRACT_TIMEOUT_MS = 30000
|
|
46
|
+
/** 补完 lock 之后那次 pnpm **只读**校验的超时。刻意比 lock-importer 的 60s 短:
|
|
47
|
+
* 自更新这条路上 pnpm 本来就是"可能很慢"的那一环,不能为了复核再让用户多等一分钟;
|
|
48
|
+
* 超时按"**无法确认**"处理(verified=null),**不**回滚 —— 只有 pnpm 真的报错才回滚。 */
|
|
49
|
+
const LOCK_VERIFY_TIMEOUT_MS = 20000
|
|
50
|
+
/** 工作目录前缀(临时目录,跑完即删;名字里带随机段避免并发互撞)。 */
|
|
51
|
+
const WORK_PREFIX = 'dsh-selfupdate-'
|
|
52
|
+
|
|
53
|
+
/** registry 包名 → URL 段(与 install.js#curlManualInstall **同一口径**:scope 的 `/` 编成 `%2f`)。 */
|
|
54
|
+
function encodePackageName(packageName) {
|
|
55
|
+
const name = String(packageName ?? '')
|
|
56
|
+
if (!name.startsWith('@')) return encodeURIComponent(name)
|
|
57
|
+
return `@${encodeURIComponent(name.slice(1).split('/')[0])}%2f${encodeURIComponent(name.split('/').slice(1).join('/'))}`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** curl 可执行名(与既有通道一致:Windows 上是 curl.exe —— 有些环境里 PATH 上只有它)。 */
|
|
61
|
+
function curlBin() {
|
|
62
|
+
return process.platform === 'win32' ? 'curl.exe' : 'curl'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 离线/测试模式:CI 里 `DSH_TEST_SKIP_NETWORK=1`(见 .github/workflows/test.yml),照着跳过网络。 */
|
|
66
|
+
function networkBlocked(deps = {}) {
|
|
67
|
+
const env = typeof deps.env === 'object' && deps.env !== null ? deps.env : process.env
|
|
68
|
+
return String(env?.DSH_TEST_SKIP_NETWORK ?? '') === '1'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 建一个本次自更新专用的临时工作目录。 */
|
|
72
|
+
function makeWorkDir(deps = {}) {
|
|
73
|
+
const base = typeof deps.tmpDir === 'function' ? deps.tmpDir() : tmpdir()
|
|
74
|
+
const dir = join(base, `${WORK_PREFIX}${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`)
|
|
75
|
+
mkdirSync(dir, { recursive: true })
|
|
76
|
+
return dir
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 删掉一个工作目录并**核实**(infra/fsx.js#removeDirVerified:本机实测 `%TEMP%` 下 rmSync 会
|
|
80
|
+
* **静默落空** —— 不抛错、目录仍在;临时目录里装着下载下来的 tarball,不能"以为删了"就算了)。
|
|
81
|
+
* 返回 `{ removed, note }`;没删掉时 note 里带绝对路径(用户可自己清)。 */
|
|
82
|
+
function cleanWorkDir(dir) {
|
|
83
|
+
const target = typeof dir === 'string' ? dir : ''
|
|
84
|
+
if (target === '') return { removed: false, note: null }
|
|
85
|
+
const verdict = removeDirVerified(target)
|
|
86
|
+
if (verdict.ok === true) return { removed: true, note: null }
|
|
87
|
+
return { removed: false, note: `临时目录没删掉(${target}):${verdict.error ?? '原因未知'}(可自行删除)` }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `curl -sL -m <秒> -o <tgz> <url>`(与 install.js#curlManualInstall 同一形态)。 */
|
|
91
|
+
async function downloadToFile(url, tgz, { timeout = DOWNLOAD_TIMEOUT_MS, deps = {} } = {}) {
|
|
92
|
+
const run = typeof deps.exec === 'function' ? deps.exec : execFileAsync
|
|
93
|
+
await run(curlBin(), ['-s', '-L', '-m', String(Math.max(5, Math.ceil(timeout / 1000))), '-o', tgz, url], { timeout: timeout + 10000, windowsHide: true })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** `tar -xzf <tgz> -C <dir>`(与既有通道同一形态)。 */
|
|
97
|
+
async function extractTarball(tgz, dir, { deps = {} } = {}) {
|
|
98
|
+
const run = typeof deps.exec === 'function' ? deps.exec : execFileAsync
|
|
99
|
+
await run('tar', ['-xzf', tgz, '-C', dir], { timeout: EXTRACT_TIMEOUT_MS, windowsHide: true })
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 解压目录里含 package.json 的那一层(npm tarball 恒为 `package/`,但别假设)。 */
|
|
103
|
+
function findPackageDir(dir) {
|
|
104
|
+
const direct = join(dir, 'package')
|
|
105
|
+
if (existsSync(join(direct, 'package.json'))) return direct
|
|
106
|
+
let entries = []
|
|
107
|
+
try {
|
|
108
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
109
|
+
} catch {
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
for (const entry of entries) {
|
|
113
|
+
if (!entry.isDirectory()) continue
|
|
114
|
+
const candidate = join(dir, entry.name)
|
|
115
|
+
if (existsSync(join(candidate, 'package.json'))) return candidate
|
|
116
|
+
}
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 目录里的文件相对路径集合(用于如实报"铺了几个文件、原地保留了几个")。只读、失败返回空集。 */
|
|
121
|
+
function filesUnder(dir) {
|
|
122
|
+
const out = new Set()
|
|
123
|
+
const walk = (current, prefix) => {
|
|
124
|
+
let entries = []
|
|
125
|
+
try {
|
|
126
|
+
entries = readdirSync(current, { withFileTypes: true })
|
|
127
|
+
} catch {
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
for (const entry of entries) {
|
|
131
|
+
const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`
|
|
132
|
+
if (entry.isDirectory()) walk(join(current, entry.name), rel)
|
|
133
|
+
else if (entry.isFile()) out.add(rel)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (existsSync(dir)) walk(dir, '')
|
|
137
|
+
return out
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* ① 预先备好目标版本的 tarball(**pnpm 之前**跑;失败只记原因,不阻断后续)。
|
|
142
|
+
* 走的是既有链路:registry 元数据(`fetchJsonUrl`,可注入)→ `meta.versions[版本].dist.tarball` → curl 下载。
|
|
143
|
+
* 返回 `{ ok, version, tgzPath, workDir, integrity, tarball, registry, reason, ms }`。
|
|
144
|
+
* `integrity` 来自元数据 `dist.integrity`(pnpm 自己写进 lock 的就是这个值)——
|
|
145
|
+
* 拿不到就为 null,调用方据此**不**改 lock 的 packages 段(绝不用旧版本的摘要顶替)。
|
|
146
|
+
*/
|
|
147
|
+
async function prepareSelfUpdateTarball({ packageName, version, registries = [], deps = {} } = {}) {
|
|
148
|
+
const startedAt = Date.now()
|
|
149
|
+
const out = { ok: false, version: typeof version === 'string' ? version : null, tgzPath: null, workDir: null, integrity: null, tarball: null, registry: null, reason: null, ms: 0 }
|
|
150
|
+
const label = (error) => (error instanceof Error ? error.message : String(error))
|
|
151
|
+
const fetchJson = typeof deps.fetchJson === 'function' ? deps.fetchJson : fetchJsonUrl
|
|
152
|
+
if (typeof version !== 'string' || version.trim() === '') {
|
|
153
|
+
out.reason = '没有目标版本,未预先下载 tarball'
|
|
154
|
+
return out
|
|
155
|
+
}
|
|
156
|
+
if (networkBlocked(deps)) {
|
|
157
|
+
out.reason = '已跳过网络(DSH_TEST_SKIP_NETWORK=1)—— 未预先下载 tarball'
|
|
158
|
+
return out
|
|
159
|
+
}
|
|
160
|
+
const regs = Array.isArray(registries) ? registries.filter((r) => typeof r === 'string' && r !== '') : []
|
|
161
|
+
if (regs.length === 0) {
|
|
162
|
+
out.reason = '没有可用的软件源,未预先下载 tarball'
|
|
163
|
+
return out
|
|
164
|
+
}
|
|
165
|
+
let dist = null
|
|
166
|
+
const metaErrors = []
|
|
167
|
+
for (const registry of regs) {
|
|
168
|
+
try {
|
|
169
|
+
// eslint-disable-next-line no-await-in-loop
|
|
170
|
+
const meta = await fetchJson(`${registry}/${encodePackageName(packageName)}`, META_TIMEOUT_MS)
|
|
171
|
+
const record = meta?.versions?.[version]
|
|
172
|
+
if (record === undefined || record === null) {
|
|
173
|
+
metaErrors.push(`${registry}:元数据里没有 ${version} 这个版本`)
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
const tarball = typeof record.dist?.tarball === 'string' ? record.dist.tarball : null
|
|
177
|
+
if (tarball === null) {
|
|
178
|
+
metaErrors.push(`${registry}:${version} 没有 dist.tarball`)
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
181
|
+
dist = { registry, tarball, integrity: typeof record.dist?.integrity === 'string' && record.dist.integrity !== '' ? record.dist.integrity : null }
|
|
182
|
+
break
|
|
183
|
+
} catch (error) {
|
|
184
|
+
metaErrors.push(`${registry}:${label(error)}`)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (dist === null) {
|
|
188
|
+
out.reason = `拿不到 ${version} 的 registry 元数据(${metaErrors.join(';') || '原因未知'})`
|
|
189
|
+
out.ms = Date.now() - startedAt
|
|
190
|
+
return out
|
|
191
|
+
}
|
|
192
|
+
let workDir = null
|
|
193
|
+
try {
|
|
194
|
+
workDir = makeWorkDir(deps)
|
|
195
|
+
const tgzPath = join(workDir, 'pkg.tgz')
|
|
196
|
+
const download = typeof deps.download === 'function' ? deps.download : downloadToFile
|
|
197
|
+
await download(dist.tarball, tgzPath, { timeout: deps.downloadTimeoutMs ?? DOWNLOAD_TIMEOUT_MS, deps })
|
|
198
|
+
const size = existsSync(tgzPath) ? statSync(tgzPath).size : 0
|
|
199
|
+
if (size <= 0) throw new Error(`下载下来的 tarball 是空文件(${tgzPath})`)
|
|
200
|
+
out.ok = true
|
|
201
|
+
out.version = version
|
|
202
|
+
out.tgzPath = tgzPath
|
|
203
|
+
out.workDir = workDir
|
|
204
|
+
out.integrity = dist.integrity
|
|
205
|
+
out.tarball = dist.tarball
|
|
206
|
+
out.registry = dist.registry
|
|
207
|
+
return out
|
|
208
|
+
} catch (error) {
|
|
209
|
+
out.reason = `下载 ${version} 的 tarball 失败:${label(error)}`
|
|
210
|
+
if (workDir !== null) {
|
|
211
|
+
cleanWorkDir(workDir)
|
|
212
|
+
}
|
|
213
|
+
return out
|
|
214
|
+
} finally {
|
|
215
|
+
out.ms = Date.now() - startedAt
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* ② 用备好的 tarball **只覆盖文件、不清空目录**地铺进 `node_modules/<包名>`,随后**读回**版本核实。
|
|
221
|
+
* 与既有 curl 通道的区别只有一条、但正是这条要命:**不先 rmSync 整目录**
|
|
222
|
+
* (宿主正读着这些文件时,删目录比覆盖文件更容易被 Windows 拒绝;而且删完再失败就只剩空目录)。
|
|
223
|
+
* 返回 `{ ok, version, files, preserved, boxNote, reason }`:`preserved` = 目录里原有的、**不在 tarball 里**的文件数
|
|
224
|
+
* (copyTree 一个都不删,这个数是给"没清空目录"作证的)。
|
|
225
|
+
*/
|
|
226
|
+
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 }
|
|
228
|
+
const label = (error) => (error instanceof Error ? error.message : String(error))
|
|
229
|
+
if (prepared === null || prepared === undefined || prepared.ok !== true || typeof prepared.tgzPath !== 'string') {
|
|
230
|
+
out.reason = `没有可用的预下载 tarball${prepared?.reason ? `(${prepared.reason})` : ''}`
|
|
231
|
+
return out
|
|
232
|
+
}
|
|
233
|
+
const target = join(String(profileDir), 'node_modules', ...String(packageName).split('/'))
|
|
234
|
+
let extractDir = null
|
|
235
|
+
try {
|
|
236
|
+
extractDir = makeWorkDir(deps)
|
|
237
|
+
const extract = typeof deps.extract === 'function' ? deps.extract : extractTarball
|
|
238
|
+
await extract(prepared.tgzPath, extractDir, { deps })
|
|
239
|
+
const pkgDir = findPackageDir(extractDir)
|
|
240
|
+
if (pkgDir === null) throw new Error('解包后未找到含 package.json 的目录')
|
|
241
|
+
// 盒子校验(既有件):包名不符 / 入口文件缺失 / bundle 引用缺失 —— 覆盖旧版本**之前**拦住
|
|
242
|
+
const box = verifyPackageBox(pkgDir, profileDir, packageName)
|
|
243
|
+
out.boxNote = box?.note ?? null
|
|
244
|
+
const tarballFiles = filesUnder(pkgDir)
|
|
245
|
+
const existing = filesUnder(target)
|
|
246
|
+
let preserved = 0
|
|
247
|
+
for (const rel of existing) if (!tarballFiles.has(rel)) preserved += 1
|
|
248
|
+
// ★ 只覆盖:copyTree 只 copyFileSync(不删任何东西),目录缺就建目录 —— 见 infra/fsx.js
|
|
249
|
+
copyTree(pkgDir, target)
|
|
250
|
+
try {
|
|
251
|
+
writeFileSync(join(target, '.dsh-installed-at'), String(Date.now()), 'utf8')
|
|
252
|
+
} catch {}
|
|
253
|
+
const installed = readVersionOf(target)
|
|
254
|
+
out.version = installed
|
|
255
|
+
out.files = tarballFiles.size
|
|
256
|
+
out.preserved = preserved
|
|
257
|
+
const want = typeof expectedVersion === 'string' && expectedVersion !== '' ? expectedVersion : null
|
|
258
|
+
if (want !== null && installed !== want) {
|
|
259
|
+
out.reason = `铺完之后读回的版本是 ${installed ?? '(读不出来)'},不是目标版本 ${want}`
|
|
260
|
+
return out
|
|
261
|
+
}
|
|
262
|
+
out.ok = true
|
|
263
|
+
return out
|
|
264
|
+
} catch (error) {
|
|
265
|
+
out.reason = `覆盖文件失败:${label(error)}`
|
|
266
|
+
return out
|
|
267
|
+
} finally {
|
|
268
|
+
if (extractDir !== null) {
|
|
269
|
+
cleanWorkDir(extractDir)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** 读一份包目录里清单的版本(读不到返回 null)。 */
|
|
275
|
+
function readVersionOf(dir) {
|
|
276
|
+
try {
|
|
277
|
+
const pkg = JSON.parse(readFileSync(join(String(dir), 'package.json'), 'utf8'))
|
|
278
|
+
return typeof pkg?.version === 'string' ? pkg.version : null
|
|
279
|
+
} catch {
|
|
280
|
+
return null
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** 该包在清单里属于哪个依赖分区 —— **不在这里再写一份**:由调用方(selfupdate.js#manifestSectionOf,
|
|
285
|
+
* 复用 lockfile-health.js 的既有解析器)传进来,两处各写一份必然分叉。 */
|
|
286
|
+
|
|
287
|
+
/**
|
|
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。
|
|
293
|
+
* 返回 `{ attempted, changed, unchanged, version, integrityWritten, renamed, verified, verifyNote, rolledBack, reason, note }`。
|
|
294
|
+
*/
|
|
295
|
+
async function patchSelfUpdateLock({ profileDir, packageName, targetVersion, spec = null, integrity = null, section = 'dependencies', deps = {} } = {}) {
|
|
296
|
+
const out = { attempted: false, changed: false, unchanged: false, version: null, integrityWritten: false, renamed: { packages: false, snapshots: false }, verified: null, verifyNote: null, rolledBack: false, reason: null, note: null }
|
|
297
|
+
const label = (error) => (error instanceof Error ? error.message : String(error))
|
|
298
|
+
const specText = typeof spec === 'string' ? spec.trim() : ''
|
|
299
|
+
const rangeLike = specText === '' || /^[\^~]?\d/u.test(specText) || /^(>=|>|=)\s*\d/u.test(specText)
|
|
300
|
+
if (rangeLike !== true) {
|
|
301
|
+
// 来源型 spec(git/file/link/URL):既有约定是**原样保留**清单声明,lock 的 importer 条目也因此不改
|
|
302
|
+
// (改成版本号就会与清单对不上,pnpm 下一次操作会把链接/来源丢掉)。
|
|
303
|
+
out.reason = `清单 spec 是来源型(${specText})—— 按既有约定保留来源,importer 条目不改写,lock 未补`
|
|
304
|
+
return out
|
|
305
|
+
}
|
|
306
|
+
const lockFile = join(String(profileDir), 'pnpm-lock.yaml')
|
|
307
|
+
let before = null
|
|
308
|
+
try {
|
|
309
|
+
before = readFileSync(lockFile, 'utf8')
|
|
310
|
+
} catch (error) {
|
|
311
|
+
out.reason = `读不到 pnpm-lock.yaml(${label(error)})—— lock 未补`
|
|
312
|
+
return out
|
|
313
|
+
}
|
|
314
|
+
out.attempted = true
|
|
315
|
+
const write = typeof deps.writeLock === 'function' ? deps.writeLock : writeLockRegistryVersion
|
|
316
|
+
let written = null
|
|
317
|
+
try {
|
|
318
|
+
written = await write(String(profileDir), {
|
|
319
|
+
name: packageName,
|
|
320
|
+
// 清单会被收尾段同步成裸版本号(见 self-update-guard.js#finishSelfUpdate),lock 的 specifier 跟它一致
|
|
321
|
+
spec: targetVersion,
|
|
322
|
+
version: targetVersion,
|
|
323
|
+
integrity,
|
|
324
|
+
section,
|
|
325
|
+
deps: deps.writeDeps ?? {},
|
|
326
|
+
})
|
|
327
|
+
} catch (error) {
|
|
328
|
+
out.reason = `写 lock 失败:${label(error)}`
|
|
329
|
+
return out
|
|
330
|
+
}
|
|
331
|
+
out.changed = written?.changed === true
|
|
332
|
+
out.unchanged = written?.unchanged === true
|
|
333
|
+
out.version = written?.version ?? null
|
|
334
|
+
out.integrityWritten = written?.integrityWritten === true
|
|
335
|
+
out.renamed = written?.renamed ?? out.renamed
|
|
336
|
+
// 写入器"改成了、但有话要说"(例如没有该版本的完整性摘要 ⇒ packages/snapshots 段按规则不动):
|
|
337
|
+
// 这句是**说明**不是失败,放 note 里如实下发(reason 只留给真正的失败)。
|
|
338
|
+
out.note = typeof written?.reason === 'string' && written.reason !== '' ? written.reason : null
|
|
339
|
+
if (out.changed !== true && out.unchanged !== true) {
|
|
340
|
+
out.reason = written?.reason ?? 'lock 未写入(原因未给出)'
|
|
341
|
+
return out
|
|
342
|
+
}
|
|
343
|
+
const verify = typeof deps.verifyLock === 'function' ? deps.verifyLock : verifyLockReadOnly
|
|
344
|
+
try {
|
|
345
|
+
const verdict = await verify({ profileDir, registry: deps.registry ?? null, execOpts: { timeout: LOCK_VERIFY_TIMEOUT_MS }, ...(deps.verifyArgs ?? {}) })
|
|
346
|
+
out.verified = verdict?.verified ?? null
|
|
347
|
+
out.verifyNote = verdict?.reason ?? null
|
|
348
|
+
} catch (error) {
|
|
349
|
+
out.verified = null
|
|
350
|
+
out.verifyNote = `只读校验执行失败:${label(error)}`
|
|
351
|
+
}
|
|
352
|
+
// 只读校验**没跑完**(超时/被杀/执行方式不可用)不算"明确不通过" ⇒ 按无法确认处理,不回滚:
|
|
353
|
+
// 这类失败说明的是"这台机器上 pnpm 跑不动",而不是"写进去的 lock 有问题"。
|
|
354
|
+
if (out.verified === false && /超时|timed out|timeout|已终止整棵进程树|killed|ENOENT|Cannot find module|不可用/iu.test(String(out.verifyNote ?? ''))) {
|
|
355
|
+
out.verified = null
|
|
356
|
+
out.verifyNote = `pnpm 只读校验没跑完(按无法确认处理,未回滚):${out.verifyNote ?? '原因未知'}`
|
|
357
|
+
}
|
|
358
|
+
if (out.verified === false) {
|
|
359
|
+
// 明确不通过:还原成改动前的字节(宁可报没补上,也绝不留下 pnpm 会拒绝的 lock)
|
|
360
|
+
try {
|
|
361
|
+
writeFileSync(lockFile, before, 'utf8')
|
|
362
|
+
out.rolledBack = true
|
|
363
|
+
out.changed = false
|
|
364
|
+
out.reason = `pnpm 的只读校验未通过(${out.verifyNote ?? '原因未知'})—— 已把 pnpm-lock.yaml 还原成改动前的内容,按未补上处理`
|
|
365
|
+
} catch (error) {
|
|
366
|
+
out.reason = `pnpm 的只读校验未通过(${out.verifyNote ?? '原因未知'}),且还原 pnpm-lock.yaml 失败:${label(error)}`
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return out
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** 收尾:删掉本次临时工作目录(不抛、不阻塞、绝不影响返回结构)。 */
|
|
373
|
+
function disposeSelfUpdateTarball(prepared) {
|
|
374
|
+
return cleanWorkDir(typeof prepared?.workDir === 'string' ? prepared.workDir : '')
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export {
|
|
378
|
+
DOWNLOAD_TIMEOUT_MS,
|
|
379
|
+
EXTRACT_TIMEOUT_MS,
|
|
380
|
+
LOCK_VERIFY_TIMEOUT_MS,
|
|
381
|
+
META_TIMEOUT_MS,
|
|
382
|
+
WORK_PREFIX,
|
|
383
|
+
disposeSelfUpdateTarball,
|
|
384
|
+
downloadToFile,
|
|
385
|
+
encodePackageName,
|
|
386
|
+
extractTarball,
|
|
387
|
+
filesUnder,
|
|
388
|
+
findPackageDir,
|
|
389
|
+
overlaySelfUpdateTarball,
|
|
390
|
+
patchSelfUpdateLock,
|
|
391
|
+
prepareSelfUpdateTarball,
|
|
392
|
+
readVersionOf,
|
|
393
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// L1 · domain —— selfupdate-read.js(自更新/对账共用的**只读**读数:spec 判定、已装版本、lock 里被钉住的版本)
|
|
2
|
+
//
|
|
3
|
+
// 为什么单独一个模块(2026-11-04 加法):domain/selfupdate.js 贴着 600 行架构棘轮
|
|
4
|
+
// (tests/test-architecture-guard.mjs),而本轮要给"pnpm 卡住/失败后立刻手铺 + 补 lock"腾出空间。
|
|
5
|
+
// 这几个函数本来就是自洽的一族"只读、纯函数式、不认识 cordis"的读数,搬出来只换绑定来源:
|
|
6
|
+
// · 名字与语义**一个字节都没改**;selfupdate.js 继续 import 它们并**原样 re-export**
|
|
7
|
+
// (外部 persist.js / plugin-actions.js / unpin-dev-dir.js / install-version.js 与 6 个测试套件
|
|
8
|
+
// 都还从 selfupdate.js 取这几个名字,导出面一个字不能少);
|
|
9
|
+
// · 真值只有一份:lock 的键判据仍然只来自 lock-importer.js#lockEntryKeyMatches
|
|
10
|
+
// (见下面 lockVersion 的长注释 —— 两处各写一份必然再次分叉,0.5.38 就是那个现场)。
|
|
11
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { lockEntryKeyMatches } from './lock-importer.js'
|
|
14
|
+
|
|
15
|
+
/** spec 是否是 registry 版本范围(`^1.2.3` / `~1.2.3` / `1.2.3` / `>=1` / `=1`)。
|
|
16
|
+
* 只有这种才能用 `pnpm update` 在**不改写 spec**的前提下把 lock 提到范围内最新;
|
|
17
|
+
* git / file / link / workspace 来源没有"范围"可言,只能走 `pnpm add <pkg>@<版本>`。 */
|
|
18
|
+
function isRegistryRange(spec) {
|
|
19
|
+
if (typeof spec !== 'string') return false
|
|
20
|
+
const s = spec.trim()
|
|
21
|
+
if (/^[\^~]?\d/u.test(s)) return true
|
|
22
|
+
return /^(>=|>|=)\s*\d/u.test(s)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** profile 里该包的依赖声明(spec)。读不到返回 null。 */
|
|
26
|
+
function profileSpec(profileDir, name) {
|
|
27
|
+
try {
|
|
28
|
+
const pkg = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'))
|
|
29
|
+
const spec = pkg?.dependencies?.[name]
|
|
30
|
+
return typeof spec === 'string' ? spec : null
|
|
31
|
+
} catch {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 已安装版本(直接读 node_modules 里那份 package.json)。 */
|
|
37
|
+
function readInstalledVersion(profileDir, name) {
|
|
38
|
+
try {
|
|
39
|
+
const pkg = JSON.parse(readFileSync(join(profileDir, 'node_modules', ...name.split('/'), 'package.json'), 'utf8'))
|
|
40
|
+
return typeof pkg?.version === 'string' ? pkg.version : null
|
|
41
|
+
} catch {
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 从 pnpm-lock.yaml 读出该包被**钉住**的版本。解析策略(lock 格式五花八门,只认两种确定性位置,避免子串误命中):
|
|
48
|
+
* ① importers 段:行内容恰为 `'<name>':`(可带缩进),随后几行内的 `version: X`;
|
|
49
|
+
* ② packages 段:行首(可带缩进/引号)`<name>@<版本或 git+...>:` 的键名。
|
|
50
|
+
* 读不到返回 null(调用方据此判定"没能核实",不谎报成功)。
|
|
51
|
+
*
|
|
52
|
+
* ★ 2026-09-22 修复(缺陷②连带):旧版 ① 在遇到 `specifier: …` 行时**直接 break**,而 importers 段
|
|
53
|
+
* 恒为 `specifier:` 在前、`version:` 在后 → ① 从来没生效过,全靠 ② 兜底;而 ② 的正则用 `[^':\s]+`
|
|
54
|
+
* 取值,遇到 `name@https://…tgz` / `link:…` 会在第一个冒号处截断(读出 `http`)或读不到(link 依赖
|
|
55
|
+
* 在 lock 里没有 packages 条目 → 返回 null)。后果:来源钉住的包永远被判为「漂移」→ 每次安装都白跑
|
|
56
|
+
* 一次 pnpm add,并给用户一条假的「没写进 lock」警告。现在 ① 跳过 specifier 行、② 取到行尾。
|
|
57
|
+
*/
|
|
58
|
+
function lockVersion(profileDir, name) {
|
|
59
|
+
const file = join(profileDir, 'pnpm-lock.yaml')
|
|
60
|
+
if (!existsSync(file)) return null
|
|
61
|
+
let lines = []
|
|
62
|
+
try {
|
|
63
|
+
lines = readFileSync(file, 'utf8').split(/\r?\n/u)
|
|
64
|
+
} catch {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
68
|
+
// 键形态三种都认(0.5.38 改错):`name`、`'name':`(pnpm 对**含 `/`/`@`** 的包名加引号)、
|
|
69
|
+
// `name:`(pnpm 对**未加 scope** 的包名不加引号)。旧判据漏了第三种 ⇒ 未加 scope 的 `link:`
|
|
70
|
+
// 依赖在 importers 里明明有条目却读不出来(真机 desktop profile 的 `dshmarket` 就是:
|
|
71
|
+
// lock 的 35-37 行写着 `dshmarket: / specifier: link:… / version: link:../../plugin-src/dshmarket`,
|
|
72
|
+
// 而 `lockVersion()` 返回 null)——后果是面板永远报"没写进 pnpm-lock.yaml"、pin 永远 partial、
|
|
73
|
+
// 每次对账都白跑一次 `pnpm add`。真值一律从下面这一处读,别在调用方再补一套。
|
|
74
|
+
// 0.5.39:键判据搬进 lock-importer.js#lockEntryKeyMatches —— 定点**写**入也要认同一批键形态,
|
|
75
|
+
// 两处各写一份必然再次分叉(写进去的键、读的时候认不出,就是 0.5.38 那个现场)。
|
|
76
|
+
if (lockEntryKeyMatches(lines[i], name)) {
|
|
77
|
+
for (let k = i + 1; k < Math.min(i + 8, lines.length); k += 1) {
|
|
78
|
+
const m = /^\s*version:\s*(\S+)\s*$/u.exec(lines[k])
|
|
79
|
+
if (m) return m[1]
|
|
80
|
+
// 只在新包的键行(`'<name>':` 形态)处停止;`specifier: X` 有值,不是键行,必须继续往下看
|
|
81
|
+
if (/^\s*'?[^\s:]+'?:\s*$/u.test(lines[k])) break
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const escaped = name.replace(/[/\\^$*+?.()|[\]{}]/gu, '\\$&')
|
|
86
|
+
// 取到行尾再剥尾部的引号/冒号:`'name@https://…tgz':` / `name@git+https://…#sha:` / `name@1.2.3:`
|
|
87
|
+
const keyRe = new RegExp(`^\\s*'?${escaped}@(.+?)'?:\\s*$`, 'u')
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
const m = keyRe.exec(line)
|
|
90
|
+
if (m) return m[1]
|
|
91
|
+
}
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export { isRegistryRange, lockVersion, profileSpec, readInstalledVersion }
|