@noob-stupid/dsh-plugin-console 0.5.32 → 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/README.md +40 -10
- package/README.zh.md +31 -5
- package/lib/client.js +87 -55
- package/lib/server/domain/compat-verdict.js +241 -0
- package/lib/server/domain/compat.js +88 -74
- package/lib/server/domain/framework-cleanup.js +225 -24
- package/lib/server/domain/framework-residuals.js +52 -3
- package/lib/server/domain/peer-veto.js +103 -0
- package/lib/server/domain/settings-api-scan.js +266 -0
- package/lib/server/routes/plugins.js +25 -12
- package/lib/server/routes/state.js +31 -6
- package/package.json +1 -1
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// L1 · domain —— settings-api-scan.js(「已删除 dsh-settings API」源码扫描;0.5.33 改错时从 compat.js 搬出)
|
|
2
|
+
// 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
|
|
3
|
+
//
|
|
4
|
+
// 为什么单独成模块:判据从"源码里出现过这个名字"改成"这个名字**真的从
|
|
5
|
+
// `@deepseek-ai/dsh-settings` 绑定进来**"——要先剥注释/字符串字面量,再解析 import/require 的
|
|
6
|
+
// 绑定子句,逻辑比原先的按行子串匹配长得多,而 compat.js 已经 544 行(守卫上限 600)。
|
|
7
|
+
// 搬移保持对外契约不变:`REMOVED_SETTINGS_SYMBOLS` / `referencesRemovedSymbol` /
|
|
8
|
+
// `scanSettingsApiUsage` 三个名字照旧由 compat.js 导出。
|
|
9
|
+
//
|
|
10
|
+
// ── 0.5.33 改错(真机误报,证据充分)────────────────────────────────────────────────
|
|
11
|
+
// 现症:`dshmarket` 被判 `check:'fail'`(= 「源码仍引用 0.1.2 起已删除的 dsh-settings API」),
|
|
12
|
+
// 于是**框架升级预扫会把这个好插件自动禁用**。逐条核对它的三处命中:
|
|
13
|
+
// · dshmarket/lib/settings.js:45 / :53 / :73 —— 全在**注释**里;
|
|
14
|
+
// · dshmarket/lib/routes.js:3136 `settingsNamespace: settingsNamespaceState(),` —— 一个
|
|
15
|
+
// **HTTP 载荷对象的属性名**(值是自己的局部函数 `settingsNamespaceState`,与本包无关);
|
|
16
|
+
// · 该包对 `@deepseek-ai/dsh-settings` 的 **import 零命中**(它早已把那两个 helper 内联)。
|
|
17
|
+
// 旧判据(`text.includes(sym)` → v0.3.35 换成标识符边界 + 排除局部定义)挡不住这两类:
|
|
18
|
+
// 注释里的名字与对象键上的名字都不是"引用",但它们都在**代码行**上、也不是本地定义。
|
|
19
|
+
//
|
|
20
|
+
// 新判据(更严格,不放宽任何真引用):
|
|
21
|
+
// ① 先掩掉注释、字符串/模板字面量、正则字面量(等长空格替换,保留换行与列偏移);
|
|
22
|
+
// 唯一保留的是**模块说明符字符串**(`from 'x'` / `require('x')` / `import('x')`),
|
|
23
|
+
// 否则没法知道某个绑定来自哪个包。
|
|
24
|
+
// ② 只在**真的对这个包**的 import/require 绑定子句里取名字:
|
|
25
|
+
// `import { settingsNamespace } from '@deepseek-ai/dsh-settings'`、
|
|
26
|
+
// `import settingsNamespace from …`、`import * as s from …` 后的 `s.settingsNamespace`、
|
|
27
|
+
// `const { settingsNamespace } = require('@deepseek-ai/dsh-settings')`、
|
|
28
|
+
// `const { installSettingsSection } = await import('@deepseek-ai/dsh-settings')`。
|
|
29
|
+
// ③ 对象键、局部同名函数、别的模块的同名导入、注释与字符串里的名字 —— 一律不算。
|
|
30
|
+
|
|
31
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
32
|
+
import { join } from 'node:path'
|
|
33
|
+
|
|
34
|
+
/** 0.1.2-rc.1 起被官方删除的 dsh-settings API(命中即"实际不兼容")。 */
|
|
35
|
+
const REMOVED_SETTINGS_SYMBOLS = ['settingsNamespace', 'installSettingsSection']
|
|
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
|
+
|
|
52
|
+
/** 这些 API 原本来自哪个包(只有真的从它绑定进来的名字才算引用)。 */
|
|
53
|
+
const SETTINGS_MODULE = '@deepseek-ai/dsh-settings'
|
|
54
|
+
|
|
55
|
+
const SPECIFIER_RE = `['"]${SETTINGS_MODULE.replace(/[/\\^$*+?.()|[\]{}]/gu, '\\$&')}['"]`
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 把注释、字符串/模板字面量、正则字面量掩成**等长空格**(换行/回车保留,行号与列偏移不变)。
|
|
59
|
+
* `keepSpecifier` 为真时保留"模块说明符"字符串的原样内容(`from 'x'` / `require('x')` / `import('x')`)。
|
|
60
|
+
*
|
|
61
|
+
* 这是**掩码**而不是删除:后续判据仍然按整份源码做正则,位置对得上才好排查。
|
|
62
|
+
*/
|
|
63
|
+
function maskLiterals(text, keepSpecifier) {
|
|
64
|
+
const src = String(text ?? '')
|
|
65
|
+
const out = src.split('')
|
|
66
|
+
const blank = (from, to) => {
|
|
67
|
+
for (let k = from; k < to; k += 1) if (out[k] !== '\n' && out[k] !== '\r') out[k] = ' '
|
|
68
|
+
}
|
|
69
|
+
const isWordChar = (ch) => ch !== undefined && /[A-Za-z0-9_$]/u.test(ch)
|
|
70
|
+
const wordBefore = (index) => {
|
|
71
|
+
let j = index
|
|
72
|
+
while (j > 0 && (src[j - 1] === ' ' || src[j - 1] === '\t')) j -= 1
|
|
73
|
+
let k = j
|
|
74
|
+
while (k > 0 && isWordChar(src[k - 1])) k -= 1
|
|
75
|
+
return src.slice(k, j)
|
|
76
|
+
}
|
|
77
|
+
const prevMeaningful = (index) => {
|
|
78
|
+
for (let k = index - 1; k >= 0; k -= 1) {
|
|
79
|
+
const ch = src[k]
|
|
80
|
+
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') continue
|
|
81
|
+
return ch
|
|
82
|
+
}
|
|
83
|
+
return ''
|
|
84
|
+
}
|
|
85
|
+
/** 当前位置若是 `word(` 的第一个实参,返回 `word`(用于 require(...) / import(...) 的说明符判定)。 */
|
|
86
|
+
const callWordBefore = (index) => {
|
|
87
|
+
let k = index - 1
|
|
88
|
+
while (k >= 0 && (src[k] === ' ' || src[k] === '\t')) k -= 1
|
|
89
|
+
if (k < 0 || src[k] !== '(') return ''
|
|
90
|
+
k -= 1
|
|
91
|
+
while (k >= 0 && (src[k] === ' ' || src[k] === '\t')) k -= 1
|
|
92
|
+
const end = k + 1
|
|
93
|
+
while (k >= 0 && isWordChar(src[k])) k -= 1
|
|
94
|
+
return src.slice(k + 1, end)
|
|
95
|
+
}
|
|
96
|
+
// 正则字面量的判定沿用仓库既有启发式(tests/test-architecture-guard.mjs 的 stripCode 同款):
|
|
97
|
+
// 前一个有效字符说明这里不可能是除法,或者前一个词是 return/typeof 这类关键字。
|
|
98
|
+
const REGEX_PREV = new Set(['(', ',', '=', ':', '[', '!', '&', '|', '?', '{', '}', ';', '\n', ''])
|
|
99
|
+
const REGEX_KEYWORDS = new Set(['return', 'typeof', 'case', 'in', 'of', 'do', 'else', 'void', 'delete', 'new', 'instanceof', 'yield', 'await'])
|
|
100
|
+
let i = 0
|
|
101
|
+
while (i < src.length) {
|
|
102
|
+
const c = src[i]
|
|
103
|
+
const n = src[i + 1]
|
|
104
|
+
if (c === '/' && n === '/') {
|
|
105
|
+
const end = src.indexOf('\n', i)
|
|
106
|
+
const to = end < 0 ? src.length : end
|
|
107
|
+
blank(i, to)
|
|
108
|
+
i = to
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
if (c === '/' && n === '*') {
|
|
112
|
+
const end = src.indexOf('*/', i + 2)
|
|
113
|
+
const to = end < 0 ? src.length : end + 2
|
|
114
|
+
blank(i, to)
|
|
115
|
+
i = to
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
if (c === "'" || c === '"' || c === '`') {
|
|
119
|
+
let j = i + 1
|
|
120
|
+
while (j < src.length && src[j] !== c) {
|
|
121
|
+
if (src[j] === '\\') j += 1
|
|
122
|
+
j += 1
|
|
123
|
+
}
|
|
124
|
+
const end = j < src.length ? j + 1 : src.length
|
|
125
|
+
// 说明符字符串保留:紧跟在 `from` 之后,或作为 require(...) / import(...) 的第一个实参。
|
|
126
|
+
const asCallArgument = /^(?:require|import)$/u.test(callWordBefore(i))
|
|
127
|
+
const isSpecifier = keepSpecifier === true && c !== '`' && (wordBefore(i) === 'from' || asCallArgument)
|
|
128
|
+
if (!isSpecifier) blank(i + 1, end - 1)
|
|
129
|
+
i = end
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
if (c === '/') {
|
|
133
|
+
const pm = prevMeaningful(i)
|
|
134
|
+
if (REGEX_PREV.has(pm) || REGEX_KEYWORDS.has(wordBefore(i))) {
|
|
135
|
+
let j = i + 1
|
|
136
|
+
let inClass = false
|
|
137
|
+
while (j < src.length) {
|
|
138
|
+
const ch = src[j]
|
|
139
|
+
if (ch === '\\') { j += 2; continue }
|
|
140
|
+
if (ch === '[') inClass = true
|
|
141
|
+
else if (ch === ']') inClass = false
|
|
142
|
+
else if (ch === '/' && !inClass) { j += 1; break }
|
|
143
|
+
else if (ch === '\n') break
|
|
144
|
+
j += 1
|
|
145
|
+
}
|
|
146
|
+
blank(i, j)
|
|
147
|
+
i = j
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
i += 1
|
|
152
|
+
}
|
|
153
|
+
return out.join('')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 从一个绑定子句里取出**被导入的名字**(`a as b` 取 `a`;默认导入取它自己;`*` 交给命名空间那条路)。 */
|
|
157
|
+
function importedNamesOf(clause) {
|
|
158
|
+
const names = new Set()
|
|
159
|
+
const text = String(clause ?? '')
|
|
160
|
+
const brace = text.match(/\{([^}]*)\}/u)
|
|
161
|
+
if (brace !== null) {
|
|
162
|
+
for (const part of brace[1].split(',')) {
|
|
163
|
+
const seg = part.trim()
|
|
164
|
+
if (seg === '') continue
|
|
165
|
+
const imported = seg.split(/\s+as\s+/u)[0].trim()
|
|
166
|
+
if (/^[A-Za-z_$][\w$]*$/u.test(imported)) names.add(imported)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const rest = brace === null ? text : text.replace(brace[0], ' ')
|
|
170
|
+
for (const seg of rest.split(',')) {
|
|
171
|
+
const t = seg.trim()
|
|
172
|
+
if (t === '' || t === '*' || /\s/u.test(t)) continue
|
|
173
|
+
if (/^[A-Za-z_$][\w$]*$/u.test(t)) names.add(t)
|
|
174
|
+
}
|
|
175
|
+
return names
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 从源码里取出「真的从 `@deepseek-ai/dsh-settings` 绑定进来的名字」。
|
|
180
|
+
* 返回 `{ named, namespaces }`:`named` 是直接绑定的符号,`namespaces` 是包级命名空间
|
|
181
|
+
* (`import * as s` / `const s = require(…)`),它们的成员访问 `s.settingsNamespace` 也算引用。
|
|
182
|
+
*/
|
|
183
|
+
function settingsApiBindings(text) {
|
|
184
|
+
// 掩码只做一次:注释/字符串/正则变空格,模块说明符字符串保留 —— 判据据此认包。
|
|
185
|
+
const code = maskLiterals(text, true)
|
|
186
|
+
const named = new Set()
|
|
187
|
+
const namespaces = new Set()
|
|
188
|
+
// ① ESM 命名空间导入:`import * as s from '<mod>'`
|
|
189
|
+
for (const m of code.matchAll(new RegExp(`\\bimport\\s*\\*\\s*as\\s+([A-Za-z_$][\\w$]*)\\s+from\\s*${SPECIFIER_RE}`, 'gu'))) {
|
|
190
|
+
namespaces.add(m[1])
|
|
191
|
+
}
|
|
192
|
+
// ② ESM 具名/默认导入:`import { a, b as c } from '<mod>'` / `import d from '<mod>'`
|
|
193
|
+
// 子句里不允许再出现 import/from,长度封顶 2000 —— 挡住"跨到上一条 import 语句"的假匹配。
|
|
194
|
+
for (const m of code.matchAll(new RegExp(`\\bimport\\s+(?!\\*)((?:(?!\\bimport\\b|\\bfrom\\b)[\\s\\S]){0,2000}?)\\s+from\\s*${SPECIFIER_RE}`, 'gu'))) {
|
|
195
|
+
for (const name of importedNamesOf(m[1])) named.add(name)
|
|
196
|
+
}
|
|
197
|
+
// ③ CJS / 动态导入的解构绑定:`const { a } = require('<mod>')` / `= await import('<mod>')`
|
|
198
|
+
const callRe = `(?:require|import)\\s*\\(\\s*${SPECIFIER_RE}\\s*\\)`
|
|
199
|
+
for (const m of code.matchAll(new RegExp(`(?:const|let|var)\\s*\\{([^}]*)\\}\\s*=\\s*(?:await\\s+)?${callRe}`, 'gu'))) {
|
|
200
|
+
for (const name of importedNamesOf(`{${m[1]}}`)) named.add(name)
|
|
201
|
+
}
|
|
202
|
+
// ④ CJS / 动态导入的命名空间绑定:`const s = require('<mod>')`
|
|
203
|
+
for (const m of code.matchAll(new RegExp(`(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*(?:await\\s+)?${callRe}`, 'gu'))) {
|
|
204
|
+
namespaces.add(m[1])
|
|
205
|
+
}
|
|
206
|
+
return { named, namespaces }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 命中的已删除符号列表(空数组 = 干净)。判据见文件头:只有真绑定/真成员访问才算。 */
|
|
210
|
+
function settingsApiReferences(text) {
|
|
211
|
+
const { named, namespaces } = settingsApiBindings(text)
|
|
212
|
+
const found = []
|
|
213
|
+
for (const sym of REMOVED_SETTINGS_SYMBOLS) {
|
|
214
|
+
if (named.has(sym)) { found.push(sym); continue }
|
|
215
|
+
for (const ns of namespaces) {
|
|
216
|
+
if (new RegExp(`(?<![\\w$.])${ns}\\s*\\.\\s*${sym}(?![\\w$])`, 'u').test(maskLiterals(text, true))) { found.push(sym); break }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return found
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 源码是否**引用**了某个已删除符号(对外契约不变;判据已收紧到"真绑定/真成员访问")。 */
|
|
223
|
+
function referencesRemovedSymbol(text, sym) {
|
|
224
|
+
return settingsApiReferences(text).includes(sym)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 扫描已安装包源码,检测对已删除的 dsh-settings API 的引用。
|
|
229
|
+
*
|
|
230
|
+
* 2026-09-04 教训:0.1.2-rc.1 起 settingsNamespace / installSettingsSection 已删除,
|
|
231
|
+
* 静态声明检查判 pass 是假通过;真实兼容性只有模块 import 时见分晓,而 loader 单行失败
|
|
232
|
+
* = 整个服务启动崩溃。
|
|
233
|
+
*
|
|
234
|
+
* 扫描预算(未改):限深 3 层、上限 120 个文件、单文件读前 400KB。
|
|
235
|
+
*/
|
|
236
|
+
function scanSettingsApiUsage(pkgDir) {
|
|
237
|
+
const found = []
|
|
238
|
+
const seen = new Set()
|
|
239
|
+
const walk = (dir, depth) => {
|
|
240
|
+
if (depth > 3) return
|
|
241
|
+
let entries = []
|
|
242
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
243
|
+
for (const entry of entries) {
|
|
244
|
+
const full = join(dir, entry.name)
|
|
245
|
+
if (entry.isDirectory()) {
|
|
246
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
|
|
247
|
+
walk(full, depth + 1)
|
|
248
|
+
continue
|
|
249
|
+
}
|
|
250
|
+
if (!entry.isFile() || !/\.(?:js|mjs|cjs)$/u.test(entry.name)) continue
|
|
251
|
+
if (seen.has(full)) continue
|
|
252
|
+
seen.add(full)
|
|
253
|
+
if (seen.size > 120) return
|
|
254
|
+
try {
|
|
255
|
+
const text = readFileSync(full, 'utf8').slice(0, 400000)
|
|
256
|
+
for (const sym of settingsApiReferences(text)) {
|
|
257
|
+
if (!found.includes(sym)) found.push(sym)
|
|
258
|
+
}
|
|
259
|
+
} catch {}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
walk(pkgDir, 0)
|
|
263
|
+
return found
|
|
264
|
+
}
|
|
265
|
+
|
|
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,9 +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
|
-
|
|
331
|
+
// 0.5.33 改错 ①-2:文案必须写清「为什么不行 + 出路」(旧文案只有一句"适配校验未通过",用户不知道下一步
|
|
332
|
+
// 该做什么);0.5.34:真不兼容的行**一个字都不放宽** —— 仍然 409、仍然零改动(补丁与清单逐字节不变)。
|
|
333
|
+
sendError(res, 409, `适配校验未通过:${String(check.reason ?? '适配校验未通过')}。出路:先点「检测更新」把它更新到兼容当前框架 ${fwVer} 的版本(更新完成会自动重跑源码扫描,通过即解锁);若它已不再需要,可在补丁里删掉/禁用这一行。本次未改动补丁与适配门清单。`)
|
|
331
334
|
return
|
|
332
335
|
}
|
|
336
|
+
// 可解锁的两种来路(判据都不放松):① 真的更新过(版本变化)② 旧判据的 fail 已被当前判据撤回
|
|
337
|
+
const view = verdicts.get(rowId) ?? null, rescinded = view?.rescinded === true, changed = version !== null && version !== entry.version
|
|
333
338
|
await removeDisableBlock(patchPath, rowId)
|
|
334
339
|
entry.status = 'adopted'
|
|
335
340
|
entry.adoptedAt = Date.now()
|
|
@@ -337,8 +342,9 @@ async function routeAdaptUnlock(req, res, rc) {
|
|
|
337
342
|
entry.adoptedFramework = fwVer
|
|
338
343
|
entry.check = check.decision
|
|
339
344
|
entry.checkNote = check.reason ?? null
|
|
345
|
+
entry.adoptedBasis = changed ? 'version-changed' : (rescinded ? 'verdict-rescinded' : 'scan-pass')
|
|
340
346
|
writeCompatPending(pending)
|
|
341
|
-
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 })
|
|
342
348
|
return
|
|
343
349
|
}
|
|
344
350
|
|
|
@@ -355,7 +361,7 @@ async function routeAdaptUnlockAll(req, res, rc) {
|
|
|
355
361
|
sendError(res, 400, 'root 无效')
|
|
356
362
|
return
|
|
357
363
|
}
|
|
358
|
-
const pending =
|
|
364
|
+
const pending = readCompatPendingWithVerdicts({ baseDir: dirname(findPatchPath(ctx)) }).pending
|
|
359
365
|
const patchPath = findPatchPath(ctx)
|
|
360
366
|
const profileDir = dirname(patchPath)
|
|
361
367
|
// v0.3.45:匹配规则改成「moduleName 前缀 **或** 属主行集合」——
|
|
@@ -523,6 +529,13 @@ async function routeCleanResiduals(req, res, rc) {
|
|
|
523
529
|
extra.dryRun = dryRun
|
|
524
530
|
}
|
|
525
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
|
+
}
|
|
526
539
|
if (failed.length > 0) {
|
|
527
540
|
const detail = failed.slice(0, 3).map((f) => `${f.name}(${f.error ?? '未知原因'})`).join(';')
|
|
528
541
|
const keptNote = kept.length > 0 ? `;已在用插件 ${kept.length} 个(保留不删)` : ''
|
|
@@ -3,9 +3,10 @@
|
|
|
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
|
-
import { detectFrameworkUpgrade } from '../domain/framework.js'
|
|
8
|
+
import { currentFrameworkVersion, detectFrameworkUpgrade } from '../domain/framework.js'
|
|
9
|
+
import { peerVetoFor } from '../domain/peer-veto.js'
|
|
9
10
|
import { readExtraBundleRows } from '../domain/install-job.js'
|
|
10
11
|
import { installJobView, readGithubAuth } from '../domain/install.js'
|
|
11
12
|
import { readPluginDetails } from '../domain/market.js'
|
|
@@ -46,7 +47,12 @@ async function routeStateGet(req, res, rc) {
|
|
|
46
47
|
setPatchHealReport(patchHeal)
|
|
47
48
|
}
|
|
48
49
|
const extraRows = await readExtraBundleRows(dirname(patchPath))
|
|
49
|
-
const
|
|
50
|
+
const profileDir = dirname(patchPath)
|
|
51
|
+
// 0.5.34 加法(**读取代发**):给 profileDir ⇒ 判据戳落后的记录用当前判据只读重算,
|
|
52
|
+
// 结论变了才回写(幂等:结论不变**零写盘**)。真不兼容的重算仍 fail,一个字都不动。
|
|
53
|
+
const pendingState = readCompatPendingWithVerdicts({ baseDir: profileDir })
|
|
54
|
+
const compatPending = pendingState.pending
|
|
55
|
+
const compatVerdicts = pendingState.verdicts
|
|
50
56
|
const compatPendingRows = new Set((compatPending?.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => p.rowId))
|
|
51
57
|
// 兼容门总开关 + 自动检测(只提示不自动开)
|
|
52
58
|
const compatGate = readCompatGate()
|
|
@@ -58,11 +64,22 @@ async function routeStateGet(req, res, rc) {
|
|
|
58
64
|
rollbackRec = { from: rr.from ?? null, to: rr.to ?? null, at: rr.at ?? null, applicable: true }
|
|
59
65
|
}
|
|
60
66
|
} catch {}
|
|
67
|
+
// 0.5.33 加法 D-⑧(诊断缺口):**补丁要求启用、运行时却没挂载**(fiberPhase === null)的行,
|
|
68
|
+
// 本地复算框架启动时的 peer 预检,补出短句原因 + 出路。框架那一侧只在**内存里**把
|
|
69
|
+
// row.disabled 置 true(不写用户补丁)、原因只 process.stderr.write —— 面板此前一个字都看不出。
|
|
70
|
+
// 判据与 dsh-app-boot 的 evaluatePluginCompatibility / preflight 对齐(见 domain/peer-veto.js)。
|
|
71
|
+
const vetoFrameworkVersion = currentFrameworkVersion(ctx)
|
|
61
72
|
const entries = listEntries(ctx).map((entry) => {
|
|
62
73
|
const meta = entryPkgMeta(entry.moduleName, ctx.baseUrl ?? 'file:///', profileDirOf(ctx))
|
|
74
|
+
const disabledByPatch = patch.disables.includes(entry.rowId)
|
|
75
|
+
let veto = null
|
|
76
|
+
if (!disabledByPatch && entry.fiberPhase === null && vetoFrameworkVersion !== null) {
|
|
77
|
+
try { veto = peerVetoFor({ moduleName: entry.moduleName, profileDir, frameworkVersion: vetoFrameworkVersion }) } catch { veto = null }
|
|
78
|
+
}
|
|
79
|
+
const verdict = compatVerdicts.get(entry.rowId) ?? null
|
|
63
80
|
return {
|
|
64
81
|
...entry,
|
|
65
|
-
userDisabled:
|
|
82
|
+
userDisabled: disabledByPatch,
|
|
66
83
|
userForced: patch.forced.includes(entry.rowId),
|
|
67
84
|
extra: patch.inserts.includes(entry.rowId) || extraRows.has(entry.moduleName) || extraRows.has(entry.rowId),
|
|
68
85
|
installDate: meta?.installDate ?? null,
|
|
@@ -70,9 +87,17 @@ async function routeStateGet(req, res, rc) {
|
|
|
70
87
|
repository: meta?.repository ?? null,
|
|
71
88
|
// v0.3.45:只有"补丁里此刻确实还禁着它"的行才显示【待适配】——
|
|
72
89
|
// 记录与开关脱节时(用户手动启用过 / 补丁块被清掉)不能继续挂着「待适配」误导人
|
|
73
|
-
pendingCompat: compatPendingRows.has(entry.rowId) &&
|
|
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,
|
|
74
96
|
// 自动检测结果(只提示,不自动开):该待适配行现在是否已适配(插件已更新 + 扫描通过)
|
|
97
|
+
// 或**旧判据的误报已被撤回**(0.5.34:不再要求"版本变化",否则用户被永久钉住)
|
|
75
98
|
adoptable: adoptable.get(entry.rowId) ?? null,
|
|
99
|
+
// 框架 peer 预检的本地复算结果(null = 无话可说;只在这类行上下发,不新增常驻面板行)
|
|
100
|
+
veto,
|
|
76
101
|
}
|
|
77
102
|
})
|
|
78
103
|
const compat = await detectCompat(ctx.baseUrl ?? 'file:///')
|
|
@@ -97,7 +122,7 @@ async function routeStateGet(req, res, rc) {
|
|
|
97
122
|
const selfPkg = JSON.parse(readFileSync(join(pluginRoot(), 'package.json'), 'utf8'))
|
|
98
123
|
selfVersion = typeof selfPkg.version === 'string' ? selfPkg.version : null
|
|
99
124
|
} catch {}
|
|
100
|
-
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 })) })
|
|
101
126
|
return
|
|
102
127
|
}
|
|
103
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",
|