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

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.
@@ -31,9 +31,10 @@
31
31
 
32
32
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
33
33
  import { basename, dirname, join, resolve } from 'node:path'
34
- import { pathToFileURL } from 'node:url'
34
+ import { fileURLToPath, pathToFileURL } from 'node:url'
35
35
  import { queuedWrite } from '../infra/fsx.js'
36
36
  import { validateAgentConfig } from './preset-yaml.js'
37
+ import { migratePluginRowKeys, validatePresetPluginRows } from './preset-rows.js'
37
38
 
38
39
  /** 声明行指向的模块名(框架侧预设插件,0.1.7-rc.2 起才有)。 */
39
40
  const AGENT_PRESET_MODULE = '@deepseek-ai/dsh-agent-preset'
@@ -377,14 +378,40 @@ function buildPresetDeclaration(dir, id, opts = {}) {
377
378
  if (verdict.ok !== true) {
378
379
  return { ok: false, reason: 'invalid-composition', detail: `agent.cordis.yml 未通过结构校验:${verdict.problems[0] ?? ''}` }
379
380
  }
380
- const absolutized = absolutizeComposition(composition.text, dir, opts)
381
+ // ① 插件 config 旧键迁移(**加法**:把框架 schema 根本读不到的旧键改成真键;表见 preset-rows.js)。
382
+ // 与框架升级那一步**同一张表**(PRESET_CONFIG_KEY_MIGRATIONS),只是判据挂在**写行那一刻** ——
383
+ // 预设还会经「装预设型子包 / 源码装配 / 手工放文件」落盘,那些路完全不过升级步骤(真机 D-⑦)。
384
+ // 只动唯一命中、且**该行还没有真键**的那一行;版本判据过不去就一个字都不动。
385
+ const migrationVersion = opts.frameworkVersion ?? null
386
+ const migratedKeys = migratePluginRowKeys(composition.text, migrationVersion)
387
+ // ② 写行**之前**的契约校验:行结构 / `file:///` 目标存在性 / 已确证的框架契约。
388
+ // 命中即**如实拒绝写入** —— 绝不往用户 profile 里写一行必然「加载失败」的声明。
389
+ const rowsVerdict = validatePresetPluginRows(migratedKeys.text, {
390
+ checkFiles: true,
391
+ presetDir: dir,
392
+ exists: (localPath) => existsSync(localPath),
393
+ })
394
+ if (rowsVerdict.ok !== true) {
395
+ const first = rowsVerdict.problems[0]
396
+ return {
397
+ ok: false,
398
+ reason: first.code === 'file-target-missing' || first.code === 'file-url-unparsable' || first.code === 'relative-target-missing'
399
+ ? 'unresolvable-plugin-file'
400
+ : 'invalid-plugin-rows',
401
+ detail: `预设的 plugins 行未通过契约校验:${rowsVerdict.problems.map((p) => p.detail).join(';')}`
402
+ + `(出路:${first.hint})`,
403
+ problems: rowsVerdict.problems,
404
+ }
405
+ }
406
+ const absolutized = absolutizeComposition(migratedKeys.text, dir, opts)
381
407
  const compositionText = normalizeComposition(absolutized.text)
382
408
  const meta = presetDisplayMeta(dir, presetId)
383
409
  const config = { id: presetId, name: meta.name, description: meta.description, order: meta.order }
384
410
  const rowId = `${PRESET_ROW_PREFIX}${presetId}`
385
411
  return {
386
412
  ok: true, id: presetId, rowId, dir, config, compositionText,
387
- compositionFile: composition.file, rewritten: absolutized.rewritten,
413
+ compositionFile: composition.file, rewritten: absolutized.rewritten, aliased: migratedKeys.migrated,
414
+ migrationNote: migratedKeys.skippedReason,
388
415
  rowText: renderPresetRow(config, compositionText),
389
416
  }
390
417
  }
@@ -424,16 +451,17 @@ function declarePresetRow(dir, id, opts = {}) {
424
451
  const {
425
452
  patchPath = null, profileDir = null, now = Date.now,
426
453
  backup = true, verify = true, toFileUrl = pathToFileURL, exists = existsSync,
454
+ frameworkVersion = null,
427
455
  } = opts
428
- const built = buildPresetDeclaration(dir, id, { toFileUrl, exists })
456
+ const built = buildPresetDeclaration(dir, id, { toFileUrl, exists, frameworkVersion })
429
457
  if (built.ok !== true) {
430
- return { ok: false, status: 'failed', reason: built.reason, detail: built.detail, rowId: null, patchPath: null, backup: null, changed: false, verified: null, rewritten: [], note: built.detail }
458
+ return { ok: false, status: 'failed', reason: built.reason, detail: built.detail, rowId: null, patchPath: null, backup: null, changed: false, verified: null, rewritten: [], aliased: [], problems: built.problems ?? [], note: built.detail }
431
459
  }
432
460
  const target = resolvePatchPath({ patchPath, profileDir })
433
461
  if (target === null) {
434
462
  return {
435
463
  ok: false, status: 'skipped', reason: 'no-patch-path', rowId: built.rowId, patchPath: null, backup: null,
436
- changed: false, verified: null, rewritten: built.rewritten, config: built.config,
464
+ changed: false, verified: null, rewritten: built.rewritten, aliased: built.aliased, config: built.config,
437
465
  note: `未写入声明行:本次调用没有目标 profile(既没有 patchPath 也没有 profileDir)—— `
438
466
  + `**文件已就位,但当前框架版本(0.1.7-rc.x 起)需要声明行才能显示**;请在目标 profile 的 cordis.patch.yml 里补一行 preset-${built.id}`,
439
467
  }
@@ -443,7 +471,7 @@ function declarePresetRow(dir, id, opts = {}) {
443
471
  if (!exists(dirname(target))) {
444
472
  return {
445
473
  ok: false, status: 'failed', reason: 'no-profile-dir', rowId: built.rowId, patchPath: target, backup: null,
446
- changed: false, verified: null, rewritten: built.rewritten, config: built.config,
474
+ changed: false, verified: null, rewritten: built.rewritten, aliased: built.aliased, config: built.config,
447
475
  detail: `目标 profile 目录不存在:${dirname(target)}`,
448
476
  note: `未写入声明行:目标 profile 目录不存在(${dirname(target)})—— **文件已就位,但当前框架版本需要声明行才能显示**`,
449
477
  }
@@ -456,7 +484,7 @@ function declarePresetRow(dir, id, opts = {}) {
456
484
  if (outcome.changed !== true) {
457
485
  return {
458
486
  ok: true, status: 'unchanged', rowId: built.rowId, patchPath: target, backup: null, changed: false,
459
- verified: verify === true ? verifyDeclaredRow(before, built) : null, rewritten: built.rewritten, config: built.config,
487
+ verified: verify === true ? verifyDeclaredRow(before, built) : null, rewritten: built.rewritten, aliased: built.aliased, config: built.config,
460
488
  removed: outcome.removed,
461
489
  // 与 created/updated 用**同一句**核心文案:幂等重装时用户同样要看懂"它在、重启后可选",
462
490
  // 只多一句"本次未改动任何字节"(诚实交代用户的文件没被动过)。
@@ -471,7 +499,7 @@ function declarePresetRow(dir, id, opts = {}) {
471
499
  } catch (error) {
472
500
  return {
473
501
  ok: false, status: 'failed', reason: 'backup-failed', rowId: built.rowId, patchPath: target, backup: null,
474
- changed: false, verified: null, rewritten: built.rewritten, config: built.config,
502
+ changed: false, verified: null, rewritten: built.rewritten, aliased: built.aliased, config: built.config,
475
503
  detail: `备份失败(绝不在没备份的情况下改用户补丁):${error instanceof Error ? error.message : String(error)}`,
476
504
  note: '未写入声明行:改前备份失败,已放弃写入(补丁文件一个字节都没动)',
477
505
  }
@@ -483,7 +511,7 @@ function declarePresetRow(dir, id, opts = {}) {
483
511
  } catch (error) {
484
512
  return {
485
513
  ok: false, status: 'failed', reason: 'write-failed', rowId: built.rowId, patchPath: target, backup: backupPath,
486
- changed: false, verified: null, rewritten: built.rewritten, config: built.config,
514
+ changed: false, verified: null, rewritten: built.rewritten, aliased: built.aliased, config: built.config,
487
515
  detail: `写入失败:${error instanceof Error ? error.message : String(error)}`,
488
516
  note: `写入声明行失败(${error instanceof Error ? error.message : String(error)})—— `
489
517
  + '**文件已就位,但当前框架版本(0.1.7-rc.x 起)需要声明行才能显示**',
@@ -494,7 +522,7 @@ function declarePresetRow(dir, id, opts = {}) {
494
522
  if (readBack === null) {
495
523
  return {
496
524
  ok: false, status: 'failed', reason: 'readback-failed', rowId: built.rowId, patchPath: target, backup: backupPath,
497
- changed: false, verified: null, rewritten: built.rewritten, config: built.config,
525
+ changed: false, verified: null, rewritten: built.rewritten, aliased: built.aliased, config: built.config,
498
526
  detail: '写后读回失败(读不到文件)', note: '写入声明行后读回失败:无法确认是否写成,请人工检查该补丁文件',
499
527
  }
500
528
  }
@@ -506,10 +534,14 @@ function declarePresetRow(dir, id, opts = {}) {
506
534
  reason: ok ? null : 'verify-failed',
507
535
  detail: ok ? null : `读回核实不通过:${checked.problems.join(';')}`,
508
536
  rowId: built.rowId, patchPath: target, backup: backupPath, changed: true,
509
- verified: checked, rewritten: built.rewritten, config: built.config, removed: outcome.removed,
537
+ verified: checked, rewritten: built.rewritten, aliased: built.aliased, config: built.config, removed: outcome.removed,
510
538
  note: ok
511
539
  ? presetDeclarationSentence(built.rowId, target)
512
540
  + (built.rewritten.length > 0 ? `;composition 里 ${built.rewritten.length} 处相对文件已改写为 file: URL(基准是 profile 目录,不是预设目录)` : '')
541
+ + (built.aliased.length > 0
542
+ ? `;composition 里 ${built.aliased.length} 处插件旧键已按框架 schema 迁移(`
543
+ + `${built.aliased.map((r) => `${r.pkg} 的 ${r.from} → ${r.to}(第 ${r.line} 行)`).join('、')})`
544
+ : '')
513
545
  + extra
514
546
  : `声明行写入后读回核实不通过:${checked.problems.join(';')}(已备份到 ${backupPath},请人工检查)`,
515
547
  }
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.28",
3
+ "version": "0.5.29",
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",