@noob-stupid/dsh-plugin-console 0.5.106 → 0.5.108
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 +33 -4
- package/lib/server/domain/install-job.js +27 -0
- package/lib/server/domain/install-self-overlay.js +158 -0
- package/lib/server/domain/install.js +4 -0
- package/lib/server/domain/self-update-guard.js +12 -5
- package/lib/server/domain/selfupdate.js +3 -1
- package/package.json +1 -1
|
@@ -17,6 +17,8 @@ import { targetVersionOf, versionMissOf, versionMissText } from './install-versi
|
|
|
17
17
|
import { tryPresetSourceChannel } from './preset-source.js'
|
|
18
18
|
// 自更新兜底(2026-11-03 加法,见 domain/self-update-guard.js 顶部事故记录):只在"装的包名 === 本插件自己"时接管
|
|
19
19
|
import { beginSelfUpdateGuard, selfUpdateFailureText, settleSelfUpdateGuard } from './self-update-guard.js'
|
|
20
|
+
// 覆盖式安装(2026-11-04 加法,见 domain/install-self-overlay.js 顶部现场):`/install` 上"目标是自己"时**不跑通道序列**
|
|
21
|
+
import { runSelfOverlayInstall } from './install-self-overlay.js'
|
|
20
22
|
// 通用"超时/中断把目录弄坏 ⇒ 复核 + 恢复"(2026-11-04 加法,见 domain/install-recovery.js 顶部):对**所有**包生效
|
|
21
23
|
import { withInstallRecovery } from './install-recovery.js'
|
|
22
24
|
|
|
@@ -74,10 +76,33 @@ async function tryCandidateChannels(args) {
|
|
|
74
76
|
async function runSelfUpdateGuarded(args) {
|
|
75
77
|
const inner = () => tryCandidateChannelsInner(args)
|
|
76
78
|
const guardDeps = args?.selfUpdateDeps ?? {}
|
|
79
|
+
const job = args?.job !== null && args?.job !== undefined && typeof args.job === 'object' ? args.job : null
|
|
80
|
+
const namedVersion = targetVersionOf(args?.job, args?.name)
|
|
77
81
|
const session = beginSelfUpdateGuard({
|
|
78
82
|
packageName: args?.name, profileDir: args?.profileDir,
|
|
79
|
-
targetVersion:
|
|
83
|
+
targetVersion: namedVersion, deps: guardDeps,
|
|
80
84
|
})
|
|
85
|
+
// ★ 2026-11-04 加法(真机:详情页那颗「更新」走的是 `POST /install`,0.5.105 的"自更新不走 pnpm"根本没被触发):
|
|
86
|
+
// 目标 === 本插件自己 ⇒ 先走**覆盖式安装**(domain/install-self-overlay.js:下载 tarball → 只覆盖不清空 → 读回版本)。
|
|
87
|
+
// 铺成了就**到此为止**:竞速 / pnpm / curl / release / preset / git / 定向重试一条都不启动(零 pnpm、不动 lock)。
|
|
88
|
+
// 铺不成(离线 / 拿不到 tarball / 覆盖失败)⇒ 不改变任何既有语义,落回下面那段原路;别的包 `overlay === null`,
|
|
89
|
+
// 同样一个字节都不多走(负控:tests/test-install-self-overlay.mjs ④)。
|
|
90
|
+
const overlay = await runSelfOverlayInstall({
|
|
91
|
+
job: args?.job ?? null, packageName: args?.name, profileDir: args?.profileDir,
|
|
92
|
+
registries: args?.registries ?? [], deps: args?.selfOverlayDeps ?? {},
|
|
93
|
+
})
|
|
94
|
+
if (overlay !== null && overlay.ok === true) {
|
|
95
|
+
// 收尾判据与既有自更新同源(settleSelfUpdateGuard);`landed:true` 才允许它删本次备份(见那里的注释)
|
|
96
|
+
const selfUpdate = settleSelfUpdateGuard(session, { targetVersion: overlay.targetVersion ?? namedVersion, deps: guardDeps, landed: true })
|
|
97
|
+
if (job !== null) { job.selfUpdate = selfUpdate; job.selfOverlay = overlay }
|
|
98
|
+
if (selfUpdate === null || selfUpdate.verified === true) return { installedName: args?.name, lastError: null, source: null }
|
|
99
|
+
// 文件铺上了,但守卫的同一判据复核不过 ⇒ 如实失败(不在一份已经不对劲的新目录上继续施工)
|
|
100
|
+
return { installedName: null, lastError: new Error(selfUpdateFailureText(selfUpdate) ?? '自更新校验未通过(原因未知)') }
|
|
101
|
+
}
|
|
102
|
+
if (overlay !== null && job !== null) {
|
|
103
|
+
// 覆盖没成 ⇒ 如实说明"落回既有通道",否则用户看到 pnpm 起来会以为改动没生效
|
|
104
|
+
noteChannel(job, `覆盖式安装没成功(${overlay.reason ?? '原因未知'}),已回落既有通道序列`)
|
|
105
|
+
}
|
|
81
106
|
// ★ 2026-11-04 加法(见 domain/install-recovery.js 顶部现场):不是自更新 ⇒ 走**通用**恢复那一层 ——
|
|
82
107
|
// 对所有包生效(真机清空的不止控制台:用户的 dsh-workbuddy-connect 也被清空过)。
|
|
83
108
|
// 为什么二选一、不叠加:两层用的都是同一个备份后缀(`.pre-update-<ts>`),叠在一起会互相把对方
|
|
@@ -87,7 +112,7 @@ async function runSelfUpdateGuarded(args) {
|
|
|
87
112
|
const runWith = (channels) => tryCandidateChannelsInner(channels === null || channels === undefined ? args : { ...args, ch: channels })
|
|
88
113
|
return await withInstallRecovery({
|
|
89
114
|
job: args?.job ?? null, name: args?.name, profileDir: args?.profileDir,
|
|
90
|
-
targetVersion:
|
|
115
|
+
targetVersion: namedVersion, ch: args?.ch ?? null, deps: args?.recoveryDeps ?? {},
|
|
91
116
|
}, runWith)
|
|
92
117
|
}
|
|
93
118
|
let outcome = null
|
|
@@ -97,8 +122,12 @@ async function runSelfUpdateGuarded(args) {
|
|
|
97
122
|
} catch (error) {
|
|
98
123
|
thrown = error
|
|
99
124
|
}
|
|
100
|
-
|
|
101
|
-
|
|
125
|
+
// ★ 2026-11-04 改错(用户红线:备份不许在失败路径上被清理):`landed` 必须由这里**明说** ——
|
|
126
|
+
// "通道真的报了装上"才算这次落地;通道全失败 / 抛异常时目录可能原地没动,那时 verified 仍是 true,
|
|
127
|
+
// 旧判据就会把备份删掉(真机 `bak=1` → 8 秒后 `bak=0`,随后 pnpm 把目录清空 ⇒ "自动恢复失败:备份目录不存在")。
|
|
128
|
+
const landed = thrown === null && outcome !== null && outcome !== undefined && typeof outcome.installedName === 'string' && outcome.installedName !== ''
|
|
129
|
+
const selfUpdate = settleSelfUpdateGuard(session, { targetVersion: namedVersion, deps: guardDeps, landed })
|
|
130
|
+
if (job !== null) job.selfUpdate = selfUpdate
|
|
102
131
|
if (thrown !== null) throw thrown
|
|
103
132
|
if (selfUpdate !== null && selfUpdate.verified === true) return outcome
|
|
104
133
|
return { installedName: null, lastError: new Error(selfUpdateFailureText(selfUpdate) ?? '自更新校验未通过(原因未知)') }
|
|
@@ -21,6 +21,8 @@ import { verifyInstalledEntry } from './install-verify.js'
|
|
|
21
21
|
import { selfUpdateFailureText } from './self-update-guard.js'
|
|
22
22
|
// 通用超时/中断恢复(2026-11-04 加法,见 domain/install-recovery.js 顶部):「安装把目录弄坏了」的那句话由它产出
|
|
23
23
|
import { installRecoveryFailureText } from './install-recovery.js'
|
|
24
|
+
// 覆盖式安装(2026-11-04 加法,见 domain/install-self-overlay.js 顶部现场):成功时那一句如实文案由它产出
|
|
25
|
+
import { selfOverlayNote } from './install-self-overlay.js'
|
|
24
26
|
import { applyNpmNameHint, composeProbeFailureText, expandSubpackageCandidates, fetchRepoPackage, fetchRepoPackageEx, fetchSubdirPlugins, fetchSubpackageNames, npmNameHintForRepo, packageProbeErrorText, subpackageCandidates } from './market.js'
|
|
25
27
|
import { decideNoRootManifest } from './subdir-detect.js'
|
|
26
28
|
import { runSubdirInstallJob } from './subdir-install.js'
|
|
@@ -282,12 +284,37 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
282
284
|
// 包互斥闸的排队上限(domain/install-lock.js#INSTALL_LOCK_WAIT_MS 是生产默认值;
|
|
283
285
|
// 这里只是**单测注入缝**,生产调用方不传 deps.installLockWaitMs ⇒ undefined ⇒ 默认值)
|
|
284
286
|
lockWaitMs: Number.isFinite(deps.installLockWaitMs) ? deps.installLockWaitMs : undefined,
|
|
287
|
+
// 覆盖式安装("目标就是本插件自己"那条路)的**单测注入缝**:元数据/下载/解包替身由此喂进去,
|
|
288
|
+
// 生产调用方不传 ⇒ undefined ⇒ 用既有实现,行为一个字都不变(见 domain/install-self-overlay.js)
|
|
289
|
+
selfOverlayDeps: deps.selfOverlayDeps ?? undefined,
|
|
285
290
|
expand: (expanded || job.npmNameHint !== undefined) ? null : expandSubpackages,
|
|
286
291
|
})
|
|
287
292
|
installedName = attempt.installedName
|
|
288
293
|
lastError = attempt.lastError
|
|
289
294
|
if (installedName !== null) { installedSource = attempt.source ?? null; break }
|
|
290
295
|
}
|
|
296
|
+
// ★ 2026-11-04 加法(真机:详情页那颗「更新」走 /install ⇒ 0.5.105 的"自更新不走 pnpm"根本没被触发):
|
|
297
|
+
// 目标就是本插件自己、且**覆盖式安装已经铺上了** ⇒ 到此为止。下面那些收口步骤一个都不跑,因为
|
|
298
|
+
// `syncAggregateSubpackageVersions` / `ensureBundlePatchIntegrity` / `reconcileLockfile` / `ensurePersisted`
|
|
299
|
+
// 都会去动包管理器或写 lock/清单(本轮的硬要求正是"零 pnpm + lock 一个字节都不变")。
|
|
300
|
+
// `job.selfOverlay` 只由 domain/install-self-overlay.js 在**真的铺上**时写入(install-channels.js 也只在这条
|
|
301
|
+
// 路上挂它)⇒ 装别的包、以及自更新的"覆盖没成 ⇒ 落回既有通道"两条路都永远进不到这里。
|
|
302
|
+
if (installedName !== null && job.selfOverlay !== null && job.selfOverlay !== undefined) {
|
|
303
|
+
job.candidateDone = true
|
|
304
|
+
job.candidateName = installedName
|
|
305
|
+
job.packageName = installedName
|
|
306
|
+
// 只读装后校验(入口 / 补丁行 / bundle 引用):只提示,不改任何判定 —— 与既有通道同一份判据
|
|
307
|
+
job.entryCheck = verifyInstalledEntry(profileDir, installedName)
|
|
308
|
+
job.installedVersion = job.selfOverlay.version ?? job.entryCheck?.version ?? null
|
|
309
|
+
// 面板「已安装」那一行显示 curlNote;⚠️ 那句 lock 说明显示 lockNote(两点都如实,不静默)
|
|
310
|
+
const overlayNote = selfOverlayNote(job.selfOverlay)
|
|
311
|
+
if (overlayNote !== null) job.curlNote = overlayNote
|
|
312
|
+
// lock 这一栏只报**事实**:本次没写 lock(`lockVersion` 留空 = 没读/未知,绝不把目标版本冒充成 lock 里的版本)
|
|
313
|
+
job.lockUpdated = false
|
|
314
|
+
job.lockNote = job.selfOverlay.lockNote ?? null
|
|
315
|
+
job.status = 'done'
|
|
316
|
+
return
|
|
317
|
+
}
|
|
291
318
|
// 预设收口(D-③ 源码装配成功 / D-⑥ 落地物里带预设):能按预设收口就直接 return —— 见 preset-in-package.js
|
|
292
319
|
// (这两条路都**没有 node_modules 插件**,写补丁行/声明依赖会让补丁行指向无入口模块 → 启动崩溃)。
|
|
293
320
|
if (await settlePresetOutcome({ job, installedName, profileDir, repo: job.repo, deps: sourceDeps }) === true) return
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// L1 · domain —— install-self-overlay.js(`POST /install` 上「目标就是本插件自己」的覆盖式安装;2026-11-04 加法)
|
|
2
|
+
//
|
|
3
|
+
// ── 现场(真机 2026-11-04 20:21,8 秒粒度采样;用户提供的原始时间线)──────────────────────────
|
|
4
|
+
// 用户点的是「**详情页的更新按钮**」—— 那颗按钮走 `POST /install`(lib/client.js#startJob),
|
|
5
|
+
// **不是** `POST /self-update`。所以 0.5.105「自更新彻底不走 pnpm」那套改动**一次都没被触发**:
|
|
6
|
+
// 20:21:14 目录 = 空壳(1 文件) pnpm = 有 1 个在跑 备份 = 0
|
|
7
|
+
// 20:22:14 目录 = 空壳(1 文件) pnpm = 仍在跑 备份 = 0
|
|
8
|
+
// 即:`/install` 这条路上通道序列照旧先启动 pnpm,而 pnpm 一启动就**先把目标目录清空**,
|
|
9
|
+
// 本机它要 3~9 分钟且必被超时杀 ⇒ 目录一直空着;排在它后面的 curl 兜底永远轮不到。
|
|
10
|
+
//
|
|
11
|
+
// ── 本模块做什么(判据只有一个:目标包名 === 本插件包名)──────────────────────────────────
|
|
12
|
+
// 命中 ⇒ **整条通道序列(竞速 / pnpm / curl / release / preset / git / 定向重试)一个都不跑**,
|
|
13
|
+
// 改走与 `/self-update` 同一条"下载 tarball → **只覆盖、不清空** → 读回核对版本"的路:
|
|
14
|
+
// ① 目标版本:作业点名的版本(`job.version`)优先;没点名就按 registry 的 `dist-tags.latest` 探一次;
|
|
15
|
+
// ② 预下载 tarball + 覆盖解包 —— **复用** domain/selfupdate-copy.js(唯一实现,含 breakHardLinksUnder);
|
|
16
|
+
// ③ 备份 / 自愈 / 绝对路径 manualCommand —— **复用** domain/self-update-guard.js(唯一实现);
|
|
17
|
+
// ④ **pnpm-lock.yaml 一个字节都不动**(用户 2026-11-04 拍板:「不按 lock 不就行了,我们不是取消 lock 了吗」)。
|
|
18
|
+
// 理由:改 lock 是**风险来源**(写坏会波及整个 profile),而"被还原"的代价很低 —— 再点一次更新即可
|
|
19
|
+
// (覆盖式只要几秒钟)。所以本次**不写 lock、不做 lock 外科手术**,只在结果里如实记一条 `lockNote`
|
|
20
|
+
// (「本次没有改 lock,下次包管理器操作可能把版本还原」),绝不谎报"已同步"。
|
|
21
|
+
//
|
|
22
|
+
// ── 边界(别越线)────────────────────────────────────────────────────────────────────
|
|
23
|
+
// ① 只对**本插件自己**生效:`isSelfUpdate()` 不成立时 `runSelfOverlayInstall()` 直接返回 null,
|
|
24
|
+
// 调用方走原路径 —— 装别的包的通道序列、超时、版本闸、AI 兜底、清单与 lock 写入**一个字节都不变**
|
|
25
|
+
// (负控见 tests/test-install-self-overlay.mjs ④);
|
|
26
|
+
// ② 覆盖**没成**(离线 / 拿不到 tarball / 解包失败)⇒ 返回 `ok:false` 并**交还控制权**:调用方落回
|
|
27
|
+
// **既有**通道序列(语义与改动前逐字相同)。本模块绝不半路改判既有通道的行为。
|
|
28
|
+
// ③ 本模块**不判断"更新成功与否"**:落地与否由「读回的版本 === 目标版本」(覆盖那一步自己核对)
|
|
29
|
+
// + 守卫的同一判据说了算;任何一项不成立都如实报,绝不报成功。
|
|
30
|
+
// ④ 清单一族(`<profile>/package.json` 的 dependencies)本次也不改:清单与 lock 保持**互相一致**的旧状态,
|
|
31
|
+
// 比"清单写新版、lock 还写旧版"这种两处对不上的状态干净(后者正是 reconcileLockfile 报的 `misrecorded`)。
|
|
32
|
+
import { lstatSync } from 'node:fs'
|
|
33
|
+
import { join } from 'node:path'
|
|
34
|
+
import { probeRegistryPackage } from './dep-source.js'
|
|
35
|
+
import { targetVersionOf } from './install-version.js'
|
|
36
|
+
import { isSelfUpdate } from './self-update-guard.js'
|
|
37
|
+
import { disposeSelfUpdateTarball, overlaySelfUpdateTarball, prepareSelfUpdateTarball } from './selfupdate-copy.js'
|
|
38
|
+
|
|
39
|
+
/** 通道名(与 `/self-update` 那条路同一个说法:覆盖式 = manual-overlay)。 */
|
|
40
|
+
const OVERLAY_CHANNEL = 'manual-overlay'
|
|
41
|
+
|
|
42
|
+
/** 「lock 一个字节都没动」的如实说明(结果里/面板上都用这一句;**不许**改写成"已同步")。 */
|
|
43
|
+
const SELF_OVERLAY_LOCK_NOTE = '本次没有改 pnpm-lock.yaml(覆盖式安装只动 node_modules/<本插件>,清单与 lock 都保持原样):'
|
|
44
|
+
+ '之后任何一次包管理器操作(开关插件、dsh plugin add/remove)都可能把版本还原成 lock 里那一版 —— 届时在面板上再点一次更新即可(覆盖式只要几秒钟)'
|
|
45
|
+
|
|
46
|
+
/** 目录是不是链接(junction 在 lstat 上也表现为符号链接)—— 只看形状;读不到按"不是链接"处理。 */
|
|
47
|
+
function isLinkedDir(dir) {
|
|
48
|
+
try { return lstatSync(dir).isSymbolicLink() === true } catch { return false }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const labelOf = (error) => (error instanceof Error ? error.message : String(error))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 目标版本:点名的优先,其次 registry 的 `dist-tags.latest`(**只读探测**,不装任何东西)。
|
|
56
|
+
* 返回 `{ version, source, reason }`:`version:null` 时 `reason` 里有如实原因(调用方据此落回既有通道。
|
|
57
|
+
*/
|
|
58
|
+
async function resolveSelfOverlayVersion({ job = null, packageName = '', registries = [], deps = {} } = {}) {
|
|
59
|
+
const named = targetVersionOf(job, packageName)
|
|
60
|
+
if (named !== null) return { version: named, source: 'job', reason: null }
|
|
61
|
+
const list = (Array.isArray(registries) ? registries : []).filter((r) => typeof r === 'string' && r.trim() !== '')
|
|
62
|
+
if (list.length === 0) return { version: null, source: null, reason: '没有可用的软件源,无法确定目标版本' }
|
|
63
|
+
const probe = typeof deps.probeRegistry === 'function' ? deps.probeRegistry : probeRegistryPackage
|
|
64
|
+
try {
|
|
65
|
+
const found = await probe(packageName, list, { fetchJson: deps.fetchJson, timeoutMs: deps.metaTimeoutMs })
|
|
66
|
+
const latest = typeof found?.latest === 'string' && found.latest !== '' ? found.latest : null
|
|
67
|
+
if (found?.resolvable !== true || latest === null) {
|
|
68
|
+
const tried = Array.isArray(found?.tried) && found.tried.length > 0 ? `(${found.tried.join(';')})` : ''
|
|
69
|
+
return { version: null, source: null, reason: `探测不到 ${packageName} 的最新版本${tried}` }
|
|
70
|
+
}
|
|
71
|
+
return { version: latest, source: 'registry', reason: null }
|
|
72
|
+
} catch (error) {
|
|
73
|
+
return { version: null, source: null, reason: `探测最新版本失败:${labelOf(error)}` }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 覆盖式安装的**唯一入口**。
|
|
79
|
+
* 不是本插件自己 ⇒ 返回 null(调用方走原路径,一个字节都不多走);是本插件自己 ⇒ 一定返回一个报告对象:
|
|
80
|
+
* `ok:true` 已经铺上并且**读回的版本 === 目标版本**(可以就此收口,不用再碰任何通道);
|
|
81
|
+
* `ok:false` 没铺成(`reason` 里是如实原因)—— 调用方落回**既有**通道序列,语义不变。
|
|
82
|
+
* 返回结构(也直接进 `job.selfOverlay`,面板/接口都看得到;只增字段):
|
|
83
|
+
* `{ ok, channel, packageName, targetVersion, versionSource, version, files, preserved, links, boxNote,
|
|
84
|
+
* tarballNote, cleanupNote, lockTouched, lockNote, reason, ms }`。
|
|
85
|
+
*/
|
|
86
|
+
async function runSelfOverlayInstall({ job = null, packageName = '', profileDir = '', registries = [], deps = {} } = {}) {
|
|
87
|
+
if (isSelfUpdate(packageName, deps) !== true) return null
|
|
88
|
+
const startedAt = Date.now()
|
|
89
|
+
const out = {
|
|
90
|
+
ok: false, channel: OVERLAY_CHANNEL, packageName,
|
|
91
|
+
targetVersion: null, versionSource: null, version: null,
|
|
92
|
+
files: 0, preserved: 0, links: null, boxNote: null,
|
|
93
|
+
tarballNote: null, cleanupNote: null,
|
|
94
|
+
// lock 与清单**全程不动**(用户 2026-11-04 拍板):这两个字段是给用户看的、也是自检用的
|
|
95
|
+
lockTouched: false, manifestTouched: false, lockNote: SELF_OVERLAY_LOCK_NOTE,
|
|
96
|
+
reason: null, ms: 0,
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
if (typeof profileDir !== 'string' || profileDir.trim() === '') {
|
|
100
|
+
out.reason = '没有 profile 目录,无法做覆盖式安装'
|
|
101
|
+
return out
|
|
102
|
+
}
|
|
103
|
+
// ① 目标版本(点名优先,其次 registry 的 latest —— 只读探测)
|
|
104
|
+
const resolved = await resolveSelfOverlayVersion({ job, packageName, registries, deps })
|
|
105
|
+
out.targetVersion = resolved.version
|
|
106
|
+
out.versionSource = resolved.source
|
|
107
|
+
if (resolved.version === null) {
|
|
108
|
+
out.reason = resolved.reason ?? '拿不到目标版本,未做覆盖式安装'
|
|
109
|
+
return out
|
|
110
|
+
}
|
|
111
|
+
// ①′ 目标目录是**链接**(link:/junction 安装,用户自己的源码树)⇒ 不覆盖:
|
|
112
|
+
// 覆盖会顺着链接写进用户的工作区(把开发者正在改的那份源码换掉),这与 install-recovery 的
|
|
113
|
+
// 边界③同一条理由("宁可不动手,也要说清为什么不动手")。落回既有通道,一个字节都不多走。
|
|
114
|
+
if (isLinkedDir(join(String(profileDir), 'node_modules', ...String(packageName).split('/'))) === true) {
|
|
115
|
+
out.reason = `node_modules/${packageName} 是链接(link:/junction 安装,指向用户自己的目录)⇒ 不做覆盖式安装,已回落既有通道`
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
118
|
+
// ② 预下载 tarball(复用的既有实现:registry 元数据 → dist.tarball → curl 下载)
|
|
119
|
+
const prepared = await prepareSelfUpdateTarball({ packageName, version: resolved.version, registries, deps })
|
|
120
|
+
out.tarballNote = prepared.ok === true ? null : (prepared.reason ?? null)
|
|
121
|
+
// ③ **只覆盖、不清空**地铺进 node_modules/<本插件>,随后读回核对版本(同一个既有实现)
|
|
122
|
+
const overlay = await overlaySelfUpdateTarball({ prepared, profileDir, packageName, expectedVersion: resolved.version, deps })
|
|
123
|
+
const cleanup = disposeSelfUpdateTarball(prepared) // 下载物与解包目录收尾即删(删不掉要如实记)
|
|
124
|
+
out.cleanupNote = cleanup.removed === true ? null : (cleanup.note ?? null)
|
|
125
|
+
out.version = overlay.version ?? null
|
|
126
|
+
out.files = overlay.files ?? 0
|
|
127
|
+
out.preserved = overlay.preserved ?? 0
|
|
128
|
+
out.links = overlay.links ?? null
|
|
129
|
+
out.boxNote = overlay.boxNote ?? null
|
|
130
|
+
out.ok = overlay.ok === true
|
|
131
|
+
if (out.ok !== true) {
|
|
132
|
+
out.reason = overlay.reason ?? prepared.reason ?? '覆盖式安装没有成功'
|
|
133
|
+
}
|
|
134
|
+
return out
|
|
135
|
+
} catch (error) {
|
|
136
|
+
out.reason = `覆盖式安装抛错:${labelOf(error)}`
|
|
137
|
+
return out
|
|
138
|
+
} finally {
|
|
139
|
+
out.ms = Date.now() - startedAt
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 覆盖式安装成功时给用户看的那一句(**绝不静默**:铺了几个文件、原地保留几个、lock 没动)。
|
|
144
|
+
* 调用方把它塞进 `job.curlNote`(面板「已安装」那一行就显示这个字段)。 */
|
|
145
|
+
function selfOverlayNote(overlay) {
|
|
146
|
+
if (overlay === null || overlay === undefined || overlay.ok !== true) return null
|
|
147
|
+
const version = overlay.version === null || overlay.version === undefined ? '?' : String(overlay.version)
|
|
148
|
+
const parts = [`用覆盖式安装升到 v${version}(只覆盖了 ${overlay.files ?? 0} 个文件、**没有清空目录**,原地保留 ${overlay.preserved ?? 0} 个不在 tarball 里的文件;全程没有调用包管理器 pnpm)`]
|
|
149
|
+
if (typeof overlay.boxNote === 'string' && overlay.boxNote !== '') parts.push(overlay.boxNote)
|
|
150
|
+
if (overlay.links !== null && overlay.links !== undefined && (overlay.links.broken > 0 || overlay.links.failed > 0)) {
|
|
151
|
+
parts.push(`断开硬链接 ${overlay.links.broken ?? 0} 个${overlay.links.failed > 0 ? `(${overlay.links.failed} 个没断开,已原地覆盖)` : ''}`)
|
|
152
|
+
}
|
|
153
|
+
if (typeof overlay.cleanupNote === 'string' && overlay.cleanupNote !== '') parts.push(overlay.cleanupNote)
|
|
154
|
+
parts.push('重启服务后生效')
|
|
155
|
+
return parts.join(';')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export { OVERLAY_CHANNEL, SELF_OVERLAY_LOCK_NOTE, resolveSelfOverlayVersion, runSelfOverlayInstall, selfOverlayNote }
|
|
@@ -510,6 +510,10 @@ function installJobView(job) {
|
|
|
510
510
|
// 更新会出现,**非自更新作业恒 null**(既有契约一字不改)。`verified:false` ⇒ 作业必为 failed 且 job.error
|
|
511
511
|
// 已写清原因/残缺目录/手动命令;`healed:true` ⇒ 已从备份恢复成更新前的版本(那不是"更新成功")。
|
|
512
512
|
selfUpdate: job.selfUpdate ?? null,
|
|
513
|
+
// 覆盖式安装的结果(2026-11-04 加法,见 domain/install-self-overlay.js 顶部现场):**只有**
|
|
514
|
+
// "目标就是本插件自己、且真的用覆盖式铺上了"时才非 null(装别的包恒 null,既有契约一字不改)。
|
|
515
|
+
// 里面如实写明:铺了几个文件 / 原地保留几个 / lock 一个字节都没动(含那句 lockNote)。
|
|
516
|
+
selfOverlay: job.selfOverlay ?? null,
|
|
513
517
|
// 同一个包互斥的结果(2026-11-04 加法,见 domain/install-lock.js 顶部现场):`queued:true` ⇒ 本次在排队
|
|
514
518
|
// 等前一个作业(有 `waitedMs` 就能看出等了多久);`timeout:true` ⇒ 排队超时、本次**没装**(job.error 已写清)。
|
|
515
519
|
// 非并发场景恒 null(既有契约一字不改)。
|
|
@@ -250,9 +250,12 @@ function restoreSelfPackageDir(pkgDir, backupPath, deps = {}) {
|
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
252
|
} else {
|
|
253
|
-
|
|
253
|
+
// ★ 2026-11-04 加法:目录**整个不见了**(安装把它删掉/搬走)也要能从备份放回来。旧代码在这里只建父目录、
|
|
254
|
+
// mode 保持 null ⇒ 下面的复制分支被跳过 ⇒ "有备份却恢复不了"(真机 `cleanupStalePackageDir` 就是这样删的)。
|
|
255
|
+
try { mkdirSync(dir, { recursive: true }) } catch {}
|
|
256
|
+
out.mode = 'created'
|
|
254
257
|
}
|
|
255
|
-
if (out.mode === 'renamed') {
|
|
258
|
+
if (out.mode === 'renamed' || out.mode === 'created') {
|
|
256
259
|
try {
|
|
257
260
|
copy(backupPath, dir)
|
|
258
261
|
} catch (error) {
|
|
@@ -389,7 +392,7 @@ function noteGuardStep(session, step) {
|
|
|
389
392
|
* → 备份收口(最多留 KEEP_BACKUPS 份)→ 产出如实报告。session 为 null 时原样返回 null。
|
|
390
393
|
* 返回的报告就是作业里那个 `selfUpdate` 字段(只增字段,见 installJobView)。
|
|
391
394
|
*/
|
|
392
|
-
function settleSelfUpdateGuard(session, { targetVersion = undefined, deps = {} } = {}) {
|
|
395
|
+
function settleSelfUpdateGuard(session, { targetVersion = undefined, deps = {}, landed = false } = {}) {
|
|
393
396
|
if (session === null || session === undefined || typeof session !== 'object') return null
|
|
394
397
|
const io = { ...(session.deps ?? {}), ...(deps ?? {}) }
|
|
395
398
|
const expected = targetVersion === undefined
|
|
@@ -443,8 +446,12 @@ function settleSelfUpdateGuard(session, { targetVersion = undefined, deps = {} }
|
|
|
443
446
|
}
|
|
444
447
|
}
|
|
445
448
|
}
|
|
446
|
-
//
|
|
447
|
-
|
|
449
|
+
// ★ 2026-11-04 改错(用户红线「失败路径必须保留备份」;真机 `bak=1` → 8 秒后 `bak=0` 随后目录被清空):
|
|
450
|
+
// 旧判据只看 `out.verified === true`,而它只证明"目录里有一份完整的自身副本" —— 通道**全部失败、目录原地
|
|
451
|
+
// 没动**时同样成立,于是备份被当成"更新成功"删掉,紧接着 pnpm 把目录清空 ⇒「自动恢复失败:备份目录不存在」。
|
|
452
|
+
// 现在必须由调用方**明说这次真的落地了**(`landed === true` 且同一判据复核通过)才允许删本次备份;
|
|
453
|
+
// 任何失败 / 中断 / 未指定 landed 的调用都**保留**备份(保守:宁可多留一份旧版)。
|
|
454
|
+
const dropped = landed === true && out.verified === true ? dropBackupOf(out.backupPath) : null
|
|
448
455
|
const pruned = pruneSelfBackups(pkgDir, { keep: KEEP_BACKUPS, deps: io })
|
|
449
456
|
out.backupCleanup = {
|
|
450
457
|
removed: [...(dropped !== null && dropped.removed === true ? [out.backupPath] : []), ...pruned.removed],
|
|
@@ -162,7 +162,9 @@ async function selfUpdateToLatestLocked({ profileDir, latest, registries, curlMa
|
|
|
162
162
|
const cleanup = disposeSelfUpdateTarball(prepared)
|
|
163
163
|
|
|
164
164
|
// 收尾:全部动作走完才统一校验(仍不过才自愈),然后交给**原样搬出**的收尾段组装返回结构
|
|
165
|
-
|
|
165
|
+
// ★ 2026-11-04 改错:`landed` 必须显式给 —— 只有"盘上真的是目标版本"才允许守卫删掉本次备份;
|
|
166
|
+
// 通道报成功而目录原地没动(或已被掏空)时,那份备份是唯一的旧版,绝不能删(见 settleSelfUpdateGuard)。
|
|
167
|
+
const selfUpdate = settleSelfUpdateGuard(guard, { targetVersion: latest, landed: installed === latest })
|
|
166
168
|
const tarball = {
|
|
167
169
|
ok: prepared.ok === true,
|
|
168
170
|
version: prepared.version ?? latest,
|
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.108",
|
|
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",
|