@noob-stupid/dsh-plugin-console 0.5.28 → 0.5.30

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.
@@ -0,0 +1,285 @@
1
+ // L1 · domain —— preset-rows.js(预设 **plugins 行**的契约校验 + 已知别名归一;0.5.29 改错+加法)
2
+ //
3
+ // 为什么必须有它(**真机缺陷 D-⑦**,2026-09-27 定位):
4
+ // `preset-declare.js` 把 `.agent-presets/<id>/agent.cordis.yml` 原样搬成 profile 补丁里的
5
+ // `plugins:` —— 但**只做结构校验**(preset-yaml.js:空文件 / tab / 未闭合引号 / 缩进跳级)。
6
+ // 于是这样一份**结构完全合法、框架却挂不上**的 composition 被照写不误:
7
+ // - id: persona
8
+ // name: '@deepseek-ai/dsh-persona'
9
+ // config:
10
+ // text: You are a helpful software engineer assistant. # ← 该插件不认 text
11
+ // 框架侧 `@deepseek-ai/dsh-persona` 的 Config 是 `prefix: z.string().required()`,
12
+ // 装配时抛 `$.prefix missing required value`;注册表 `activate()` 把它吞成 `record.broken`,
13
+ // 界面那张卡片就永远顶着一枚红框「加载失败」(框架自己的 `brokenBadge`),用户点也点不动。
14
+ // 而**改前判据一条都拦不住**:YAML 合法、文件都在、包名都在。
15
+ //
16
+ // 本模块只做三件事,全部**证据驱动、可离线判定**(判据表见 PLUGIN_ROW_CONSTRAINTS):
17
+ // ① 行结构:不是映射 / 没有非空 `name`(框架 `entryListProblem` 同样会拒);
18
+ // ② `file:///` 引用目标**必须存在**(不存在 → 如实拒绝,绝不写一行必然加载失败的声明);
19
+ // ③ **已确证的框架契约**逐条核(缺必填键 / 用了别名键)。命中即拒绝并给可执行出路。
20
+ //
21
+ // ⚠️ 判据表**故意不猜**:只收「本机拿真机错误 + 框架真 schema 双向确证过」的条目。
22
+ // 没有证据的插件一律放行 —— 宁可漏判,也绝不因为猜错而把一个**本来可用**的预设挡在门外
23
+ // (那是把缺陷换个方向,与 preset-yaml.js 的宽严分寸同一取舍)。
24
+ //
25
+ // 分层:L1 domain —— 纯函数、无 IO、无 ctx;文件存在性 / 路径解码 / 包解析均可注入(便于离线断言)。
26
+ // 行数预算:本模块独立于 preset-declare.js,两者各自留在 600 行架构硬顶内。
27
+
28
+ import { join } from 'node:path'
29
+ import { fileURLToPath } from 'node:url'
30
+
31
+ /** 已知插件的行契约。每条都必须有**真机错误原文**背书(见文件头 D-⑦)。 */
32
+ const PLUGIN_ROW_CONSTRAINTS = [
33
+ {
34
+ /** 行 `name:` 精确匹配该插件的行才受这条约束。 */
35
+ module: '@deepseek-ai/dsh-persona',
36
+ /** 必填键(缺了框架 schema 直接抛 `$.<key> missing required value`)。 */
37
+ required: ['prefix'],
38
+ /** 报错时给用户的可执行出路。 */
39
+ hint: '@deepseek-ai/dsh-persona 只认 prefix/suffix/complete/includeRuntimeContext;'
40
+ + 'config.text 是旧版字段(0.1.5 起改名为 prefix),框架 schema 读不到它 → 请把 config.text 改名为 config.prefix',
41
+ },
42
+ ]
43
+
44
+ /**
45
+ * **插件 config 键改名迁移表 —— 全仓唯一出处。**
46
+ *
47
+ * ⚠️ 这张表原本只在 `presets.js`(`PRESET_CONFIG_MIGRATIONS`)里,**只在框架升级那一步**跑。
48
+ * 真实缺口:预设的来源不止"框架升级"一条 —— 装一个预设型子包 / 源码装配 / 手工放文件,
49
+ * 都会把 `agent.cordis.yml` 落到 `.agent-presets/`,**完全不经过升级步骤**,于是旧键原样留下。
50
+ * 本机事实(2026-09-27):`router-spec/agent.cordis.yml` 是当天 21:27 装配落盘的,
51
+ * 内容里仍是 `text:` → 声明行照写 → 卡片永远「加载失败」。所以判据要挂在**写行那一刻**。
52
+ *
53
+ * `presets.js` 从这里取同一张表,两处判据不会再分叉。
54
+ */
55
+ const PRESET_CONFIG_KEY_MIGRATIONS = [
56
+ { pkg: '@deepseek-ai/dsh-persona', from: 'text', to: 'prefix', since: '0.1.5' },
57
+ ]
58
+
59
+ /** 粗粒度版本解析(只认 `maj.min.pat`,`-rc.n` 之类预发布尾缀忽略);不可解析返回 null。 */
60
+ function parseDottedVersion(value) {
61
+ const m = String(value ?? '').match(/(\d+)\.(\d+)\.(\d+)/u)
62
+ return m === null ? null : { maj: Number(m[1]), min: Number(m[2]), pat: Number(m[3]) }
63
+ }
64
+
65
+ /** `target >= since`?任一侧不可解析 → false(**不敢迁移**比乱迁移安全)。 */
66
+ function isVersionAtLeast(target, since) {
67
+ const t = parseDottedVersion(target)
68
+ const s = parseDottedVersion(since)
69
+ if (t === null || s === null) return false
70
+ if (t.maj !== s.maj) return t.maj > s.maj
71
+ if (t.min !== s.min) return t.min > s.min
72
+ return t.pat >= s.pat
73
+ }
74
+
75
+ /**
76
+ * 纯文本版的键改名迁移(不碰盘):只改 `name:` 精确命中 `mig.pkg` 的行,且**仅当该行 config
77
+ * 里还没有真键**(已有 `prefix` 就一个字节都不动 —— 绝不覆盖用户自己写的值)。
78
+ * 返回 `{ text, migrated: [{ pkg, from, to, line }], skippedReason }`。
79
+ *
80
+ * ⚠️ `targetVersion` 为 `null`/不可解析时的取舍(**很要紧,别随手改**):
81
+ * 声明行这套机制**只在框架 0.1.7-rc.x 起才存在**(0.1.5 及更早靠目录发现,写行没有意义),
82
+ * 能走到本函数的场景必然 ≥ 0.1.7,因此**未知版本按「全部迁移都适用」处理**。
83
+ * 反过来若按 `isVersionAtLeast(null, …) === false` 处理,就会在"拿不到版本"时静默不动 ——
84
+ * 于是又写出那行必然「加载失败」的声明,把本函数存在的意义整条抹掉。
85
+ * 真机 D-⑦ 的现场正是"拿不到版本"(装配服务不知道框架版本)。
86
+ */
87
+ function migratePluginRowKeys(compositionText, targetVersion, opts = {}) {
88
+ const rules = opts.rules ?? PRESET_CONFIG_KEY_MIGRATIONS
89
+ const unknownVersion = parseDottedVersion(targetVersion) === null
90
+ const apply = unknownVersion ? [...rules] : rules.filter((r) => isVersionAtLeast(targetVersion, r.since))
91
+ const lines = String(compositionText ?? '').replace(/\r\n/gu, '\n').split('\n')
92
+ const migrated = []
93
+ if (apply.length === 0) {
94
+ return { text: lines.join('\n'), migrated, skippedReason: `目标版本 ${JSON.stringify(String(targetVersion ?? ''))} 低于全部迁移的引入版本 —— 不动用户配置` }
95
+ }
96
+ let rowName = null
97
+ let configIndent = -1
98
+ let rowIndent = -1
99
+ for (let i = 0; i < lines.length; i += 1) {
100
+ const raw = lines[i]
101
+ if (raw.trim() === '' || /^\s*#/u.test(raw)) continue
102
+ const indent = (raw.match(/^ */u) ?? [''])[0].length
103
+ const item = raw.match(/^(\s*)- id:\s*(\S+)\s*$/u)
104
+ if (item !== null) { rowIndent = indent; rowName = null; configIndent = -1; continue }
105
+ if (rowIndent < 0) continue
106
+ if (indent <= rowIndent) { rowName = null; configIndent = -1; continue }
107
+ const kv = raw.match(/^(\s*)([A-Za-z_][\w-]*):(.*)$/u)
108
+ if (kv === null) continue
109
+ const directKey = indent === rowIndent + 2
110
+ if (directKey && kv[2] === 'name') { rowName = kv[3].trim().replace(/^['"]|['"]$/gu, ''); configIndent = -1; continue }
111
+ if (directKey && kv[2] === 'config' && kv[3].trim() === '') { configIndent = indent; continue }
112
+ if (configIndent < 0 || indent <= configIndent) continue
113
+ for (const rule of apply) {
114
+ if (rule.pkg !== rowName || rule.from !== kv[2]) continue
115
+ // 该行 config 里已有真键 → 本次跳过(绝不覆盖用户自己的值)
116
+ let hasNew = false
117
+ for (let j = configIndent + 1; j < lines.length; j += 1) {
118
+ const l = lines[j]
119
+ if (l.trim() === '') continue
120
+ const ind = (l.match(/^ */u) ?? [''])[0].length
121
+ if (ind <= configIndent) break
122
+ if (new RegExp(`^\\s*${rule.to}:`, 'u').test(l)) { hasNew = true; break }
123
+ }
124
+ if (hasNew) continue
125
+ lines[i] = `${kv[1]}${rule.to}:${kv[3]}`
126
+ migrated.push({ pkg: rule.pkg, from: rule.from, to: rule.to, line: i + 1 })
127
+ }
128
+ }
129
+ return { text: lines.join('\n'), migrated, skippedReason: null }
130
+ }
131
+
132
+ /** 值归一:去引号、折行、`''`→`'`;空串一律视作**未提供**(框架 schema 对 `''` 与缺失的处理不同,这里只判存在性)。 */
133
+ /** 行解析:composition 里的 `- id:` / `name:` / `config:` + config 直属子键(只认缩进语义,不做通用 YAML)。 */
134
+ function parseCompositionRows(text) {
135
+ // ⚠️ 必须先归一 CRLF:真机磁盘上的 agent.cordis.yml 就是 CRLF(本机三个预设全是),
136
+ // 行尾的 `\r` 会让 `^(\s*)- id:\s*(\S+)\s*$` 这类判据整体失配(自测真抓到过:归一一条都不命中)。
137
+ const lines = String(text ?? '').replace(/\r\n/gu, '\n').split('\n')
138
+ const rows = []
139
+ let row = null
140
+ let configIndent = null
141
+ let blockKey = null
142
+ let blockIndent = 0
143
+ for (let index = 0; index < lines.length; index += 1) {
144
+ const raw = lines[index]
145
+ if (raw.trim() === '' || /^\s*#/u.test(raw)) {
146
+ if (blockKey !== null && row !== null) row.config[blockKey] += '\n'
147
+ continue
148
+ }
149
+ const indent = (raw.match(/^ */u) ?? [''])[0].length
150
+ if (blockKey !== null && indent > blockIndent) {
151
+ row.config[blockKey] += `${raw}\n`
152
+ continue
153
+ }
154
+ blockKey = null
155
+ const item = raw.match(/^(\s*)- id:\s*(\S+)\s*$/u)
156
+ if (item !== null) {
157
+ row = {
158
+ id: item[2].replace(/^['"]|['"]$/gu, ''),
159
+ indent,
160
+ name: null,
161
+ config: {},
162
+ line: index + 1,
163
+ }
164
+ rows.push(row)
165
+ configIndent = null
166
+ continue
167
+ }
168
+ if (row === null) continue
169
+ const kv = raw.match(/^\s*([A-Za-z_][\w-]*):\s*(.*)$/u)
170
+ if (kv === null) continue
171
+ const key = kv[1]
172
+ const value = kv[2].trim()
173
+ if (indent <= row.indent) { row = null; configIndent = null; continue }
174
+ if (indent === row.indent + 2 && key === 'name') { row.name = value.replace(/^['"]|['"]$/gu, ''); configIndent = null; continue }
175
+ if (indent === row.indent + 2 && key === 'config' && value === '') { configIndent = indent; continue }
176
+ if (key === 'id') continue
177
+ // config 直属子键:缩进必须比 `config:` 更深
178
+ if (configIndent !== null && indent > configIndent) {
179
+ row.config[key] = value
180
+ if (/^[|>][-+]?\d*$/u.test(value)) { blockKey = key; blockIndent = indent }
181
+ }
182
+ }
183
+ return rows
184
+ }
185
+
186
+ /** 默认解码器:`file:` URL → 磁盘路径(Node 自己的实现会拒绝坏百分号编码)。 */
187
+ const defaultToLocalPath = (url) => fileURLToPath(url)
188
+ /** 默认路径拼接(相对引用以预设目录为基准)。 */
189
+ const defaultJoinPath = (dir, relative) => join(dir, relative)
190
+
191
+ /** 值归一:去引号、折行、`''`→`'`;空串一律视作**未提供**(框架 schema 对 `''` 与缺失的处理不同,这里只判存在性)。 */
192
+ function normalizeConfigValue(raw) {
193
+ if (raw === undefined || raw === null) return null
194
+ const text = String(raw).trim()
195
+ if (text === '') return null
196
+ if (text.startsWith("'") && text.endsWith("'") && text.length >= 2) return text.slice(1, -1).replace(/''/gu, "'")
197
+ if (text.startsWith('"') && text.endsWith('"') && text.length >= 2) return text.slice(1, -1)
198
+ return text
199
+ }
200
+
201
+ /**
202
+ * ① 行结构 + ② `file:///` 目标存在性 + ③ 已确证契约。返回 `{ ok, problems: [{ code, row, detail, hint }] }`。
203
+ * `exists` 可注入(离线断言用);`checkFile` 为 false 时跳过 ②(只想要契约判据时用)。
204
+ */
205
+ function validatePresetPluginRows(compositionText, opts = {}) {
206
+ // `exists` 吃**磁盘路径**(不是 URL);`toLocalPath` / `joinPath` 可注入,便于离线断言。
207
+ const {
208
+ exists = null, checkFiles = true, presetDir = null,
209
+ toLocalPath = defaultToLocalPath, joinPath = defaultJoinPath,
210
+ } = opts
211
+ const problems = []
212
+ const rows = parseCompositionRows(compositionText)
213
+
214
+ if (rows.length === 0) {
215
+ problems.push({ code: 'no-plugin-rows', row: null, detail: 'composition 里没有任何 `- id:` 插件行', hint: '框架挂载空预设没有意义,请检查 agent.cordis.yml' })
216
+ return { ok: false, problems, rows: 0 }
217
+ }
218
+
219
+ for (const row of rows) {
220
+ // ① 结构:框架 `entryListProblem` 会先拒掉没有 name 的行
221
+ if (typeof row.name !== 'string' || row.name === '') {
222
+ problems.push({ code: 'row-names-no-plugin', row: row.id, detail: `行 ${row.id} 没有 name(框架要求非空字符串)`, hint: '补上 `name:`(包名或 file:/// URL)' })
223
+ continue
224
+ }
225
+ // ② file:/// 目标必须存在 —— 写一行指向不存在文件的声明,等于必然「加载失败」
226
+ if (checkFiles && /^file:\/\//iu.test(row.name)) {
227
+ let localPath = null
228
+ try {
229
+ const url = new URL(row.name)
230
+ if (url.protocol !== 'file:') throw new Error('不是 file: 协议')
231
+ // 必须真的解码成磁盘路径:坏百分号编码(`%zz`)在 URL 层是"合法"的,
232
+ // 到加载器才知道读不了 —— 那正是要在**写行之前**拦下的形状。
233
+ localPath = toLocalPath(url)
234
+ } catch (error) {
235
+ problems.push({ code: 'file-url-unparsable', row: row.id, detail: `行 ${row.id} 的 file: 引用无法解析成磁盘路径:${row.name}(${error.message})`, hint: '改成合法的 file:/// 绝对 URL(路径片段要按 URL 规则百分号编码)' })
236
+ continue
237
+ }
238
+ let present = null
239
+ try { present = exists === null ? null : exists(localPath) } catch { present = null }
240
+ if (present === false) {
241
+ problems.push({ code: 'file-target-missing', row: row.id, detail: `行 ${row.id} 引用的文件不存在:${localPath}`, hint: '该预设引用了磁盘上没有的文件 —— 请补齐文件或修正引用(绝不写必然加载失败的行)' })
242
+ }
243
+ }
244
+ // ②′ 相对引用(`./x.mjs` / `../x.mjs`):声明行落在 **profile 补丁**里,相对基准变成 profile 目录,
245
+ // 所以预设目录里不存在同名文件的相对引用**改不过去、留着也必然加载失败**。
246
+ // 改前行为是"照写、留给读回核实判红" —— 那已经**写坏用户补丁**了(自测真抓到过这个形状:
247
+ // ok:false + verify-failed + 补丁已被改动)。契约校验必须在**动手写之前**拦下它。
248
+ if (checkFiles && /^\.\.?\//u.test(row.name) && presetDir !== null) {
249
+ const target = joinPath(presetDir, row.name)
250
+ let present = null
251
+ try { present = exists === null ? null : exists(target) } catch { present = null }
252
+ if (present === false) {
253
+ problems.push({
254
+ code: 'relative-target-missing',
255
+ row: row.id,
256
+ detail: `行 ${row.id} 用相对引用 \`${row.name}\`,但预设目录里没有这个文件(${target})`,
257
+ hint: '相对引用在 profile 补丁里以 **profile 目录**为基准、且预设目录里没有该文件可改写 —— '
258
+ + '请补齐该文件,或把这一行改成真实存在的包名 / file:/// 绝对 URL(绝不写必然加载失败的行)',
259
+ })
260
+ }
261
+ }
262
+ // ③ 已确证的框架契约
263
+ for (const rule of PLUGIN_ROW_CONSTRAINTS) {
264
+ if (row.name !== rule.module) continue
265
+ const keys = Object.keys(row.config)
266
+ const present = keys.filter((k) => normalizeConfigValue(row.config[k]) !== null)
267
+ const missing = rule.required.filter((k) => !present.includes(k))
268
+ if (missing.length > 0) {
269
+ problems.push({
270
+ code: 'missing-required-config',
271
+ row: row.id,
272
+ detail: `行 ${row.id}(${row.name})缺少必填 config 键 ${missing.map((k) => `\`${k}\``).join('、')}`
273
+ + `(现有键:${keys.length === 0 ? '无' : keys.map((k) => `\`${k}\``).join('、')})—— 框架 schema 会抛 \`$.${missing[0]} missing required value\``,
274
+ hint: rule.hint,
275
+ })
276
+ }
277
+ }
278
+ }
279
+ return { ok: problems.length === 0, problems, rows: rows.length }
280
+ }
281
+
282
+ export {
283
+ PLUGIN_ROW_CONSTRAINTS, PRESET_CONFIG_KEY_MIGRATIONS, isVersionAtLeast, migratePluginRowKeys,
284
+ normalizeConfigValue, parseCompositionRows, parseDottedVersion, validatePresetPluginRows,
285
+ }
@@ -6,6 +6,7 @@ import { join } from 'node:path'
6
6
  import { isVersionAtLeast } from './framework.js'
7
7
  import { escapeRegExp } from '../infra/mask.js'
8
8
  import { dshHome } from '../infra/paths.js'
9
+ import { PRESET_CONFIG_KEY_MIGRATIONS } from './preset-rows.js'
9
10
 
10
11
  /**
11
12
  * 预设/组合文件的配置迁移门禁(2026-09-10 事故):
@@ -13,10 +14,12 @@ import { dshHome } from '../infra/paths.js'
13
14
  * 适配门只扫「已装插件包源码」,**预设不在其中**(~/.dsh/.agent-presets/<name>/agent.cordis.yml),
14
15
  * 于是升级后预设挂载失败 → 服务/会话起不来(这次就是这么挂的)。
15
16
  * 升级前按目标版本扫描并自动迁移(写 .bak 备份),返回迁移清单供步骤展示。
17
+ *
18
+ * ⚠️ 0.5.29 起这张表**搬到 preset-rows.js**(全仓唯一出处):升级只是预设落盘的五条路之一,
19
+ * 装预设型子包 / 源码装配 / 手工放文件都不经过这里,于是旧键会原样留下、声明行照写、卡片永远「加载失败」
20
+ * (真机 D-⑦)。preset-declare.js 现在在**写行那一刻**用同一张表做纯文本迁移,判据不会再分叉。
16
21
  */
17
- const PRESET_CONFIG_MIGRATIONS = [
18
- { pkg: '@deepseek-ai/dsh-persona', from: 'text', to: 'prefix', since: '0.1.5' },
19
- ]
22
+ const PRESET_CONFIG_MIGRATIONS = PRESET_CONFIG_KEY_MIGRATIONS
20
23
 
21
24
  /** 待扫描的 agent 配置文件:所有预设 + profile 的 host 组合。 */
22
25
  function collectAgentConfigFiles(profileDir) {
@@ -33,14 +33,35 @@ const CMD_META_RE = /[\^&|<>()]/gu
33
33
  * · `"a" "b"` + windowsVerbatimArguments → `/s` 会把首尾两个引号一起剥掉 → 也失败
34
34
  * · `""a" "b""` + windowsVerbatimArguments → 正确解析成两个参数 ✓(含空格 / `&` / `|` / `>` 的参数
35
35
  * 在段内加 `^` 转义后也原样到达)
36
- * 所以本函数产出的是"**内层**命令字符串"(形如 `"corepack" "pnpm" "add" "x"`,**永不含 `\"`**),
36
+ * 所以本函数产出的是"**内层**命令字符串"(形如 `corepack pnpm add x`,**永不含 `\"`**;只有含空白的段才带引号 —— 2026-09-28 改错,见函数内注释),
37
37
  * 最外层那对引号由 cmdShellArgv 负责加。
38
38
  * 已知边界(如实记,不假装覆盖):`%VAR%` 与开启延迟展开时的 `!VAR!` 是 cmd 自己的展开,
39
39
  * **任何引号/`^` 都挡不住**;我们生成的 argv(包名、registry、布尔选项、绝对路径)不含这两个字符,
40
40
  * 真出现时该通道会失败并如实报错(不会静默跑成别的命令)。 */
41
41
  function cmdCommandLine(parts) {
42
42
  return parts
43
- .map((part) => `"${String(part).replace(CMD_META_RE, (ch) => `^${ch}`)}"`)
43
+ .map((part) => String(part))
44
+ .map((text) => {
45
+ // 2026-09-28 改错(真机:`cmd-corepack` 一条都跑不动 → pnpm-runners / suite-fallback /
46
+ // install-smoke 三套红,报 `cmd-corepack :: status=1 out=""`):
47
+ // `corepack` 在本机是 **cmd 批处理 shim**(`D:\nvm4w\nodejs\corepack.cmd` →
48
+ // `node …\corepack\dist\corepack.js %*`)。对批处理,cmd **不会剥掉参数外层的引号**,
49
+ // 引号会随 `%*` 一路进到 node 的 argv —— 于是 corepack 收到的是 `"pnpm"` / `"--version"`
50
+ // (带引号当成了包管理器名)→ 直接崩:status=1、stdout 空。
51
+ // 真 cmd.exe + windowsVerbatimArguments 实测(同一台机):
52
+ // · `""corepack" "pnpm" "--version""` → status=1、stdout=""(现行形态,坏)
53
+ // · `"corepack pnpm --version"` → status=0、stdout="11.21.0"(按需引号,好)
54
+ // 所以**只为含空白的段加引号**;其余裸串 + `^` 转义 cmd 元字符(裸串里 `^` 是标准且可靠的转义)。
55
+ // 含空白的段仍走「引号 + 段内 ^ 转义」,与既有行为一致,不改语义。
56
+ // 已知边界(如实记):段内含 `"` 时无法在 cmd 里安全表达 —— 我们生成的 argv(包名/registry/布尔
57
+ // 选项/绝对路径)不含裸引号;真出现时该通道会失败并如实报错,不会静默跑成别的命令。
58
+ // 「干净 token」(无空白、无 cmd 元字符)→ 裸串。**只有这种情况**能去引号:
59
+ // 含空白的段必须带引号;含 `& | < > ( ) ^` 的段,实测**裸串 + `^` 转义会失效**
60
+ // (真机 ⑥:裸的 `a^&b` 被 cmd 当分隔符断开,收到 `[a`),必须沿用「引号 + 段内 ^」的既有形态。
61
+ const clean = !/[\s]/u.test(text) && !/[\^&|<>()]/u.test(text)
62
+ if (clean) return text
63
+ return `"${text.replace(CMD_META_RE, (ch) => `^${ch}`)}"`
64
+ })
44
65
  .join(' ')
45
66
  }
46
67
 
@@ -0,0 +1,99 @@
1
+ // L0 · infra —— framework-root.js(框架运行时解析基准;0.5.30 加法)
2
+ //
3
+ // 为什么必须有它(**真机事故 2026-09-28 / 2026-09-29**):
4
+ // 框架 0.2.0-rc.1 把 `@deepseek-ai/*` 的实体全部搬进 `.pnpm` **内部**那层,profile 顶层
5
+ // projection 还停在旧版(本机实测:顶层 239 个 `@deepseek-ai` 目录仍是 0.1.5-rc.2 的残影)。
6
+ // 任何"只拿 profileDir 当解析基准"的判断都会把**真存在的框架内置包**(`dsh-agent-preset`、
7
+ // `dsh-workflow-ptc`)判成"缺失" —— 实测后果:`healPatchSafety()` 给它们自动写了 4 个
8
+ // `disabled: true` 块(2026-09-29 01:05:44),把用户的 preset-router-* 三条全停掉。
9
+ //
10
+ // 所以「框架内置包在不在」必须走**多基准并集**:任一基准能解析即算命中。
11
+ // 本模块把该判据集中一处,供只读体检(routes/patch-audit.js)与补丁自愈(domain/patch.js)共用 ——
12
+ // 两处若各写一份,下次框架再挪目录就会只有一处被修好。
13
+ //
14
+ // 行数预算:独立小模块,留在 600 行架构硬顶内。
15
+
16
+ import { existsSync } from 'node:fs'
17
+ import { dirname, isAbsolute, join, resolve, sep } from 'node:path'
18
+ import { resolvePackageJson } from './paths.js'
19
+
20
+ /** 框架命名空间:`@deepseek-ai/*` 随框架版本走,承载者永远是框架安装树。 */
21
+ const FRAMEWORK_SCOPE = '@deepseek-ai/'
22
+
23
+ /** 包名归一:`@scope/name/sub` → `@scope/name`;裸名原样。 */
24
+ function basePackageName(moduleName) {
25
+ const text = String(moduleName ?? '')
26
+ if (text.startsWith('@')) {
27
+ const parts = text.split('/')
28
+ return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : text
29
+ }
30
+ return text.split('/')[0]
31
+ }
32
+
33
+ /** 归一后的包名是否属于框架命名空间(`@deepseek-ai/dsh-*`)。 */
34
+ function isFrameworkModuleName(moduleName) {
35
+ const name = basePackageName(moduleName)
36
+ return name === FRAMEWORK_SCOPE.slice(0, -1) || name.startsWith(FRAMEWORK_SCOPE)
37
+ }
38
+
39
+ /** 路径是否落在给定根目录内(大小写不敏感,Windows 与 POSIX 都成立)。 */
40
+ function isUnder(childPath, rootDir) {
41
+ if (typeof childPath !== 'string' || childPath === '' || typeof rootDir !== 'string' || rootDir === '') return false
42
+ let child = resolve(childPath).toLowerCase()
43
+ let root = resolve(rootDir).toLowerCase()
44
+ if (!root.endsWith(sep)) root += sep
45
+ if (!child.endsWith(sep)) child += sep
46
+ return child.startsWith(root)
47
+ }
48
+
49
+ /**
50
+ * 框架运行时解析基准(**有序**,先准后宽):
51
+ * ① `@deepseek-ai/dsh` 所在的那层 `node_modules`(= `.pnpm` 实体内部,框架内置包的真正家)
52
+ * ② 从 ① 向上逐级找到的第一个含 `.pnpm` 的目录(与 ① 同层时去重)
53
+ * ③ `@deepseek-ai/dsh` 解析不出来时的兜底:`baseDir` / `profileDir` 自身(供离线测试桩使用)
54
+ * 返回去重后的绝对路径数组;一个都拿不到时返回 `[]`(调用方必须把空数组当"无法判定"而非"缺失")。
55
+ */
56
+ function frameworkBases(baseDir, profileDir) {
57
+ const out = []
58
+ const push = (dir) => {
59
+ if (typeof dir !== 'string' || dir === '' || !isAbsolute(dir)) return
60
+ const norm = resolve(dir)
61
+ if (!out.includes(norm)) out.push(norm)
62
+ }
63
+ let found = null
64
+ // base 有两种合法写法(`…/profiles/web` 这种**含 node_modules 的目录**,或 `…/.pnpm/<pkg>/node_modules`
65
+ // 这种 **node_modules 本身**);resolvePackageJson 只认前者,所以后者要退一级再试 ——
66
+ // 否则框架基准这一路永远解析不到 `@deepseek-ai/dsh`,整个框架基准列表会退化成兜底值。
67
+ for (const base of [baseDir, profileDir]) {
68
+ if (typeof base !== 'string' || base === '') continue
69
+ for (const cand of [base, dirname(base)]) {
70
+ if (typeof cand !== 'string' || cand === '') continue
71
+ try {
72
+ found = resolvePackageJson('@deepseek-ai/dsh', cand, profileDir)
73
+ } catch { found = null }
74
+ if (found !== null) break
75
+ }
76
+ if (found !== null) break
77
+ }
78
+ if (found !== null) {
79
+ // ★ 2026-09-30 改错(本批修 A 事故时抓到):这里过去是 `dirname(dirname(pkgPath))` ——
80
+ // pkgPath 形如 `<…>/node_modules/@deepseek-ai/dsh/package.json`,两层 dirname 只退到
81
+ // **scope 目录** `<…>/node_modules/@deepseek-ai`(实测 `existsSync(inner@deepseek-ai)` = false),
82
+ // 拿它当解析基准必然解析不到任何包。正确是**三层**:package.json → dsh → @deepseek-ai → node_modules。
83
+ const inner = dirname(dirname(dirname(found)))
84
+ push(inner)
85
+ let dir = inner
86
+ for (let i = 0; i < 6; i += 1) {
87
+ if (existsSync(join(dir, '.pnpm'))) { push(dir); break }
88
+ const parent = dirname(dir)
89
+ if (parent === dir) break
90
+ dir = parent
91
+ }
92
+ }
93
+ // 兜底:框架树找不到时,至少把 profile 目录本身当基准 —— 否则 `bases` 为空会让
94
+ // 自愈把所有 insert 行都判成"无法确认"(宁缺勿滥,但那会让真缺失的行不再被停用)。
95
+ if (out.length === 0) { push(profileDir); push(baseDir) }
96
+ return out
97
+ }
98
+
99
+ export { FRAMEWORK_SCOPE, basePackageName, frameworkBases, isFrameworkModuleName, isUnder }
@@ -0,0 +1,73 @@
1
+ // L0 · infra —— package-resolve.js(Node 解析器封装:包名可解析性 + 可用包名枚举;0.5.30 加法)
2
+ //
3
+ // 为什么不用「store 目录名」判断包在不在(真机教训 2026-09-28):
4
+ // pnpm 会**截断超长目录名**(`@deepseek-ai+dsh-workflow-ptc` → `@deepseek-ai+dsh-workflow-p_<hash>`),
5
+ // 按目录名前缀匹配会把**存在的包**误判成不存在 —— 本机实测:`dsh-workflow-ptc`、`dsh-agent-preset`
6
+ // 两个真存在的包都被目录名法误报。所以体检一律走 **Node 自己的解析器**(与框架加载补丁行同一套解析),
7
+ // 并在多个基准上取并集:① profile 目录(第三方插件在这里)② 框架运行时根(`@deepseek-ai/*` 在这里)。
8
+
9
+ import { createRequire } from 'node:module'
10
+ import { existsSync, readdirSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+
13
+ /**
14
+ * 基准目录 → `createRequire` 的探针位置。
15
+ * base 有两种合法写法,本函数把两者都收下(判据:base 自己是不是就叫 node_modules):
16
+ * · base 是**含 node_modules 的目录**(如 profile 目录 `<…>/profiles/web`)→ 探针 `<base>/__patch_audit_probe__.js`
17
+ * · base 本身就是 **node_modules 目录**(如 `…/.pnpm/@deepseek-ai+dsh@…/node_modules`)→ 探针放它内部
18
+ * 为什么必须兼容第二种:框架内置包的实体只存在于 `.pnpm/<pkg>/node_modules/` 这一层,
19
+ * 说"框架基准"时最自然的写法就是那层本身;若一律拼 `<base>/node_modules/…`,
20
+ * 就会去找 `<…>/node_modules/node_modules` —— **真存在的包被判成不存在**(本套 ① 段即是回归)。
21
+ */
22
+ function probePathOf(base) {
23
+ return base.replace(/[\\/]+$/u, '').toLowerCase().endsWith('node_modules')
24
+ ? join(base, '__patch_audit_probe__.js')
25
+ : join(base, 'node_modules', '__patch_audit_probe__.js')
26
+ }
27
+
28
+ /** 造多基准解析器:任一基准能解析即算命中(`from` 如实回带,便于报告里说明"从哪解析到的")。 */
29
+ function makePackageResolver(bases) {
30
+ const reqs = []
31
+ for (const base of (Array.isArray(bases) ? bases : [])) {
32
+ if (typeof base !== 'string' || base === '') continue
33
+ try { reqs.push(createRequire(probePathOf(base))) } catch {}
34
+ }
35
+ return (name) => {
36
+ for (const req of reqs) {
37
+ try { return { ok: true, from: req.resolve(name) } } catch {}
38
+ }
39
+ return { ok: false, reason: '所有基准都解析不到(包可能已被移除/改名,或该子路径不存在)' }
40
+ }
41
+ }
42
+
43
+ /** 枚举一个 node_modules 目录下可见的包名(含 scope 内一层)。 */
44
+ function listPackageNames(nodeModulesDir, limit = 4000) {
45
+ const out = []
46
+ try {
47
+ for (const entry of readdirSync(nodeModulesDir, { withFileTypes: true })) {
48
+ if (out.length >= limit) break
49
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue
50
+ if (entry.name.startsWith('@')) {
51
+ for (const sub of readdirSync(join(nodeModulesDir, entry.name), { withFileTypes: true })) {
52
+ if (out.length >= limit) break
53
+ if (sub.isDirectory() && !sub.name.startsWith('.')) out.push(`${entry.name}/${sub.name}`)
54
+ }
55
+ continue
56
+ }
57
+ out.push(entry.name)
58
+ }
59
+ } catch {}
60
+ return out
61
+ }
62
+
63
+ /** 合并多个 node_modules 目录下的包名(去重)—— 供「疑似改名」当候选集。 */
64
+ function listAvailablePackages(dirs) {
65
+ const set = new Set()
66
+ for (const dir of (Array.isArray(dirs) ? dirs : [])) {
67
+ if (typeof dir !== 'string' || dir === '' || !existsSync(dir)) continue
68
+ for (const name of listPackageNames(dir)) set.add(name)
69
+ }
70
+ return [...set]
71
+ }
72
+
73
+ export { listAvailablePackages, listPackageNames, makePackageResolver }
@@ -0,0 +1,64 @@
1
+ // L0 · infra —— registry-versions.js(registry 元数据探针:包名 → 某版本是否已发布;0.5.30 加法)
2
+ //
3
+ // 用途:**升级前**核对「profile 补丁里引用的框架包,在目标版本里还在不在」。
4
+ // 真机事故 2026-09-28:0.2.0-rc.1 移除了 `@deepseek-ai/dsh-workflow-worker-thread`(引擎改为 ptc),
5
+ // 补丁仍指向旧包 → 升级后 `workflowEngine` 无提供方 → 老会话 resume 失败。
6
+ // 这类「官方移除/改名」在升级前**只能用 registry 元数据看见**(目标版本还没装到本机)。
7
+ //
8
+ // 三个取舍:
9
+ // ① 同一次预检里**同一个包只查一次**(内存缓存)—— 补丁里同名包可能出现在多处;
10
+ // ② 网络失败/元数据缺失 → 返回 **null(未知)**,绝不当作「不存在」;
11
+ // ③ 小并发(默认 4)+ 单请求超时,避免预检被 30 个包串行拖死。
12
+
13
+ import { fetchJsonUrl } from './http.js'
14
+
15
+ /** 包名 → URL 片段(`@a/b` → `@a%2fb`)。 */
16
+ function encodePackageName(name) {
17
+ return String(name).replace('/', '%2f')
18
+ }
19
+
20
+ /** 造版本探针:`(name) => { ok, versions?, error? }`(带内存缓存)。 */
21
+ function makeVersionProbe({ registry, fetchJson, timeoutMs = 9000, cache = new Map() } = {}) {
22
+ const fetchImpl = typeof fetchJson === 'function' ? fetchJson : (url, t) => fetchJsonUrl(url, t)
23
+ return async (name) => {
24
+ if (cache.has(name)) return cache.get(name)
25
+ let result
26
+ try {
27
+ const meta = await fetchImpl(`${registry}/${encodePackageName(name)}`, timeoutMs)
28
+ const versions = meta && typeof meta === 'object' && meta.versions !== null && typeof meta.versions === 'object'
29
+ ? Object.keys(meta.versions)
30
+ : []
31
+ result = { ok: true, versions }
32
+ } catch (error) {
33
+ result = { ok: false, error: String(error?.message ?? error) }
34
+ }
35
+ cache.set(name, result)
36
+ return result
37
+ }
38
+ }
39
+
40
+ /** 批量核对:`[{name, lines}]` → `[{name, lines, published: true|false|null}]`(小并发,保序)。 */
41
+ async function checkPublishedVersions(entries, { probe, version, concurrency = 4 } = {}) {
42
+ const list = Array.isArray(entries) ? entries : []
43
+ const width = Math.max(1, Number.isFinite(concurrency) && concurrency > 0 ? Math.floor(concurrency) : 4)
44
+ const out = new Array(list.length)
45
+ let cursor = 0
46
+ const worker = async () => {
47
+ for (;;) {
48
+ const index = cursor
49
+ cursor += 1
50
+ if (index >= list.length) return
51
+ const entry = list[index]
52
+ let published = null
53
+ try {
54
+ const res = await probe(entry.name)
55
+ if (res?.ok === true) published = Array.isArray(res.versions) ? res.versions.includes(version) : null
56
+ } catch { published = null }
57
+ out[index] = { name: entry.name, lines: Array.isArray(entry.lines) ? entry.lines : [], published }
58
+ }
59
+ }
60
+ await Promise.all(Array.from({ length: Math.min(width, list.length) }, () => worker()))
61
+ return out.filter((x) => x !== undefined)
62
+ }
63
+
64
+ export { checkPublishedVersions, encodePackageName, makeVersionProbe }
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { dirname } from 'node:path'
15
15
  import { compareFingerprint, computeFingerprint, readCompatMode, readFingerprint, readRollbackRecord, stampFingerprint, writeCompatMode } from '../domain/compat-state.js'
16
+ import { NOTICE_MODES, detectCompat, readCompatAck, readCompatGate, writeCompatAck, writeCompatGate } from '../domain/compat.js'
16
17
  import { currentFrameworkVersion } from '../domain/framework.js'
17
18
  import { applyDependencyBackfill, planDependencyBackfill } from '../domain/manifest.js'
18
19
  import { preferredRegistries } from '../domain/sources.js'
@@ -127,4 +128,43 @@ async function routeDeclareInstalled(req, res, rc) {
127
128
  }
128
129
  }
129
130
 
130
- export { routeCompatMode, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled }
131
+ /** 兼容提示:手动关闭 / 设定展示模式(2026-09-28 加法)。
132
+ *
133
+ * 背景(用户实测):面板顶部那句「当前 DSH web 包版本 X 不在受支持的 0.1.x 系列内…」是**披露**,
134
+ * 但它以前**关不掉** —— 纯 `<p>`、没有关闭入口、也没有持久化。用户诉求:可以手动关。
135
+ *
136
+ * 两种入参(互斥,按字段判断):
137
+ * · `{ webAppVersion }` → 记住"这个版本我看过了":同版本不再提示,**框架版本一变重新提示**
138
+ * · `{ mode }` → 展示模式 `always` / `once-per-version`(默认)/ `off`
139
+ *
140
+ * ⚠ 硬约束:**只影响展示,不改 `supported`** —— 门控判定与"该不该拦"一个字都不变,
141
+ * 披露不能被关成"看不见的风险"。版本还会校验:只接受**当前探测到**的那一个,
142
+ * 避免把 ack 记到别的版本上(否则升到新版本时会被误吞)。
143
+ */
144
+ async function routeCompatAck(req, res, rc) {
145
+ const body = rc.body ?? {}
146
+ if (typeof body.mode === 'string') {
147
+ const mode = body.mode.trim().toLowerCase()
148
+ if (!NOTICE_MODES.includes(mode)) {
149
+ sendError(res, 400, `未知的提示模式:${body.mode}(可选 ${NOTICE_MODES.join(' / ')})`)
150
+ return
151
+ }
152
+ const gate = writeCompatGate({ noticeMode: mode })
153
+ sendJson(res, 200, { ok: true, noticeMode: gate.noticeMode, ack: readCompatAck() })
154
+ return
155
+ }
156
+ const version = typeof body.webAppVersion === 'string' ? body.webAppVersion.trim() : ''
157
+ if (version === '' || !/^\d+\.\d+\.\d+/u.test(version)) {
158
+ sendError(res, 400, 'webAppVersion 缺失或格式不对(形如 0.2.0-rc.1)')
159
+ return
160
+ }
161
+ let detected = null
162
+ try { detected = (await detectCompat(rc?.ctx?.baseUrl ?? 'file:///')).webAppVersion } catch {}
163
+ if (detected !== null && detected !== version) {
164
+ sendError(res, 400, `版本不匹配:当前探测到 ${detected},请求里是 ${version}(请刷新后重试)`)
165
+ return
166
+ }
167
+ const ack = writeCompatAck(version)
168
+ sendJson(res, 200, { ok: true, ack, noticeMode: readCompatGate().noticeMode })
169
+ }
170
+ export { routeCompatAck, routeCompatMode, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled }