@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,437 @@
1
+ // L2 · routes —— 软件源与 Gitee 授权(GET|POST /sources · GET /gitee-oauth-url · GET /gitee-oauth-callback · POST /registry-scan)
2
+ // 分层 Step 8b:从 lib/index.js 的 handle() 原样搬出(只搬移未改逻辑;缩进保持原样)
3
+
4
+ import { webPort } from '../domain/runtime.js'
5
+ import { DEFAULT_SOURCES, GITEE_AUTH_URL, GITEE_TOKEN_URL, consumeGiteeOAuthState, createGiteeOAuthState, giteeStatusView, isAllowedGitSourceUrl, isAllowedSourceUrl, maskSources, readGiteeConfig, readSources, writeSources } from '../domain/sources.js'
6
+ import { fetchJsonUrl, postJsonUrl } from '../infra/http.js'
7
+ import { sendError, sendJson } from '../infra/httpd.js'
8
+ import { setMarketIndexCache } from '../state.js'
9
+
10
+ async function routeSourcesGet(req, res, rc) {
11
+ const ctx = rc.ctx
12
+ const url = rc.url
13
+ const pathname = rc.pathname
14
+ const method = rc.method
15
+ const webPort = rc.deps.webPort
16
+ const sources = readSources()
17
+ sendJson(res, 200, { ok: true, sources: maskSources(sources), giteeStatus: giteeStatusView(sources) })
18
+ return
19
+ }
20
+
21
+ async function routeGiteeOauthUrlGet(req, res, rc) {
22
+ const ctx = rc.ctx
23
+ const pathname = rc.pathname
24
+ const method = rc.method
25
+ const webPort = rc.deps.webPort
26
+ const gitee = readGiteeConfig(readSources())
27
+ if (!gitee.clientId) {
28
+ sendError(res, 400, '请先在软件源管理中配置 Gitee 应用的 client_id / client_secret')
29
+ return
30
+ }
31
+ const port = webPort(ctx)
32
+ const redirect = `http://127.0.0.1:${port}/plugin-console/gitee-oauth-callback`
33
+ // scope 请求 user_info + projects:Gitee 会校验请求的 scope 必须在应用已勾选的权限范围内,
34
+ // 应用权限必须同步勾选 user_info、projects,否则报「请求范围无效、未知或格式不正确」
35
+ const state = createGiteeOAuthState()
36
+ const url = `${GITEE_AUTH_URL}?client_id=${encodeURIComponent(gitee.clientId)}&redirect_uri=${encodeURIComponent(redirect)}&response_type=code&scope=${encodeURIComponent('user_info projects')}&state=${encodeURIComponent(state)}`
37
+ sendJson(res, 200, { ok: true, url, redirect, state })
38
+ return
39
+ }
40
+
41
+ async function routeGiteeOauthCallbackGet(req, res, rc) {
42
+ const ctx = rc.ctx
43
+ const url = rc.url
44
+ const pathname = rc.pathname
45
+ const method = rc.method
46
+ const webPort = rc.deps.webPort
47
+ const code = new URL(req.url ?? '/', 'http://x').searchParams.get('code')
48
+ const state = new URL(req.url ?? '/', 'http://x').searchParams.get('state')
49
+ const sources = readSources()
50
+ const gitee = readGiteeConfig(sources)
51
+ const failPage = (text) => {
52
+ res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' })
53
+ res.end(`<h3>${text}</h3>`)
54
+ }
55
+ if (!code) {
56
+ failPage('这是 Gitee 授权回调地址,不能直接访问。<br>正确流程:插件面板 → 软件源管理 → 填入 client_id / client_secret → 保存配置 → 点击「授权登录 Gitee」,授权完成后会自动跳回这里。')
57
+ return
58
+ }
59
+ if (!state || !consumeGiteeOAuthState(state)) {
60
+ failPage('Gitee OAuth state 校验失败,请重新发起授权。')
61
+ return
62
+ }
63
+ if (!gitee.clientId || !gitee.clientSecret) {
64
+ failPage('未配置 Gitee 应用:请先在 gitee.com 创建第三方应用(回调地址填本页完整地址),再在插件面板 → 软件源管理 中填入 client_id / client_secret 并保存。')
65
+ return
66
+ }
67
+ const port = webPort(ctx)
68
+ const redirect = `http://127.0.0.1:${port}/plugin-console/gitee-oauth-callback`
69
+ const result = await postJsonUrl(GITEE_TOKEN_URL, {
70
+ grant_type: 'authorization_code',
71
+ code,
72
+ client_id: gitee.clientId,
73
+ client_secret: gitee.clientSecret,
74
+ redirect_uri: redirect,
75
+ })
76
+ if (result.status < 200 || result.status >= 300 || !result.body || !result.body.access_token) {
77
+ failPage('Gitee 授权失败:token 交换错误,请检查 client_id / client_secret')
78
+ return
79
+ }
80
+ gitee.token = result.body.access_token
81
+ try {
82
+ const user = await fetchJsonUrl(`https://gitee.com/api/v5/user?access_token=${encodeURIComponent(gitee.token)}`)
83
+ gitee.login = user && typeof user.login === 'string' ? user.login : ''
84
+ } catch {}
85
+ sources.gitee = gitee
86
+ await writeSources(sources)
87
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
88
+ res.end('<h3>Gitee 授权成功,可以关闭此页面并回到插件面板</h3>')
89
+ return
90
+ }
91
+
92
+ async function routeRegistryScan(req, res, rc) {
93
+ const ctx = rc.ctx
94
+ const url = rc.url
95
+ const pathname = rc.pathname
96
+ const method = rc.method
97
+ const webPort = rc.deps.webPort
98
+ const body = rc.body
99
+ const sources = readSources()
100
+ const scanPkg = '@noob-stupid/dsh-plugin-console'
101
+ const encoded = encodeURIComponent(scanPkg)
102
+ const scanOne = async (r) => {
103
+ const started = Date.now()
104
+ const base = { id: r.id, name: r.name, url: r.url, primary: r.primary === true }
105
+ try {
106
+ const response = await fetch(`${String(r.url).replace(/\/+$/u, '')}/${encoded}`, {
107
+ headers: { accept: 'application/vnd.npm.install-v1+json' },
108
+ signal: AbortSignal.timeout(8000),
109
+ })
110
+ const ms = Date.now() - started
111
+ if (!response.ok) return { ...base, ok: false, ms, status: response.status, latest: null, versions: null, error: `HTTP ${response.status}` }
112
+ const data = await response.json()
113
+ const latest = typeof data?.['dist-tags']?.latest === 'string' ? data['dist-tags'].latest : null
114
+ const versions = data?.versions !== undefined && data.versions !== null && typeof data.versions === 'object' ? Object.keys(data.versions).length : null
115
+ return { ...base, ok: true, ms, status: response.status, latest, versions, error: null }
116
+ } catch (error) {
117
+ const ms = Date.now() - started
118
+ const message = error?.name === 'TimeoutError'
119
+ ? '超时(8s)'
120
+ : (error instanceof Error ? error.message : String(error))
121
+ return { ...base, ok: false, ms, status: null, latest: null, versions: null, error: message }
122
+ }
123
+ }
124
+ const results = await Promise.all(sources.registries.map((r) => scanOne(r)))
125
+ sendJson(res, 200, { ok: true, pkg: scanPkg, scannedAt: Date.now(), results })
126
+ return
127
+ }
128
+
129
+ async function routeSources(req, res, rc) {
130
+ const ctx = rc.ctx
131
+ const pathname = rc.pathname
132
+ const method = rc.method
133
+ const webPort = rc.deps.webPort
134
+ const body = rc.body
135
+ const { action } = body
136
+ const sources = readSources()
137
+ if (action === 'add') {
138
+ const url = typeof body.url === 'string' ? body.url.trim() : ''
139
+ const name = typeof body.name === 'string' ? body.name.trim() : ''
140
+ if (!isAllowedSourceUrl(url)) {
141
+ sendError(res, 400, '软件源地址必须是 https:// 开头(或本机/私网 http://)的合法 URL')
142
+ return
143
+ }
144
+ if (sources.registries.some((r) => r.url === url)) {
145
+ sendError(res, 400, '该软件源已存在')
146
+ return
147
+ }
148
+ const entry = {
149
+ id: `src-${Date.now().toString(36)}`,
150
+ name: name || url,
151
+ url,
152
+ primary: sources.registries.length === 0,
153
+ }
154
+ sources.registries.push(entry)
155
+ await writeSources(sources)
156
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
157
+ return
158
+ }
159
+ if (action === 'remove') {
160
+ sendError(res, 400, '软件源不可删除(插件安装依赖的 npm 源,请使用编辑/设为主源)')
161
+ return
162
+ }
163
+ if (action === 'edit') {
164
+ const id = typeof body.id === 'string' ? body.id : ''
165
+ const target = sources.registries.find((r) => r.id === id)
166
+ if (!target) {
167
+ sendError(res, 404, '没有这个软件源')
168
+ return
169
+ }
170
+ const url = typeof body.url === 'string' ? body.url.trim() : target.url
171
+ const name = typeof body.name === 'string' ? body.name.trim() : target.name
172
+ if (!isAllowedSourceUrl(url)) {
173
+ sendError(res, 400, '软件源地址必须是 https:// 开头(或本机/私网 http://)的合法 URL')
174
+ return
175
+ }
176
+ if (sources.registries.some((r) => r.url === url && r.id !== id)) {
177
+ sendError(res, 400, '该软件源地址已存在')
178
+ return
179
+ }
180
+ target.url = url
181
+ target.name = name || url
182
+ await writeSources(sources)
183
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
184
+ return
185
+ }
186
+ if (action === 'set-primary') {
187
+ const id = typeof body.id === 'string' ? body.id : ''
188
+ if (!sources.registries.some((r) => r.id === id)) {
189
+ sendError(res, 404, '没有这个软件源')
190
+ return
191
+ }
192
+ for (const r of sources.registries) r.primary = r.id === id
193
+ await writeSources(sources)
194
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
195
+ return
196
+ }
197
+ if (action === 'add-search') {
198
+ const name = typeof body.name === 'string' ? body.name.trim() : ''
199
+ const url = typeof body.url === 'string' ? body.url.trim() : ''
200
+ if (!isAllowedSourceUrl(url)) {
201
+ sendError(res, 400, '搜索地址必须是 https:// 开头(或本机/私网 http://)的合法 URL')
202
+ return
203
+ }
204
+ if (!url.includes('{q}')) {
205
+ sendError(res, 400, '搜索 URL 模板必须包含 {q} 占位符')
206
+ return
207
+ }
208
+ if (sources.searchSources.some((s) => s.url === url)) {
209
+ sendError(res, 400, '该搜索源已存在')
210
+ return
211
+ }
212
+ // 可选请求头(认证等):[{name, value}] 结构,仅服务端使用,不下发浏览器
213
+ const headers = Array.isArray(body.headers)
214
+ ? body.headers
215
+ .filter((h) => h && typeof h.name === 'string' && h.name.trim() !== '' && typeof h.value === 'string')
216
+ .map((h) => ({ name: h.name.trim().slice(0, 100), value: h.value.slice(0, 500) }))
217
+ : []
218
+ sources.searchSources.push({
219
+ id: `search-${Date.now().toString(36)}`,
220
+ name: name || url,
221
+ type: 'custom',
222
+ url,
223
+ ...(headers.length > 0 ? { headers } : {}),
224
+ })
225
+ await writeSources(sources)
226
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
227
+ return
228
+ }
229
+ if (action === 'remove-search') {
230
+ const id = typeof body.id === 'string' ? body.id : ''
231
+ const target = sources.searchSources.find((s) => s.id === id)
232
+ if (!target) {
233
+ sendError(res, 404, '没有这个搜索源')
234
+ return
235
+ }
236
+ if (target.type === 'builtin') {
237
+ sendError(res, 400, '内置搜索源不可删除')
238
+ return
239
+ }
240
+ sources.searchSources = sources.searchSources.filter((s) => s.id !== id)
241
+ await writeSources(sources)
242
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
243
+ return
244
+ }
245
+ if (action === 'add-index') {
246
+ const name = typeof body.name === 'string' ? body.name.trim() : ''
247
+ const url = typeof body.url === 'string' ? body.url.trim() : ''
248
+ if (!isAllowedSourceUrl(url)) {
249
+ sendError(res, 400, '索引地址必须是 https:// 开头(或本机/私网 http://)的合法 URL')
250
+ return
251
+ }
252
+ if ((sources.indexSources ?? []).some((s) => s.url === url)) {
253
+ sendError(res, 400, '该索引源已存在')
254
+ return
255
+ }
256
+ sources.indexSources = [...(sources.indexSources ?? []), {
257
+ id: `idx-${Date.now().toString(36)}`,
258
+ name: name || url,
259
+ url,
260
+ primary: (sources.indexSources ?? []).length === 0,
261
+ }]
262
+ setMarketIndexCache(null)
263
+ await writeSources(sources)
264
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
265
+ return
266
+ }
267
+ if (action === 'edit-index') {
268
+ const id = typeof body.id === 'string' ? body.id : ''
269
+ const name = typeof body.name === 'string' ? body.name.trim() : ''
270
+ const url = typeof body.url === 'string' ? body.url.trim() : ''
271
+ if (!isAllowedSourceUrl(url)) {
272
+ sendError(res, 400, '索引地址必须是 https:// 开头(或本机/私网 http://)的合法 URL')
273
+ return
274
+ }
275
+ const target = (sources.indexSources ?? []).find((s) => s.id === id)
276
+ if (!target) {
277
+ sendError(res, 404, '没有这个索引源')
278
+ return
279
+ }
280
+ target.name = name || url
281
+ target.url = url
282
+ setMarketIndexCache(null)
283
+ await writeSources(sources)
284
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
285
+ return
286
+ }
287
+ if (action === 'set-index-primary') {
288
+ const id = typeof body.id === 'string' ? body.id : ''
289
+ const list = sources.indexSources ?? []
290
+ if (!list.some((s) => s.id === id)) {
291
+ sendError(res, 404, '没有这个索引源')
292
+ return
293
+ }
294
+ for (const s of list) s.primary = s.id === id
295
+ setMarketIndexCache(null)
296
+ await writeSources(sources)
297
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
298
+ return
299
+ }
300
+ if (action === 'remove-index') {
301
+ const id = typeof body.id === 'string' ? body.id : ''
302
+ const list = sources.indexSources ?? []
303
+ if (!list.some((s) => s.id === id)) {
304
+ sendError(res, 404, '没有这个索引源')
305
+ return
306
+ }
307
+ const rest = list.filter((s) => s.id !== id)
308
+ if (rest.length === 0) {
309
+ sendError(res, 400, '至少保留一个索引源(可先添加自建镜像再删除默认源)')
310
+ return
311
+ }
312
+ if (!rest.some((s) => s.primary)) rest[0].primary = true
313
+ sources.indexSources = rest
314
+ setMarketIndexCache(null)
315
+ await writeSources(sources)
316
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
317
+ return
318
+ }
319
+ if (action === 'set-index-merge') {
320
+ sources.indexMerge = body.merge === true
321
+ setMarketIndexCache(null)
322
+ await writeSources(sources)
323
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
324
+ return
325
+ }
326
+ if (action === 'add-git') {
327
+ const name = typeof body.name === 'string' ? body.name.trim() : ''
328
+ const urlTemplate = typeof body.urlTemplate === 'string' ? body.urlTemplate.trim() : ''
329
+ if (!urlTemplate.includes('{owner}') || !urlTemplate.includes('{repo}')) {
330
+ sendError(res, 400, 'Git 源模板必须同时包含 {owner} 与 {repo} 占位符')
331
+ return
332
+ }
333
+ if (!isAllowedGitSourceUrl(urlTemplate)) {
334
+ sendError(res, 400, 'Git 源地址必须是 https://(或本机/私网 http://、file:// 本地裸仓库)的合法 URL')
335
+ return
336
+ }
337
+ if ((sources.gitSources ?? []).some((s) => s.urlTemplate === urlTemplate)) {
338
+ sendError(res, 400, '该 Git 源已存在')
339
+ return
340
+ }
341
+ sources.gitSources = [...(sources.gitSources ?? []), {
342
+ id: `git-${Date.now().toString(36)}`,
343
+ name: name || urlTemplate,
344
+ urlTemplate,
345
+ primary: (sources.gitSources ?? []).length === 0,
346
+ }]
347
+ await writeSources(sources)
348
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
349
+ return
350
+ }
351
+ if (action === 'edit-git') {
352
+ const id = typeof body.id === 'string' ? body.id : ''
353
+ const name = typeof body.name === 'string' ? body.name.trim() : ''
354
+ const urlTemplate = typeof body.urlTemplate === 'string' ? body.urlTemplate.trim() : ''
355
+ if (!urlTemplate.includes('{owner}') || !urlTemplate.includes('{repo}')) {
356
+ sendError(res, 400, 'Git 源模板必须同时包含 {owner} 与 {repo} 占位符')
357
+ return
358
+ }
359
+ if (!isAllowedGitSourceUrl(urlTemplate)) {
360
+ sendError(res, 400, 'Git 源地址必须是 https://(或本机/私网 http://、file:// 本地裸仓库)的合法 URL')
361
+ return
362
+ }
363
+ const target = (sources.gitSources ?? []).find((s) => s.id === id)
364
+ if (!target) {
365
+ sendError(res, 404, '没有这个 Git 源')
366
+ return
367
+ }
368
+ target.name = name || urlTemplate
369
+ target.urlTemplate = urlTemplate
370
+ await writeSources(sources)
371
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
372
+ return
373
+ }
374
+ if (action === 'set-git-primary') {
375
+ const id = typeof body.id === 'string' ? body.id : ''
376
+ const list = sources.gitSources ?? []
377
+ if (!list.some((s) => s.id === id)) {
378
+ sendError(res, 404, '没有这个 Git 源')
379
+ return
380
+ }
381
+ for (const s of list) s.primary = s.id === id
382
+ await writeSources(sources)
383
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
384
+ return
385
+ }
386
+ if (action === 'remove-git') {
387
+ const id = typeof body.id === 'string' ? body.id : ''
388
+ const list = sources.gitSources ?? []
389
+ if (!list.some((s) => s.id === id)) {
390
+ sendError(res, 404, '没有这个 Git 源')
391
+ return
392
+ }
393
+ const rest = list.filter((s) => s.id !== id)
394
+ if (rest.length === 0) {
395
+ sendError(res, 400, '至少保留一个 Git 源(可先添加自建镜像再删除默认源)')
396
+ return
397
+ }
398
+ if (!rest.some((s) => s.primary)) rest[0].primary = true
399
+ sources.gitSources = rest
400
+ await writeSources(sources)
401
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
402
+ return
403
+ }
404
+ if (action === 'reset') {
405
+ const defaults = JSON.parse(JSON.stringify(DEFAULT_SOURCES))
406
+ setMarketIndexCache(null)
407
+ await writeSources(defaults)
408
+ sendJson(res, 200, { ok: true, sources: maskSources(defaults) })
409
+ return
410
+ }
411
+ if (action === 'gitee-setup') {
412
+ const clientId = typeof body.clientId === 'string' ? body.clientId.trim() : ''
413
+ const clientSecret = typeof body.clientSecret === 'string' ? body.clientSecret.trim() : ''
414
+ const keepClientId = body.keepClientId === true
415
+ if ((!keepClientId && !clientId) || !clientSecret) {
416
+ sendError(res, 400, 'client_id 与 client_secret 不能为空')
417
+ return
418
+ }
419
+ const current = readGiteeConfig(sources)
420
+ // keepClientId:前端回显的是打码 clientId(abc12345…),用户只改了 secret 时保留原配置
421
+ sources.gitee = { ...current, clientId: keepClientId ? current.clientId : clientId, clientSecret }
422
+ await writeSources(sources)
423
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
424
+ return
425
+ }
426
+ if (action === 'gitee-clear') {
427
+ const current = readGiteeConfig(sources)
428
+ sources.gitee = { clientId: current.clientId, clientSecret: current.clientSecret, token: '', login: '' }
429
+ await writeSources(sources)
430
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
431
+ return
432
+ }
433
+ sendError(res, 400, '未知操作(add / edit / set-primary / remove / add-search / remove-search / add-index / edit-index / set-index-primary / remove-index / set-index-merge / add-git / edit-git / set-git-primary / remove-git / gitee-setup / gitee-clear / reset)')
434
+ return
435
+ }
436
+
437
+ export { routeSourcesGet, routeGiteeOauthUrlGet, routeGiteeOauthCallbackGet, routeRegistryScan, routeSources }
@@ -0,0 +1,125 @@
1
+ // L2 · routes —— 状态与详情(GET /state · POST /details)
2
+ // 分层 Step 8b:从 lib/index.js 的 handle() 原样搬出(只搬移未改逻辑;缩进保持原样)
3
+
4
+ import { readFileSync, existsSync } from 'node:fs'
5
+ import { dirname, join } from 'node:path'
6
+ import { detectAdoptablePending, detectCompat, gatingSummary, readCompatGate, readCompatPending } from '../domain/compat.js'
7
+ import { compUiUrl, findComponents } from '../domain/components.js'
8
+ import { detectFrameworkUpgrade } from '../domain/framework.js'
9
+ import { readExtraBundleRows } from '../domain/install-job.js'
10
+ import { installJobView, readGithubAuth } from '../domain/install.js'
11
+ import { readPluginDetails } from '../domain/market.js'
12
+ import { healPatchSafety, readPatchState } from '../domain/patch.js'
13
+ import { pendingRestartJobs } from '../domain/revoke.js'
14
+ import { listEntries } from '../domain/runtime.js'
15
+ import { sendError, sendJson } from '../infra/httpd.js'
16
+ import { dshHome, entryPkgMeta, findPatchPath, pluginRoot, profileDirOf, rowIdOf } from '../infra/paths.js'
17
+ import { installJobs, patchHealAt, patchHealReport, setPatchHealAt, setPatchHealReport } from '../state.js'
18
+
19
+ async function routeStateGet(req, res, rc) {
20
+ const ctx = rc.ctx
21
+ const url = rc.url
22
+ const pathname = rc.pathname
23
+ const method = rc.method
24
+ const detectFrameworkUpgrade = rc.deps.detectFrameworkUpgrade
25
+ const detectCompat = rc.deps.detectCompat
26
+ const listEntries = rc.deps.listEntries
27
+ const detectAdoptablePending = rc.deps.detectAdoptablePending
28
+ const readExtraBundleRows = rc.deps.readExtraBundleRows
29
+ const patchPath = findPatchPath(ctx)
30
+ const patch = await readPatchState(patchPath)
31
+ // 补丁安全自愈(核心行误禁用恢复 / 缺失模块行自动禁用)——每 2 分钟最多跑一次
32
+ let patchHeal = null
33
+ try {
34
+ if (patchHealAt === null || Date.now() - patchHealAt > 120000) {
35
+ setPatchHealAt(Date.now())
36
+ patchHeal = await healPatchSafety(patchPath)
37
+ } else {
38
+ patchHeal = patchHealReport
39
+ }
40
+ if (patchHeal !== null) setPatchHealReport(patchHeal)
41
+ } catch {}
42
+ const extraRows = await readExtraBundleRows(dirname(patchPath))
43
+ const compatPending = readCompatPending()
44
+ const compatPendingRows = new Set((compatPending?.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => p.rowId))
45
+ // 兼容门总开关 + 自动检测(只提示不自动开)
46
+ const compatGate = readCompatGate()
47
+ const adoptable = compatGate.autoDetect ? detectAdoptablePending(ctx) : new Map()
48
+ let rollbackRec = null
49
+ try {
50
+ const rr = JSON.parse(readFileSync(join(dshHome(), 'plugin-console', 'framework-rollback.json'), 'utf8'))
51
+ if (rr !== null && typeof rr.checkpointDir === 'string' && existsSync(join(rr.checkpointDir, '.pnpm'))) {
52
+ rollbackRec = { from: rr.from ?? null, to: rr.to ?? null, at: rr.at ?? null, applicable: true }
53
+ }
54
+ } catch {}
55
+ const entries = listEntries(ctx).map((entry) => {
56
+ const meta = entryPkgMeta(entry.moduleName, ctx.baseUrl ?? 'file:///', profileDirOf(ctx))
57
+ return {
58
+ ...entry,
59
+ userDisabled: patch.disables.includes(entry.rowId),
60
+ userForced: patch.forced.includes(entry.rowId),
61
+ extra: patch.inserts.includes(entry.rowId) || extraRows.has(entry.moduleName) || extraRows.has(entry.rowId),
62
+ installDate: meta?.installDate ?? null,
63
+ version: meta?.version ?? null,
64
+ repository: meta?.repository ?? null,
65
+ // v0.3.45:只有"补丁里此刻确实还禁着它"的行才显示【待适配】——
66
+ // 记录与开关脱节时(用户手动启用过 / 补丁块被清掉)不能继续挂着「待适配」误导人
67
+ pendingCompat: compatPendingRows.has(entry.rowId) && patch.disables.includes(entry.rowId),
68
+ // 自动检测结果(只提示,不自动开):该待适配行现在是否已适配(插件已更新 + 扫描通过)
69
+ adoptable: adoptable.get(entry.rowId) ?? null,
70
+ }
71
+ })
72
+ const compat = await detectCompat(ctx.baseUrl ?? 'file:///')
73
+ const auth = readGithubAuth()
74
+ const jobs = [...installJobs.values()].filter((job) => job.status === 'installing').map(installJobView)
75
+ const recentFailures = [...installJobs.values()].filter((job) => job.status === 'failed').slice(-3).map(installJobView)
76
+ // 已安装但尚未生效的任务(2026-09-20 真装真卸演练实测):bundle 型插件要重启才被加载,
77
+ // 装完 /install 返回 entryId: null、/state 里新增 loader 条目 = 0 —— 面板过去完全看不出
78
+ // 「装了但还没生效」,也无法撤销。前端据此渲染「已安装·重启后生效」徽标 + 按 jobId 删除。
79
+ const pendingRestart = pendingRestartJobs([...installJobs.values()], listEntries(ctx))
80
+ // 框架升级检测与适配(备份快照 + 重打框架补丁),try 包裹不阻塞 state 返回
81
+ let framework = null
82
+ try { framework = detectFrameworkUpgrade(ctx) } catch {}
83
+ // 回滚可用性(2026-09-11 用户困惑:版本已经回滚了,回滚按钮还能点)——
84
+ // 当前版本已经等于记录里的 from 时,再点回滚等于"恢复到你现在这个版本",无意义。
85
+ if (rollbackRec !== null) {
86
+ const currentVer = typeof framework?.version === 'string' ? framework.version : null
87
+ rollbackRec.applicable = !(currentVer !== null && rollbackRec.from !== null && currentVer === rollbackRec.from)
88
+ }
89
+ let selfVersion = null
90
+ try {
91
+ const selfPkg = JSON.parse(readFileSync(join(pluginRoot(), 'package.json'), 'utf8'))
92
+ selfVersion = typeof selfPkg.version === 'string' ? selfPkg.version : null
93
+ } catch {}
94
+ sendJson(res, 200, { ok: true, entries, patchPath, compat, installJobs: jobs, recentFailures, pendingRestart, github: { loggedIn: auth.loggedIn, login: auth.login }, patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts }, framework, patchHeal: patchHeal === null ? null : { healed: patchHeal.healed ?? [], autoDisabled: patchHeal.autoDisabled ?? [], healedAt: patchHeal.healedAt ?? 0 }, gating: gatingSummary(ctx, compatPending, entries), compatPending: compatPending === null ? null : { frameworkVersion: compatPending.frameworkVersion ?? null, upgradeFrom: compatPending.upgradeFrom ?? null, pending: (compatPending.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => ({ rowId: p.rowId, moduleName: p.moduleName, version: p.version ?? null, checkNote: p.checkNote ?? null, check: p.check ?? null, riskyApprovedAt: p.riskyApprovedAt ?? null, adoptable: adoptable.get(p.rowId) ?? null })) }, compatGate, rollback: rollbackRec, selfVersion, components: findComponents().map((c) => ({ id: c.id, name: c.name, kind: c.kind ?? 'server', pid: c.pid ?? null, port: c.port ?? null, healthUrl: c.healthUrl ?? null, uiUrl: compUiUrl(c), autoStart: c.autoStart === true })) })
95
+ return
96
+ }
97
+
98
+ async function routeDetails(req, res, rc) {
99
+ const ctx = rc.ctx
100
+ const url = rc.url
101
+ const pathname = rc.pathname
102
+ const method = rc.method
103
+ const detectFrameworkUpgrade = rc.deps.detectFrameworkUpgrade
104
+ const detectCompat = rc.deps.detectCompat
105
+ const listEntries = rc.deps.listEntries
106
+ const detectAdoptablePending = rc.deps.detectAdoptablePending
107
+ const readExtraBundleRows = rc.deps.readExtraBundleRows
108
+ const body = rc.body
109
+ const { entryId } = body
110
+ if (typeof entryId !== 'string' || !/^[A-Za-z0-9_:.-]{1,80}$/u.test(entryId)) {
111
+ sendError(res, 400, 'entryId 无效')
112
+ return
113
+ }
114
+ const entry = ctx.loader.entries().find((candidate) => candidate.id === entryId)
115
+ if (!entry) {
116
+ sendError(res, 404, `没有名为 ${entryId} 的插件条目`)
117
+ return
118
+ }
119
+ const moduleName = entry.options.name
120
+ const details = await readPluginDetails(moduleName, ctx.baseUrl ?? 'file:///', profileDirOf(ctx))
121
+ sendJson(res, 200, { ok: true, entryId, rowId: rowIdOf(ctx, entryId), moduleName, ...details })
122
+ return
123
+ }
124
+
125
+ export { routeStateGet, routeDetails }
@@ -0,0 +1,22 @@
1
+ // 跨请求共享的可变运行时状态(分层 Step 8a 从 lib/index.js 抽出)
2
+ //
3
+ // 为什么单独成模块:ESM 的导入绑定是**只读**的(守卫 ⑧ 会拦截"外部给导入绑定赋值"),
4
+ // 而路由拆分后这些状态的写点会散落到 lib/server/routes/**。所以这里:
5
+ // · 只导出**读取绑定**(读没问题)+ setter / next 函数;
6
+ // · 所有赋值都发生在本模块内部(合法且可查)。
7
+
8
+
9
+ export let marketIndexCache = null
10
+ export let fwCheckCache = null
11
+ export let patchHealAt = null
12
+ export let patchHealReport = null
13
+ export let installJobSeq = 0
14
+ export let aiJobSeq = 0
15
+ export const installJobs = new Map()
16
+
17
+ export const setMarketIndexCache = (v) => { marketIndexCache = v }
18
+ export const setFwCheckCache = (v) => { fwCheckCache = v }
19
+ export const setPatchHealAt = (v) => { patchHealAt = v }
20
+ export const setPatchHealReport = (v) => { patchHealReport = v }
21
+ export const nextInstallJobSeq = () => (installJobSeq += 1)
22
+ export const nextAiJobSeq = () => (aiJobSeq += 1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.3.67",
3
+ "version": "0.4.0",
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",