@noob-stupid/dsh-plugin-console 0.5.102 → 0.5.104
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.
|
@@ -281,6 +281,168 @@ async function writeLockImporterEntry(profileDir, { name, spec, section = IMPORT
|
|
|
281
281
|
})
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
/**
|
|
285
|
+
* **registry 版本**的 lock 定点写入(2026-11-04 加法,自更新手铺兜底专用):
|
|
286
|
+
* 一次动作把该包的三处写到目标版本 —— ① importer 条目(`specifier` + `version`,走**同一个**规划器
|
|
287
|
+
* `planLockImporterEntry`);② `packages:` 键名里的版本 + `resolution.integrity`;③ `snapshots:` 键名里的版本。
|
|
288
|
+
* 为什么走本模块(而不是另写一套 lock 解析):lock 的**唯一**写入口就在这里(读 → 纯函数规划 → 写 → 读回核实
|
|
289
|
+
* → 走 fsx 的写队列),自更新的"补 lock"必须复用同一把尺子,否则读得出来的键写得进去、写的键读不出来。
|
|
290
|
+
*
|
|
291
|
+
* 与 `writeLockImporterEntry` 的差别只有一处:那个只认 `link:` 来源(`lockVersionText` 对版本号返回 null,
|
|
292
|
+
* 因为 registry 依赖的 lock 记录**不止** importer 一行);这里由调用方**显式**给出目标版本与完整性,
|
|
293
|
+
* 再交给 `planLockVersionKeys` 处理那两段。给出完整性才写完整性(见那个函数的保守规则③)。
|
|
294
|
+
* 返回 `{ changed, unchanged, specifier, version, importerChanged, renamed, integrityWritten, reason }`。
|
|
295
|
+
*/
|
|
296
|
+
async function writeLockRegistryVersion(profileDir, { name, spec, version, integrity = null, section = IMPORTER_SECTIONS[0], deps = {} } = {}) {
|
|
297
|
+
const readText = typeof deps.readText === 'function' ? deps.readText : (p) => readFileSync(p, 'utf8')
|
|
298
|
+
const writeText = typeof deps.writeText === 'function' ? deps.writeText : (p, text) => writeFileSync(p, text, 'utf8')
|
|
299
|
+
const file = join(String(profileDir), LOCKFILE_NAME)
|
|
300
|
+
const want = typeof version === 'string' ? version.trim() : ''
|
|
301
|
+
const specifier = typeof spec === 'string' && spec.trim() !== '' ? spec.trim() : want
|
|
302
|
+
const fail = (reason) => ({ changed: false, unchanged: false, specifier, version: want, importerChanged: false, renamed: { packages: false, snapshots: false }, integrityWritten: false, reason })
|
|
303
|
+
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u.test(want)) return fail(`目标版本 ${want === '' ? '(空)' : want} 不是裸版本号 —— 不往 lock 里写非版本形态`)
|
|
304
|
+
return queuedWrite(async () => {
|
|
305
|
+
let text = null
|
|
306
|
+
try {
|
|
307
|
+
text = await readText(file)
|
|
308
|
+
} catch (error) {
|
|
309
|
+
return fail(`读不到 ${LOCKFILE_NAME}(${error instanceof Error ? error.message : String(error)}),未写入`)
|
|
310
|
+
}
|
|
311
|
+
const importerPlan = planLockImporterEntry(text, { name, specifier, version: want, section })
|
|
312
|
+
if (importerPlan.ok !== true) return fail(importerPlan.reason ?? 'lock importer 段规划失败(形态不认识),未写入')
|
|
313
|
+
const keysPlan = planLockVersionKeys(importerPlan.text, { name, toVersion: want, integrity })
|
|
314
|
+
if (keysPlan.ok !== true) return fail(keysPlan.reason ?? 'packages/snapshots 段规划失败,未写入')
|
|
315
|
+
const next = keysPlan.text
|
|
316
|
+
const importerChanged = importerPlan.changed === true
|
|
317
|
+
if (next === text) {
|
|
318
|
+
return { changed: false, unchanged: true, specifier: importerPlan.specifier ?? specifier, version: want, importerChanged, renamed: keysPlan.renamed, integrityWritten: keysPlan.integrityWritten, reason: keysPlan.reason ?? null }
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
await writeText(file, next)
|
|
322
|
+
} catch (error) {
|
|
323
|
+
return fail(`写盘失败:${error instanceof Error ? error.message : String(error)}`)
|
|
324
|
+
}
|
|
325
|
+
let after = null
|
|
326
|
+
try {
|
|
327
|
+
after = await readText(file)
|
|
328
|
+
} catch (error) {
|
|
329
|
+
return fail(`写盘后读回失败:${error instanceof Error ? error.message : String(error)}`)
|
|
330
|
+
}
|
|
331
|
+
if (after !== next) return fail('写盘后读回与预期不一致(未能核实),按未写入处理')
|
|
332
|
+
return { changed: true, unchanged: false, specifier: importerPlan.specifier ?? specifier, version: want, importerChanged, renamed: keysPlan.renamed, integrityWritten: keysPlan.integrityWritten, reason: keysPlan.reason ?? null }
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* `packages:` / `snapshots:` 段里 `'<name>@<版本>'` 键行的定位(同一个包的所有**解析记录**行)。
|
|
338
|
+
* 只认这两段的键行(段头顶格 + 2 空格缩进的键),避免把 importers 里的同名条目、或别的包读进来。
|
|
339
|
+
* 返回 `[{ index, section, indent, version, quoted }]`;版本里可能带 peer 后缀 `(peer@1.0.0)`,原样保留。
|
|
340
|
+
*/
|
|
341
|
+
function resolutionKeyRows(lines, name) {
|
|
342
|
+
const escaped = String(name).replace(/[/\\^$*+?.()|[\]{}]/gu, '\\$&')
|
|
343
|
+
// 键行形态三种都认:`'name@1.0.0':`、`name@1.0.0:`、`'name@1.0.0': {}`(真机 lock 的 snapshots 就是最后一种)
|
|
344
|
+
const keyRe = new RegExp(`^(\\s*)'?${escaped}@([^\\s']+?)'?\\s*:\\s*(\\{.*\\})?\\s*$`, 'u')
|
|
345
|
+
const rows = []
|
|
346
|
+
let section = null
|
|
347
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
348
|
+
const top = /^([A-Za-z][A-Za-z0-9_]*):\s*$/u.exec(lines[i])
|
|
349
|
+
if (top !== null) {
|
|
350
|
+
section = top[1]
|
|
351
|
+
continue
|
|
352
|
+
}
|
|
353
|
+
if (section !== 'packages' && section !== 'snapshots') continue
|
|
354
|
+
const m = keyRe.exec(lines[i])
|
|
355
|
+
if (m === null) continue
|
|
356
|
+
rows.push({ index: i, section, indent: m[1], version: m[2], rest: m[3] ?? null, quoted: lines[i].trim().startsWith("'") })
|
|
357
|
+
}
|
|
358
|
+
return rows
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* 纯函数:把 lock 里该包的**解析记录**(`packages:` / `snapshots:` 的键与 integrity)改到目标版本。
|
|
363
|
+
* 为什么需要它(2026-11-04 加法):自更新的手铺兜底只把文件铺进去,`pnpm-lock.yaml` 里仍钉着旧版本 ——
|
|
364
|
+
* 那正是"更新成功、下一次 pnpm 操作又被还原"的老毛病。importer 条目由 planLockImporterEntry 负责
|
|
365
|
+
* (同一份规划器,不另写一套),这里只补它表达不了的那两段:键名里的版本 + `resolution.integrity`。
|
|
366
|
+
*
|
|
367
|
+
* 四条保守规则(**绝不猜**,做不到就一个字节都不写并把原因带回去):
|
|
368
|
+
* ① 没有**该版本**的完整性摘要(registry 元数据 `dist.integrity`)⇒ 整段都不动 —— 把旧版本的 integrity
|
|
369
|
+
* 挂到新版本号下面比"不写"更糟(pnpm 会 ERR_PNPM_TARBALL_INTEGRITY);
|
|
370
|
+
* ② 只有**唯一**一个陈旧键、且它的块里有**单行** `resolution: {…}` 才改名(版本与完整性必须一起落地);
|
|
371
|
+
* ③ 目标版本键已存在 ⇒ 不改名,只把 integrity 补成该版本的;
|
|
372
|
+
* ④ snapshots 段只在 packages 段真的改成功之后才跟着改(两段保持一致)。
|
|
373
|
+
* 返回 `{ ok, changed, text, renamed:{packages,snapshots}, integrityWritten, reason }`。
|
|
374
|
+
*/
|
|
375
|
+
function planLockVersionKeys(text, { name, toVersion, integrity = null } = {}) {
|
|
376
|
+
const src = typeof text === 'string' ? text : String(text ?? '')
|
|
377
|
+
const want = typeof toVersion === 'string' ? toVersion.trim() : ''
|
|
378
|
+
const digest = typeof integrity === 'string' ? integrity.trim() : ''
|
|
379
|
+
const unchanged = (reason) => ({ ok: true, changed: false, text: src, renamed: { packages: false, snapshots: false }, integrityWritten: false, reason })
|
|
380
|
+
if (want === '') return { ...unchanged('没有目标版本,未改动'), ok: false }
|
|
381
|
+
if (digest === '') return unchanged('没有该版本的完整性摘要(registry 元数据缺失)⇒ 未改 packages/snapshots 段(绝不把旧 integrity 挂到新版本号上)')
|
|
382
|
+
const eol = /\r\n/u.test(src) ? '\r\n' : '\n'
|
|
383
|
+
const trailing = /\r?\n$/u.test(src)
|
|
384
|
+
const lines = src.split(/\r?\n/u)
|
|
385
|
+
if (trailing && lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
|
|
386
|
+
const rows = resolutionKeyRows(lines, name)
|
|
387
|
+
if (rows.length === 0) return unchanged('lock 里没有该包的 packages/snapshots 解析记录(下一次 pnpm 操作会自己补出来),未改动')
|
|
388
|
+
const out = [...lines]
|
|
389
|
+
const renamed = { packages: false, snapshots: false }
|
|
390
|
+
let integrityWritten = false
|
|
391
|
+
const reasons = []
|
|
392
|
+
/** 在该行所属块的 `resolution:` 行上换 integrity;块里没有单行 resolution 就什么都不写(不伪造结构)。 */
|
|
393
|
+
const writeIntegrity = (row) => {
|
|
394
|
+
for (let k = row.index + 1; k < out.length; k += 1) {
|
|
395
|
+
if (out[k].trim() !== '' && !/^\s{3,}/u.test(out[k])) break
|
|
396
|
+
const m = /^(\s*resolution:\s*)\{[^}]*\}(\s*)$/u.exec(out[k])
|
|
397
|
+
if (m === null) continue
|
|
398
|
+
if (!out[k].includes(digest)) {
|
|
399
|
+
out[k] = `${m[1]}{integrity: ${digest}}${m[2]}`
|
|
400
|
+
integrityWritten = true
|
|
401
|
+
}
|
|
402
|
+
return true
|
|
403
|
+
}
|
|
404
|
+
return false
|
|
405
|
+
}
|
|
406
|
+
for (const section of ['packages', 'snapshots']) {
|
|
407
|
+
const mine = rows.filter((r) => r.section === section)
|
|
408
|
+
if (mine.length === 0) continue
|
|
409
|
+
const fresh = mine.filter((r) => r.version === want || r.version.startsWith(`${want}(`))
|
|
410
|
+
if (fresh.length > 0) {
|
|
411
|
+
if (section === 'packages' && writeIntegrity(fresh[0]) !== true) reasons.push('packages 里的条目没有单行 resolution 记录 ⇒ integrity 未写(不伪造结构)')
|
|
412
|
+
continue
|
|
413
|
+
}
|
|
414
|
+
if (mine.length > 1) {
|
|
415
|
+
reasons.push(`${section} 里有 ${mine.length} 个陈旧键(${mine.map((r) => r.version).join('、')})—— 未改名(不猜该留哪一个)`)
|
|
416
|
+
break
|
|
417
|
+
}
|
|
418
|
+
const row = mine[0]
|
|
419
|
+
const suffix = /\(.*\)$/u.exec(row.version)?.[0] ?? ''
|
|
420
|
+
const keyBody = row.quoted ? `'${name}@${want}${suffix}':` : `${name}@${want}${suffix}:`
|
|
421
|
+
const nextKey = `${row.indent}${keyBody}${row.rest === null ? '' : ` ${row.rest}`}`
|
|
422
|
+
// 规则②:版本与完整性必须**一起**落地 —— 先在草稿上换 integrity,换不成就整体放弃这一段
|
|
423
|
+
const draft = [...out]
|
|
424
|
+
const saved = out[row.index]
|
|
425
|
+
out[row.index] = nextKey
|
|
426
|
+
if (section === 'packages' && writeIntegrity(row) !== true) {
|
|
427
|
+
out[row.index] = saved
|
|
428
|
+
reasons.push('packages 里的条目没有单行 resolution 记录 ⇒ 未改名(版本与完整性必须一起落地,不伪造结构)')
|
|
429
|
+
for (let k = 0; k < draft.length; k += 1) out[k] = draft[k]
|
|
430
|
+
integrityWritten = false
|
|
431
|
+
break
|
|
432
|
+
}
|
|
433
|
+
renamed[section] = true
|
|
434
|
+
}
|
|
435
|
+
const changed = renamed.packages || renamed.snapshots || integrityWritten
|
|
436
|
+
return {
|
|
437
|
+
ok: true,
|
|
438
|
+
changed,
|
|
439
|
+
text: changed ? out.join(eol) + (trailing ? eol : '') : src,
|
|
440
|
+
renamed,
|
|
441
|
+
integrityWritten,
|
|
442
|
+
reason: changed ? null : (reasons.join(';') || null),
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
284
446
|
/**
|
|
285
447
|
* 从 lock 自己的 `settings:` 段取出 pnpm 配置,供**只读**校验按 lock 的形态复算
|
|
286
448
|
* (不镜像它时 pnpm 直接 `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`:本机实测 `autoInstallPeers`)。
|
|
@@ -405,7 +567,10 @@ export {
|
|
|
405
567
|
lockVersionText,
|
|
406
568
|
lockfileSettingsFlags,
|
|
407
569
|
planLockImporterEntry,
|
|
570
|
+
planLockVersionKeys,
|
|
571
|
+
resolutionKeyRows,
|
|
408
572
|
verifyArgsFor,
|
|
409
573
|
verifyLockReadOnly,
|
|
410
574
|
writeLockImporterEntry,
|
|
575
|
+
writeLockRegistryVersion,
|
|
411
576
|
}
|
|
@@ -474,7 +474,7 @@ function settleSelfUpdateGuard(session, { targetVersion = undefined, deps = {} }
|
|
|
474
474
|
* · 校验没过时把那段失败文案同时推进 `errors` 与 `note`(面板/接口都看得见);
|
|
475
475
|
* · `installedVersion` 在"已自愈"时改报**盘上真实那一版**(否则会留一个 null 让人以为目录还是空的)。
|
|
476
476
|
*/
|
|
477
|
-
function finishSelfUpdate({ profileDir, packageName, latest, spec, installed, method, command, errors, lockVersion, lockUpdated, lockNote, registryRange, selfUpdate = null }) {
|
|
477
|
+
function finishSelfUpdate({ profileDir, packageName, latest, spec, installed, method, command, errors, lockVersion, lockUpdated, lockNote, registryRange, selfUpdate = null, copy = null, tarball = null, lockPatch = null }) {
|
|
478
478
|
const CONSOLE_PACKAGE = packageName
|
|
479
479
|
const isRegistryRange = registryRange
|
|
480
480
|
const label = (e) => (e instanceof Error ? e.message : String(e))
|
|
@@ -505,6 +505,15 @@ function finishSelfUpdate({ profileDir, packageName, latest, spec, installed, me
|
|
|
505
505
|
const noteParts = [lockUpdated ? '已写入 pnpm-lock.yaml,重启服务后生效' : '已铺入文件但未写入 pnpm-lock.yaml:重启后可用,但会被 pnpm 还原']
|
|
506
506
|
if (sourceSwitch !== null) noteParts.push(sourceSwitch)
|
|
507
507
|
if (specUpdated !== null) noteParts.push(`清单 spec 已同步为 ${specUpdated}(防 pnpm 降级)`)
|
|
508
|
+
// ★ 2026-11-04 加法:手铺那一步到底怎么铺的、lock 补没补上,都要让用户看得见(绝不静默)
|
|
509
|
+
if (copy !== null && copy !== undefined && copy?.channel === 'overlay') {
|
|
510
|
+
noteParts.push(`用预下载的 tarball 只覆盖了 ${copy.files ?? '?'} 个文件、**没有清空目录**(原地保留 ${copy.preserved ?? 0} 个不在 tarball 里的文件)`)
|
|
511
|
+
}
|
|
512
|
+
if (lockPatch !== null && lockPatch !== undefined && lockPatch?.attempted === true) {
|
|
513
|
+
if (lockPatch.changed === true && lockPatch.verified === true) noteParts.push(`pnpm-lock.yaml 已补齐 ${latest} 的版本/完整性并通过 pnpm 只读校验`)
|
|
514
|
+
else if (lockPatch.changed === true && lockPatch.verified === null) noteParts.push(`pnpm-lock.yaml 已补齐 ${latest} 的版本/完整性,但 pnpm 的只读校验**无法确认**(${lockPatch.verifyNote ?? '原因未知'})`)
|
|
515
|
+
else if (lockPatch.reason !== null && lockPatch.reason !== undefined) noteParts.push(`pnpm-lock.yaml 未补齐:${lockPatch.reason}`)
|
|
516
|
+
}
|
|
508
517
|
|
|
509
518
|
// ★ 加法:自更新校验的结论(只有"本插件自己 + 更新前就有完整副本"时 selfUpdate 才非空)
|
|
510
519
|
const verified = selfUpdate === null || selfUpdate === undefined ? true : selfUpdate.verified === true
|
|
@@ -512,7 +521,7 @@ function finishSelfUpdate({ profileDir, packageName, latest, spec, installed, me
|
|
|
512
521
|
if (reason !== null) errors.push(reason)
|
|
513
522
|
if (reason !== null) noteParts.push(reason)
|
|
514
523
|
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 }
|
|
524
|
+
return { method, spec: specUpdated ?? spec, installedVersion, lockVersion, lockUpdated, lockNote, command, note: noteParts.join(';'), errors, ok: verified, reason, selfUpdate: selfUpdate ?? null, copy: copy ?? null, tarball: tarball ?? null, lockPatch: lockPatch ?? null }
|
|
516
525
|
}
|
|
517
526
|
|
|
518
527
|
/**
|
|
@@ -543,6 +552,10 @@ function selfUpdateView({ result, current = null, latest = null }) {
|
|
|
543
552
|
errors: result.errors,
|
|
544
553
|
selfUpdate: result.selfUpdate ?? null,
|
|
545
554
|
reason: result.reason ?? null,
|
|
555
|
+
// ★ 2026-11-04 加法(手铺兜底):这三项只增不减 —— 既有字段名与取值语义一个都没动
|
|
556
|
+
copy: result.copy ?? null,
|
|
557
|
+
tarball: result.tarball ?? null,
|
|
558
|
+
lockPatch: result.lockPatch ?? null,
|
|
546
559
|
}
|
|
547
560
|
}
|
|
548
561
|
|
|
@@ -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 }
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// 现在的顺序:① spec 是版本范围 → `pnpm update <pkg>`(spec 不变、lock 提到范围内最新)
|
|
11
11
|
// ② 仍不是 latest(超出范围 / spec 是 git·file 来源)→ `pnpm add <pkg>@<latest>`(spec 与 lock 同步改写)
|
|
12
12
|
// ③ 回读核实 readInstalledVersion / lockVersion;都不匹配才回落手铺文件,且**必须**带 lockNote 警告。
|
|
13
|
-
import {
|
|
13
|
+
import { readFileSync } from 'node:fs'
|
|
14
14
|
import { join } from 'node:path'
|
|
15
15
|
import { linkSpecFor, linkSpecIsIntact, materializePackageForLink, probeRegistryPackage, sameSourceSpec, suggestedPinAction } from './dep-source.js'
|
|
16
16
|
import { canWriteImporterEntry, lockEntryKeyMatches, verifyLockReadOnly, writeLockImporterEntry } from './lock-importer.js'
|
|
@@ -22,91 +22,18 @@ import { withInstallLockOr } from './install-lock.js'
|
|
|
22
22
|
// 自更新兜底(2026-11-03 加法,见 self-update-guard.js 顶部事故记录):
|
|
23
23
|
// begin/note/settle 是同一套判据与同一份 manualCommand;finishSelfUpdate 是本文件收尾段**原样搬出**的(腾行数用)。
|
|
24
24
|
import { beginSelfUpdateGuard, finishSelfUpdate, noteGuardStep, settleSelfUpdateGuard } from './self-update-guard.js'
|
|
25
|
+
// 只读读数(spec 判定 / 已装版本 / lock 里钉住的版本)2026-11-04 搬进 selfupdate-read.js:
|
|
26
|
+
// 本文件贴着 600 行架构棘轮,而本轮要给"pnpm 卡住/失败后立刻手铺 + 补 lock"腾出空间。
|
|
27
|
+
// 名字与语义一个字未改;下面的 export 语句照旧把它们**原样 re-export**(外部调用方与测试不用改)。
|
|
28
|
+
import { isRegistryRange, lockVersion, profileSpec, readInstalledVersion } from './selfupdate-read.js'
|
|
29
|
+
// 手铺兜底(2026-11-04 加法,见 selfupdate-copy.js 顶部现场):pnpm 之前先把 tarball 备好 →
|
|
30
|
+
// pnpm 超时/失败就**只覆盖文件、不清空目录**地铺进去 → 再把 lock 的版本/完整性补上。
|
|
31
|
+
import { disposeSelfUpdateTarball, overlaySelfUpdateTarball, patchSelfUpdateLock, prepareSelfUpdateTarball } from './selfupdate-copy.js'
|
|
25
32
|
import { fetchJsonUrl } from '../infra/http.js'
|
|
26
33
|
import { runPnpmWithFallback } from '../infra/exec.js'
|
|
27
34
|
|
|
28
35
|
const CONSOLE_PACKAGE = '@noob-stupid/dsh-plugin-console'
|
|
29
36
|
|
|
30
|
-
/** spec 是否是 registry 版本范围(`^1.2.3` / `~1.2.3` / `1.2.3` / `>=1` / `=1`)。
|
|
31
|
-
* 只有这种才能用 `pnpm update` 在**不改写 spec**的前提下把 lock 提到范围内最新;
|
|
32
|
-
* git / file / link / workspace 来源没有"范围"可言,只能走 `pnpm add <pkg>@<版本>`。 */
|
|
33
|
-
function isRegistryRange(spec) {
|
|
34
|
-
if (typeof spec !== 'string') return false
|
|
35
|
-
const s = spec.trim()
|
|
36
|
-
if (/^[\^~]?\d/u.test(s)) return true
|
|
37
|
-
return /^(>=|>|=)\s*\d/u.test(s)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** profile 里该包的依赖声明(spec)。读不到返回 null。 */
|
|
41
|
-
function profileSpec(profileDir, name) {
|
|
42
|
-
try {
|
|
43
|
-
const pkg = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'))
|
|
44
|
-
const spec = pkg?.dependencies?.[name]
|
|
45
|
-
return typeof spec === 'string' ? spec : null
|
|
46
|
-
} catch {
|
|
47
|
-
return null
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** 已安装版本(直接读 node_modules 里那份 package.json)。 */
|
|
52
|
-
function readInstalledVersion(profileDir, name) {
|
|
53
|
-
try {
|
|
54
|
-
const pkg = JSON.parse(readFileSync(join(profileDir, 'node_modules', ...name.split('/'), 'package.json'), 'utf8'))
|
|
55
|
-
return typeof pkg?.version === 'string' ? pkg.version : null
|
|
56
|
-
} catch {
|
|
57
|
-
return null
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* 从 pnpm-lock.yaml 读出该包被**钉住**的版本。解析策略(lock 格式五花八门,只认两种确定性位置,避免子串误命中):
|
|
63
|
-
* ① importers 段:行内容恰为 `'<name>':`(可带缩进),随后几行内的 `version: X`;
|
|
64
|
-
* ② packages 段:行首(可带缩进/引号)`<name>@<版本或 git+...>:` 的键名。
|
|
65
|
-
* 读不到返回 null(调用方据此判定"没能核实",不谎报成功)。
|
|
66
|
-
*
|
|
67
|
-
* ★ 2026-09-22 修复(缺陷②连带):旧版 ① 在遇到 `specifier: …` 行时**直接 break**,而 importers 段
|
|
68
|
-
* 恒为 `specifier:` 在前、`version:` 在后 → ① 从来没生效过,全靠 ② 兜底;而 ② 的正则用 `[^':\s]+`
|
|
69
|
-
* 取值,遇到 `name@https://…tgz` / `link:…` 会在第一个冒号处截断(读出 `http`)或读不到(link 依赖
|
|
70
|
-
* 在 lock 里没有 packages 条目 → 返回 null)。后果:来源钉住的包永远被判为「漂移」→ 每次安装都白跑
|
|
71
|
-
* 一次 pnpm add,并给用户一条假的「没写进 lock」警告。现在 ① 跳过 specifier 行、② 取到行尾。
|
|
72
|
-
*/
|
|
73
|
-
function lockVersion(profileDir, name) {
|
|
74
|
-
const file = join(profileDir, 'pnpm-lock.yaml')
|
|
75
|
-
if (!existsSync(file)) return null
|
|
76
|
-
let lines = []
|
|
77
|
-
try {
|
|
78
|
-
lines = readFileSync(file, 'utf8').split(/\r?\n/u)
|
|
79
|
-
} catch {
|
|
80
|
-
return null
|
|
81
|
-
}
|
|
82
|
-
for (let i = 0; i < lines.length; i += 1) {
|
|
83
|
-
// 键形态三种都认(0.5.38 改错):`name`、`'name':`(pnpm 对**含 `/`/`@`** 的包名加引号)、
|
|
84
|
-
// `name:`(pnpm 对**未加 scope** 的包名不加引号)。旧判据漏了第三种 ⇒ 未加 scope 的 `link:`
|
|
85
|
-
// 依赖在 importers 里明明有条目却读不出来(真机 desktop profile 的 `dshmarket` 就是:
|
|
86
|
-
// lock 的 35-37 行写着 `dshmarket: / specifier: link:… / version: link:../../plugin-src/dshmarket`,
|
|
87
|
-
// 而 `lockVersion()` 返回 null)——后果是面板永远报"没写进 pnpm-lock.yaml"、pin 永远 partial、
|
|
88
|
-
// 每次对账都白跑一次 `pnpm add`。真值一律从下面这一处读,别在调用方再补一套。
|
|
89
|
-
// 0.5.39:键判据搬进 lock-importer.js#lockEntryKeyMatches —— 定点**写**入也要认同一批键形态,
|
|
90
|
-
// 两处各写一份必然再次分叉(写进去的键、读的时候认不出,就是 0.5.38 那个现场)。
|
|
91
|
-
if (lockEntryKeyMatches(lines[i], name)) {
|
|
92
|
-
for (let k = i + 1; k < Math.min(i + 8, lines.length); k += 1) {
|
|
93
|
-
const m = /^\s*version:\s*(\S+)\s*$/u.exec(lines[k])
|
|
94
|
-
if (m) return m[1]
|
|
95
|
-
// 只在新包的键行(`'<name>':` 形态)处停止;`specifier: X` 有值,不是键行,必须继续往下看
|
|
96
|
-
if (/^\s*'?[^\s:]+'?:\s*$/u.test(lines[k])) break
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
const escaped = name.replace(/[/\\^$*+?.()|[\]{}]/gu, '\\$&')
|
|
101
|
-
// 取到行尾再剥尾部的引号/冒号:`'name@https://…tgz':` / `name@git+https://…#sha:` / `name@1.2.3:`
|
|
102
|
-
const keyRe = new RegExp(`^\\s*'?${escaped}@(.+?)'?:\\s*$`, 'u')
|
|
103
|
-
for (const line of lines) {
|
|
104
|
-
const m = keyRe.exec(line)
|
|
105
|
-
if (m) return m[1]
|
|
106
|
-
}
|
|
107
|
-
return null
|
|
108
|
-
}
|
|
109
|
-
|
|
110
37
|
/** 给用户的兜底建议命令(照用户报告里那条:让 lock 也变成新版本,而不是只改文件)。 */
|
|
111
38
|
function selfUpdateCommand(latest, profileName = '<你的profile>') {
|
|
112
39
|
return `dsh plugin --profile ${profileName} add ${CONSOLE_PACKAGE}@${latest}`
|
|
@@ -135,8 +62,18 @@ async function selfUpdateToLatest(args = {}) {
|
|
|
135
62
|
)
|
|
136
63
|
}
|
|
137
64
|
|
|
138
|
-
/**
|
|
139
|
-
|
|
65
|
+
/**
|
|
66
|
+
* 上面那个互斥外壳里的正文。
|
|
67
|
+
*
|
|
68
|
+
* ★ 2026-11-04 加法(**三步的触发条件与顺序一个字未改**,只多了"预先备 tarball"和"手铺后补 lock"):
|
|
69
|
+
* 现场:pnpm 一启动就清空目标目录,在 profile 里重算整棵依赖树要几分钟 → 撞 90s/180s 超时被杀 →
|
|
70
|
+
* 目录停在"空了"的状态,手铺那一步(第③步)压根还没轮到;即使轮到,既有 curl 通道也是"先删再铺"。
|
|
71
|
+
* 现在:⓪ 先用既有 curl/元数据链路把目标版本 tarball 备到手(失败不影响后续)→ ① ② 照旧 →
|
|
72
|
+
* ③ 需要手铺时**先用备好的 tarball 只覆盖文件(绝不清空目录)**,不行才回落到既有 curl 通道 →
|
|
73
|
+
* ③c 手铺成功但 lock 没跟上 ⇒ 立刻补 lock(版本 + 完整性)并复核;补不上就如实记 lockNote。
|
|
74
|
+
* `copyDeps` 只是单测注入口(默认全是既有实现),路由侧一个字都不用改。
|
|
75
|
+
*/
|
|
76
|
+
async function selfUpdateToLatestLocked({ profileDir, latest, registries, curlManualInstall, runPnpm = runPnpmWithFallback, pnpmAdd = pnpmInstall, profileName = null, execOpts = {}, selfUpdateDeps = {}, copyDeps = {} }) {
|
|
140
77
|
const command = selfUpdateCommand(latest, profileName ?? '<你的profile>')
|
|
141
78
|
const spec = profileSpec(profileDir, CONSOLE_PACKAGE)
|
|
142
79
|
const errors = []
|
|
@@ -145,6 +82,9 @@ async function selfUpdateToLatestLocked({ profileDir, latest, registries, curlMa
|
|
|
145
82
|
// 自更新兜底(规矩:只对本插件生效 / 更新前就有完整副本 / 真的装在 node_modules 里,三条闸都在 begin 里)
|
|
146
83
|
const guard = beginSelfUpdateGuard({ packageName: CONSOLE_PACKAGE, profileDir, targetVersion: latest, deps: selfUpdateDeps })
|
|
147
84
|
|
|
85
|
+
// ⓪ 走 pnpm **之前**先把目标版本的 tarball 备到手(失败只记原因,绝不阻断后续:三步顺序不变)
|
|
86
|
+
const prepared = await prepareSelfUpdateTarball({ packageName: CONSOLE_PACKAGE, version: latest, registries, deps: copyDeps })
|
|
87
|
+
|
|
148
88
|
// ① spec 是版本范围 → pnpm update(spec 不变,lock 提到范围内最新)
|
|
149
89
|
if (isRegistryRange(spec)) {
|
|
150
90
|
try {
|
|
@@ -170,27 +110,65 @@ async function selfUpdateToLatestLocked({ profileDir, latest, registries, curlMa
|
|
|
170
110
|
let lock = lockVersion(profileDir, CONSOLE_PACKAGE)
|
|
171
111
|
let lockUpdated = installed === latest && lock === latest
|
|
172
112
|
let lockNote = null
|
|
113
|
+
let copy = null
|
|
114
|
+
let lockPatch = null
|
|
173
115
|
|
|
174
|
-
// ③ 包管理器都没成 →
|
|
116
|
+
// ③ 包管理器都没成 → 手铺文件兜底(触发条件与 0.5.102 一致:`!lockUpdated`)
|
|
175
117
|
if (!lockUpdated) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
118
|
+
// ③a 先用**已备好的 tarball 只覆盖文件、不清空目录**(pnpm 卡住/失败时那条"立刻手铺"的腿)
|
|
119
|
+
const overlay = await overlaySelfUpdateTarball({ prepared, profileDir, packageName: CONSOLE_PACKAGE, expectedVersion: latest, deps: copyDeps })
|
|
120
|
+
if (overlay.ok === true) {
|
|
121
|
+
method = method === null ? 'manual-overlay' : `${method}+manual-overlay`
|
|
122
|
+
installed = overlay.version ?? readInstalledVersion(profileDir, CONSOLE_PACKAGE)
|
|
123
|
+
copy = { channel: 'overlay', files: overlay.files, preserved: overlay.preserved, version: overlay.version ?? null, boxNote: overlay.boxNote ?? null, noClear: true, reason: null }
|
|
124
|
+
} else {
|
|
125
|
+
// ③b 没 tarball / 覆盖失败 → **既有** curl 通道(先删再铺),行为与 0.5.102 逐字一致
|
|
126
|
+
try {
|
|
127
|
+
const info = await curlManualInstall(profileDir, CONSOLE_PACKAGE, registries, null, latest)
|
|
128
|
+
method = method === null ? 'manual-copy' : `${method}+manual-copy`
|
|
129
|
+
installed = info?.version ?? readInstalledVersion(profileDir, CONSOLE_PACKAGE)
|
|
130
|
+
copy = { channel: 'curl', files: null, preserved: null, version: info?.version ?? null, boxNote: info?.boxNote ?? null, noClear: false, reason: overlay.reason ?? null }
|
|
131
|
+
} catch (error) {
|
|
132
|
+
errors.push(`手动铺文件失败:${label(error)}`)
|
|
133
|
+
copy = { channel: null, files: null, preserved: null, version: null, boxNote: null, noClear: false, reason: overlay.reason ?? null }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
lock = lockVersion(profileDir, CONSOLE_PACKAGE)
|
|
137
|
+
lockUpdated = installed === latest && lock === latest
|
|
138
|
+
// ③c 手铺成功(版本对了)但 lock 还钉在旧版本 ⇒ **必须补 lock**,否则下一次 pnpm 操作就把它还原
|
|
139
|
+
if (installed === latest && !lockUpdated) {
|
|
140
|
+
lockPatch = await patchSelfUpdateLock({
|
|
141
|
+
profileDir, packageName: CONSOLE_PACKAGE, targetVersion: latest, spec,
|
|
142
|
+
integrity: prepared.ok === true ? prepared.integrity : null,
|
|
143
|
+
section: manifestSectionOf(profileDir, CONSOLE_PACKAGE), deps: copyDeps,
|
|
144
|
+
})
|
|
180
145
|
lock = lockVersion(profileDir, CONSOLE_PACKAGE)
|
|
181
146
|
lockUpdated = installed === latest && lock === latest
|
|
182
|
-
|
|
183
|
-
errors.push(`手动铺文件失败:${label(error)}`)
|
|
147
|
+
if (lockPatch.reason !== null) errors.push(`补 pnpm-lock.yaml 未成功:${lockPatch.reason}`)
|
|
184
148
|
}
|
|
185
149
|
noteGuardStep(guard, 'manual-copy')
|
|
186
150
|
if (!lockUpdated) {
|
|
187
151
|
lockNote = `此更新未写入 pnpm-lock.yaml(lock 里仍是 ${lock ?? '未知'}):之后任何 pnpm 操作(开关插件、dsh plugin add/remove)都会把它还原成 lock 里的版本。要真正落地请执行:${command}`
|
|
152
|
+
if (lockPatch !== null && lockPatch.reason !== null) lockNote = `${lockNote}(补 lock 未成功:${lockPatch.reason})`
|
|
188
153
|
}
|
|
189
154
|
}
|
|
190
155
|
|
|
156
|
+
// 临时工作目录(预下载的 tarball / 解包目录)收尾即删;删不掉要如实记下来(本机 %TEMP% 下 rmSync 会静默落空)
|
|
157
|
+
const cleanup = disposeSelfUpdateTarball(prepared)
|
|
158
|
+
|
|
191
159
|
// 收尾:三步都走完才统一校验(全部走完仍不过才自愈),然后交给**原样搬出**的收尾段组装返回结构
|
|
192
160
|
const selfUpdate = settleSelfUpdateGuard(guard, { targetVersion: latest })
|
|
193
|
-
|
|
161
|
+
const tarball = {
|
|
162
|
+
ok: prepared.ok === true,
|
|
163
|
+
version: prepared.version ?? latest,
|
|
164
|
+
integrity: prepared.integrity ?? null,
|
|
165
|
+
tarball: prepared.tarball ?? null,
|
|
166
|
+
registry: prepared.registry ?? null,
|
|
167
|
+
reason: prepared.reason ?? null,
|
|
168
|
+
ms: prepared.ms ?? 0,
|
|
169
|
+
cleanup: { removed: cleanup.removed === true, note: cleanup.note ?? null },
|
|
170
|
+
}
|
|
171
|
+
return finishSelfUpdate({ profileDir, packageName: CONSOLE_PACKAGE, latest, spec, installed, method, command, errors, lockVersion: lock, lockUpdated, lockNote, registryRange: isRegistryRange, selfUpdate, copy, tarball, lockPatch })
|
|
194
172
|
}
|
|
195
173
|
|
|
196
174
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noob-stupid/dsh-plugin-console",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.104",
|
|
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",
|