@noob-stupid/dsh-plugin-console 0.5.31 → 0.5.33
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 +72 -10
- package/README.zh.md +63 -5
- package/lib/client.js +85 -53
- package/lib/server/domain/auto-disable.js +226 -0
- package/lib/server/domain/auto-preflight-run.js +184 -0
- package/lib/server/domain/auto-preflight.js +345 -0
- package/lib/server/domain/compat-state.js +57 -8
- package/lib/server/domain/compat.js +10 -60
- package/lib/server/domain/patch-yaml-check.js +151 -0
- package/lib/server/domain/peer-veto.js +103 -0
- package/lib/server/domain/safe-boot.js +520 -0
- package/lib/server/domain/settings-api-scan.js +251 -0
- package/lib/server/routes/compat.js +240 -11
- package/lib/server/routes/framework-preflight.js +14 -1
- package/lib/server/routes/index.js +4 -0
- package/lib/server/routes/plugins.js +3 -1
- package/lib/server/routes/safe-boot.js +139 -0
- package/lib/server/routes/state.js +17 -3
- package/package.json +1 -1
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// L1 · domain —— patch-yaml-check.js(`cordis.patch.yml` 的**结构校验**;0.5.32 加法 D3)
|
|
2
|
+
//
|
|
3
|
+
// 为什么不能直接复用 `domain/preset-yaml.js#validateAgentConfig`(**真机实测**,2026-09-29):
|
|
4
|
+
// 那把尺子是按 **agent 预设文件**(顶层 `- id:` 序列)校准的,拿它量真机补丁会**整片误杀**:
|
|
5
|
+
// 两个 live profile 的 `cordis.patch.yml`(web 56 KB / desktop 55 KB)实测各被判 17 / 12 条问题,
|
|
6
|
+
// 全部是**合法写法**:
|
|
7
|
+
// · `description: '第一行…` + 后续行 ` 续行…'` —— YAML 的**多行引号标量**(跨行合法),
|
|
8
|
+
// 逐行判「引号未闭合」→ 假红;
|
|
9
|
+
// · 续行缩进比父级深 → 逐行判「缩进跳级/文件被截断」→ 假红。
|
|
10
|
+
// 误杀的代价在这里同样是反向的:D3 的恢复入口拿它当写盘前闸门 → **一恢复就被自家校验拒掉**。
|
|
11
|
+
//
|
|
12
|
+
// 所以这里只钉**能确证损坏**的形态(全部离线可判定,且对真机补丁 0 误报):
|
|
13
|
+
// ① 行首缩进里有 **tab** —— YAML 明确禁止用 tab 缩进(框架解析器必然报错);
|
|
14
|
+
// ② **顶层空数组占位符 `[]`**(issue #7 事故形态):模板初始化的 `[]` 后面再追加 `- id:` 条目,
|
|
15
|
+
// 同一文档流里「数组结束符 + 后续项」= 非法 YAML → dsh 启动直接崩;
|
|
16
|
+
// ③ 顶层(缩进 0)出现**不是** `- ` 序列项的行(既不是注释/空行/占位符)—— 补丁文件的形状是
|
|
17
|
+
// 「序列项 + 其子键」,顶层键值对说明文件被拼坏/截断;
|
|
18
|
+
// ④ **多行引号标量未闭合**(真正的截断):跨行扫描引号状态,直到文件末尾仍未闭合才判红
|
|
19
|
+
// —— 这一条恰好是②之外最常见的"写坏"形态;
|
|
20
|
+
// ⑤ 块标量(`|` / `>`)整段跳过判据(其内容是散文,不是 YAML 结构)。
|
|
21
|
+
//
|
|
22
|
+
// 分层:L1 domain —— 纯函数、无 IO、永不抛(解析失败是正常结局,调用方按 ok=false 处理)。
|
|
23
|
+
|
|
24
|
+
/** 缩进宽度(空格数;tab 会在 ① 里单独判掉,不参与计数)。 */
|
|
25
|
+
function indentWidth(line) {
|
|
26
|
+
const m = String(line).match(/^ */u)
|
|
27
|
+
return m === null ? 0 : m[0].length
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 行首缩进里有没有 tab(YAML 禁止;框架解析器会直接报错)。 */
|
|
31
|
+
function hasTabIndent(line) {
|
|
32
|
+
return /^[ \t]*\t/u.test(String(line))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 剥掉行尾注释(只认**不在引号里**且 `#` 前有空白或行首的 `#`)。 */
|
|
36
|
+
function stripTrailingComment(line) {
|
|
37
|
+
const text = String(line)
|
|
38
|
+
let quote = null
|
|
39
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
40
|
+
const ch = text[i]
|
|
41
|
+
if (quote !== null) {
|
|
42
|
+
if (quote === '"' && ch === '\\') { i += 1; continue }
|
|
43
|
+
if (ch === quote) quote = null
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
if (ch === "'" || ch === '"') { quote = ch; continue }
|
|
47
|
+
if (ch === '#' && (i === 0 || /\s/u.test(text[i - 1]))) return text.slice(0, i)
|
|
48
|
+
}
|
|
49
|
+
return text
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 块标量指示符(`|`、`>`、`|-`、`>+2`…):只有**整个值**就是指示符时才算(`> foo` 是普通标量)。 */
|
|
53
|
+
function blockScalarIndicator(value) {
|
|
54
|
+
const m = String(value).trim().match(/^([|>])([+-]?\d*|\d*[+-]?)$/u)
|
|
55
|
+
return m === null ? null : m[0]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 从一行里取出「值起点之后的引号状态」:
|
|
60
|
+
* · 值不以引号开头 → `{ opens: false, quote: null }`(引号在标量中间不算跨行开始);
|
|
61
|
+
* · 以单/双引号开头 → 逐字符推进(双引号吃 `\\` 转义,单引号吃 `''`),返回是否仍未闭合。
|
|
62
|
+
*/
|
|
63
|
+
function scanValueQuotes(value, initialQuote = null) {
|
|
64
|
+
const text = String(value)
|
|
65
|
+
let quote = initialQuote
|
|
66
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
67
|
+
const ch = text[i]
|
|
68
|
+
if (quote === null) {
|
|
69
|
+
if (ch === "'" || ch === '"') quote = ch
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
if (quote === '"' && ch === '\\') { i += 1; continue }
|
|
73
|
+
if (ch === quote) {
|
|
74
|
+
if (quote === "'" && text[i + 1] === "'") { i += 1; continue }
|
|
75
|
+
quote = null
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { quote }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 结构校验(**纯函数**,永不抛)。返回 `{ ok, problems, summary }`。
|
|
83
|
+
* `summary` 只放"看得懂的事实"(行数 / 顶层项数 / 多行标量数),供报告与面板短句使用。
|
|
84
|
+
*/
|
|
85
|
+
function validatePatchYaml(text) {
|
|
86
|
+
const source = typeof text === 'string' ? text : String(text ?? '')
|
|
87
|
+
const lines = source.replace(/\r\n/gu, '\n').split('\n')
|
|
88
|
+
const problems = []
|
|
89
|
+
let topItems = 0
|
|
90
|
+
let multilineScalars = 0
|
|
91
|
+
let placeholderLines = 0
|
|
92
|
+
let inBlockScalar = false
|
|
93
|
+
let blockIndent = 0
|
|
94
|
+
let openQuote = null
|
|
95
|
+
|
|
96
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
97
|
+
const raw = lines[i]
|
|
98
|
+
const lineNo = i + 1
|
|
99
|
+
if (raw.trim() === '') continue
|
|
100
|
+
if (/^\s*#/u.test(raw)) continue
|
|
101
|
+
// ① tab 缩进(无论在哪一层都不合法)
|
|
102
|
+
if (hasTabIndent(raw)) { problems.push(`第 ${lineNo} 行的缩进里有 tab(YAML 禁止用 tab 缩进)`); continue }
|
|
103
|
+
const indent = indentWidth(raw)
|
|
104
|
+
// 多行引号标量的续行:内容不参与结构判定,只继续找闭合
|
|
105
|
+
if (openQuote !== null) {
|
|
106
|
+
multilineScalars += 1
|
|
107
|
+
const st = scanValueQuotes(raw, openQuote)
|
|
108
|
+
openQuote = st.quote
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
// 块标量内容:整段跳过(缩进 ≤ 块首行缩进的非空行 = 块结束)
|
|
112
|
+
if (inBlockScalar) {
|
|
113
|
+
if (indent > blockIndent) continue
|
|
114
|
+
inBlockScalar = false
|
|
115
|
+
}
|
|
116
|
+
const content = stripTrailingComment(raw)
|
|
117
|
+
if (content.trim() === '') continue
|
|
118
|
+
// ② 顶层空数组占位符(issue #7:`[]` 后面再跟条目 = 非法 YAML,启动直接崩)
|
|
119
|
+
if (/^\s*\[\s*\]\s*$/u.test(content)) { placeholderLines += 1; continue }
|
|
120
|
+
// ③ 顶层必须是序列项(补丁文件的形状:`- id:` / `- insert:` + 其子键)
|
|
121
|
+
if (indent === 0) {
|
|
122
|
+
if (!/^-\s/u.test(content) && content.trim() !== '-') {
|
|
123
|
+
problems.push(`第 ${lineNo} 行是顶层键值对(补丁文件顶层必须是 \`- \` 序列项):${content.trim().slice(0, 60)}`)
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
topItems += 1
|
|
127
|
+
}
|
|
128
|
+
// ④ / ⑤ 值里的块标量与跨行引号
|
|
129
|
+
const kv = content.match(/^(\s*)(?:-\s+)?[^\s:#][^:]*:\s*(.*)$/u)
|
|
130
|
+
if (kv === null) continue
|
|
131
|
+
const value = kv[2].trim()
|
|
132
|
+
if (blockScalarIndicator(value) !== null) { inBlockScalar = true; blockIndent = indent; continue }
|
|
133
|
+
const st = scanValueQuotes(value, null)
|
|
134
|
+
if (st.quote !== null) openQuote = st.quote
|
|
135
|
+
}
|
|
136
|
+
// ④ 收尾:跨行引号到文件末尾仍未闭合 = 真截断
|
|
137
|
+
if (openQuote !== null) {
|
|
138
|
+
problems.push(`文件末尾仍有未闭合的引号(${openQuote === "'" ? '单引号' : '双引号'})—— 文件被截断或写坏`)
|
|
139
|
+
}
|
|
140
|
+
// ② 收尾:占位符与真实条目共存(真实事故形态)
|
|
141
|
+
if (placeholderLines > 0 && topItems > 0) {
|
|
142
|
+
problems.push(`顶层空数组占位符 \`[]\` 与 ${topItems} 个序列项共存(同一文档流非法,框架解析会崩)—— 写入前必须移除占位符`)
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
ok: problems.length === 0,
|
|
146
|
+
problems,
|
|
147
|
+
summary: { lines: lines.length, topItems, multilineScalars, placeholderLines },
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export { blockScalarIndicator, hasTabIndent, indentWidth, scanValueQuotes, stripTrailingComment, validatePatchYaml }
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// L1 · domain —— peer-veto.js(框架 peer 预检「否决」的**本地复算**;0.5.33 加法,诊断缺口 D-⑧)
|
|
2
|
+
// 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
|
|
3
|
+
//
|
|
4
|
+
// ── 要解决什么 ────────────────────────────────────────────────────────────────────
|
|
5
|
+
// 真机症状:「补丁里明明写着启用(或压根没写 disabled)、运行时却没挂载(fiberPhase === null),
|
|
6
|
+
// 面板上一个字都不说为什么」。真实机制(框架侧,不是我们的 bug):
|
|
7
|
+
// `@deepseek-ai/dsh-app-boot/lib/index.js`
|
|
8
|
+
// · `evaluatePluginCompatibility(manifest, exemptions, runtimeVersion)`(:286-313)
|
|
9
|
+
// —— 只看 **peerDependencies**,只看 `@deepseek-ai/dsh` / `@deepseek-ai/dsh-*`,
|
|
10
|
+
// `workspace:^|~|*` 视为「等于当前运行时」,其余按 semver(includePrerelease)判;
|
|
11
|
+
// · `preflight()`(:2063-2109)—— 判不满足就在**内存里** `row.disabled = true`(**不写用户补丁**),
|
|
12
|
+
// 原因只 `process.stderr.write` 一行。
|
|
13
|
+
// 本机典型现场:profile 顶层压着 `@deepseek-ai/dsh-schedule@0.0.1-rc.3` 残影,它的 peer 写
|
|
14
|
+
// `@deepseek-ai/dsh-agent@^0.0.1-rc.3` 等 7 项 → 对运行中的 0.2.0-rc.2 **必然不满足** → 启动即被否决,
|
|
15
|
+
// 而补丁尾部还是 `- id: schedule / disabled: false`,面板只显示「未挂载」。
|
|
16
|
+
//
|
|
17
|
+
// ── 本模块做什么(**加法**:不改任何既有判据与开关,只补一句原因 + 一条出路)────────────
|
|
18
|
+
// 在同一判据下本地复算一遍:读该行**实际解析到的包** manifest 的 peerDependencies × 当前框架版本。
|
|
19
|
+
// 判定不满足 → 给短句原因 + 出路;包解析不到 → **另给**「包不存在」原因(两者绝不混为一谈)。
|
|
20
|
+
//
|
|
21
|
+
// ── 保守性(宁可不说,不可错说)────────────────────────────────────────────────
|
|
22
|
+
// 框架用的是 node-semver 的 `{ includePrerelease: true }`;仓库既有的 semver 工具里,
|
|
23
|
+
// `semverRangeMatchLoose` 与它最接近但**偏宽松**(把它当"满足"的少数情形,框架可能判不满足)。
|
|
24
|
+
// 这里**故意用宽松的那一支**:只有连宽松读法都不满足时才说话 —— 与 ①-1「不误报」同一纪律。
|
|
25
|
+
// 代价是可能漏报少数否决(表现回退成"什么都不显示",不会错说原因)。
|
|
26
|
+
|
|
27
|
+
import { readFileSync } from 'node:fs'
|
|
28
|
+
import { resolvePackageJson } from '../infra/paths.js'
|
|
29
|
+
import { semverRangeMatchLoose } from '../infra/semver.js'
|
|
30
|
+
|
|
31
|
+
/** 框架预检只看这个前缀的 peer(与 dsh-app-boot 的 `name !== '@deepseek-ai/dsh' && !name.startsWith('@deepseek-ai/dsh-')` 对齐)。 */
|
|
32
|
+
const PEER_PREFIX = '@deepseek-ai/dsh'
|
|
33
|
+
|
|
34
|
+
/** `workspace:^` / `workspace:~` / `workspace:*` 在框架里等价于「当前运行时版本」→ 永远满足。 */
|
|
35
|
+
const WORKSPACE_RANGES = new Set(['workspace:^', 'workspace:~', 'workspace:*'])
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 纯判据:返回**不满足**的 peer 列表(空数组 = 没有相关 peer 声明,或全部满足)。
|
|
39
|
+
* 与 `dsh-app-boot` 的 `evaluatePluginCompatibility` 逐条对齐(见文件头)。
|
|
40
|
+
*/
|
|
41
|
+
function unsatisfiedPeers(manifest, runtimeVersion) {
|
|
42
|
+
if (manifest === null || typeof manifest !== 'object') return []
|
|
43
|
+
if (!Object.hasOwn(manifest, 'peerDependencies')) return []
|
|
44
|
+
const deps = manifest.peerDependencies
|
|
45
|
+
if (deps === null || typeof deps !== 'object' || Array.isArray(deps)) return []
|
|
46
|
+
if (typeof runtimeVersion !== 'string' || runtimeVersion === '') return []
|
|
47
|
+
const out = []
|
|
48
|
+
for (const [name, range] of Object.entries(deps)) {
|
|
49
|
+
if (name !== PEER_PREFIX && !name.startsWith(`${PEER_PREFIX}-`)) continue
|
|
50
|
+
if (typeof range !== 'string') { out.push({ name, range: String(range) }); continue }
|
|
51
|
+
const requirement = WORKSPACE_RANGES.has(range) ? runtimeVersion : range
|
|
52
|
+
if (requirement.trim() === '' || !semverRangeMatchLoose(runtimeVersion, requirement)) out.push({ name, range })
|
|
53
|
+
}
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 短句化的「需要什么」:一项给全名,多项给首项 + 总数(面板一行放得下)。 */
|
|
58
|
+
function describePeers(peers) {
|
|
59
|
+
const first = `${peers[0].name}@${peers[0].range}`
|
|
60
|
+
return peers.length === 1 ? first : `${first} 等 ${peers.length} 项`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 行级复算(IO 可注入,便于离线断言)。
|
|
65
|
+
*
|
|
66
|
+
* 入参:`{ moduleName, profileDir, frameworkVersion }`
|
|
67
|
+
* 返回:`null`(无话可说)|`{ kind:'peers'|'missing', reason, hint }`
|
|
68
|
+
* · `kind:'peers'` —— 本地复算判定 peer 不满足(大概率就是框架否决它的原因)
|
|
69
|
+
* · `kind:'missing'` —— 包解析不到 / 清单读不出来(**另一种原因**,措辞与出路都不同)
|
|
70
|
+
*/
|
|
71
|
+
function peerVetoFor({ moduleName, profileDir, frameworkVersion, deps = {} } = {}) {
|
|
72
|
+
const { resolvePkg = resolvePackageJson, readText = (p) => readFileSync(p, 'utf8') } = deps
|
|
73
|
+
if (typeof moduleName !== 'string' || moduleName === '' || moduleName.startsWith('cordis:')) return null
|
|
74
|
+
if (typeof profileDir !== 'string' || profileDir === '') return null
|
|
75
|
+
if (typeof frameworkVersion !== 'string' || frameworkVersion === '') return null
|
|
76
|
+
let pkgPath = null
|
|
77
|
+
try { pkgPath = resolvePkg(moduleName, profileDir) } catch { pkgPath = null }
|
|
78
|
+
if (pkgPath === null) {
|
|
79
|
+
return {
|
|
80
|
+
kind: 'missing',
|
|
81
|
+
reason: `被框架否决:包不存在(${moduleName} 在本 profile 解析不到)`,
|
|
82
|
+
hint: `出路:这一行指向的包已经不在本 profile 里(升级/卸载后的残留行)——重装或更新该插件即可;确实不需要它时,在补丁里删掉或禁用它。`,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
let manifest = null
|
|
86
|
+
try { manifest = JSON.parse(readText(pkgPath)) } catch { manifest = null }
|
|
87
|
+
if (manifest === null || typeof manifest !== 'object') {
|
|
88
|
+
return {
|
|
89
|
+
kind: 'missing',
|
|
90
|
+
reason: `被框架否决:包清单读不出来(${moduleName})`,
|
|
91
|
+
hint: '出路:重装该插件——package.json 读不出来时框架自己也无法校验它,只能按最坏情况跳过挂载。',
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const peers = unsatisfiedPeers(manifest, frameworkVersion)
|
|
95
|
+
if (peers.length === 0) return null
|
|
96
|
+
return {
|
|
97
|
+
kind: 'peers',
|
|
98
|
+
reason: `被框架否决:peer 不满足(需要 ${describePeers(peers)},当前 ${frameworkVersion})`,
|
|
99
|
+
hint: `出路:把 ${moduleName} 更新到兼容当前框架的版本(首选);若它是升级后残留的旧包,从 profile 里清掉;要保留这个精确版本,就得用框架的精确版本豁免(dsh plugin allow-version / 插件管理器 → profile 的 compatibility.json)。`,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export { PEER_PREFIX, WORKSPACE_RANGES, unsatisfiedPeers, peerVetoFor }
|