@noob-stupid/dsh-plugin-console 0.3.66 → 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.
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +127 -32
- package/lib/index.js +60 -9687
- package/lib/server/domain/ai-run.js +479 -0
- package/lib/server/domain/ai.js +246 -0
- package/lib/server/domain/compat.js +474 -0
- package/lib/server/domain/components.js +108 -0
- package/lib/server/domain/dep-source.js +122 -0
- package/lib/server/domain/format-contract.js +265 -0
- package/lib/server/domain/format-scan.js +431 -0
- package/lib/server/domain/framework.js +393 -0
- package/lib/server/domain/install-job.js +561 -0
- package/lib/server/domain/install.js +599 -0
- package/lib/server/domain/jobs.js +28 -0
- package/lib/server/domain/market.js +409 -0
- package/lib/server/domain/patch.js +203 -0
- package/lib/server/domain/presets.js +93 -0
- package/lib/server/domain/quarantine.js +224 -0
- package/lib/server/domain/release-source.js +504 -0
- package/lib/server/domain/repoland.js +119 -0
- package/lib/server/domain/revoke.js +184 -0
- package/lib/server/domain/runtime.js +118 -0
- package/lib/server/domain/selfupdate.js +319 -0
- package/lib/server/domain/skills.js +234 -0
- package/lib/server/domain/sources.js +297 -0
- package/lib/server/domain/suite.js +220 -0
- package/lib/server/infra/exec.js +98 -0
- package/lib/server/infra/fsx.js +163 -0
- package/lib/server/infra/fw-integrity-check.js +37 -0
- package/lib/server/infra/http.js +373 -0
- package/lib/server/infra/httpd.js +51 -0
- package/lib/server/infra/mask.js +19 -0
- package/lib/server/infra/paths.js +177 -0
- package/lib/server/infra/semver.js +168 -0
- package/lib/server/routes/ai.js +172 -0
- package/lib/server/routes/components.js +254 -0
- package/lib/server/routes/framework-preflight.js +154 -0
- package/lib/server/routes/framework-upgrade.js +679 -0
- package/lib/server/routes/framework.js +544 -0
- package/lib/server/routes/github-login.js +198 -0
- package/lib/server/routes/index.js +128 -0
- package/lib/server/routes/install.js +116 -0
- package/lib/server/routes/market.js +415 -0
- package/lib/server/routes/plugins.js +562 -0
- package/lib/server/routes/skills.js +107 -0
- package/lib/server/routes/sources.js +437 -0
- package/lib/server/routes/state.js +125 -0
- package/lib/server/state.js +22 -0
- 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 }
|