@noob-stupid/dsh-plugin-console 0.3.67 → 0.4.0

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.
Files changed (48) hide show
  1. package/lib/client.js +126 -31
  2. package/lib/index.js +60 -9687
  3. package/lib/server/domain/ai-run.js +479 -0
  4. package/lib/server/domain/ai.js +246 -0
  5. package/lib/server/domain/compat.js +474 -0
  6. package/lib/server/domain/components.js +108 -0
  7. package/lib/server/domain/dep-source.js +122 -0
  8. package/lib/server/domain/format-contract.js +265 -0
  9. package/lib/server/domain/format-scan.js +431 -0
  10. package/lib/server/domain/framework.js +393 -0
  11. package/lib/server/domain/install-job.js +561 -0
  12. package/lib/server/domain/install.js +599 -0
  13. package/lib/server/domain/jobs.js +28 -0
  14. package/lib/server/domain/market.js +409 -0
  15. package/lib/server/domain/patch.js +203 -0
  16. package/lib/server/domain/presets.js +93 -0
  17. package/lib/server/domain/quarantine.js +224 -0
  18. package/lib/server/domain/release-source.js +504 -0
  19. package/lib/server/domain/repoland.js +119 -0
  20. package/lib/server/domain/revoke.js +184 -0
  21. package/lib/server/domain/runtime.js +118 -0
  22. package/lib/server/domain/selfupdate.js +319 -0
  23. package/lib/server/domain/skills.js +234 -0
  24. package/lib/server/domain/sources.js +297 -0
  25. package/lib/server/domain/suite.js +220 -0
  26. package/lib/server/infra/exec.js +98 -0
  27. package/lib/server/infra/fsx.js +163 -0
  28. package/lib/server/infra/fw-integrity-check.js +37 -0
  29. package/lib/server/infra/http.js +373 -0
  30. package/lib/server/infra/httpd.js +51 -0
  31. package/lib/server/infra/mask.js +19 -0
  32. package/lib/server/infra/paths.js +177 -0
  33. package/lib/server/infra/semver.js +168 -0
  34. package/lib/server/routes/ai.js +172 -0
  35. package/lib/server/routes/components.js +254 -0
  36. package/lib/server/routes/framework-preflight.js +154 -0
  37. package/lib/server/routes/framework-upgrade.js +679 -0
  38. package/lib/server/routes/framework.js +544 -0
  39. package/lib/server/routes/github-login.js +198 -0
  40. package/lib/server/routes/index.js +128 -0
  41. package/lib/server/routes/install.js +116 -0
  42. package/lib/server/routes/market.js +415 -0
  43. package/lib/server/routes/plugins.js +562 -0
  44. package/lib/server/routes/skills.js +107 -0
  45. package/lib/server/routes/sources.js +437 -0
  46. package/lib/server/routes/state.js +125 -0
  47. package/lib/server/state.js +22 -0
  48. package/package.json +1 -1
@@ -0,0 +1,93 @@
1
+ // L1 · domain —— presets.js(预设/agent 配置迁移:config 文件收集、persona 迁移、升级期迁移;分层 Step 6 从 lib/index.js 搬出,只搬移未改逻辑)
2
+ // 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md 三
3
+
4
+ import { readFileSync, writeFileSync, existsSync, readdirSync, copyFileSync } from 'node:fs'
5
+ import { join } from 'node:path'
6
+ import { isVersionAtLeast } from './framework.js'
7
+ import { escapeRegExp } from '../infra/mask.js'
8
+ import { dshHome } from '../infra/paths.js'
9
+
10
+ /**
11
+ * 预设/组合文件的配置迁移门禁(2026-09-10 事故):
12
+ * 框架升级会改「插件配置 schema」——0.1.5 的 @deepseek-ai/dsh-persona 把 text 改名为 prefix(必填)。
13
+ * 适配门只扫「已装插件包源码」,**预设不在其中**(~/.dsh/.agent-presets/<name>/agent.cordis.yml),
14
+ * 于是升级后预设挂载失败 → 服务/会话起不来(这次就是这么挂的)。
15
+ * 升级前按目标版本扫描并自动迁移(写 .bak 备份),返回迁移清单供步骤展示。
16
+ */
17
+ const PRESET_CONFIG_MIGRATIONS = [
18
+ { pkg: '@deepseek-ai/dsh-persona', from: 'text', to: 'prefix', since: '0.1.5' },
19
+ ]
20
+
21
+ /** 待扫描的 agent 配置文件:所有预设 + profile 的 host 组合。 */
22
+ function collectAgentConfigFiles(profileDir) {
23
+ const files = []
24
+ const presetRoot = join(dshHome(), '.agent-presets')
25
+ try {
26
+ for (const entry of readdirSync(presetRoot, { withFileTypes: true })) {
27
+ if (!entry.isDirectory()) continue
28
+ for (const name of ['agent.cordis.yml', 'agent.cordis.yaml']) {
29
+ const f = join(presetRoot, entry.name, name)
30
+ if (existsSync(f)) files.push(f)
31
+ }
32
+ }
33
+ } catch {}
34
+ for (const name of ['cordis.yml', 'cordis.yaml', 'cordis.patch.yml']) {
35
+ const f = join(profileDir, name)
36
+ if (existsSync(f)) files.push(f)
37
+ }
38
+ return files
39
+ }
40
+
41
+ /** 迁移一个文件里 persona 行 config 的旧键(仅当尚无新键时改写,先写 .bak)。 */
42
+ function migratePresetPersona(file, mig) {
43
+ let text = null
44
+ try { text = readFileSync(file, 'utf8') } catch { return null }
45
+ const lines = text.split(/\r?\n/u)
46
+ let rowAt = -1
47
+ for (let i = 0; i < lines.length; i += 1) {
48
+ if (new RegExp(`name:\\s*['"]?${escapeRegExp(mig.pkg)}['"]?\\s*$`, 'u').test(lines[i])) { rowAt = i; break }
49
+ }
50
+ if (rowAt === -1) return null
51
+ let cfgAt = -1
52
+ for (let i = rowAt + 1; i < lines.length; i += 1) {
53
+ if (/^\s*config:\s*$/u.test(lines[i])) { cfgAt = i; break }
54
+ if (/^\s*-\s+id:/u.test(lines[i])) break
55
+ }
56
+ if (cfgAt === -1) return null
57
+ const cfgIndent = lines[cfgAt].match(/^\s*/u)[0].length
58
+ let endAt = lines.length
59
+ for (let i = cfgAt + 1; i < lines.length; i += 1) {
60
+ const line = lines[i]
61
+ if (line.trim() === '' || /^\s*#/u.test(line)) continue
62
+ if (line.match(/^\s*/u)[0].length <= cfgIndent) { endAt = i; break }
63
+ }
64
+ let hasNew = false
65
+ let hit = -1
66
+ for (let i = cfgAt + 1; i < endAt; i += 1) {
67
+ if (new RegExp(`^\\s*${mig.to}:`, 'u').test(lines[i])) { hasNew = true; break }
68
+ if (new RegExp(`^\\s*${mig.from}:`, 'u').test(lines[i])) hit = i
69
+ }
70
+ if (hasNew || hit === -1) return null
71
+ lines[hit] = lines[hit].replace(new RegExp(`^(\\s*)${mig.from}:`, 'u'), `$1${mig.to}:`)
72
+ try {
73
+ copyFileSync(file, `${file}.bak-${Date.now()}`)
74
+ writeFileSync(file, lines.join('\n'), 'utf8')
75
+ } catch { return null }
76
+ return { file, from: mig.from, to: mig.to, line: hit + 1, pkg: mig.pkg }
77
+ }
78
+
79
+ /** 升级前扫描预设/组合文件并按目标版本迁移(返回迁移清单)。导出供测试直接调用。 */
80
+ function migrateAgentConfigsForUpgrade(profileDir, targetVersion) {
81
+ const out = []
82
+ if (typeof targetVersion !== 'string' || targetVersion === '') return out
83
+ for (const mig of PRESET_CONFIG_MIGRATIONS) {
84
+ if (!isVersionAtLeast(targetVersion, mig.since)) continue
85
+ for (const file of collectAgentConfigFiles(profileDir)) {
86
+ const done = migratePresetPersona(file, mig)
87
+ if (done !== null) out.push(done)
88
+ }
89
+ }
90
+ return out
91
+ }
92
+
93
+ export { PRESET_CONFIG_MIGRATIONS, collectAgentConfigFiles, migratePresetPersona, migrateAgentConfigsForUpgrade }
@@ -0,0 +1,224 @@
1
+ // L1 · domain —— quarantine.js(启动失败隔离:记录读取 / 合并进兼容清单 / 对账 / 隔离计划)
2
+ // 分层 Step 8c-1 从 domain/compat.js 拆出(该文件当时 627 行、超守卫 600 行上限),只搬移未改逻辑。
3
+
4
+ import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync, copyFileSync } from 'node:fs'
5
+ import { dirname, join } from 'node:path'
6
+ import { readCompatPending, rowIdModuleMap, writeCompatPending } from './compat.js'
7
+ import { CORE_PATCH_ROW_IDS } from './patch.js'
8
+ import { dshHome } from '../infra/paths.js'
9
+
10
+ /** 把升级脚本留下的「启动失败隔离」记录并入适配门清单(只处理一次:处理后改名 .applied)。
11
+ * 作用:服务被隔离救回来之后,用户能在面板上看到「谁被自动关了、为什么」,并可逐个解锁。 */
12
+ /** 读取升级脚本写下的隔离记录(含 BOM 兼容)。
13
+ * ★ 2026-09-11 事故根因:升级脚本用 PowerShell `Set-Content -Encoding UTF8` 写这个文件,
14
+ * PS5.1 会**带 UTF-8 BOM**;而合并逻辑先"复制 + 删除"再判断 JSON.parse 结果 → BOM 让 parse
15
+ * 必失败 → 记录被销毁、却从未并进适配门清单 → 界面上那 20 行只剩一个**没有解释的【停用】**。
16
+ * 这里统一剥 BOM 再解析;解析失败返回 null(调用方会保留文件、留错误日志,绝不销毁证据)。 */
17
+ function readQuarantineRecord() {
18
+ const file = join(dshHome(), 'plugin-console', 'fw-quarantine.json')
19
+ if (!existsSync(file)) return null
20
+ let raw = ''
21
+ try { raw = readFileSync(file, 'utf8') } catch { return null }
22
+ if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1)
23
+ try {
24
+ const rec = JSON.parse(raw)
25
+ return rec !== null && typeof rec === 'object' ? rec : null
26
+ } catch { return null }
27
+ }
28
+
29
+ /** 合并隔离记录失败时留痕(v0.3.44):静默 catch 让 2026-09-11 那次 20 行隔离记录凭空消失,
30
+ * 界面只剩一个没有解释的【停用】,而且事后完全查不到原因。错误落到 fw-merge-error.log。 */
31
+ function logQuarantineMergeError(error) {
32
+ try {
33
+ const file = join(dshHome(), 'plugin-console', 'fw-merge-error.log')
34
+ mkdirSync(dirname(file), { recursive: true })
35
+ writeFileSync(file, `${new Date().toISOString()} ${error?.stack ?? String(error)}\n`, { flag: 'a' })
36
+ } catch {}
37
+ }
38
+
39
+ /** 把某行的待适配记录标记为「已适配」(保留历史判定痕迹:check / checkNote / riskyApprovedAt)。
40
+ * 语义(用户定案 2026-09-11):**启用即视为已适配**,但要留下"曾被判定/隔离"的痕迹供事后查。 */
41
+ function markPendingAdopted(pending, rowId, by, meta) {
42
+ const rec = (pending?.pending ?? []).find((p) => p.rowId === rowId)
43
+ if (rec === undefined) return false
44
+ rec.status = 'adopted'
45
+ rec.adoptedAt = Date.now()
46
+ rec.adoptedBy = by
47
+ if (meta !== null && meta !== undefined) {
48
+ if (typeof meta.moduleName === 'string' && meta.moduleName !== '') rec.moduleName = meta.moduleName
49
+ if (typeof meta.version === 'string' && meta.version !== '') rec.version = meta.version
50
+ }
51
+ return true
52
+ }
53
+
54
+ /**
55
+ * 启动失败日志分析器(纯函数,导出供测试):
56
+ * 从服务/预设的启动失败日志里提取「谁把服务搞挂了」——用于**启动失败隔离**(升级后服务拉不起来时,
57
+ * 先禁用/隔离肇事者并重试,而不是整包回滚)。三类信号:
58
+ * ① 预设挂载失败:`preset "router-spec" failed to mount` 或路径 `.agent-presets/<name>/agent.cordis.yml`
59
+ * ② loader 条目应用失败:`failed to apply loader entry <rowId> (<moduleName>)`
60
+ * ③ 模块解析失败:`Cannot find module '<moduleName>'`
61
+ * 返回 { presets:[name], modules:[{rowId,moduleName}], lines:[命中行] }
62
+ */
63
+ function analyzeBootFailure(logText) {
64
+ const text = String(logText ?? '')
65
+ const presets = new Set()
66
+ const modules = new Map() // moduleName -> rowId|null
67
+ const hits = []
68
+ const pushHit = (line) => { if (hits.length < 40 && !hits.includes(line.trim())) hits.push(line.trim().slice(0, 300)) }
69
+
70
+ for (const line of text.split(/\r?\n/u)) {
71
+ if (line.trim() === '') continue
72
+ let matched = false
73
+ for (const m of line.matchAll(/preset\s+"([^"]+)"\s+failed to mount/gu)) { presets.add(m[1]); matched = true }
74
+ for (const m of line.matchAll(/\.agent-presets[\\/]([^\\/\s"']+)[\\/]agent\.cordis\.ya?ml/gu)) { presets.add(m[1]); matched = true }
75
+ for (const m of line.matchAll(/failed to apply loader entry\s+(\S+)\s+\(([^)]+)\)/gu)) { modules.set(m[2], m[1]); matched = true }
76
+ for (const m of line.matchAll(/Cannot find module '([^']+)'/gu)) {
77
+ if (!modules.has(m[1])) modules.set(m[1], null)
78
+ matched = true
79
+ }
80
+ if (matched) pushHit(line)
81
+ }
82
+ return {
83
+ presets: [...presets],
84
+ modules: [...modules].map(([moduleName, rowId]) => ({ moduleName, rowId })),
85
+ lines: hits,
86
+ }
87
+ }
88
+
89
+ /** 启动失败隔离决策器(纯函数,导出供测试)。
90
+ * 输入:启动失败日志 + 当前可开关行清单([{rowId,moduleName,toggleable}])。
91
+ * 输出一份**可直接执行的隔离方案**(PowerShell 侧只负责照做,不掺判断逻辑):
92
+ * presets 要隔离的预设文件(改名 .broken-<ts>,避免预设挂载失败拖垮整个服务)
93
+ * rows 要写入 disabled:true 的行(已剔除核心行/受保护行/控制台自身)
94
+ * safeMode 无明确肇事者时是否建议安全模式(禁用全部第三方行,先让服务起来)
95
+ * coreHits 命中的行属于核心/受保护(禁它没用,正确动作是回滚框架)
96
+ * 设计依据:loader 单行 import 失败 = 整个服务启动崩溃;静态扫描抓不到全部不兼容,
97
+ * 所以必须有一条「起不来 → 定位肇事者 → 隔离 → 重试」的运行时兜底。 */
98
+ function planQuarantine({ logText, candidates, presetRoot = null, exists = existsSync }) {
99
+ const analysis = analyzeBootFailure(logText)
100
+ const byModule = new Map()
101
+ const byRow = new Map()
102
+ for (const c of candidates ?? []) {
103
+ if (typeof c?.rowId !== 'string' || c.rowId === '') continue
104
+ if (typeof c.moduleName === 'string' && c.moduleName !== '') byModule.set(c.moduleName, c)
105
+ byRow.set(c.rowId, c)
106
+ }
107
+ const rows = []
108
+ const coreHits = []
109
+ const unknown = []
110
+ for (const m of analysis.modules) {
111
+ const hit = (m.rowId !== null && byRow.get(m.rowId)) || byModule.get(m.moduleName) || null
112
+ if (hit === null) { unknown.push(m.moduleName); continue }
113
+ const isCore = CORE_PATCH_ROW_IDS.has(hit.rowId) || hit.rowId === 'plugin-console'
114
+ if (isCore || hit.toggleable === false) { coreHits.push({ rowId: hit.rowId, moduleName: hit.moduleName }); continue }
115
+ if (!rows.includes(hit.rowId)) rows.push(hit.rowId)
116
+ }
117
+ const root = presetRoot ?? join(dshHome(), '.agent-presets')
118
+ const presets = []
119
+ for (const name of analysis.presets) {
120
+ const file = join(root, name, 'agent.cordis.yml')
121
+ presets.push({ name, file, exists: exists(file) })
122
+ }
123
+ return {
124
+ presets,
125
+ rows,
126
+ unknown,
127
+ coreHits,
128
+ safeMode: presets.length === 0 && rows.length === 0,
129
+ lines: analysis.lines,
130
+ }
131
+ }
132
+
133
+ /** 把升级脚本留下的「启动失败隔离」记录并入适配门清单。
134
+ * 顺序很关键(v0.3.44):**先写清单并校验成功,再归档移除记录**。反过来的话,
135
+ * 任何解析/写入意外都会"记录没了、清单也没进",用户只看到一个没有理由的【停用】。
136
+ * 导出仅供测试直接驱动(避免测试为了跑它而 apply 整个插件)。 */
137
+ function mergeQuarantineRecord(ports) {
138
+ const file = join(dshHome(), 'plugin-console', 'fw-quarantine.json')
139
+ if (!existsSync(file)) return null
140
+ const rec = readQuarantineRecord()
141
+ if (rec === null) {
142
+ logQuarantineMergeError(new Error('隔离记录无法解析(已按 BOM 兼容处理仍失败),保留文件待下次启动重试'))
143
+ return null
144
+ }
145
+ const rows = (Array.isArray(rec.rows) ? rec.rows : []).filter((rowId) => typeof rowId === 'string' && rowId !== '')
146
+ const pending = readCompatPending() ?? { frameworkVersion: null, upgradeFrom: null, pending: [] }
147
+ if (!Array.isArray(pending.pending)) pending.pending = []
148
+ const lines = Array.isArray(rec.lines) ? rec.lines : []
149
+ // 隔离记录里只有 rowId(脚本写的),但清单里其它地方都按 moduleName 认身份:
150
+ // 「全家桶一键启用已适配」按 moduleName 前缀匹配、`检测到已适配 vX` 也要 moduleName 才能算。
151
+ // 所以这里按当前 loader 反查补上(v0.3.45:补不上就会永远匹配不到 —— 用户实测「点了说没有待适配行」)。
152
+ const info = ports === undefined || ports === null ? new Map() : rowIdModuleMap(ports)
153
+ for (const rowId of rows) {
154
+ const meta = info.get(rowId) ?? null
155
+ const record = {
156
+ rowId,
157
+ moduleName: meta?.moduleName ?? null,
158
+ version: meta?.version ?? null,
159
+ status: 'pending',
160
+ check: 'unknown',
161
+ checkNote: `启动失败隔离(${rec.mode ?? 'targeted'}):${lines[0] ?? '启动日志命中,服务曾被它拖垮'}`,
162
+ forcedAt: Date.now(),
163
+ source: 'boot-quarantine',
164
+ }
165
+ const at = pending.pending.findIndex((p) => p.rowId === rowId)
166
+ if (at >= 0) pending.pending[at] = { ...pending.pending[at], ...record }
167
+ else pending.pending.push(record)
168
+ // 该行现在就是启用的(比如用户在隔离之后已经手动启用过)→ 直接记成已适配,别留一个假 pending
169
+ if (meta?.enabled === true) markPendingAdopted(pending, rowId, 'row-enabled', meta)
170
+ }
171
+ // 预设隔离单独记录(它不是插件行,没有「启用」语义;解锁 = 把 .broken 文件改回来)
172
+ if (Array.isArray(rec.presets) && rec.presets.length > 0) {
173
+ pending.presetsQuarantined = rec.presets.map((name) => ({ name, at: rec.at ?? null, note: 'agent.cordis.yml 已改名 .broken(启动失败隔离),确认修好后改回文件名即可恢复' }))
174
+ }
175
+ pending.quarantineAt = rec.at ?? null
176
+ pending.quarantineLines = lines.slice(0, 5)
177
+ writeCompatPending(pending)
178
+ // 写后校验:清单里必须真的能看到这些行,才允许销毁原始记录
179
+ const back = readCompatPending()
180
+ const missing = rows.filter((rowId) => !(back?.pending ?? []).some((p) => p.rowId === rowId))
181
+ if (back === null || missing.length > 0) {
182
+ logQuarantineMergeError(new Error(`隔离记录写入清单后校验失败(缺失 ${missing.length}/${rows.length} 行),保留记录待下次启动重试`))
183
+ return rec
184
+ }
185
+ try {
186
+ copyFileSync(file, `${file}.applied-${Date.now()}`)
187
+ rmSync(file, { force: true })
188
+ } catch {}
189
+ return rec
190
+ }
191
+
192
+ /** 启动时让清单与现实对账(v0.3.45):
193
+ * ① 老记录缺 moduleName → 按当前 loader 补上(否则「全家桶一键启用已适配」永远匹配不到);
194
+ * ② 记录还是 pending、但这一行**当前已经启用**(用户手动启用过 / 补丁被清过)→ 转 adopted。
195
+ * 不这么做就会出现用户实测的那种矛盾:**已启用的行,重启后仍挂着【待适配】**。
196
+ * 返回 { backfilled, adopted } 供日志/测试核对;无变化则不写盘。 */
197
+ function reconcileCompatPending(ports) {
198
+ const pending = readCompatPending()
199
+ if (pending === null || !Array.isArray(pending.pending)) return { backfilled: 0, adopted: 0 }
200
+ const info = rowIdModuleMap(ports)
201
+ let backfilled = 0
202
+ let adopted = 0
203
+ for (const rec of pending.pending) {
204
+ if ((rec.status ?? 'pending') !== 'pending') continue
205
+ const meta = info.get(rec.rowId) ?? null
206
+ if (meta !== null) {
207
+ if ((rec.moduleName === null || rec.moduleName === undefined || rec.moduleName === '') && typeof meta.moduleName === 'string' && meta.moduleName !== '') {
208
+ rec.moduleName = meta.moduleName
209
+ if (meta.version !== null && meta.version !== undefined) rec.version = meta.version
210
+ backfilled += 1
211
+ }
212
+ if (meta.enabled === true) {
213
+ if (markPendingAdopted(pending, rec.rowId, 'row-enabled', meta)) adopted += 1
214
+ }
215
+ }
216
+ }
217
+ if (backfilled > 0 || adopted > 0) {
218
+ pending.updatedAt = new Date().toISOString()
219
+ writeCompatPending(pending)
220
+ }
221
+ return { backfilled, adopted }
222
+ }
223
+
224
+ export { readQuarantineRecord, logQuarantineMergeError, markPendingAdopted, mergeQuarantineRecord, reconcileCompatPending, analyzeBootFailure, planQuarantine }