@noob-stupid/dsh-plugin-console 0.3.67 → 0.4.1
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/lib/client.js +126 -31
- 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 +434 -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 +160 -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,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
|
+
"version": "0.4.1",
|
|
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",
|