@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,562 @@
1
+ // L2 · routes —— 插件开关与清理(/toggle · /uninstall · /clean-residuals · /self-update · /adapt-unlock · /adapt-unlock-all)
2
+ // 分层 Step 8b:从 lib/index.js 的 handle() 原样搬出(只搬移未改逻辑;缩进保持原样)
3
+
4
+ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
5
+ import { dirname, join } from 'node:path'
6
+ import { createRequire } from 'node:module'
7
+ import { checkPluginFrameworkCompat, probePluginImport, readCompatPending, rowIdModuleMap, writeCompatPending } from '../domain/compat.js'
8
+ import { pnpmRemove } from '../domain/install-job.js'
9
+ import { curlManualInstall, readExtraBundleOwners } from '../domain/install.js'
10
+ import { disableEntry, enableEntry, readPatchState, removeDisableBlock, removeInsertRow } from '../domain/patch.js'
11
+ import { markPendingAdopted } from '../domain/quarantine.js'
12
+ import { pendingRestartJobs, packageDirIn, needsPnpmRemove, revokePendingInstall } from '../domain/revoke.js'
13
+ import { deriveEntryId, isProtectedModule, listEntries } from '../domain/runtime.js'
14
+ import { selfUpdateToLatest } from '../domain/selfupdate.js'
15
+ import { orderedRegistries, readSources } from '../domain/sources.js'
16
+ import { fetchJsonUrl } from '../infra/http.js'
17
+ import { sendError, sendJson } from '../infra/httpd.js'
18
+ import { removeDirVerifiedAsync } from '../infra/fsx.js'
19
+ import { dshHome, findPatchPath, pluginRoot, resolvePackageJson, rowIdOf } from '../infra/paths.js'
20
+ import { installJobs } from '../state.js'
21
+
22
+ /** 插件控制台自身的包名(撤销分支禁止删自己;@deepseek-ai/* 由通用护栏拒绝)。 */
23
+ const CONSOLE_PACKAGE = '@noob-stupid/dsh-plugin-console'
24
+
25
+ /**
26
+ * 把与这次删除对应的「已安装·重启后生效」任务记为已撤销(/state 的 pendingRestart 据此过滤)。
27
+ * 为什么两处都要调:无论按 entryId(重启后从列表删)还是按 jobId(重启前撤销)删掉一个包,
28
+ * 它的安装任务若还挂着 status==='done',/state 就会继续显示一行删不掉的幽灵行。
29
+ * 同一包名的多条记录(装过又更新过)一并作废 —— 它们指向同一个包。
30
+ */
31
+ function markJobsRevoked(packageName, rowId) {
32
+ for (const job of installJobs.values()) {
33
+ if (job.status !== 'done' || job.revokedAt !== undefined) continue
34
+ if (job.packageName === packageName || (typeof rowId === 'string' && rowId !== '' && job.entryId === rowId)) {
35
+ job.revokedAt = Date.now()
36
+ }
37
+ }
38
+ }
39
+
40
+ async function routeToggle(req, res, rc) {
41
+ const isProtectedModule = rc.deps.isProtectedModule
42
+ const rowIdModuleMap = rc.deps.rowIdModuleMap
43
+ const ctx = rc.ctx
44
+ const url = rc.url
45
+ const pathname = rc.pathname
46
+ const method = rc.method
47
+ const body = rc.body
48
+ const { entryId, enabled } = body
49
+ if (typeof entryId !== 'string' || !/^[A-Za-z0-9_:.-]{1,80}$/u.test(entryId)) {
50
+ sendError(res, 400, 'entryId 无效')
51
+ return
52
+ }
53
+ if (typeof enabled !== 'boolean') {
54
+ sendError(res, 400, 'enabled 必须是布尔值')
55
+ return
56
+ }
57
+ const exists = ctx.loader.entries().some((entry) => entry.id === entryId)
58
+ if (!exists) {
59
+ sendError(res, 404, `没有名为 ${entryId} 的插件条目`)
60
+ return
61
+ }
62
+ const target = ctx.loader.entries().find((entry) => entry.id === entryId)
63
+ if (isProtectedModule(target?.options?.name)) {
64
+ sendError(res, 403, `${target.options.name} 属于宿主基础设施,禁止开关(停用会破坏热加载/传输/存储链)`)
65
+ return
66
+ }
67
+ const rowId = rowIdOf(ctx, entryId)
68
+ if (rowId === 'plugin-console') {
69
+ sendError(res, 400, '不能停用插件控制台自身')
70
+ return
71
+ }
72
+ if (enabled) {
73
+ // 框架升级适配门(用户定案 2026-09-11:**软禁**)——自动禁用的目的是「保证新框架能起来」,
74
+ // 不是「剥夺用户控制权」:默认自动禁用,但允许手动强行启用(首次请求返回 needsConfirm,
75
+ // 前端弹风险提示确认框;带 confirmRisky:true 才放行)。
76
+ // 注意下面还有一条**硬**门禁:启用前的 import 冒烟检查——那条是事实性崩溃(模块根本加载不了),
77
+ // 不允许覆盖(否则下次启动必崩,与「服务永不崩」冲突)。
78
+ const compatPending = readCompatPending()
79
+ const pend = (compatPending?.pending ?? []).find((p) => p.rowId === rowId && (p.status ?? 'pending') === 'pending')
80
+ if (pend) {
81
+ if (body.confirmRisky !== true) {
82
+ sendError(
83
+ res,
84
+ 409,
85
+ `「${rowId}」在框架升级适配门清单中(框架 ${compatPending.frameworkVersion ?? '?'}${pend.checkNote ? `,判定:${pend.checkNote}` : ''})——已自动禁用。强行启用可能让 DSH 下次启动失败,需要你确认。`,
86
+ {
87
+ code: 'compat-confirm',
88
+ rowId,
89
+ moduleName: pend.moduleName ?? null,
90
+ frameworkVersion: compatPending.frameworkVersion ?? null,
91
+ checkNote: pend.checkNote ?? null,
92
+ },
93
+ )
94
+ return
95
+ }
96
+ try {
97
+ const next = readCompatPending()
98
+ const rec = (next?.pending ?? []).find((p) => p.rowId === rowId)
99
+ if (rec) { rec.riskyApprovedAt = Date.now(); writeCompatPending(next) }
100
+ } catch {}
101
+ }
102
+ }
103
+ const patchPath = findPatchPath(ctx)
104
+ // 启用前冒烟检查(服务永不崩机制):第三方模块在独立子进程中试 import,
105
+ // 失败(SyntaxError/缺失导出/模块缺失)即拒绝启用,杜绝「单行 import 失败→整个服务启动崩溃」
106
+ if (enabled) {
107
+ const moduleName = target?.options?.name
108
+ if (typeof moduleName === 'string' && !moduleName.startsWith('cordis:') && !moduleName.startsWith('@deepseek-ai/')) {
109
+ try {
110
+ const probe = await probePluginImport(moduleName, dirname(patchPath))
111
+ if (probe.ok !== true) {
112
+ sendError(res, 409, `启用前冒烟检查未通过:模块加载失败(${probe.detail ?? '未知'})——该插件与当前框架不兼容或依赖缺失,已阻止启用(服务不会再被拖崩);请先「检测更新/更新并适配」其适配版`)
113
+ return
114
+ }
115
+ } catch {}
116
+ }
117
+ }
118
+ const result = enabled
119
+ ? await enableEntry(patchPath, rowId)
120
+ : await disableEntry(patchPath, rowId)
121
+ // v0.3.45(用户定案:启用即视为已适配,但保留痕迹):手动启用一个待适配行后,
122
+ // 清单里的 pending 记录要转成 adopted —— 否则重启后界面上会出现「已启用却还挂着【待适配】」,
123
+ // 用户实测就是这样(5 行)。check / checkNote / riskyApprovedAt 一律保留供事后查。
124
+ if (enabled) {
125
+ try {
126
+ const list = readCompatPending()
127
+ const meta = rowIdModuleMap(ctx).get(rowId) ?? null
128
+ if (list !== null && markPendingAdopted(list, rowId, 'manual-enable', meta)) {
129
+ list.updatedAt = new Date().toISOString()
130
+ writeCompatPending(list)
131
+ }
132
+ } catch {}
133
+ }
134
+ sendJson(res, 200, { ok: true, entryId, rowId, enabled, changed: result.changed, patchPath })
135
+ return
136
+ }
137
+
138
+ /**
139
+ * 按安装任务撤销「已安装但尚未生效」的安装(/uninstall 的 jobId 分支)。
140
+ *
141
+ * 2026-09-20 真装真卸演练实测的缺口:面板装完插件后 /install 返回 entryId: null、
142
+ * GET /state 里新增 loader 条目 = 0(bundle 型要重启才被加载),而旧 /uninstall 只按
143
+ * **运行中** loader 条目查找 → 恒 404,于是「刚装错的插件在重启前无法从面板卸载」。
144
+ * 安全护栏与 entry 分支完全一致:@deepseek-ai/* · isProtectedModule · 控制台自身。
145
+ */
146
+ async function uninstallByJobId(res, rc, jobId) {
147
+ const isProtectedModule = rc.deps.isProtectedModule
148
+ const listEntries = rc.deps.listEntries
149
+ const pnpmRemove = rc.deps.pnpmRemove
150
+ const ctx = rc.ctx
151
+ const job = installJobs.get(jobId)
152
+ if (job === undefined) {
153
+ sendError(res, 404, `没有这个安装任务(jobId=${jobId})——无法撤销`)
154
+ return
155
+ }
156
+ if (job.revokedAt !== undefined) {
157
+ sendError(res, 400, `该安装任务已经撤销过了(${new Date(job.revokedAt).toISOString()}),无需重复操作`)
158
+ return
159
+ }
160
+ if (job.status !== 'done') {
161
+ sendError(res, 400, job.status === 'installing'
162
+ ? `该安装任务还在进行中(stage=${job.stage ?? '?'}),完成后才能撤销`
163
+ : `该安装任务没有成功装成(status=${job.status}${job.error ? `:${job.error}` : ''}),没有可撤销的安装`)
164
+ return
165
+ }
166
+ const packageName = typeof job.packageName === 'string' ? job.packageName.trim() : ''
167
+ if (packageName === '') {
168
+ sendError(res, 400, '该安装任务没有记录包名(可能未装成、或已由现有聚合包提供),无法按任务撤销')
169
+ return
170
+ }
171
+ if (packageName.startsWith('@deepseek-ai/')) {
172
+ sendError(res, 403, `${packageName} 是 DSH 框架官方包,禁止删除`)
173
+ return
174
+ }
175
+ if (isProtectedModule(packageName)) {
176
+ sendError(res, 403, `${packageName} 属于宿主基础设施,禁止删除`)
177
+ return
178
+ }
179
+ const rowId = typeof job.entryId === 'string' && job.entryId !== '' ? job.entryId : deriveEntryId(packageName, new Set())
180
+ if (rowId === 'plugin-console' || packageName === CONSOLE_PACKAGE) {
181
+ sendError(res, 400, '不能删除插件控制台自身')
182
+ return
183
+ }
184
+ // 本分支只服务「已安装但尚未生效」:包名若已在运行中的 loader 条目里(重启已完成),
185
+ // 撤销会留下「包已删、模块还挂在内存里」的半状态 —— 如实拒绝并指路(按列表条目删除)。
186
+ if (pendingRestartJobs([job], listEntries(ctx)).length === 0) {
187
+ sendError(res, 400, `「${packageName}」已经在运行中的插件列表里(重启已完成)——请直接在列表里删除该条目,不必按 jobId 撤销`)
188
+ return
189
+ }
190
+ const patchPath = findPatchPath(ctx)
191
+ const profileDir = dirname(patchPath)
192
+ const result = await revokePendingInstall(job, { profileDir, patchPath, pnpmRemove })
193
+ // 只有包目录真的没了才算「这次安装已撤销」:此后不再出现在 /state 的 pendingRestart 里。
194
+ // 包还在盘上时不打这个标记 —— 保留待重启条目让用户能再点一次删除,比假装干净好。
195
+ if (result.verified.packageGone === true) markJobsRevoked(result.packageName, typeof job.entryId === 'string' ? job.entryId : null)
196
+ sendJson(res, 200, {
197
+ ok: true,
198
+ removed: 'pending-install',
199
+ jobId,
200
+ packageName: result.packageName,
201
+ bundle: result.bundle,
202
+ restart: false,
203
+ rowIds: result.rowIds,
204
+ verified: result.verified,
205
+ warn: result.warn,
206
+ uninstallError: result.uninstallError,
207
+ })
208
+ return
209
+ }
210
+
211
+ async function routeUninstall(req, res, rc) {
212
+ const isProtectedModule = rc.deps.isProtectedModule
213
+ const pnpmRemove = rc.deps.pnpmRemove
214
+ const ctx = rc.ctx
215
+ const url = rc.url
216
+ const pathname = rc.pathname
217
+ const method = rc.method
218
+ const body = rc.body
219
+ // 入参两种形态:entryId(运行中的 loader 条目)· jobId(已安装但尚未生效的安装任务,见 domain/revoke.js)
220
+ const { entryId, jobId } = body
221
+ const entryIdOk = typeof entryId === 'string' && /^[A-Za-z0-9_:.-]{1,80}$/u.test(entryId)
222
+ const jobIdOk = typeof jobId === 'string' && /^[A-Za-z0-9_.:-]{1,80}$/u.test(jobId)
223
+ if (!entryIdOk && !jobIdOk) {
224
+ sendError(res, 400, 'entryId 无效(撤销尚未生效的安装请改传 jobId)')
225
+ return
226
+ }
227
+ const entry = entryIdOk ? ctx.loader.entries().find((candidate) => candidate.id === entryId) : undefined
228
+ if (!entry) {
229
+ if (jobIdOk) {
230
+ await uninstallByJobId(res, rc, jobId)
231
+ return
232
+ }
233
+ sendError(res, 404, `没有名为 ${entryId} 的插件条目`)
234
+ return
235
+ }
236
+ const moduleName = entry.options.name
237
+ const rowId = rowIdOf(ctx, entryId)
238
+ if (rowId === 'plugin-console') {
239
+ sendError(res, 400, '不能删除插件控制台自身')
240
+ return
241
+ }
242
+ if (isProtectedModule(moduleName)) {
243
+ sendError(res, 403, `${moduleName} 属于宿主基础设施,禁止删除`)
244
+ return
245
+ }
246
+ const patchPath = findPatchPath(ctx)
247
+ const profileDir = dirname(patchPath)
248
+ const patch = await readPatchState(patchPath)
249
+ // bundle 来源的额外插件(如皮肤中心):删除其所属 bundle
250
+ const owners = await readExtraBundleOwners(profileDir)
251
+ const ownerBundle = owners.get(rowId) ?? owners.get(moduleName)
252
+ if (ownerBundle !== undefined) {
253
+ // 2026-09-04 事故教训:删除聚合包子路径行(如 @linxin666/dsh-web-all/plugin-manager)时
254
+ // 曾把整个 bundle 从清单移除,pnpm 卸载失败(corepack 报错)后重启,全家桶整体消失。
255
+ // 现改为:任何 bundle 行的「删除」= 仅停用该行(patch disabled:true),bundle 清单不动,
256
+ // 之后随时可「启用」恢复;整体卸载请走包管理器。
257
+ await disableEntry(patchPath, rowId)
258
+ sendJson(res, 200, {
259
+ ok: true,
260
+ removed: 'row',
261
+ packageName: moduleName,
262
+ restart: false,
263
+ note: '该行来自聚合包 ' + ownerBundle + ',已仅停用本行(bundle 保留,可随时启用恢复);整体卸载请用包管理器执行 pnpm remove ' + ownerBundle,
264
+ })
265
+ return
266
+ }
267
+ if (!patch.inserts.includes(rowId)) {
268
+ sendError(res, 400, '该插件不是用户安装的额外插件(不可删除)')
269
+ return
270
+ }
271
+ await removeInsertRow(patchPath, rowId)
272
+ let uninstallError = null
273
+ // 目录已不在、manifest 也没引用 → 不必再拉一次 pnpm:那只会在本机换来一句没有信息量的
274
+ // "Command failed: corepack pnpm remove …"(演练实测)。判断规则与 jobId 撤销分支同一套。
275
+ if (await needsPnpmRemove(profileDir, moduleName)) {
276
+ try {
277
+ await pnpmRemove(profileDir, moduleName)
278
+ } catch (error) {
279
+ uninstallError = error instanceof Error ? error.message : String(error)
280
+ }
281
+ }
282
+ // 任务记录是否作废看**事实**(包目录真的没了),不看 pnpm 的退出码;删不干净时留着让用户重试
283
+ if (!existsSync(packageDirIn(profileDir, moduleName))) markJobsRevoked(moduleName, rowId)
284
+ sendJson(res, 200, { ok: true, removed: 'entry', packageName: moduleName, restart: false, uninstallError })
285
+ return
286
+ }
287
+
288
+ async function routeAdaptUnlock(req, res, rc) {
289
+ const ctx = rc.ctx
290
+ const url = rc.url
291
+ const pathname = rc.pathname
292
+ const method = rc.method
293
+ const body = rc.body
294
+ // 待适配行「已适配,立即解锁」:无需等新版本——对已装模块重跑源码扫描,通过即移除禁用块。
295
+ const rowId = typeof body.rowId === 'string' ? body.rowId : ''
296
+ if (!/^[A-Za-z0-9_:.-]{1,80}$/u.test(rowId)) {
297
+ sendError(res, 400, 'rowId 无效')
298
+ return
299
+ }
300
+ const pending = readCompatPending()
301
+ const entry = (pending?.pending ?? []).find((p) => p.rowId === rowId && (p.status ?? 'pending') === 'pending')
302
+ if (entry === undefined) {
303
+ sendError(res, 404, '该行不在适配门待适配清单中(或已解锁)')
304
+ return
305
+ }
306
+ const patchPath = findPatchPath(ctx)
307
+ const profileDir = dirname(patchPath)
308
+ let pkg = null
309
+ let pkgDir = null
310
+ let version = null
311
+ try {
312
+ const require = createRequire(join(profileDir, 'package.json'))
313
+ const pkgPath = resolvePackageJson(entry.moduleName, profileDir)
314
+ if (pkgPath !== null) {
315
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
316
+ version = typeof pkg?.version === 'string' ? pkg.version : null
317
+ pkgDir = dirname(pkgPath)
318
+ }
319
+ } catch {}
320
+ const fwVer = typeof pending.frameworkVersion === 'string' ? pending.frameworkVersion : '?'
321
+ const check = pkg !== null ? checkPluginFrameworkCompat(pkg, fwVer, pkgDir) : { decision: 'unknown', reason: '无法读取包信息' }
322
+ if (check.decision === 'fail') {
323
+ sendError(res, 409, `适配校验未通过:${check.reason}`)
324
+ return
325
+ }
326
+ await removeDisableBlock(patchPath, rowId)
327
+ entry.status = 'adopted'
328
+ entry.adoptedAt = Date.now()
329
+ entry.adoptedVersion = version
330
+ entry.adoptedFramework = fwVer
331
+ entry.check = check.decision
332
+ entry.checkNote = check.reason ?? null
333
+ writeCompatPending(pending)
334
+ sendJson(res, 200, { ok: true, rowId, adopted: true, check: check.decision, note: check.reason ?? null })
335
+ return
336
+ }
337
+
338
+ async function routeAdaptUnlockAll(req, res, rc) {
339
+ const rowIdModuleMap = rc.deps.rowIdModuleMap
340
+ const ctx = rc.ctx
341
+ const url = rc.url
342
+ const pathname = rc.pathname
343
+ const method = rc.method
344
+ const body = rc.body
345
+ // 全家桶「一键启用已适配」:对家庭内所有待适配行批量重跑源码扫描,通过的全部解锁,未通过保留禁用。
346
+ const root = typeof body.root === 'string' ? body.root : ''
347
+ if (!/^(@[a-z0-9-][a-z0-9-._~]*\/)?[a-z0-9-][a-z0-9-._~]*$/u.test(root) || root.length > 214) {
348
+ sendError(res, 400, 'root 无效')
349
+ return
350
+ }
351
+ const pending = readCompatPending()
352
+ const patchPath = findPatchPath(ctx)
353
+ const profileDir = dirname(patchPath)
354
+ // v0.3.45:匹配规则改成「moduleName 前缀 **或** 属主行集合」——
355
+ // 隔离记录合并进来的老行 moduleName 为空(脚本只写 rowId),只按 moduleName 比会匹配不到,
356
+ // 于是用户点「一键启用已适配」得到「该全家桶没有待适配行」(实测就是这个)。
357
+ const info = rowIdModuleMap(ctx)
358
+ const bundleRowIds = new Set()
359
+ for (const [rowId, meta] of info) {
360
+ const name = meta?.moduleName
361
+ if (typeof name === 'string' && name !== '' && (name === root || name.startsWith(root + '/'))) bundleRowIds.add(rowId)
362
+ }
363
+ const inFamily = (p) => {
364
+ if (typeof p.moduleName === 'string' && p.moduleName !== '' && (p.moduleName === root || p.moduleName.startsWith(root + '/'))) return true
365
+ return bundleRowIds.has(p.rowId) || info.get(p.rowId)?.moduleName === root
366
+ }
367
+ // 目标 = ① 待适配(pending)② **已记已适配、但从没通过源码扫描**(check !== 'pass')。
368
+ // ② 这类正是"账面上已适配、补丁里却还禁着"的行 —— 老逻辑只挑 pending,于是用户点「一键启用已适配」
369
+ // 得到"没有待适配行、无需操作",可它们其实一行都没启用(实测就是这个)。
370
+ const targets = (pending?.pending ?? []).filter((p) => {
371
+ if (!inFamily(p)) return false
372
+ const status = p.status ?? 'pending'
373
+ if (status === 'pending') return true
374
+ return status === 'adopted' && p.check !== 'pass'
375
+ })
376
+ if (targets.length === 0) {
377
+ // 无待适配行=全部已适配/已解锁,属正常状态:返回友好提示而非错误(点击「一键启用已适配」不应报"操作失败")
378
+ sendJson(res, 200, { ok: true, unlocked: [], kept: [], note: '该全家桶内没有待扫描/待解锁的行(全部已通过源码扫描或已解锁)' })
379
+ return
380
+ }
381
+ const fwVer = typeof pending.frameworkVersion === 'string' ? pending.frameworkVersion : '?'
382
+ const unlocked = []
383
+ const kept = []
384
+ for (const p of targets) {
385
+ let pkg = null
386
+ let pkgDir = null
387
+ let version = null
388
+ try {
389
+ const pkgPath = resolvePackageJson(p.moduleName, profileDir)
390
+ if (pkgPath !== null) {
391
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
392
+ version = typeof pkg?.version === 'string' ? pkg.version : null
393
+ pkgDir = dirname(pkgPath)
394
+ }
395
+ } catch {}
396
+ const check = pkg !== null ? checkPluginFrameworkCompat(pkg, fwVer, pkgDir) : { decision: 'fail', reason: '无法读取包信息' }
397
+ if (check.decision === 'fail') {
398
+ kept.push({ rowId: p.rowId, reason: check.reason })
399
+ continue
400
+ }
401
+ await removeDisableBlock(patchPath, p.rowId)
402
+ p.status = 'adopted'
403
+ p.adoptedAt = Date.now()
404
+ p.adoptedVersion = version
405
+ p.adoptedFramework = fwVer
406
+ p.check = check.decision
407
+ p.checkNote = check.reason ?? null
408
+ unlocked.push(p.rowId)
409
+ }
410
+ writeCompatPending(pending)
411
+ // kept = 扫描未通过(保持禁用,并带上原因);unlocked = 本次真解锁的行
412
+ sendJson(res, 200, {
413
+ ok: true,
414
+ root,
415
+ unlocked,
416
+ kept,
417
+ scanned: targets.length,
418
+ note: kept.length === 0
419
+ ? `已重跑源码扫描并解锁 ${unlocked.length} 行`
420
+ : `扫描 ${targets.length} 行:解锁 ${unlocked.length} 行,${kept.length} 行未通过(保持禁用,原因见下)`,
421
+ })
422
+ return
423
+ }
424
+
425
+ async function routeCleanResiduals(req, res, rc) {
426
+ const ctx = rc.ctx
427
+ const url = rc.url
428
+ const pathname = rc.pathname
429
+ const method = rc.method
430
+ const body = rc.body
431
+ // 清理残余备份/旧子包(2026-09-24 重写,用户实测「点清除后 9 项删不掉」推动):
432
+ // ① 只删**能证明是垃圾**的东西:`*.old-*` 备份、pnpm `*_tmp_<pid>_<n>` 中断残留;
433
+ // ② `@linxin666/*` 只有在**当前 loader 里没有任何行引用**时才删。原判据用的是旧聚合包
434
+ // `dsh-web-ui-all` 的 dependencies,会把用户后来单独安装、正在用的插件也判成「未声明旧子包」——
435
+ // 实测那一次点了清理,它准备删的 9 项里有 7 个是真插件(`dsh-i18n` 当时还是**已挂载**的行);
436
+ // ③ 顺带清 `~/.dsh/plugin-console/` 下的陈旧副本(实测堆了 375 个 `fw-quarantine.json.applied-*`)——
437
+ // 这些才是名副其实的「残余备份」,原实现压根不看它们;
438
+ // ④ 删除统一走 `removeDirVerifiedAsync`(清只读位 → rmSync → 轮询核实 → `rmdir /s /q` 兜底),
439
+ // 失败时把**真实原因**带回响应,不再用「当前环境可能禁止删除」把人引向不存在的权限问题。
440
+ const profileDir = dirname(findPatchPath(ctx))
441
+ const nodeModules = join(profileDir, 'node_modules')
442
+ const removed = []
443
+ const kept = []
444
+ const failed = []
445
+ const TMP_DIR_RE = /_tmp_\d+(?:_\d+)?$/u
446
+ const removeOne = async (name, target) => {
447
+ const r = await removeDirVerifiedAsync(target)
448
+ if (r.ok) removed.push({ name, method: r.method })
449
+ else failed.push({ name, path: target, error: r.error })
450
+ }
451
+ // 0) 在用/已装的行(**含被禁用的行**:装着就不是残留,只有真正孤儿才删)
452
+ const inUse = new Set()
453
+ try {
454
+ for (const entry of listEntries(ctx)) {
455
+ if (typeof entry.moduleName === 'string' && entry.moduleName !== '') inUse.add(entry.moduleName)
456
+ }
457
+ } catch {}
458
+ // 1) .old-* 备份 + pnpm _tmp_ 中断残留(顶层 + 作用域目录)
459
+ const scanDirs = [nodeModules]
460
+ try { if (existsSync(join(nodeModules, '@linxin666'))) scanDirs.push(join(nodeModules, '@linxin666')) } catch {}
461
+ for (const dir of scanDirs) {
462
+ try {
463
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
464
+ if (!entry.isDirectory()) continue
465
+ if (!/\.old-/u.test(entry.name) && !TMP_DIR_RE.test(entry.name)) continue
466
+ await removeOne(entry.name, join(dir, entry.name))
467
+ }
468
+ } catch {}
469
+ }
470
+ // 2) @linxin666 下「没有任何行引用」的孤儿包(在用的一律保留并如实列出)
471
+ try {
472
+ const scoped = join(nodeModules, '@linxin666')
473
+ if (existsSync(scoped)) {
474
+ for (const entry of readdirSync(scoped, { withFileTypes: true })) {
475
+ if (!entry.isDirectory()) continue
476
+ if (TMP_DIR_RE.test(entry.name)) continue // 已在 ① 处理
477
+ if (!existsSync(join(scoped, entry.name, 'package.json'))) continue
478
+ const full = '@linxin666/' + entry.name
479
+ if (inUse.has(full)) { kept.push(full); continue }
480
+ await removeOne(full, join(scoped, entry.name))
481
+ }
482
+ }
483
+ } catch {}
484
+ // 3) plugin-console 目录下的陈旧副本:quarantine 快照保留最近 2 个;日志/计数 >7 天;.bak-* >30 天
485
+ try {
486
+ const dir = join(dshHome(), 'plugin-console')
487
+ if (existsSync(dir)) {
488
+ const entries = readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile())
489
+ const snapshots = entries.map((e) => e.name).filter((n) => /^fw-quarantine\.json\.applied-/u.test(n)).sort()
490
+ for (const name of snapshots.slice(0, Math.max(0, snapshots.length - 2))) await removeOne(name, join(dir, name))
491
+ const now = Date.now()
492
+ const olderThan = (name, days) => {
493
+ try { return now - statSync(join(dir, name)).mtimeMs > days * 86400000 } catch { return false }
494
+ }
495
+ for (const entry of entries) {
496
+ const staleLog = /^(?:upgrade-watch-.*\.log|restart-guard-\d+\.count)$/u.test(entry.name) && olderThan(entry.name, 7)
497
+ const staleBak = /\.bak-/u.test(entry.name) && olderThan(entry.name, 30)
498
+ if (staleLog || staleBak) await removeOne(entry.name, join(dir, entry.name))
499
+ }
500
+ }
501
+ } catch {}
502
+ const payload = { ok: failed.length === 0, removed, kept, failed, count: removed.length }
503
+ if (failed.length > 0) {
504
+ const detail = failed.slice(0, 3).map((f) => `${f.name}(${f.error ?? '未知原因'})`).join(';')
505
+ const keptNote = kept.length > 0 ? `;已在用插件 ${kept.length} 个(保留不删)` : ''
506
+ sendJson(res, 200, { ...payload, error: `有 ${failed.length} 项没能删除:${detail}${failed.length > 3 ? ' 等' : ''}${keptNote}` })
507
+ return
508
+ }
509
+ sendJson(res, 200, payload)
510
+ return
511
+ }
512
+
513
+ async function routeSelfUpdate(req, res, rc) {
514
+ const ctx = rc.ctx
515
+ const url = rc.url
516
+ const pathname = rc.pathname
517
+ const method = rc.method
518
+ const body = rc.body
519
+ // Hub 自身一键更新:下载 npm 最新 tarball 到当前 profile,成功后由前端重启生效
520
+ let selfVersion = null
521
+ try {
522
+ const selfPkg = JSON.parse(readFileSync(join(pluginRoot(), 'package.json'), 'utf8'))
523
+ selfVersion = typeof selfPkg.version === 'string' ? selfPkg.version : null
524
+ } catch {}
525
+ let latest = null
526
+ try {
527
+ const data = await fetchJsonUrl('https://registry.npmmirror.com/@noob-stupid%2fdsh-plugin-console')
528
+ latest = data?.['dist-tags']?.latest ?? null
529
+ } catch {}
530
+ if (!latest || selfVersion === null || latest === selfVersion) {
531
+ sendJson(res, 200, { ok: false, current: selfVersion, latest, updated: false, reason: latest === selfVersion ? '已是最新版本' : '版本检测失败' })
532
+ return
533
+ }
534
+ const profileDir = dirname(findPatchPath(ctx))
535
+ const registries = orderedRegistries(readSources())
536
+ try {
537
+ // 包管理器优先(见 domain/selfupdate.js 顶部注释):旧实现只把文件铺进 node_modules、不写
538
+ // pnpm-lock.yaml → "看起来升级成功、下一次 pnpm 操作就被还原"(用户 2026-09-20 实测报告)。
539
+ const result = await selfUpdateToLatest({ profileDir, latest, registries, curlManualInstall })
540
+ sendJson(res, 200, {
541
+ ok: true,
542
+ current: selfVersion,
543
+ latest,
544
+ updated: true,
545
+ version: result.installedVersion ?? latest,
546
+ method: result.method,
547
+ spec: result.spec,
548
+ installedVersion: result.installedVersion,
549
+ lockVersion: result.lockVersion,
550
+ lockUpdated: result.lockUpdated,
551
+ lockNote: result.lockNote,
552
+ command: result.command,
553
+ note: result.note,
554
+ errors: result.errors,
555
+ })
556
+ } catch (error) {
557
+ sendError(res, 500, `Hub 自动更新失败:${error instanceof Error ? error.message : String(error)}`)
558
+ }
559
+ return
560
+ }
561
+
562
+ export { routeToggle, routeUninstall, routeAdaptUnlock, routeAdaptUnlockAll, routeCleanResiduals, routeSelfUpdate }
@@ -0,0 +1,107 @@
1
+ // L2 · routes —— 技能(GET /skills-installed · POST /skill-remove · POST /skill-toggle)
2
+ // 分层 Step 8b:从 lib/index.js 的 handle() 原样搬出(只搬移未改逻辑;缩进保持原样)
3
+
4
+ import { existsSync } from 'node:fs'
5
+ import { join, resolve } from 'node:path'
6
+ import { listInstalledSkills, setSkillEnabled } from '../domain/skills.js'
7
+ import { removeDirVerified } from '../infra/fsx.js'
8
+ import { sendError, sendJson } from '../infra/httpd.js'
9
+ import { dshHome } from '../infra/paths.js'
10
+
11
+ async function routeSkillsInstalledGet(req, res, rc) {
12
+ const ctx = rc.ctx
13
+ const url = rc.url
14
+ const pathname = rc.pathname
15
+ const method = rc.method
16
+ // 已安装技能清单(~/.dsh/skills 用户根)+ 插件自带技能(ctx.skills 聚合,只读展示)
17
+ const skills = listInstalledSkills()
18
+ let pluginSkills = []
19
+ try {
20
+ const skillsSvc = ctx.get('skills')
21
+ const extra = await Promise.race([
22
+ (async () => (typeof skillsSvc?.list === 'function' ? await skillsSvc.list({}) : []))(),
23
+ new Promise((resolve) => setTimeout(() => resolve([]), 1500)),
24
+ ])
25
+ for (const s of extra ?? []) {
26
+ if (s && typeof s.name === 'string' && !skills.some((x) => x.name === s.name)) {
27
+ pluginSkills.push({ name: s.name, description: s.description ?? null, provider: s.provider ?? null, system: true })
28
+ }
29
+ }
30
+ } catch {}
31
+ sendJson(res, 200, { ok: true, skills, pluginSkills })
32
+ return
33
+ }
34
+
35
+ async function routeSkillRemove(req, res, rc) {
36
+ const ctx = rc.ctx
37
+ const url = rc.url
38
+ const pathname = rc.pathname
39
+ const method = rc.method
40
+ const body = rc.body
41
+ // 删除已安装技能:仅接受 kebab-case 名称(防目录穿越),删除 ~/.dsh/skills/<name>
42
+ const name = typeof body.name === 'string' ? body.name.trim() : ''
43
+ if (name.startsWith('.')) {
44
+ // 点号开头是系统/隐藏技能根(如 .system,dsh-skill-filesystem 保留目录):禁止删除
45
+ sendError(res, 403, `技能 ${name} 属于系统/隐藏技能,禁止删除(保留 DSH 自带技能)`)
46
+ return
47
+ }
48
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/u.test(name)) {
49
+ sendError(res, 400, '技能名称无效(仅允许 kebab-case)')
50
+ return
51
+ }
52
+ const dest = join(dshHome(), 'skills', name)
53
+ if (!existsSync(dest)) {
54
+ sendError(res, 404, `技能 ${name} 不存在`)
55
+ return
56
+ }
57
+ // 删完必须核实:本机环境可能让 rmSync 静默落空(见 removeDirVerified 注释),
58
+ // 旧代码删完直接 {ok:true} → 用户以为删了,技能其实还在(2026-09-20 演练实测)。
59
+ const result = removeDirVerified(dest)
60
+ if (!result.ok) {
61
+ sendError(res, 500, `删除技能失败:目录仍存在(${dest})${result.error ? `,原因:${result.error}` : ''}——当前环境可能禁止删除该目录,请手动删除它`)
62
+ return
63
+ }
64
+ sendJson(res, 200, { ok: true, name })
65
+ return
66
+ }
67
+
68
+ async function routeSkillToggle(req, res, rc) {
69
+ const ctx = rc.ctx
70
+ const url = rc.url
71
+ const pathname = rc.pathname
72
+ const method = rc.method
73
+ const body = rc.body
74
+ // 停用/启用技能:写入官方调用策略 frontmatter(disable-model-invocation / user-invocable),
75
+ // 可逆(原始内容备份于技能目录 .dsh-skill-fm.bak);系统/隐藏技能禁止停用(保护机制)。
76
+ const name = typeof body.name === 'string' ? body.name.trim() : ''
77
+ if (name.startsWith('.')) {
78
+ // 点号开头是系统/隐藏技能根(如 .system,dsh-skill-filesystem 保留目录):禁止停用
79
+ sendError(res, 403, `技能 ${name} 属于系统/隐藏技能,禁止停用(保留 DSH 自带技能)`)
80
+ return
81
+ }
82
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/u.test(name)) {
83
+ sendError(res, 400, '技能名称无效(仅允许 kebab-case)')
84
+ return
85
+ }
86
+ const dest = join(dshHome(), 'skills', name)
87
+ let skillFile = join(dest, 'SKILL.md')
88
+ if (!existsSync(skillFile)) {
89
+ // 平铺技能(<name>.md)
90
+ const flat = `${dest}.md`
91
+ if (existsSync(flat)) skillFile = flat
92
+ else {
93
+ sendError(res, 404, `技能 ${name} 不存在`)
94
+ return
95
+ }
96
+ }
97
+ const enabled = body.enabled === true
98
+ try {
99
+ setSkillEnabled(skillFile, enabled)
100
+ sendJson(res, 200, { ok: true, name, enabled, skillFile })
101
+ } catch (error) {
102
+ sendError(res, 500, `${enabled ? '启用' : '停用'}技能失败:${error instanceof Error ? error.message : String(error)}`)
103
+ }
104
+ return
105
+ }
106
+
107
+ export { routeSkillsInstalledGet, routeSkillRemove, routeSkillToggle }