@noob-stupid/dsh-plugin-console 0.5.33 → 0.5.34
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/client.js +18 -4
- package/lib/server/domain/compat-verdict.js +241 -0
- package/lib/server/domain/compat.js +84 -16
- package/lib/server/domain/framework-cleanup.js +225 -24
- package/lib/server/domain/framework-residuals.js +52 -3
- package/lib/server/domain/settings-api-scan.js +16 -1
- package/lib/server/routes/plugins.js +24 -13
- package/lib/server/routes/state.js +14 -3
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -208,6 +208,9 @@ window.__ModuleLoader__.load({
|
|
|
208
208
|
riskyEnableConfirm: "确认强行启用",
|
|
209
209
|
riskyEnableCancel: "取消",
|
|
210
210
|
adoptableDetected: "检测到已适配:当前版本 {version} 已通过源码扫描,点「已适配,立即解锁」即可启用(不会自动开启)",
|
|
211
|
+
adoptableRescinded: "判据已更新:旧版扫描给出的「不兼容」结论已撤回(当前判据下源码扫描干净,版本不变也算)。点「已适配,立即解锁」即可启用(不会自动开启)",
|
|
212
|
+
compatRescindedTag: "判据已撤回·可解锁",
|
|
213
|
+
compatRescindedHint: "这条禁用是旧判据的误报:当前判据重算后不再判「不兼容」。点上方「已适配,立即解锁」即可启用;补丁由你确认后才改。",
|
|
211
214
|
compatGateLabel: "兼容门",
|
|
212
215
|
compatGateAutoDisable: "升级时自动禁用不适配插件",
|
|
213
216
|
compatGateAutoDetect: "打开时自动检测已适配(仅提示)",
|
|
@@ -760,6 +763,9 @@ window.__ModuleLoader__.load({
|
|
|
760
763
|
riskyEnableConfirm: "Force enable",
|
|
761
764
|
riskyEnableCancel: "Cancel",
|
|
762
765
|
adoptableDetected: "Now compatible: version {version} passed the source scan — click \"Adapted - unlock now\" to enable it (never auto-enabled)",
|
|
766
|
+
adoptableRescinded: "Criteria updated: the older scan's \"incompatible\" verdict has been withdrawn (the current criteria find the source clean even without a version change). Click \"Adapted - unlock now\" to enable it (never auto-enabled)",
|
|
767
|
+
compatRescindedTag: "Verdict withdrawn - unlockable",
|
|
768
|
+
compatRescindedHint: "This disable came from a false positive of the older criteria: recomputing with the current criteria no longer says \"incompatible\". Click \"Adapted - unlock now\" above; your patch is only changed after you confirm.",
|
|
763
769
|
compatGateLabel: "Compat gate",
|
|
764
770
|
compatGateAutoDisable: "Auto-disable incompatible plugins on upgrade",
|
|
765
771
|
compatGateAutoDetect: "Detect adapted plugins on open (hint only)",
|
|
@@ -1231,9 +1237,12 @@ window.__ModuleLoader__.load({
|
|
|
1231
1237
|
// 补丁与行状态零变化 ⇒ 用户观感"点了没反应"。
|
|
1232
1238
|
// 现在只有**真的检测到已适配**(adoptable 是服务端 detectAdoptablePending 给出的对象)才出按钮;
|
|
1233
1239
|
// 其余情况不给这个承诺,原因照旧由正文那行(pendingCompatHint + checkNote)如实展示。
|
|
1240
|
+
// 0.5.34 加法:`compatRescinded`(记录里的 fail 是旧判据的误报、已被当前判据撤回)同样给一键解锁入口 ——
|
|
1241
|
+
// 这类行**没有版本变化**,旧判据要求"版本变化"会让用户被永久钉在「待适配」(真机 dshmarket 就是这样)。
|
|
1242
|
+
// 判据本身没放宽:adoptable 仍是服务端算出来的对象(真 fail 的行根本拿不到它)。
|
|
1234
1243
|
function canAdaptUnlock(entry) {
|
|
1235
1244
|
return entry !== null && entry !== undefined
|
|
1236
|
-
&& entry.pendingCompat === true
|
|
1245
|
+
&& (entry.pendingCompat === true || entry.compatRescinded === true)
|
|
1237
1246
|
&& entry.adoptable !== null && entry.adoptable !== undefined
|
|
1238
1247
|
&& typeof entry.adoptable === "object";
|
|
1239
1248
|
}
|
|
@@ -3490,7 +3499,11 @@ window.__ModuleLoader__.load({
|
|
|
3490
3499
|
entry.pendingCompat === true
|
|
3491
3500
|
? el("p", { className: styles.status, "data-pending": entry.adoptable ? "false" : "true" },
|
|
3492
3501
|
entry.adoptable
|
|
3493
|
-
?
|
|
3502
|
+
? (entry.adoptable.basis === "verdict-rescinded"
|
|
3503
|
+
// 0.5.34:旧判据的误报已被当前判据撤回(真机 dshmarket 现场)——如实说是"判据更新",
|
|
3504
|
+
// 不能说成"插件已适配"(那会让人以为作者发过新版)
|
|
3505
|
+
? t("adoptableRescinded").replace("{version}", String(entry.adoptable.version ?? "?"))
|
|
3506
|
+
: t("adoptableDetected").replace("{version}", String(entry.adoptable.version ?? "?")).replace("{prev}", String(compatInfo?.version ?? "?")))
|
|
3494
3507
|
: t("pendingCompatHint") + (compatInfo && compatInfo.checkNote ? ";已知校验:" + compatInfo.checkNote : ""))
|
|
3495
3508
|
: null,
|
|
3496
3509
|
meta !== null && meta.version ? el("div", { className: styles.rowTop },
|
|
@@ -3607,9 +3620,10 @@ window.__ModuleLoader__.load({
|
|
|
3607
3620
|
type: "button",
|
|
3608
3621
|
className: styles.toggle,
|
|
3609
3622
|
disabled: !entry.toggleable || busy === entry.entryId || entry.pendingCompat === true,
|
|
3610
|
-
|
|
3623
|
+
// 0.5.34:结论已被当前判据撤回的行不再显示「待适配」(旧判据的误报不许当事实展示)
|
|
3624
|
+
title: entry.pendingCompat === true ? (entry.compatRescinded === true ? t("compatRescindedHint") : t("pendingCompatHint")) : undefined,
|
|
3611
3625
|
onClick: () => toggle(entry, !entry.enabled),
|
|
3612
|
-
}, busy === entry.entryId ? t("loadingPhase") : (entry.pendingCompat === true ? t("pendingCompatTag") : t(entry.enabled ? "off" : "on"))),
|
|
3626
|
+
}, busy === entry.entryId ? t("loadingPhase") : (entry.pendingCompat === true ? (entry.compatRescinded === true ? t("compatRescindedTag") : t("pendingCompatTag")) : t(entry.enabled ? "off" : "on"))),
|
|
3613
3627
|
el("button", {
|
|
3614
3628
|
type: "button",
|
|
3615
3629
|
className: styles.toggle,
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// L1 · domain —— compat-verdict.js(适配门结论的**判据版本戳** + 过期结论的读取代发重算;0.5.34 加法)
|
|
2
|
+
// 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
|
|
3
|
+
//
|
|
4
|
+
// ── 真机背景(2026-10-01,用户实测)────────────────────────────────────────────────
|
|
5
|
+
// `~/.dsh/plugin-console/compat-pending.json` 里 `dshmarket` 的记录仍是
|
|
6
|
+
// `check:"fail"` + `checkNote:"源码仍引用 0.1.2 起已删除的 dsh-settings API(…)——实际不兼容…"`
|
|
7
|
+
// —— 那是 **0.5.33 收紧判据之前**的误报结论(真机复核:现在 0 命中)。面板照旧把它当事实展示
|
|
8
|
+
// (「实际不兼容,启用会让整个服务启动崩溃」+「待适配」永久钉住),**持续误导用户**。
|
|
9
|
+
//
|
|
10
|
+
// ── 本模块只做两件事(**不放宽任何判据**)──────────────────────────────────────────
|
|
11
|
+
// ① 给结论打**判据版本戳**:记录里的 `scanVerdict.version` 标明"这条结论是哪一版判据算的"
|
|
12
|
+
// (戳来自 settings-api-scan.js 的 `SCAN_CRITERIA_VERSION`,判据改一次就要改它)。
|
|
13
|
+
// ② **读取代发**:读清单时若记录的戳落后于当前判据 → 用**当前判据只读重算**
|
|
14
|
+
// · 结论变了 → 回写(`check` / `checkNote` / `scanVerdict`,含 previous + rescinded + 来源证据)
|
|
15
|
+
// · 结论没变 → **零写盘**(幂等;同一条记录不会每次读都写盘)
|
|
16
|
+
// · 包解析不到 → 不猜:如实标注"无法复核"(保留旧结论,绝不假装成当前判据的结论)
|
|
17
|
+
// `fail` 永远不会因为"戳旧"就被改成 `pass` —— 只有**真的用当前判据重算**出非 fail 才算撤回;
|
|
18
|
+
// 真不兼容的(重算仍 fail)一个字都不动。
|
|
19
|
+
//
|
|
20
|
+
// 行数预算:独立模块,留在 600 行架构硬顶内。
|
|
21
|
+
|
|
22
|
+
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
23
|
+
import { dirname, join } from 'node:path'
|
|
24
|
+
import { dshHome } from '../infra/paths.js'
|
|
25
|
+
import { SCAN_CRITERIA_VERSION } from './settings-api-scan.js'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 进程内重算备忘(纯性能,不影响判据):"结论未变 ⇒ 不写盘"意味着记录上的判据戳**一直是旧的**,
|
|
29
|
+
* 于是每次 `GET /state`(面板每隔几秒轮询一次)都会对同一批包重跑源码扫描(单包上限 120 文件 / 400KB)。
|
|
30
|
+
* 键含包路径 + 版本 + package.json 的 mtime:包一变(升级/改源码)就重算,包没变就复用。
|
|
31
|
+
*/
|
|
32
|
+
const VERDICT_MEMO = new Map()
|
|
33
|
+
const MEMO_LIMIT = 200
|
|
34
|
+
|
|
35
|
+
/** 判据版本戳(当前判据的版本;判据变了就跟着 settings-api-scan.js 一起变)。 */
|
|
36
|
+
const SCANNER_VERSION = SCAN_CRITERIA_VERSION
|
|
37
|
+
|
|
38
|
+
/** 适配门清单文件(原在 compat.js;0.5.34 搬到本模块,供"读取代发"一处收口)。 */
|
|
39
|
+
const compatPendingFile = () => join(dshHome(), 'plugin-console', 'compat-pending.json')
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 「这条禁用记录是我们自己写进去的」判据(纯函数)。
|
|
43
|
+
* 只有这类记录才允许在**误报被撤回**时给用户一键解锁入口 —— 用户手写的禁用块不在此列。
|
|
44
|
+
*/
|
|
45
|
+
const SELF_INFLICTED_SOURCES = new Set([
|
|
46
|
+
'preflight-disabled-before-upgrade', // 框架升级前的预扫(dshmarket 就是这条)
|
|
47
|
+
'boot-quarantine', // 启动失败隔离
|
|
48
|
+
'auto-preflight', // 指纹变化自动预检
|
|
49
|
+
])
|
|
50
|
+
function isSelfInflictedSource(source) {
|
|
51
|
+
return typeof source === 'string' && SELF_INFLICTED_SOURCES.has(source)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 记录上的判据戳(缺失 = 旧时代结论:必须重算)。 */
|
|
55
|
+
function stampOf(record) {
|
|
56
|
+
const s = record?.scanVerdict
|
|
57
|
+
if (s === null || typeof s !== 'object') return { version: null, decision: null, reason: null, at: null, rescinded: false, evidence: null }
|
|
58
|
+
return {
|
|
59
|
+
version: typeof s.version === 'string' ? s.version : null,
|
|
60
|
+
decision: typeof s.decision === 'string' ? s.decision : null,
|
|
61
|
+
reason: typeof s.reason === 'string' ? s.reason : null,
|
|
62
|
+
at: Number.isFinite(s.at) ? s.at : null,
|
|
63
|
+
rescinded: s.rescinded === true,
|
|
64
|
+
evidence: typeof s.evidence === 'string' ? s.evidence : null,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 这条记录的结论是不是"旧判据算的"(纯函数)。 */
|
|
69
|
+
function isStaleVerdict(record, version = SCANNER_VERSION) {
|
|
70
|
+
return stampOf(record).version !== version
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 纯判据:给一组记录算出「哪些结论过期、用当前判据重算后是什么」(IO 全由 `recompute` 注入)。
|
|
75
|
+
* `recompute(record)` → `{ decision, reason }`;解析不到包时返回 `{ unresolved: '原因' }`。
|
|
76
|
+
* 返回每条的 `{ rowId, status, decision, reason, storedDecision, storedNote, rescinded, evidence }`,
|
|
77
|
+
* status ∈ fresh(戳已是最新,无需重算)/ updated(结论变了 → 调用方回写)/ unchanged(结论没变 → **零写盘**)
|
|
78
|
+
* / unresolved(复核不了 → 如实标注,保留旧结论)。
|
|
79
|
+
*/
|
|
80
|
+
function planVerdictRefresh(records, { version = SCANNER_VERSION, recompute } = {}) {
|
|
81
|
+
const out = []
|
|
82
|
+
for (const rec of (Array.isArray(records) ? records : [])) {
|
|
83
|
+
if (rec === null || typeof rec !== 'object' || typeof rec.rowId !== 'string' || rec.rowId === '') continue
|
|
84
|
+
const storedDecision = typeof rec.check === 'string' && rec.check !== '' ? rec.check : null
|
|
85
|
+
const storedNote = typeof rec.checkNote === 'string' ? rec.checkNote : null
|
|
86
|
+
const stamp = stampOf(rec)
|
|
87
|
+
if (stamp.version === version) {
|
|
88
|
+
out.push({ rowId: rec.rowId, status: 'fresh', decision: storedDecision, reason: stamp.reason ?? storedNote, storedDecision, storedNote, rescinded: stamp.rescinded, evidence: stamp.evidence, version })
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
// 没给重算器(或明确要求只读)→ 不猜:标成"过期、未复核"
|
|
92
|
+
if (typeof recompute !== 'function') {
|
|
93
|
+
out.push({ rowId: rec.rowId, status: 'stale', decision: storedDecision, reason: storedNote, storedDecision, storedNote, rescinded: false, evidence: null, version: null })
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
let next = null
|
|
97
|
+
try {
|
|
98
|
+
next = recompute(rec)
|
|
99
|
+
} catch (error) {
|
|
100
|
+
next = { unresolved: String(error?.message ?? error) }
|
|
101
|
+
}
|
|
102
|
+
if (next === null || next === undefined) next = { unresolved: '重算没有返回结论' }
|
|
103
|
+
if (typeof next.unresolved === 'string' && next.unresolved !== '') {
|
|
104
|
+
out.push({ rowId: rec.rowId, status: 'unresolved', decision: storedDecision, reason: storedNote, storedDecision, storedNote, rescinded: false, evidence: null, version: null, unresolved: next.unresolved })
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
const decision = typeof next.decision === 'string' && next.decision !== '' ? next.decision : 'unknown'
|
|
108
|
+
const reason = typeof next.reason === 'string' ? next.reason : null
|
|
109
|
+
out.push({
|
|
110
|
+
rowId: rec.rowId,
|
|
111
|
+
status: decision === storedDecision ? 'unchanged' : 'updated',
|
|
112
|
+
decision,
|
|
113
|
+
reason,
|
|
114
|
+
storedDecision,
|
|
115
|
+
storedNote,
|
|
116
|
+
// 「旧判据判 fail,当前判据不再 fail」= 误报被撤回(这是唯一允许解除"待适配"的情形)
|
|
117
|
+
rescinded: storedDecision === 'fail' && decision !== 'fail',
|
|
118
|
+
evidence: isSelfInflictedSource(rec.source) ? String(rec.source) : null,
|
|
119
|
+
version,
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
return out
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 读取代发(IO 版):把 `planVerdictRefresh` 的结论落到记录上,**结论变了才写盘**(写后读回核实)。
|
|
127
|
+
* `deps`:`{ resolvePkg, readPkg, check, writeText, readText, now }` —— 全部可注入(离线测试)。
|
|
128
|
+
* 返回 `{ version, entries, updated, unchanged, unresolved, fresh, wrote, writeError, file }`。
|
|
129
|
+
*/
|
|
130
|
+
function refreshCompatPendingVerdicts({ pending, baseDir, file = null, deps = {} } = {}) {
|
|
131
|
+
const {
|
|
132
|
+
resolvePkg, readPkg, check, now = () => Date.now(), stat = statSync,
|
|
133
|
+
readText = (p) => readFileSync(p, 'utf8'), writeText = (p, data) => writeFileSync(p, data, 'utf8'),
|
|
134
|
+
memo = VERDICT_MEMO,
|
|
135
|
+
} = deps
|
|
136
|
+
const target = file ?? compatPendingFile()
|
|
137
|
+
const records = Array.isArray(pending?.pending) ? pending.pending : []
|
|
138
|
+
const fwVer = typeof pending?.frameworkVersion === 'string' ? pending.frameworkVersion : null
|
|
139
|
+
const recompute = (rec) => {
|
|
140
|
+
if (typeof rec.moduleName !== 'string' || rec.moduleName === '') return { unresolved: '记录里没有 moduleName(无法定位已装包)' }
|
|
141
|
+
if (typeof resolvePkg !== 'function' || typeof readPkg !== 'function' || typeof check !== 'function') return { unresolved: '读取代发缺少解析器(内部接线问题)' }
|
|
142
|
+
let pkgPath = null
|
|
143
|
+
try { pkgPath = resolvePkg(rec.moduleName, baseDir) } catch (error) { return { unresolved: '解析包失败:' + String(error?.message ?? error) } }
|
|
144
|
+
if (pkgPath === null || pkgPath === undefined) return { unresolved: `本机解析不到 ${rec.moduleName}` }
|
|
145
|
+
let pkg = null
|
|
146
|
+
try { pkg = readPkg(pkgPath) } catch (error) { return { unresolved: '读取包信息失败:' + String(error?.message ?? error) } }
|
|
147
|
+
if (pkg === null || pkg === undefined) return { unresolved: '读取包信息失败' }
|
|
148
|
+
const mtimeMs = (() => { try { return stat(pkgPath).mtimeMs } catch { return 0 } })()
|
|
149
|
+
const key = `${rec.rowId}|${pkgPath}|${pkg.version ?? ''}|${mtimeMs}|${SCANNER_VERSION}`
|
|
150
|
+
if (memo instanceof Map && memo.has(key)) return memo.get(key)
|
|
151
|
+
const verdict = check(pkg, fwVer ?? '?', dirname(pkgPath))
|
|
152
|
+
const out = { decision: verdict?.decision ?? 'unknown', reason: verdict?.reason ?? null }
|
|
153
|
+
if (memo instanceof Map) {
|
|
154
|
+
if (memo.size >= MEMO_LIMIT) memo.clear()
|
|
155
|
+
memo.set(key, out)
|
|
156
|
+
}
|
|
157
|
+
return out
|
|
158
|
+
}
|
|
159
|
+
const entries = planVerdictRefresh(records, { recompute })
|
|
160
|
+
const byRow = new Map(entries.map((e) => [e.rowId, e]))
|
|
161
|
+
const updated = []
|
|
162
|
+
const at = now()
|
|
163
|
+
for (const rec of records) {
|
|
164
|
+
const e = byRow.get(rec?.rowId)
|
|
165
|
+
if (e === undefined || e.status !== 'updated') continue
|
|
166
|
+
const previous = { decision: e.storedDecision, note: e.storedNote }
|
|
167
|
+
rec.check = e.decision
|
|
168
|
+
rec.checkNote = e.reason
|
|
169
|
+
rec.scanVerdict = { version: SCANNER_VERSION, decision: e.decision, reason: e.reason, at, previous, rescinded: e.rescinded, evidence: e.evidence }
|
|
170
|
+
rec.verdictUpdatedAt = at
|
|
171
|
+
updated.push({ rowId: e.rowId, from: e.storedDecision, to: e.decision, rescinded: e.rescinded, evidence: e.evidence })
|
|
172
|
+
}
|
|
173
|
+
const result = {
|
|
174
|
+
version: SCANNER_VERSION,
|
|
175
|
+
file: target,
|
|
176
|
+
entries,
|
|
177
|
+
updated,
|
|
178
|
+
unchanged: entries.filter((e) => e.status === 'unchanged').map((e) => e.rowId),
|
|
179
|
+
unresolved: entries.filter((e) => e.status === 'unresolved').map((e) => ({ rowId: e.rowId, reason: e.unresolved })),
|
|
180
|
+
fresh: entries.filter((e) => e.status === 'fresh').map((e) => e.rowId),
|
|
181
|
+
wrote: false,
|
|
182
|
+
writeError: null,
|
|
183
|
+
}
|
|
184
|
+
if (updated.length === 0) return result // ② 结论未变(或无可重算)→ **零写盘**
|
|
185
|
+
const payload = { ...pending, pending: records, updatedAt: new Date(at).toISOString() }
|
|
186
|
+
try {
|
|
187
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
188
|
+
writeText(target, JSON.stringify(payload, null, 2), 'utf8')
|
|
189
|
+
} catch (error) {
|
|
190
|
+
result.writeError = '回写适配门清单失败:' + String(error?.message ?? error)
|
|
191
|
+
return result
|
|
192
|
+
}
|
|
193
|
+
// 读回核实:只有真的落盘且内容一致才算写成功(绝不谎报"已更正")
|
|
194
|
+
try {
|
|
195
|
+
const back = JSON.parse(readText(target))
|
|
196
|
+
const rec = (back?.pending ?? []).find((p) => p.rowId === updated[0].rowId)
|
|
197
|
+
const ok = rec !== undefined && rec.check === updated[0].to && stampOf(rec).version === SCANNER_VERSION
|
|
198
|
+
if (!ok) result.writeError = '回写后读回内容与期望不一致(结论没真正落盘)'
|
|
199
|
+
} catch (error) {
|
|
200
|
+
result.writeError = '回写后读回失败:' + String(error?.message ?? error)
|
|
201
|
+
}
|
|
202
|
+
result.wrote = result.writeError === null
|
|
203
|
+
return result
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 一条记录对外的"结论视图"(面板/门控摘要用):当前判据下的结论 + 是否过期 + 是否已撤回。
|
|
208
|
+
* `entry` = `refreshCompatPendingVerdicts()` 的 `entries` 里对应那条(可省 → 只看记录上的判据戳)。
|
|
209
|
+
*/
|
|
210
|
+
function verdictView(record, entry = null) {
|
|
211
|
+
const stamp = stampOf(record)
|
|
212
|
+
const status = entry?.status ?? (stamp.version === SCANNER_VERSION ? 'fresh' : 'stale')
|
|
213
|
+
const evidence = entry?.evidence ?? stamp.evidence ?? (isSelfInflictedSource(record?.source) ? String(record.source) : null)
|
|
214
|
+
if (status === 'updated') {
|
|
215
|
+
return { decision: entry.decision, reason: entry.reason, version: entry.version, stale: false, rescinded: entry.rescinded === true, evidence, unresolved: null, status, previous: { decision: entry.storedDecision, note: entry.storedNote } }
|
|
216
|
+
}
|
|
217
|
+
if (status === 'fresh') {
|
|
218
|
+
return { decision: stamp.decision ?? (typeof record?.check === 'string' ? record.check : null), reason: stamp.reason ?? (typeof record?.checkNote === 'string' ? record.checkNote : null), version: stamp.version, stale: false, rescinded: stamp.rescinded, evidence, unresolved: null, status }
|
|
219
|
+
}
|
|
220
|
+
if (status === 'unchanged') {
|
|
221
|
+
// 结论与旧判据一致(已用当前判据复核过,但**不写盘**):结论可信,判据戳仍是旧的
|
|
222
|
+
return { decision: entry.decision, reason: entry.reason, version: entry.version, stale: true, rescinded: false, evidence, unresolved: null, status }
|
|
223
|
+
}
|
|
224
|
+
if (status === 'unresolved') {
|
|
225
|
+
return { decision: entry.decision, reason: entry.reason, version: null, stale: true, rescinded: false, evidence: null, unresolved: entry.unresolved, status }
|
|
226
|
+
}
|
|
227
|
+
// 'stale':本轮没有重算(没给基准/只读)→ 如实标"旧判据、未复核"
|
|
228
|
+
return { decision: stamp.decision ?? (typeof record?.check === 'string' ? record.check : null), reason: stamp.reason ?? (typeof record?.checkNote === 'string' ? record.checkNote : null), version: stamp.version, stale: true, rescinded: stamp.rescinded, evidence, unresolved: null, status: 'stale' }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export {
|
|
232
|
+
SCANNER_VERSION,
|
|
233
|
+
SELF_INFLICTED_SOURCES,
|
|
234
|
+
compatPendingFile,
|
|
235
|
+
isSelfInflictedSource,
|
|
236
|
+
isStaleVerdict,
|
|
237
|
+
planVerdictRefresh,
|
|
238
|
+
refreshCompatPendingVerdicts,
|
|
239
|
+
stampOf,
|
|
240
|
+
verdictView,
|
|
241
|
+
}
|
|
@@ -13,6 +13,8 @@ import { listEntries } from './runtime.js'
|
|
|
13
13
|
import { fetchJsonUrl } from '../infra/http.js'
|
|
14
14
|
import { dshHome, findPatchPath, pluginRoot, profileDirOf, resolvePackageJson } from '../infra/paths.js'
|
|
15
15
|
import { semverRangeMatch, semverRangeMatchLoose } from '../infra/semver.js'
|
|
16
|
+
// 0.5.34 加法:适配门结论的**判据版本戳** + 过期结论的读取代发重算(见该模块头注释)。
|
|
17
|
+
import { SCANNER_VERSION, compatPendingFile, isSelfInflictedSource, planVerdictRefresh, refreshCompatPendingVerdicts, verdictView } from './compat-verdict.js'
|
|
16
18
|
// 0.5.33 改错:已删除 API 的源码扫描搬进独立 domain 模块(判据收紧为"真的从 dsh-settings 绑定进来")。
|
|
17
19
|
// 这三个名字继续由本模块导出,对外契约不变。
|
|
18
20
|
import { REMOVED_SETTINGS_SYMBOLS, referencesRemovedSymbol, scanSettingsApiUsage } from './settings-api-scan.js'
|
|
@@ -82,8 +84,6 @@ function checkPluginFrameworkCompat(pkg, frameworkVersion, pkgDir = null) {
|
|
|
82
84
|
// 支持 0.1.x 系列(0.1.0 / 0.1.1 等);0.2 / 1.0 等破坏性大版本才标记不支持
|
|
83
85
|
const SUPPORTED_WEB_APP_PATTERN = /^0\.1\.\d+/u
|
|
84
86
|
|
|
85
|
-
const COMPAT_PENDING_FILE = () => join(dshHome(), 'plugin-console', 'compat-pending.json')
|
|
86
|
-
|
|
87
87
|
/**
|
|
88
88
|
* 启用前冒烟检查(服务永不崩机制):在独立子进程中动态 import 插件主模块,
|
|
89
89
|
* 捕获 loader 会遇到的 import/resolution 错误(SyntaxError、缺失导出、模块缺失)。
|
|
@@ -126,7 +126,49 @@ function probePluginImport(moduleName, profileDir) {
|
|
|
126
126
|
})
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
/**
|
|
129
|
+
/** 适配门清单文件(0.5.34 起由 compat-verdict.js 定义,供"读取代发"一处收口)。 */
|
|
130
|
+
const COMPAT_PENDING_FILE = compatPendingFile
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 读取框架升级适配门清单(compat-pending.json);损坏/缺失返回 `{ pending: null, ... }`。
|
|
134
|
+
*
|
|
135
|
+
* 0.5.34 加法(**读取代发**):给 `baseDir`(当前 profile 目录)时,对**判据戳落后**的记录
|
|
136
|
+
* 用当前判据只读重算:结论变了才回写(幂等:结论不变**零写盘**),解析不到包就如实标注"无法复核"。
|
|
137
|
+
* 真不兼容的(重算仍 fail)一个字都不动 —— 这条只解决"过期结论仍当事实展示",不放宽任何判据。
|
|
138
|
+
* 返回 `{ pending, refresh, verdicts }`:`verdicts` = rowId → 结论视图(面板/门控摘要用)。
|
|
139
|
+
*/
|
|
140
|
+
function readCompatPendingWithVerdicts({ baseDir = null, deps = {} } = {}) {
|
|
141
|
+
let raw = null
|
|
142
|
+
try { raw = JSON.parse(readFileSync(COMPAT_PENDING_FILE(), 'utf8')) } catch { return { pending: null, refresh: null, verdicts: new Map() } }
|
|
143
|
+
if (raw === null || typeof raw !== 'object') return { pending: null, refresh: null, verdicts: new Map() }
|
|
144
|
+
let refresh = null
|
|
145
|
+
if (baseDir !== null && baseDir !== undefined && Array.isArray(raw.pending) && raw.pending.length > 0) {
|
|
146
|
+
try {
|
|
147
|
+
refresh = refreshCompatPendingVerdicts({
|
|
148
|
+
pending: raw,
|
|
149
|
+
baseDir,
|
|
150
|
+
deps: {
|
|
151
|
+
resolvePkg: (name, base) => resolvePackageJson(name, base),
|
|
152
|
+
readPkg: (p) => JSON.parse(readFileSync(p, 'utf8')),
|
|
153
|
+
check: checkPluginFrameworkCompat,
|
|
154
|
+
...deps,
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
} catch (error) {
|
|
158
|
+
// 复核本身出问题也必须如实回报(不静默):结论按记录原样展示,附上失败原因
|
|
159
|
+
refresh = { version: SCANNER_VERSION, entries: [], updated: [], unchanged: [], unresolved: [], fresh: [], wrote: false, writeError: '读取代发重算失败:' + String(error?.message ?? error), file: COMPAT_PENDING_FILE() }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const entries = Array.isArray(refresh?.entries) ? refresh.entries : planVerdictRefresh(raw.pending)
|
|
163
|
+
const verdicts = new Map()
|
|
164
|
+
for (const rec of raw.pending) {
|
|
165
|
+
if (rec === null || typeof rec !== 'object' || typeof rec.rowId !== 'string') continue
|
|
166
|
+
verdicts.set(rec.rowId, verdictView(rec, entries.find((e) => e.rowId === rec.rowId) ?? null))
|
|
167
|
+
}
|
|
168
|
+
return { pending: raw, refresh, verdicts }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 读取清单(原契约不变:只读、不做复核)。需要"读取代发"的路径用 readCompatPendingWithVerdicts。 */
|
|
130
172
|
function readCompatPending() {
|
|
131
173
|
try { return JSON.parse(readFileSync(COMPAT_PENDING_FILE(), 'utf8')) } catch { return null }
|
|
132
174
|
}
|
|
@@ -283,15 +325,18 @@ async function detectCompat(baseUrl) {
|
|
|
283
325
|
}
|
|
284
326
|
|
|
285
327
|
/** 待适配行的「现在是否已适配」检测(只提示,不自动解锁——用户定案:我点才开)。
|
|
286
|
-
* 返回 Map<rowId, {version, check, note}
|
|
328
|
+
* 返回 Map<rowId, {version, check, note, basis}>:可解锁有两种**互不放松**的来路 ——
|
|
329
|
+
* ① `version-changed`:插件真的更新过(版本 ≠ 记录版本)且源码扫描不再 fail(原判据,不变);
|
|
330
|
+
* ② `verdict-rescinded`(0.5.34 加法):记录里的 `fail` 是**旧判据的误报**,当前判据重算后不再 fail。
|
|
331
|
+
* 真不兼容(重算仍 fail)**一个都不放行**;②也不需要"版本变化"——否则用户会被永久钉在「待适配」。 */
|
|
287
332
|
function detectAdoptablePending(ports) {
|
|
288
333
|
const out = new Map()
|
|
289
|
-
|
|
334
|
+
let profileDir = null
|
|
335
|
+
try { profileDir = dirname(findPatchPath(ports)) } catch { return out }
|
|
336
|
+
const { pending, verdicts } = readCompatPendingWithVerdicts({ baseDir: profileDir })
|
|
290
337
|
if (pending === null) return out
|
|
291
338
|
const fwVer = typeof pending.frameworkVersion === 'string' ? pending.frameworkVersion : null
|
|
292
339
|
if (fwVer === null) return out
|
|
293
|
-
let profileDir = null
|
|
294
|
-
try { profileDir = dirname(findPatchPath(ports)) } catch { return out }
|
|
295
340
|
for (const p of (pending.pending ?? []).filter((x) => (x.status ?? 'pending') === 'pending')) {
|
|
296
341
|
try {
|
|
297
342
|
const pkgPath = resolvePackageJson(p.moduleName, profileDir)
|
|
@@ -300,9 +345,18 @@ function detectAdoptablePending(ports) {
|
|
|
300
345
|
const version = typeof pkg.version === 'string' ? pkg.version : null
|
|
301
346
|
const changed = version !== null && version !== p.version
|
|
302
347
|
const check = checkPluginFrameworkCompat(pkg, fwVer, dirname(pkgPath))
|
|
303
|
-
if (
|
|
304
|
-
|
|
305
|
-
|
|
348
|
+
if (check.decision === 'fail') continue
|
|
349
|
+
const view = verdicts.get(p.rowId) ?? null
|
|
350
|
+
const rescinded = view?.rescinded === true
|
|
351
|
+
if (!changed && !rescinded) continue
|
|
352
|
+
out.set(p.rowId, {
|
|
353
|
+
version,
|
|
354
|
+
check: check.decision,
|
|
355
|
+
note: check.reason ?? null,
|
|
356
|
+
basis: changed ? 'version-changed' : 'verdict-rescinded',
|
|
357
|
+
previousVerdict: view?.storedDecision ?? null,
|
|
358
|
+
evidence: view?.evidence ?? null,
|
|
359
|
+
})
|
|
306
360
|
} catch {}
|
|
307
361
|
}
|
|
308
362
|
return out
|
|
@@ -317,8 +371,11 @@ function detectAdoptablePending(ports) {
|
|
|
317
371
|
*
|
|
318
372
|
* pending —— 当前被拦下/待解锁的行(含原因、来源、是否已通过源码扫描可解锁)
|
|
319
373
|
* adopted —— 曾经被拦、现已适配解锁的行(让用户能核对"到底动过哪些行")
|
|
374
|
+
*
|
|
375
|
+
* 0.5.34 加法:每行多带 `verdict`(当前判据下的结论视图:`stale` / `rescinded` / `unresolved`)——
|
|
376
|
+
* 旧判据的结论不许再被当成事实展示;**复核不了**(包解析不到)的行在 `note` 上如实标注。
|
|
320
377
|
*/
|
|
321
|
-
function gatingSummary(ports, compatPending, ctxEntries = null) {
|
|
378
|
+
function gatingSummary(ports, compatPending, ctxEntries = null, verdicts = null) {
|
|
322
379
|
const rec = compatPending ?? { frameworkVersion: null, upgradeFrom: null, pending: [] }
|
|
323
380
|
const all = Array.isArray(rec.pending) ? rec.pending : []
|
|
324
381
|
let info = new Map()
|
|
@@ -329,11 +386,14 @@ function gatingSummary(ports, compatPending, ctxEntries = null) {
|
|
|
329
386
|
for (const e of Array.isArray(ctxEntries) ? ctxEntries : []) {
|
|
330
387
|
if (typeof e?.rowId === 'string' && e.rowId !== '') byRow.set(e.rowId, e)
|
|
331
388
|
}
|
|
389
|
+
const byVerdict = verdicts instanceof Map ? verdicts : new Map()
|
|
332
390
|
const row = (p) => {
|
|
333
391
|
const meta = info.get(p.rowId) ?? null
|
|
334
392
|
const entry = byRow.get(p.rowId) ?? null
|
|
335
393
|
const moduleName = p.moduleName ?? meta?.moduleName ?? entry?.moduleName ?? null
|
|
336
394
|
const name = moduleName ?? p.rowId ?? '(未知行)'
|
|
395
|
+
const verdict = byVerdict.get(p.rowId) ?? verdictView(p)
|
|
396
|
+
const note = p.checkNote ?? null
|
|
337
397
|
return {
|
|
338
398
|
rowId: p.rowId ?? null,
|
|
339
399
|
name,
|
|
@@ -342,12 +402,16 @@ function gatingSummary(ports, compatPending, ctxEntries = null) {
|
|
|
342
402
|
version: p.version ?? meta?.version ?? entry?.version ?? null,
|
|
343
403
|
status: p.status ?? 'pending',
|
|
344
404
|
check: p.check ?? null,
|
|
345
|
-
|
|
405
|
+
// 复核不了的行:**如实标注**"旧判据结论、未复核"(不许继续当事实;也不猜新结论)
|
|
406
|
+
note: verdict.unresolved === null || verdict.unresolved === undefined
|
|
407
|
+
? note
|
|
408
|
+
: `${note ?? ''}(旧判据结论,未能复核:${verdict.unresolved})`,
|
|
346
409
|
source: p.source ?? null,
|
|
347
410
|
adoptedAt: p.adoptedAt ?? null,
|
|
348
411
|
forcedAt: p.forcedAt ?? null,
|
|
349
412
|
adoptedBy: p.adoptedBy ?? null,
|
|
350
413
|
enabled: entry === null ? null : entry.enabled === true,
|
|
414
|
+
verdict,
|
|
351
415
|
}
|
|
352
416
|
}
|
|
353
417
|
const isPending = (p) => (p.status ?? 'pending') === 'pending'
|
|
@@ -391,9 +455,10 @@ function rowIdModuleMap(ports) {
|
|
|
391
455
|
/**
|
|
392
456
|
* 框架升级适配门:安装/更新完成后自动校验兼容性,
|
|
393
457
|
* 通过(版本已变化 + 未在声明/依赖层面明确不兼容)则移除补丁禁用块并解锁启用。
|
|
458
|
+
* 0.5.34:走**读取代发**(`profileDir` 已知)—— 顺手把过期判据的结论复核/回写,避免旧误报继续挂在这里。
|
|
394
459
|
*/
|
|
395
460
|
async function maybeAutoAdaptCompat({ profileDir, packageName, syncedNames, patchPath, ports }) {
|
|
396
|
-
const pending =
|
|
461
|
+
const { pending } = readCompatPendingWithVerdicts({ baseDir: profileDir })
|
|
397
462
|
if (!pending || !Array.isArray(pending.pending)) return { ran: false }
|
|
398
463
|
const interested = new Set([packageName, ...(syncedNames ?? [])].filter((n) => n !== null))
|
|
399
464
|
const targets = pending.pending.filter((p) => (p.status ?? 'pending') === 'pending' && interested.has(p.moduleName))
|
|
@@ -438,7 +503,7 @@ async function maybeAutoAdaptCompat({ profileDir, packageName, syncedNames, patc
|
|
|
438
503
|
/** AI 赋能框架适配预检:查 registry 最新版声明 + 兼容门清单,给出权威说明。 */
|
|
439
504
|
async function frameworkCompatReportFor(source, ports, profileDir) {
|
|
440
505
|
const fwVer = currentFrameworkVersion(ports)
|
|
441
|
-
const pending =
|
|
506
|
+
const { pending, verdicts } = readCompatPendingWithVerdicts({ baseDir: profileDir })
|
|
442
507
|
const norm = String(source ?? '').trim()
|
|
443
508
|
const isNpm = norm.startsWith('@') || !norm.includes('/')
|
|
444
509
|
const installed = (() => {
|
|
@@ -448,7 +513,10 @@ async function frameworkCompatReportFor(source, ports, profileDir) {
|
|
|
448
513
|
return JSON.parse(readFileSync(pkgPath, 'utf8')).version ?? null
|
|
449
514
|
} catch { return null }
|
|
450
515
|
})()
|
|
451
|
-
|
|
516
|
+
// 命中行带上当前判据的结论视图(`fail` 仍然是 fail;过期/复核不了会如实标注)
|
|
517
|
+
const pendHits = (pending?.pending ?? [])
|
|
518
|
+
.filter((p) => (p.status ?? 'pending') === 'pending' && p.moduleName === norm)
|
|
519
|
+
.map((p) => ({ ...p, verdict: verdicts.get(p.rowId) ?? verdictView(p) }))
|
|
452
520
|
if (!isNpm) {
|
|
453
521
|
return {
|
|
454
522
|
kind: 'repo', frameworkVersion: fwVer, packageName: null, latest: null, check: null,
|
|
@@ -487,4 +555,4 @@ async function frameworkCompatReportFor(source, ports, profileDir) {
|
|
|
487
555
|
summary: parts.length > 0 ? parts.join(';') : `框架 ${fwVer},无待适配记录`,
|
|
488
556
|
}
|
|
489
557
|
}
|
|
490
|
-
export { gatingSummary, COMPAT_PENDING_FILE, COMPAT_GATE_FILE, COMPAT_ACK_FILE, COMPAT_GATE_DEFAULTS, NOTICE_MODES, DEFAULT_NOTICE_MODE, REMOVED_SETTINGS_SYMBOLS, FIBER_STATE, FIBER_PHASE, SUPPORTED_WEB_APP_PATTERN, referencesRemovedSymbol, scanSettingsApiUsage, isFrameworkOwnedPackage, checkPluginFrameworkCompat, probePluginImport, readCompatPending, writeCompatPending, readCompatGate, writeCompatGate, readCompatAck, writeCompatAck, resolveNoticeState, frameworkCheckPromptText, CONSOLE_VERSION, detectCompat, detectAdoptablePending, rowIdModuleMap, frameworkCompatReportFor, maybeAutoAdaptCompat }
|
|
558
|
+
export { gatingSummary, COMPAT_PENDING_FILE, COMPAT_GATE_FILE, COMPAT_ACK_FILE, COMPAT_GATE_DEFAULTS, NOTICE_MODES, DEFAULT_NOTICE_MODE, REMOVED_SETTINGS_SYMBOLS, FIBER_STATE, FIBER_PHASE, SUPPORTED_WEB_APP_PATTERN, referencesRemovedSymbol, scanSettingsApiUsage, isFrameworkOwnedPackage, checkPluginFrameworkCompat, probePluginImport, readCompatPending, readCompatPendingWithVerdicts, writeCompatPending, verdictView, SCANNER_VERSION, readCompatGate, writeCompatGate, readCompatAck, writeCompatAck, resolveNoticeState, frameworkCheckPromptText, CONSOLE_VERSION, detectCompat, detectAdoptablePending, rowIdModuleMap, frameworkCompatReportFor, maybeAutoAdaptCompat }
|
|
@@ -5,15 +5,36 @@
|
|
|
5
5
|
//
|
|
6
6
|
// 三条安全约定(对齐仓库既有纪律):
|
|
7
7
|
// ① **一律先改名备份**:顶层旧目录 → `<name>.stale-<ver>-<ts>`,绝不直接删(可回滚)。
|
|
8
|
-
// ②
|
|
9
|
-
// 推不到或目标不存在就只改名,并如实记进 `relinkSkipped`(不假装成功)。
|
|
8
|
+
// ② 改名与重建 junction **原子化**(0.5.34 改错,见下);重建失败 → 立刻把改名改回去。
|
|
10
9
|
// ③ 框架备份只删 `fw-tree/<ts>` 目录,**绝不碰** `dsh-package-backup` 与目录下的快照文件。
|
|
10
|
+
//
|
|
11
|
+
// ── 0.5.34 改错(**真机停机事故 2026-10-01**:web 服务进程 exit code=1,起不来)──────────────
|
|
12
|
+
// 现场:框架树顶层 `@deepseek-ai/cordis` 被改名成 `cordis.stale-4.0.4-<ts>`,**但没重建 junction**
|
|
13
|
+
// ⇒ 启动包 `dsh-app-boot` 的 `import '@deepseek-ai/cordis'` 解析失败 ⇒ 服务进程退出。
|
|
14
|
+
// 真根因(`git blame` 定位到旧 `:77` 改名 / 旧 `:92` 本应重建):重建链接只在
|
|
15
|
+
// 「`dsh` junction 推得出运行树 **且** 目标存在」时才做,其余分支(`no-running-scope` /
|
|
16
|
+
// `target-missing`)**只改名不重建**;真机上顶层 `dsh` 是**真实目录**而非 junction
|
|
17
|
+
// ⇒ `runningScopeDir = null` ⇒ 10 条包全部落进这条"只改名"的支路,且只在 `relinkSkipped` 里
|
|
18
|
+
// 留一行 JSON(面板不留痕、用户看不到),框架就再也起不来了。
|
|
19
|
+
// 现在(四条硬规则,全部有离线测试钉死):
|
|
20
|
+
// ① **改名后立刻重建 junction**;重建失败 → **立刻回滚改名**(改回原名)→ 记 `failed` 并写明原因。
|
|
21
|
+
// `relink:false`(显式关闭重建)不再"只改名"——那种模式正是事故本体,一律不改名并如实记 `relink-off`。
|
|
22
|
+
// ② 找不到任何可重建目标(多基准候选全不存在)→ **连名字都不动**(先探目标、再改名),记
|
|
23
|
+
// `relinkSkipped`(带原因 + 试过哪些基准)—— 宁可什么都不做,也不让框架顶层少一个包名。
|
|
24
|
+
// ③ 整批结束后做一次**框架可解析性验证**(至少 `@deepseek-ai/cordis` + `@deepseek-ai/dsh-app-boot`,
|
|
25
|
+
// 多基准:框架树顶层 + `.pnpm/node_modules`;解析结果必须落在框架树内)→ **验证失败则把本轮所有
|
|
26
|
+
// 改名回滚**(恢复到操作前状态)并返回失败原因。这条就是"防止把服务搞到起不来"的硬闸。
|
|
27
|
+
// ④ **绝不删除**任何 `.stale-*` 备份;**幂等**:`.stale-*` 不再参与判据(见 framework-residuals.js),
|
|
28
|
+
// 已建好的 junction 与同 stamp 的备份名都会让重复运行零改名、零新建链接。
|
|
29
|
+
// 返回值:`renamed`(**留在原地**的改名,含 target/via)/ `relinked` / `relinkSkipped`(含原因)/
|
|
30
|
+
// `rolledBack`(含哪些与为什么)/ `verify`(多基准解析验证结果)/ `failed`(可读原因,绝不静默吞)。
|
|
11
31
|
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
32
|
+
import { createRequire } from 'node:module'
|
|
33
|
+
import { existsSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmdirSync, statSync, symlinkSync, unlinkSync } from 'node:fs'
|
|
34
|
+
import { basename, dirname, join, resolve, sep } from 'node:path'
|
|
14
35
|
import { removeDirVerifiedAsync } from '../infra/fsx.js'
|
|
15
36
|
import { dshHome, resolvePackageJson } from '../infra/paths.js'
|
|
16
|
-
import { classifyTopLevel, planBackupRetention } from './framework-residuals.js'
|
|
37
|
+
import { FRAMEWORK_BOOT_PACKAGES, classifyTopLevel, planBackupRetention, relinkTargetCandidates } from './framework-residuals.js'
|
|
17
38
|
|
|
18
39
|
/** 目录体积(失败返回 0;只用于报告「能释放多少」)。 */
|
|
19
40
|
function dirSizeBytes(dir) {
|
|
@@ -39,18 +60,142 @@ function isLinkEntry(full, dirent, readLink = readlinkSync) {
|
|
|
39
60
|
try { readLink(full); return true } catch { return false }
|
|
40
61
|
}
|
|
41
62
|
|
|
63
|
+
/** 删掉一个**链接**(Windows 的 junction 用 rmdir 拆;符号链接用 unlink 兜底)。绝不删真实目录。 */
|
|
64
|
+
function removeLinkSafe(full, deps = {}) {
|
|
65
|
+
const { rmdir = rmdirSync, unlink = unlinkSync } = deps
|
|
66
|
+
const errors = []
|
|
67
|
+
try { rmdir(full); return { ok: true, via: 'rmdir' } } catch (error) { errors.push(String(error?.message ?? error)) }
|
|
68
|
+
try { unlink(full); return { ok: true, via: 'unlink' } } catch (error) { errors.push(String(error?.message ?? error)) }
|
|
69
|
+
return { ok: false, error: errors.join(' / ') }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function errText(error) {
|
|
73
|
+
return String(error?.message ?? error)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 把「改名 → 重建链接」这一步回滚:先拆掉本轮建的链接(若已建),再把备份名改回原名。
|
|
78
|
+
* 返回 `{ ok, problem }`;**失败必须带可读原因**(回滚失败是最严重的一类,绝不能假装成功)。
|
|
79
|
+
*/
|
|
80
|
+
function rollbackRename({ full, backupFull, linkAtName = false, deps = {} } = {}) {
|
|
81
|
+
const { exists = existsSync, rename = renameSync, readLink = readlinkSync, removeLink = removeLinkSafe } = deps
|
|
82
|
+
const problems = []
|
|
83
|
+
let occupied = false
|
|
84
|
+
try { occupied = exists(full) } catch (error) { problems.push('检查原位置失败:' + errText(error)) }
|
|
85
|
+
if (occupied) {
|
|
86
|
+
// 原位置此刻只可能是**本轮刚建的链接**(原名一刻前才被改名让出)。
|
|
87
|
+
// 真实目录一律不动 —— 宁可留下链接并如实报错,也绝不误删用户数据。
|
|
88
|
+
let linkNow = linkAtName
|
|
89
|
+
if (!linkNow) { try { readLink(full); linkNow = true } catch { linkNow = false } }
|
|
90
|
+
if (!linkNow) problems.push('原位置出现了非链接目录,拒绝覆盖')
|
|
91
|
+
else {
|
|
92
|
+
const rm = removeLink(full)
|
|
93
|
+
if (rm?.ok !== true) problems.push('拆除本轮链接失败:' + String(rm?.error ?? '未知'))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (problems.length === 0) {
|
|
97
|
+
try { rename(backupFull, full) } catch (error) { problems.push('改回原名失败:' + errText(error)) }
|
|
98
|
+
}
|
|
99
|
+
return { ok: problems.length === 0, problem: problems.join(';') }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 解析一条残留该重建到哪个真实目录(多基准;第一个存在的候选胜出),或如实说明为什么不能重建。 */
|
|
103
|
+
function resolveRelinkTarget({ fwRoot, scopeName, name, version, runningScopeDir, deps = {} } = {}) {
|
|
104
|
+
const { exists = existsSync, readdir = readdirSync } = deps
|
|
105
|
+
const storeHoistDir = fwRoot === null ? null : join(fwRoot, '.pnpm', 'node_modules')
|
|
106
|
+
const tried = []
|
|
107
|
+
for (const candidate of relinkTargetCandidates({ runningScopeDir, storeHoistDir, scopeName, name })) {
|
|
108
|
+
tried.push({ path: candidate.path, via: candidate.via })
|
|
109
|
+
let hit = false
|
|
110
|
+
try { hit = exists(candidate.path) } catch { hit = false }
|
|
111
|
+
if (hit) return { target: candidate.path, via: candidate.via, tried }
|
|
112
|
+
}
|
|
113
|
+
// 兜底:pnpm 虚拟 store 里的实体本体 `<scope>+<name>@<version>*`(版本必须**与刚改名的那份相同**,
|
|
114
|
+
// 绝不借机换版本;同版本多个 hash 时按目录名排序取第一个,确定性且语义等价)。
|
|
115
|
+
const storeDir = fwRoot === null ? null : join(fwRoot, '.pnpm')
|
|
116
|
+
if (storeDir !== null && version !== null && version !== undefined && String(version) !== '') {
|
|
117
|
+
const prefix = `${scopeName}+${name}@${version}`
|
|
118
|
+
let dirs = []
|
|
119
|
+
try { dirs = readdir(storeDir, { withFileTypes: true }).filter((d) => d.isDirectory() && (d.name === prefix || d.name.startsWith(prefix + '_'))).map((d) => d.name).sort() } catch (error) { tried.push({ path: join(storeDir, prefix + '*'), via: 'pnpm-store-entity', error: errText(error) }) }
|
|
120
|
+
for (const dir of dirs) {
|
|
121
|
+
const candidate = join(storeDir, dir, 'node_modules', scopeName, name)
|
|
122
|
+
tried.push({ path: candidate, via: 'pnpm-store-entity' })
|
|
123
|
+
let hit = false
|
|
124
|
+
try { hit = exists(candidate) } catch { hit = false }
|
|
125
|
+
if (hit) return { target: candidate, via: 'pnpm-store-entity', tried }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { target: null, via: null, tried }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 框架**可解析性**验证(多基准,只读):从「框架树顶层」与「`.pnpm/node_modules`」两个基准分别解析
|
|
133
|
+
* 关键启动包,且要求解析结果**落在框架树内**(否则是从上层别处的同名包,等于框架自己还是解析不到)。
|
|
134
|
+
* 默认用 `createRequire(base).resolve(name)` 真解析;`deps.resolveFrom` 可注入(离线测试用)。
|
|
135
|
+
*/
|
|
136
|
+
function verifyFrameworkTreeResolvable({ fwRoot, packages = FRAMEWORK_BOOT_PACKAGES, deps = {} } = {}) {
|
|
137
|
+
const { resolveFrom = (baseFile, name) => createRequire(baseFile).resolve(name), real = realpathSync } = deps
|
|
138
|
+
const treeRoot = fwRoot === null || fwRoot === undefined ? null : dirname(fwRoot)
|
|
139
|
+
const bases = treeRoot === null ? [] : [
|
|
140
|
+
{ id: 'framework-top', dir: treeRoot, file: join(treeRoot, 'package.json') },
|
|
141
|
+
{ id: 'pnpm-store', dir: join(fwRoot, '.pnpm', 'node_modules'), file: join(fwRoot, '.pnpm', 'node_modules', 'package.json') },
|
|
142
|
+
]
|
|
143
|
+
const norm = (p) => {
|
|
144
|
+
try { return real(p) } catch { return resolve(p) }
|
|
145
|
+
}
|
|
146
|
+
const inside = (child, parent) => {
|
|
147
|
+
const c = norm(child)
|
|
148
|
+
const p = norm(parent)
|
|
149
|
+
return c === p || c.startsWith(p.endsWith(sep) ? p : p + sep)
|
|
150
|
+
}
|
|
151
|
+
const checks = []
|
|
152
|
+
for (const base of bases) {
|
|
153
|
+
for (const short of packages) {
|
|
154
|
+
const name = short.includes('/') ? short : `@deepseek-ai/${short}`
|
|
155
|
+
try {
|
|
156
|
+
const resolved = resolveFrom(base.file, name)
|
|
157
|
+
if (typeof resolved !== 'string' || resolved === '') throw new Error('解析器没有返回路径')
|
|
158
|
+
if (treeRoot !== null && !inside(resolved, treeRoot)) {
|
|
159
|
+
checks.push({ base: base.id, name, ok: false, path: resolved, error: `解析到框架树之外(${resolved})——框架自己仍然解析不到它` })
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
checks.push({ base: base.id, name, ok: true, path: resolved, error: null })
|
|
163
|
+
} catch (error) {
|
|
164
|
+
checks.push({ base: base.id, name, ok: false, path: null, error: errText(error) })
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const bad = checks.filter((c) => c.ok !== true)
|
|
169
|
+
const ok = bases.length > 0 && bad.length === 0
|
|
170
|
+
return {
|
|
171
|
+
ok,
|
|
172
|
+
phase: 'post',
|
|
173
|
+
bases: bases.map((b) => ({ id: b.id, dir: b.dir })),
|
|
174
|
+
packages: packages.map((s) => (s.includes('/') ? s : `@deepseek-ai/${s}`)),
|
|
175
|
+
checks,
|
|
176
|
+
error: ok ? null : (bases.length === 0
|
|
177
|
+
? '拿不到框架树根(无法验证可解析性)'
|
|
178
|
+
: `框架可解析性验证失败:${bad.slice(0, 3).map((c) => `${c.name}@${c.base}(${c.error})`).join(';')}${bad.length > 3 ? ` 等 ${bad.length} 条` : ''}`),
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
42
182
|
/**
|
|
43
|
-
* A 档:顶层投影里的旧版本真实目录 → 改名备份(+
|
|
44
|
-
* `topScopeDir` = `<
|
|
183
|
+
* A 档:顶层投影里的旧版本真实目录 → 改名备份(+ **立刻**重建 junction;失败即回滚)。
|
|
184
|
+
* `topScopeDir` = `<fwRoot>/<scope>`(如 `…/node_modules/@deepseek-ai`);`dshLinkPath` = 同目录下的 `dsh`。
|
|
185
|
+
* 可选 `frameworkRoot`(默认 `dirname(topScopeDir)`)、`verifyPackages`(默认启动关键包)。
|
|
45
186
|
*/
|
|
46
|
-
async function cleanFrameworkTopLevel({ topScopeDir, dshLinkPath = null, runningVersion = null, relink = true, dryRun = false, stamp = null, deps = {} } = {}) {
|
|
187
|
+
async function cleanFrameworkTopLevel({ topScopeDir, dshLinkPath = null, runningVersion = null, relink = true, dryRun = false, stamp = null, frameworkRoot = null, verifyPackages = null, deps = {} } = {}) {
|
|
47
188
|
const {
|
|
48
189
|
exists = existsSync, readdir = readdirSync, readText = (f) => readFileSync(f, 'utf8'),
|
|
49
190
|
rename = renameSync, link = symlinkSync, readLink = readlinkSync,
|
|
191
|
+
resolveFrom = (baseFile, name) => createRequire(baseFile).resolve(name),
|
|
50
192
|
} = deps
|
|
193
|
+
const empty = { renamed: [], relinked: [], relinkSkipped: [], rolledBack: [], failed: [], verify: null }
|
|
51
194
|
if (topScopeDir === null || topScopeDir === undefined || !exists(topScopeDir)) {
|
|
52
|
-
return { ok: false, error: '框架顶层目录不存在(拿不到 @deepseek-ai/* 的真实位置)',
|
|
195
|
+
return { ok: false, error: '框架顶层目录不存在(拿不到 @deepseek-ai/* 的真实位置)', ...empty }
|
|
53
196
|
}
|
|
197
|
+
const fwRoot = frameworkRoot ?? dirname(topScopeDir)
|
|
198
|
+
const scopeName = basename(topScopeDir)
|
|
54
199
|
const entries = []
|
|
55
200
|
for (const dirent of readdir(topScopeDir, { withFileTypes: true })) {
|
|
56
201
|
const full = join(topScopeDir, dirent.name)
|
|
@@ -72,40 +217,96 @@ async function cleanFrameworkTopLevel({ topScopeDir, dshLinkPath = null, running
|
|
|
72
217
|
const renamed = []
|
|
73
218
|
const relinked = []
|
|
74
219
|
const relinkSkipped = []
|
|
220
|
+
const rolledBack = []
|
|
75
221
|
const failed = []
|
|
76
|
-
|
|
222
|
+
const hints = []
|
|
223
|
+
if (runningScopeDir === null && dshLinkPath !== null) hints.push('顶层 `' + basename(dshLinkPath) + '` 不是 junction(推不出运行树)——重建链接改用 .pnpm 多基准候选')
|
|
224
|
+
const io = { exists, rename, readLink }
|
|
225
|
+
|
|
226
|
+
for (const item of classified.stale) {
|
|
227
|
+
if (dryRun === true) break // 只出 planned(函数尾部统一填),一个字节都不动
|
|
228
|
+
// 显式关闭重建(relink:false):**一律不改名**。旧行为是"只改名不重建",那正是 2026-10-01 停机事故本体。
|
|
229
|
+
if (relink !== true) {
|
|
230
|
+
relinkSkipped.push({ name: item.name, version: item.version, reason: 'relink-off', detail: '调用方显式关闭重建链接(relink:false):改名后无法指回真实包体,故不改名(旧行为会只改名 → 框架解析不到包)' })
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
77
233
|
const backupName = item.name + '.stale-' + item.version + '-' + ts
|
|
234
|
+
const backupFull = join(topScopeDir, backupName)
|
|
235
|
+
const full = join(topScopeDir, item.name)
|
|
236
|
+
let backupExists = false
|
|
237
|
+
try { backupExists = exists(backupFull) } catch { backupExists = false }
|
|
238
|
+
if (backupExists) {
|
|
239
|
+
// 幂等护栏:同一个 stamp 的备份已经在 → 说明这一条早就改过名了,重复运行不产生新改名。
|
|
240
|
+
relinkSkipped.push({ name: item.name, version: item.version, reason: 'backup-exists', detail: `备份 ${backupName} 已存在 —— 疑似重复运行,本次不改名、不改链接` })
|
|
241
|
+
continue
|
|
242
|
+
}
|
|
243
|
+
// **先探目标再改名**:找不到可指回的真实包体时连名字都不动(少一个"名字短暂空缺"的窗口)。
|
|
244
|
+
const resolvedTarget = resolveRelinkTarget({ fwRoot, scopeName, name: item.name, version: item.version, runningScopeDir, deps: { exists, readdir } })
|
|
245
|
+
if (resolvedTarget.target === null) {
|
|
246
|
+
relinkSkipped.push({ name: item.name, version: item.version, reason: 'no-target', detail: '多基准候选全不存在,无法重建链接 —— 不改名(判不准就不动)', tried: resolvedTarget.tried })
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
78
249
|
try {
|
|
79
|
-
rename(
|
|
250
|
+
rename(full, backupFull)
|
|
80
251
|
} catch (error) {
|
|
81
|
-
failed.push({ name: item.name, error:
|
|
252
|
+
failed.push({ name: item.name, error: '改名备份失败:' + errText(error) })
|
|
82
253
|
continue
|
|
83
254
|
}
|
|
84
|
-
renamed.push({ name: item.name, version: item.version, backup: backupName, sizeBytes: item.sizeBytes ?? null })
|
|
85
|
-
if (relink !== true) { relinkSkipped.push({ name: item.name, reason: 'relink-off' }); continue }
|
|
86
|
-
if (runningScopeDir === null) { relinkSkipped.push({ name: item.name, reason: 'no-running-scope' }); continue }
|
|
87
|
-
const target = join(runningScopeDir, item.name)
|
|
88
|
-
let targetExists = false
|
|
89
|
-
try { targetExists = exists(target) } catch { targetExists = false }
|
|
90
|
-
if (!targetExists) { relinkSkipped.push({ name: item.name, reason: 'target-missing' }); continue }
|
|
91
255
|
try {
|
|
92
|
-
link(target,
|
|
93
|
-
relinked.push({ name: item.name, target })
|
|
256
|
+
link(resolvedTarget.target, full, 'junction')
|
|
94
257
|
} catch (error) {
|
|
95
|
-
|
|
258
|
+
const rb = rollbackRename({ full, backupFull, deps: io })
|
|
259
|
+
const why = `重建链接失败(目标 ${resolvedTarget.target}):${errText(error)}`
|
|
260
|
+
if (rb.ok) {
|
|
261
|
+
rolledBack.push({ name: item.name, version: item.version, backup: backupName, reason: 'relink-failed' })
|
|
262
|
+
failed.push({ name: item.name, error: `${why};已把改名回滚(现场未变)` })
|
|
263
|
+
} else {
|
|
264
|
+
failed.push({ name: item.name, error: `${why};**回滚改名也失败**(现场可能不一致):${rb.problem}` })
|
|
265
|
+
}
|
|
266
|
+
continue
|
|
96
267
|
}
|
|
268
|
+
renamed.push({ name: item.name, version: item.version, backup: backupName, sizeBytes: item.sizeBytes ?? null, target: resolvedTarget.target, via: resolvedTarget.via })
|
|
269
|
+
relinked.push({ name: item.name, target: resolvedTarget.target, via: resolvedTarget.via })
|
|
97
270
|
}
|
|
271
|
+
|
|
272
|
+
// ③ 整批验证(硬闸):只在**本轮真的改过名**时才具回滚意义;没改过就只如实报告当前状态。
|
|
273
|
+
const verify = verifyFrameworkTreeResolvable({ fwRoot, packages: verifyPackages ?? FRAMEWORK_BOOT_PACKAGES, deps: { resolveFrom } })
|
|
274
|
+
verify.phase = dryRun === true ? 'pre' : 'post'
|
|
275
|
+
if (verify.ok !== true && renamed.length > 0) {
|
|
276
|
+
const all = [...renamed].reverse()
|
|
277
|
+
renamed.length = 0
|
|
278
|
+
relinked.length = 0
|
|
279
|
+
for (const item of all) {
|
|
280
|
+
const rb = rollbackRename({ full: join(topScopeDir, item.name), backupFull: join(topScopeDir, item.backup), linkAtName: true, deps: io })
|
|
281
|
+
if (rb.ok) rolledBack.push({ name: item.name, version: item.version, backup: item.backup, reason: 'verify-failed' })
|
|
282
|
+
else failed.push({ name: item.name, error: `验证失败后的回滚未完成(现场可能不一致):${rb.problem}` })
|
|
283
|
+
}
|
|
284
|
+
verify.rolledBackAll = true
|
|
285
|
+
verify.error = `${verify.error} → 已把本轮全部 ${all.length} 条改名回滚(恢复到操作前状态)`
|
|
286
|
+
} else if (verify.ok !== true) {
|
|
287
|
+
verify.rolledBackAll = false
|
|
288
|
+
hints.push('本轮没有改名(dryRun / relink-off):验证结果只反映**操作前**的现场状态,未触发回滚')
|
|
289
|
+
}
|
|
290
|
+
if (relinkSkipped.some((s) => s.reason === 'no-target')) hints.push(`有 ${relinkSkipped.filter((s) => s.reason === 'no-target').length} 条找不到可指回的真实包体:**连名字都没动**(判不准就不动)`)
|
|
291
|
+
if (rolledBack.length > 0) hints.push(`本轮回滚 ${rolledBack.length} 条(原因见 rolledBack)`)
|
|
292
|
+
const ok = failed.length === 0 && verify.ok !== false
|
|
98
293
|
return {
|
|
99
|
-
ok
|
|
294
|
+
ok,
|
|
100
295
|
renamed,
|
|
101
296
|
relinked,
|
|
102
297
|
relinkSkipped,
|
|
298
|
+
rolledBack,
|
|
103
299
|
failed,
|
|
300
|
+
hints,
|
|
104
301
|
staleCount: classified.stale.length,
|
|
105
302
|
sameVersionCount: classified.sameVersion.length,
|
|
106
303
|
missingVersion: classified.missingVersion,
|
|
107
304
|
dryRun,
|
|
108
305
|
planned: classified.stale.map((s) => ({ name: s.name, version: s.version, sizeBytes: s.sizeBytes ?? null })),
|
|
306
|
+
verify,
|
|
307
|
+
error: ok ? null : (failed.length > 0
|
|
308
|
+
? `有 ${failed.length} 条没能安全落地:${failed.slice(0, 3).map((f) => `${f.name}(${f.error})`).join(';')}`
|
|
309
|
+
: String(verify.error ?? '未知失败')),
|
|
109
310
|
}
|
|
110
311
|
}
|
|
111
312
|
|
|
@@ -222,4 +423,4 @@ function readRollbackReferenced({ home = undefined, deps = {} } = {}) {
|
|
|
222
423
|
return [raw?.checkpointDir, raw?.cliBackupDir].filter((p) => typeof p === 'string' && p !== '')
|
|
223
424
|
} catch { return [] }
|
|
224
425
|
}
|
|
225
|
-
export { cleanFrameworkBackups, cleanFrameworkTopLevel, dirSizeBytes, isLinkEntry, readRollbackReferenced, reportFrameworkEntities, resolveFrameworkLayout }
|
|
426
|
+
export { cleanFrameworkBackups, cleanFrameworkTopLevel, dirSizeBytes, isLinkEntry, readRollbackReferenced, removeLinkSafe, reportFrameworkEntities, resolveFrameworkLayout, verifyFrameworkTreeResolvable }
|
|
@@ -8,15 +8,40 @@
|
|
|
8
8
|
// 而回滚只需要**回滚点引用的那一条** + **最近 N 条**。
|
|
9
9
|
//
|
|
10
10
|
// 本模块只做纯判据(IO 全由调用方注入),动作在 routes/plugins.js 的 clean-residuals scope 里:
|
|
11
|
-
// · `classifyTopLevel` → 哪些顶层真实目录属于"旧版本残留"
|
|
11
|
+
// · `classifyTopLevel` → 哪些顶层真实目录属于"旧版本残留"(同版本/链接/备份一律不动 —— 宁可少动)
|
|
12
12
|
// · `planBackupRetention` → 保留「回滚点那一条 ∪ 最近 N 条」,其余进删除清单 + 可释放字节
|
|
13
|
+
// · `relinkTargetCandidates` → 改名之后该把 junction 指回哪里(多基准候选,按优先级)
|
|
14
|
+
// · `isStaleBackupName` → 备份目录名判据(幂等:备份绝不能再被当成残留改名)
|
|
13
15
|
//
|
|
14
16
|
// 行数预算:独立模块,留在 600 行架构硬顶内。
|
|
15
17
|
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `.stale-*` 备份目录名判据(**幂等必需**)。
|
|
22
|
+
*
|
|
23
|
+
* 动作层把旧目录改名成 `<name>.stale-<version>-<ts>`;那些备份里**还带着 package.json**
|
|
24
|
+
* (version = 被备份的那个版本),所以不排除的话:
|
|
25
|
+
* 第二次运行 → 备份自己又被判成"旧版本残留" → 改成 `<...>.stale-<v>-<ts2>`(越堆越深),
|
|
26
|
+
* 而且会给这个根本不存在的名字建 junction。旧行为正是如此(真机 `cordis.stale-4.0.4-<ts>` 一眼可见)。
|
|
27
|
+
* 误判方向是**保守**的:真有一个叫 `foo.stale-bar` 的包时我们只会"不动它",不做危险动作。
|
|
28
|
+
*/
|
|
29
|
+
const STALE_BACKUP_RE = /\.stale-/u
|
|
30
|
+
function isStaleBackupName(name) {
|
|
31
|
+
return typeof name === 'string' && STALE_BACKUP_RE.test(name)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 框架**启动关键包**(相对 scope 目录名):整批改名结束后必须仍能解析,否则全量回滚。
|
|
36
|
+
* 这两个就是 2026-10-01 停机事故现场:`dsh-app-boot` 顶层 `import '@deepseek-ai/cordis'` 解析不到
|
|
37
|
+
* ⇒ web 服务进程 exit code=1。判据只认"能解析"这件事,与版本号无关。
|
|
38
|
+
*/
|
|
39
|
+
const FRAMEWORK_BOOT_PACKAGES = ['cordis', 'dsh-app-boot']
|
|
40
|
+
|
|
16
41
|
/**
|
|
17
42
|
* 顶层条目分类(纯函数)。
|
|
18
43
|
* `entries` 形如 `[{ name, version, isLink, sizeBytes }]`;`isLink` 为真表示已被 junction/符号链接接管。
|
|
19
|
-
* **保守取舍**:同版本保留;缺版本(读不到 package.json
|
|
44
|
+
* **保守取舍**:同版本保留;缺版本(读不到 package.json)保留并单列;`.stale-*` 备份一律不碰 —— 判不准就不动。
|
|
20
45
|
*/
|
|
21
46
|
function classifyTopLevel(entries, { runningVersion = null } = {}) {
|
|
22
47
|
const stale = []
|
|
@@ -26,6 +51,7 @@ function classifyTopLevel(entries, { runningVersion = null } = {}) {
|
|
|
26
51
|
if (entry === null || typeof entry !== 'object' || typeof entry.name !== 'string' || entry.name === '') continue
|
|
27
52
|
if (entry.name.startsWith('.')) continue
|
|
28
53
|
if (entry.isLink === true) continue
|
|
54
|
+
if (isStaleBackupName(entry.name)) continue // 备份(含备份的备份)永不参与判断 —— 见 isStaleBackupName
|
|
29
55
|
const version = entry.version === undefined || entry.version === null ? '' : String(entry.version)
|
|
30
56
|
if (version === '') { missingVersion.push({ name: entry.name, sizeBytes: entry.sizeBytes ?? null }); continue }
|
|
31
57
|
if (runningVersion !== null && version === String(runningVersion)) { sameVersion.push({ name: entry.name, version }); continue }
|
|
@@ -34,6 +60,29 @@ function classifyTopLevel(entries, { runningVersion = null } = {}) {
|
|
|
34
60
|
return { stale, sameVersion, missingVersion }
|
|
35
61
|
}
|
|
36
62
|
|
|
63
|
+
/**
|
|
64
|
+
* 改名之后 junction 该指向哪里(纯函数:只**列候选**,存不存在由动作层问 IO)。
|
|
65
|
+
*
|
|
66
|
+
* 多基准(真机实证:2026-10-01 事故里 `dsh` 顶层是**真实目录**而不是 junction,
|
|
67
|
+
* 老判据只从 `dsh` junction 推运行树 → `runningScopeDir = null` → 10 条包全部"只改名、没重建"):
|
|
68
|
+
* ① `runningScopeDir/<name>` —— `dsh` 是 junction 时的运行树同级目录(老语义,保留)
|
|
69
|
+
* ② `<fwRoot>/.pnpm/node_modules/<scope>/<name>` —— pnpm store 的 hoisted 投影
|
|
70
|
+
* (真机 10 条手工补回的 junction 指的正是这里,逐条读过 Target)
|
|
71
|
+
* ③ `<fwRoot>/.pnpm/node_modules/<name>` —— 非作用域名的同款投影(防御性兜底)
|
|
72
|
+
* 顺序即优先级:先运行树(框架自己正在用的那份),再 store 投影。返回 `[{ path, via }]`。
|
|
73
|
+
*/
|
|
74
|
+
function relinkTargetCandidates({ runningScopeDir = null, storeHoistDir = null, scopeName = null, name = null } = {}) {
|
|
75
|
+
if (typeof name !== 'string' || name === '') return []
|
|
76
|
+
const out = []
|
|
77
|
+
if (typeof runningScopeDir === 'string' && runningScopeDir !== '') out.push({ path: join(runningScopeDir, name), via: 'running-scope' })
|
|
78
|
+
if (typeof storeHoistDir === 'string' && storeHoistDir !== '') {
|
|
79
|
+
if (typeof scopeName === 'string' && scopeName !== '') out.push({ path: join(storeHoistDir, scopeName, name), via: 'pnpm-hoist' })
|
|
80
|
+
out.push({ path: join(storeHoistDir, name), via: 'pnpm-hoist-flat' })
|
|
81
|
+
}
|
|
82
|
+
const seen = new Set()
|
|
83
|
+
return out.filter((c) => (seen.has(c.path) ? false : (seen.add(c.path), true)))
|
|
84
|
+
}
|
|
85
|
+
|
|
37
86
|
/**
|
|
38
87
|
* 备份保留策略(纯函数):保留集合 = 「回滚点引用的路径」∪「按 mtime 最近的 N 条」。
|
|
39
88
|
* `keep` 非法/缺失时按 1 处理(最小保留)。返回 `{ kept, remove, freedBytes }`,
|
|
@@ -56,4 +105,4 @@ function planBackupRetention(snapshots, { referenced = [], keep = 1 } = {}) {
|
|
|
56
105
|
return { kept, remove, freedBytes }
|
|
57
106
|
}
|
|
58
107
|
|
|
59
|
-
export { classifyTopLevel, planBackupRetention }
|
|
108
|
+
export { FRAMEWORK_BOOT_PACKAGES, classifyTopLevel, isStaleBackupName, planBackupRetention, relinkTargetCandidates }
|
|
@@ -34,6 +34,21 @@ import { join } from 'node:path'
|
|
|
34
34
|
/** 0.1.2-rc.1 起被官方删除的 dsh-settings API(命中即"实际不兼容")。 */
|
|
35
35
|
const REMOVED_SETTINGS_SYMBOLS = ['settingsNamespace', 'installSettingsSection']
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* **判据版本戳**(0.5.34 加法):判据一变就必须改这个字符串。
|
|
39
|
+
*
|
|
40
|
+
* 为什么需要它(真机背景 2026-10-01):`compat-pending.json` 里的结论是"当时的判据"写下的,
|
|
41
|
+
* 但面板把它当**事实**一直展示:`dshmarket` 记录里压着
|
|
42
|
+
* `check:"fail"` + `checkNote:"源码仍引用 0.1.2 起已删除的 dsh-settings API…"` ——
|
|
43
|
+
* 那是 0.5.33 收紧判据**之前**的误报结论(真机复核:现在 0 命中),
|
|
44
|
+
* 于是面板持续显示"实际不兼容,启用会让整个服务启动崩溃",把用户永久钉在「待适配」。
|
|
45
|
+
* 有了版本戳,"这条结论是哪一版判据算的"就可以被后续读取代发判读 → 过期即重算(见 compat-verdict.js)。
|
|
46
|
+
*
|
|
47
|
+
* v1 = 0.3.35 的「标识符边界 + 排除局部定义」(挡不住注释里的名字与对象键上的名字)
|
|
48
|
+
* v2 = 0.5.33 的「真的从 `@deepseek-ai/dsh-settings` 绑定进来」(本文件实现)
|
|
49
|
+
*/
|
|
50
|
+
const SCAN_CRITERIA_VERSION = 'dsh-settings-scan/2'
|
|
51
|
+
|
|
37
52
|
/** 这些 API 原本来自哪个包(只有真的从它绑定进来的名字才算引用)。 */
|
|
38
53
|
const SETTINGS_MODULE = '@deepseek-ai/dsh-settings'
|
|
39
54
|
|
|
@@ -248,4 +263,4 @@ function scanSettingsApiUsage(pkgDir) {
|
|
|
248
263
|
return found
|
|
249
264
|
}
|
|
250
265
|
|
|
251
|
-
export { REMOVED_SETTINGS_SYMBOLS, SETTINGS_MODULE, maskLiterals, settingsApiBindings, settingsApiReferences, referencesRemovedSymbol, scanSettingsApiUsage }
|
|
266
|
+
export { REMOVED_SETTINGS_SYMBOLS, SCAN_CRITERIA_VERSION, SETTINGS_MODULE, maskLiterals, settingsApiBindings, settingsApiReferences, referencesRemovedSymbol, scanSettingsApiUsage }
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
|
|
5
5
|
import { dirname, join } from 'node:path'
|
|
6
6
|
import { createRequire } from 'node:module'
|
|
7
|
-
import { checkPluginFrameworkCompat, probePluginImport,
|
|
7
|
+
import { checkPluginFrameworkCompat, probePluginImport, readCompatPendingWithVerdicts, rowIdModuleMap, writeCompatPending } from '../domain/compat.js'
|
|
8
8
|
import { cleanFrameworkBackups, cleanFrameworkTopLevel, readRollbackReferenced, reportFrameworkEntities, resolveFrameworkLayout } from '../domain/framework-cleanup.js'
|
|
9
9
|
import { currentFrameworkVersion } from '../domain/framework.js'
|
|
10
10
|
import { pnpmRemove } from '../domain/install-job.js'
|
|
@@ -78,7 +78,8 @@ async function routeToggle(req, res, rc) {
|
|
|
78
78
|
// 前端弹风险提示确认框;带 confirmRisky:true 才放行)。
|
|
79
79
|
// 注意下面还有一条**硬**门禁:启用前的 import 冒烟检查——那条是事实性崩溃(模块根本加载不了),
|
|
80
80
|
// 不允许覆盖(否则下次启动必崩,与「服务永不崩」冲突)。
|
|
81
|
-
|
|
81
|
+
// 0.5.34:走**读取代发**(判据戳落后的结论先用当前判据复核/回写)⇒ 判定依据是最新的;软禁闸门本身一个字没放宽
|
|
82
|
+
const compatPending = readCompatPendingWithVerdicts({ baseDir: dirname(findPatchPath(ctx)) }).pending
|
|
82
83
|
const pend = (compatPending?.pending ?? []).find((p) => p.rowId === rowId && (p.status ?? 'pending') === 'pending')
|
|
83
84
|
if (pend) {
|
|
84
85
|
if (body.confirmRisky !== true) {
|
|
@@ -97,9 +98,9 @@ async function routeToggle(req, res, rc) {
|
|
|
97
98
|
return
|
|
98
99
|
}
|
|
99
100
|
try {
|
|
100
|
-
|
|
101
|
-
const rec = (
|
|
102
|
-
if (rec) { rec.riskyApprovedAt = Date.now(); writeCompatPending(
|
|
101
|
+
// 复核(读取代发)已经作用在 compatPending 上,直接用同一份对象写回,避免重复读+重复扫描
|
|
102
|
+
const rec = (compatPending?.pending ?? []).find((p) => p.rowId === rowId)
|
|
103
|
+
if (rec) { rec.riskyApprovedAt = Date.now(); writeCompatPending(compatPending) }
|
|
103
104
|
} catch {}
|
|
104
105
|
}
|
|
105
106
|
}
|
|
@@ -126,7 +127,7 @@ async function routeToggle(req, res, rc) {
|
|
|
126
127
|
// 用户实测就是这样(5 行)。check / checkNote / riskyApprovedAt 一律保留供事后查。
|
|
127
128
|
if (enabled) {
|
|
128
129
|
try {
|
|
129
|
-
const list =
|
|
130
|
+
const list = readCompatPendingWithVerdicts({ baseDir: dirname(findPatchPath(ctx)) }).pending
|
|
130
131
|
const meta = rowIdModuleMap(ctx).get(rowId) ?? null
|
|
131
132
|
if (list !== null && markPendingAdopted(list, rowId, 'manual-enable', meta)) {
|
|
132
133
|
list.updatedAt = new Date().toISOString()
|
|
@@ -304,14 +305,14 @@ async function routeAdaptUnlock(req, res, rc) {
|
|
|
304
305
|
sendError(res, 400, 'rowId 无效')
|
|
305
306
|
return
|
|
306
307
|
}
|
|
307
|
-
const
|
|
308
|
+
const patchPath = findPatchPath(ctx)
|
|
309
|
+
const profileDir = dirname(patchPath)
|
|
310
|
+
const { pending, verdicts } = readCompatPendingWithVerdicts({ baseDir: profileDir }) // 0.5.34:读取代发
|
|
308
311
|
const entry = (pending?.pending ?? []).find((p) => p.rowId === rowId && (p.status ?? 'pending') === 'pending')
|
|
309
312
|
if (entry === undefined) {
|
|
310
313
|
sendError(res, 404, '该行不在适配门待适配清单中(或已解锁)')
|
|
311
314
|
return
|
|
312
315
|
}
|
|
313
|
-
const patchPath = findPatchPath(ctx)
|
|
314
|
-
const profileDir = dirname(patchPath)
|
|
315
316
|
let pkg = null
|
|
316
317
|
let pkgDir = null
|
|
317
318
|
let version = null
|
|
@@ -327,11 +328,13 @@ async function routeAdaptUnlock(req, res, rc) {
|
|
|
327
328
|
const fwVer = typeof pending.frameworkVersion === 'string' ? pending.frameworkVersion : '?'
|
|
328
329
|
const check = pkg !== null ? checkPluginFrameworkCompat(pkg, fwVer, pkgDir) : { decision: 'unknown', reason: '无法读取包信息' }
|
|
329
330
|
if (check.decision === 'fail') {
|
|
330
|
-
// 0.5.33 改错 ①-2:文案必须写清「为什么不行 +
|
|
331
|
-
//
|
|
331
|
+
// 0.5.33 改错 ①-2:文案必须写清「为什么不行 + 出路」(旧文案只有一句"适配校验未通过",用户不知道下一步
|
|
332
|
+
// 该做什么);0.5.34:真不兼容的行**一个字都不放宽** —— 仍然 409、仍然零改动(补丁与清单逐字节不变)。
|
|
332
333
|
sendError(res, 409, `适配校验未通过:${String(check.reason ?? '适配校验未通过')}。出路:先点「检测更新」把它更新到兼容当前框架 ${fwVer} 的版本(更新完成会自动重跑源码扫描,通过即解锁);若它已不再需要,可在补丁里删掉/禁用这一行。本次未改动补丁与适配门清单。`)
|
|
333
334
|
return
|
|
334
335
|
}
|
|
336
|
+
// 可解锁的两种来路(判据都不放松):① 真的更新过(版本变化)② 旧判据的 fail 已被当前判据撤回
|
|
337
|
+
const view = verdicts.get(rowId) ?? null, rescinded = view?.rescinded === true, changed = version !== null && version !== entry.version
|
|
335
338
|
await removeDisableBlock(patchPath, rowId)
|
|
336
339
|
entry.status = 'adopted'
|
|
337
340
|
entry.adoptedAt = Date.now()
|
|
@@ -339,8 +342,9 @@ async function routeAdaptUnlock(req, res, rc) {
|
|
|
339
342
|
entry.adoptedFramework = fwVer
|
|
340
343
|
entry.check = check.decision
|
|
341
344
|
entry.checkNote = check.reason ?? null
|
|
345
|
+
entry.adoptedBasis = changed ? 'version-changed' : (rescinded ? 'verdict-rescinded' : 'scan-pass')
|
|
342
346
|
writeCompatPending(pending)
|
|
343
|
-
sendJson(res, 200, { ok: true, rowId, adopted: true, check: check.decision, note: check.reason ?? null })
|
|
347
|
+
sendJson(res, 200, { ok: true, rowId, adopted: true, check: check.decision, note: check.reason ?? null, basis: entry.adoptedBasis, previousVerdict: rescinded ? (view?.previous?.decision ?? 'fail') : null, evidence: rescinded ? (view?.evidence ?? null) : null })
|
|
344
348
|
return
|
|
345
349
|
}
|
|
346
350
|
|
|
@@ -357,7 +361,7 @@ async function routeAdaptUnlockAll(req, res, rc) {
|
|
|
357
361
|
sendError(res, 400, 'root 无效')
|
|
358
362
|
return
|
|
359
363
|
}
|
|
360
|
-
const pending =
|
|
364
|
+
const pending = readCompatPendingWithVerdicts({ baseDir: dirname(findPatchPath(ctx)) }).pending
|
|
361
365
|
const patchPath = findPatchPath(ctx)
|
|
362
366
|
const profileDir = dirname(patchPath)
|
|
363
367
|
// v0.3.45:匹配规则改成「moduleName 前缀 **或** 属主行集合」——
|
|
@@ -525,6 +529,13 @@ async function routeCleanResiduals(req, res, rc) {
|
|
|
525
529
|
extra.dryRun = dryRun
|
|
526
530
|
}
|
|
527
531
|
const payload = { ok: failed.length === 0, removed, kept, failed, count: removed.length, ...extra }
|
|
532
|
+
// 0.5.34 加法:框架档的**硬闸**结果必须如实浮到面板层(旧行为:改名了却没重建链接,只在 JSON 里留一行)
|
|
533
|
+
const fw = extra.framework
|
|
534
|
+
if (fw && (fw.ok === false || fw.verify?.ok === false)) {
|
|
535
|
+
const rb = (fw.rolledBack ?? []).length > 0 ? `;已回滚 ${fw.rolledBack.length} 条(${fw.rolledBack.map((r) => `${r.name}:${r.reason}`).join('、')})` : ''
|
|
536
|
+
sendJson(res, 200, { ...payload, ok: false, error: `${fw.verify?.ok === false ? `框架可解析性验证未通过(${fw.verify.error ?? '未知'})` : `有 ${fw.failed.length} 条没能安全落地`}${rb};详情见 framework 字段。` })
|
|
537
|
+
return
|
|
538
|
+
}
|
|
528
539
|
if (failed.length > 0) {
|
|
529
540
|
const detail = failed.slice(0, 3).map((f) => `${f.name}(${f.error ?? '未知原因'})`).join(';')
|
|
530
541
|
const keptNote = kept.length > 0 ? `;已在用插件 ${kept.length} 个(保留不删)` : ''
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { readFileSync, existsSync } from 'node:fs'
|
|
5
5
|
import { dirname, join } from 'node:path'
|
|
6
|
-
import { detectAdoptablePending, detectCompat, gatingSummary, readCompatGate,
|
|
6
|
+
import { detectAdoptablePending, detectCompat, gatingSummary, readCompatGate, readCompatPendingWithVerdicts } from '../domain/compat.js'
|
|
7
7
|
import { compUiUrl, findComponents } from '../domain/components.js'
|
|
8
8
|
import { currentFrameworkVersion, detectFrameworkUpgrade } from '../domain/framework.js'
|
|
9
9
|
import { peerVetoFor } from '../domain/peer-veto.js'
|
|
@@ -48,7 +48,11 @@ async function routeStateGet(req, res, rc) {
|
|
|
48
48
|
}
|
|
49
49
|
const extraRows = await readExtraBundleRows(dirname(patchPath))
|
|
50
50
|
const profileDir = dirname(patchPath)
|
|
51
|
-
|
|
51
|
+
// 0.5.34 加法(**读取代发**):给 profileDir ⇒ 判据戳落后的记录用当前判据只读重算,
|
|
52
|
+
// 结论变了才回写(幂等:结论不变**零写盘**)。真不兼容的重算仍 fail,一个字都不动。
|
|
53
|
+
const pendingState = readCompatPendingWithVerdicts({ baseDir: profileDir })
|
|
54
|
+
const compatPending = pendingState.pending
|
|
55
|
+
const compatVerdicts = pendingState.verdicts
|
|
52
56
|
const compatPendingRows = new Set((compatPending?.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => p.rowId))
|
|
53
57
|
// 兼容门总开关 + 自动检测(只提示不自动开)
|
|
54
58
|
const compatGate = readCompatGate()
|
|
@@ -72,6 +76,7 @@ async function routeStateGet(req, res, rc) {
|
|
|
72
76
|
if (!disabledByPatch && entry.fiberPhase === null && vetoFrameworkVersion !== null) {
|
|
73
77
|
try { veto = peerVetoFor({ moduleName: entry.moduleName, profileDir, frameworkVersion: vetoFrameworkVersion }) } catch { veto = null }
|
|
74
78
|
}
|
|
79
|
+
const verdict = compatVerdicts.get(entry.rowId) ?? null
|
|
75
80
|
return {
|
|
76
81
|
...entry,
|
|
77
82
|
userDisabled: disabledByPatch,
|
|
@@ -83,7 +88,13 @@ async function routeStateGet(req, res, rc) {
|
|
|
83
88
|
// v0.3.45:只有"补丁里此刻确实还禁着它"的行才显示【待适配】——
|
|
84
89
|
// 记录与开关脱节时(用户手动启用过 / 补丁块被清掉)不能继续挂着「待适配」误导人
|
|
85
90
|
pendingCompat: compatPendingRows.has(entry.rowId) && disabledByPatch,
|
|
91
|
+
// 0.5.34 加法:记录的 `fail` 若已被**当前判据**撤回(旧误报),面板据此不再说「待适配」,
|
|
92
|
+
// 改说「判据已撤回·可解锁」并给一键解锁入口(判据本身一个字都没放宽:真 fail 仍 fail)
|
|
93
|
+
compatRescinded: compatPendingRows.has(entry.rowId) && verdict?.rescinded === true,
|
|
94
|
+
// 结论视图:当前判据下的结论 + 是否过期/撤回/复核不了(面板要如实展示,不许把旧结论当事实)
|
|
95
|
+
compatVerdict: verdict,
|
|
86
96
|
// 自动检测结果(只提示,不自动开):该待适配行现在是否已适配(插件已更新 + 扫描通过)
|
|
97
|
+
// 或**旧判据的误报已被撤回**(0.5.34:不再要求"版本变化",否则用户被永久钉住)
|
|
87
98
|
adoptable: adoptable.get(entry.rowId) ?? null,
|
|
88
99
|
// 框架 peer 预检的本地复算结果(null = 无话可说;只在这类行上下发,不新增常驻面板行)
|
|
89
100
|
veto,
|
|
@@ -111,7 +122,7 @@ async function routeStateGet(req, res, rc) {
|
|
|
111
122
|
const selfPkg = JSON.parse(readFileSync(join(pluginRoot(), 'package.json'), 'utf8'))
|
|
112
123
|
selfVersion = typeof selfPkg.version === 'string' ? selfPkg.version : null
|
|
113
124
|
} catch {}
|
|
114
|
-
sendJson(res, 200, { ok: true, entries, patchPath, compat, installJobs: jobs, recentFailures, pendingRestart, github: { loggedIn: auth.loggedIn, login: auth.login }, patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts }, framework, patchHeal: patchHeal === null ? null : { healed: patchHeal.healed ?? [], autoDisabled: patchHeal.autoDisabled ?? [], healedAt: patchHeal.healedAt ?? 0, written: patchHeal.written === true, writeError: patchHeal.writeError ?? null, skippedFrameworkOwned: (patchHeal.skipped ?? []).map((s) => s.id ?? null), uncertain: (patchHeal.uncertain ?? []).map((u) => u.id ?? null) }, gating: gatingSummary(ctx, compatPending, entries), compatPending: compatPending === null ? null : { frameworkVersion: compatPending.frameworkVersion ?? null, upgradeFrom: compatPending.upgradeFrom ?? null, pending: (compatPending.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => ({ rowId: p.rowId, moduleName: p.moduleName, version: p.version ?? null, checkNote: p.checkNote ?? null, check: p.check ?? null, riskyApprovedAt: p.riskyApprovedAt ?? null, adoptable: adoptable.get(p.rowId) ?? null })) }, compatGate, rollback: rollbackRec, selfVersion, components: findComponents().map((c) => ({ id: c.id, name: c.name, kind: c.kind ?? 'server', pid: c.pid ?? null, port: c.port ?? null, healthUrl: c.healthUrl ?? null, uiUrl: compUiUrl(c), autoStart: c.autoStart === true })) })
|
|
125
|
+
sendJson(res, 200, { ok: true, entries, patchPath, compat, installJobs: jobs, recentFailures, pendingRestart, github: { loggedIn: auth.loggedIn, login: auth.login }, patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts }, framework, patchHeal: patchHeal === null ? null : { healed: patchHeal.healed ?? [], autoDisabled: patchHeal.autoDisabled ?? [], healedAt: patchHeal.healedAt ?? 0, written: patchHeal.written === true, writeError: patchHeal.writeError ?? null, skippedFrameworkOwned: (patchHeal.skipped ?? []).map((s) => s.id ?? null), uncertain: (patchHeal.uncertain ?? []).map((u) => u.id ?? null) }, gating: gatingSummary(ctx, compatPending, entries, compatVerdicts), compatPending: compatPending === null ? null : { frameworkVersion: compatPending.frameworkVersion ?? null, upgradeFrom: compatPending.upgradeFrom ?? null, pending: (compatPending.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => ({ rowId: p.rowId, moduleName: p.moduleName, version: p.version ?? null, checkNote: p.checkNote ?? null, check: p.check ?? null, riskyApprovedAt: p.riskyApprovedAt ?? null, adoptable: adoptable.get(p.rowId) ?? null, verdict: compatVerdicts.get(p.rowId) ?? null })), verdictRefresh: pendingState.refresh === null ? null : { version: pendingState.refresh.version ?? null, updated: pendingState.refresh.updated ?? [], unchanged: pendingState.refresh.unchanged ?? [], unresolved: pendingState.refresh.unresolved ?? [], wrote: pendingState.refresh.wrote === true, writeError: pendingState.refresh.writeError ?? null } }, compatGate, rollback: rollbackRec, selfVersion, components: findComponents().map((c) => ({ id: c.id, name: c.name, kind: c.kind ?? 'server', pid: c.pid ?? null, port: c.port ?? null, healthUrl: c.healthUrl ?? null, uiUrl: compUiUrl(c), autoStart: c.autoStart === true })) })
|
|
115
126
|
return
|
|
116
127
|
}
|
|
117
128
|
|
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.34",
|
|
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",
|