dsh-plugin-yapi 0.0.0-stage → 1.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.
@@ -0,0 +1,9 @@
1
+ # dsh-plugin-yapi — bundle patch.
2
+ #
3
+ # This MUST stay a plain insert: DSH's plugin market can hot-mount a bundle
4
+ # whose patch contains only insert rows (`- insert:` / `- id:` / `name:`).
5
+ # The YAPI account lives in ~/.dsh/dsh-plugin-yapi.json instead of here, so the
6
+ # patch carries no secret and the plugin can be enabled without an app restart.
7
+ - insert:
8
+ - id: yapi
9
+ name: 'dsh-plugin-yapi'
package/index.js ADDED
@@ -0,0 +1,554 @@
1
+ /**
2
+ * dsh-plugin-yapi — host half.
3
+ *
4
+ * Owns the YAPI account. The browser never sees the password: the host logs in
5
+ * lazily, keeps the `_yapi_token` / `_yapi_uid` cookie in memory, refreshes it
6
+ * on expiry, and exposes two exact `/api/yapi/*` routes to the panel:
7
+ *
8
+ * POST /api/yapi/interface { id } -> normalized interface document
9
+ * POST /api/yapi/search { projectId, keyword } -> fuzzy interface matches
10
+ * GET /api/yapi/health -> login / configuration status
11
+ * GET /api/yapi/settings -> current account (no password)
12
+ * POST /api/yapi/settings -> verify, then persist a new account
13
+ *
14
+ * Both routes sit on DSH's authenticated `/api` channel, so the panel talks to
15
+ * origin-relative URLs and the YAPI origin is never fetched from the browser.
16
+ */
17
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
18
+ import { readFile } from 'node:fs/promises'
19
+ import { homedir } from 'node:os'
20
+ import { dirname, join } from 'node:path'
21
+ import { fileURLToPath } from 'node:url'
22
+
23
+ import {
24
+ normalizeBaseUrl,
25
+ normalizeInterface,
26
+ parseDocId,
27
+ rankInterfaces,
28
+ } from './lib/core.js'
29
+
30
+ export const name = 'yapi'
31
+ export const inject = ['connection', 'skills']
32
+
33
+ /** Directory of this host module; the packaged skill and CLI live below it. */
34
+ const HERE = dirname(fileURLToPath(import.meta.url))
35
+
36
+ /** A failure with a stable machine-readable code for the panel's error box. */
37
+ class YapiError extends Error {
38
+ /**
39
+ * @param {string} message - user-facing correction.
40
+ * @param {string} code - stable failure category.
41
+ */
42
+ constructor(message, code) {
43
+ super(message)
44
+ this.name = 'YapiError'
45
+ this.code = code
46
+ }
47
+ }
48
+
49
+ /** @param {unknown} error @returns {string} */
50
+ function describe(error) {
51
+ if (error instanceof Error) return error.message
52
+ return String(error ?? '未知错误')
53
+ }
54
+
55
+ /**
56
+ * An abort signal bounded by the configured timeout, when the runtime has one.
57
+ * @param {number} milliseconds - request budget.
58
+ * @returns {AbortSignal | undefined} timeout signal or undefined.
59
+ */
60
+ function timeoutSignal(milliseconds) {
61
+ try {
62
+ return AbortSignal.timeout(milliseconds)
63
+ } catch {
64
+ return undefined
65
+ }
66
+ }
67
+
68
+ /**
69
+ * JSON response with no caching and an explicit UTF-8 charset.
70
+ * @param {unknown} value - serializable body.
71
+ * @param {number} [status] - HTTP status.
72
+ * @returns {Response} the response the connection bridge forwards.
73
+ */
74
+ function jsonResponse(value, status = 200) {
75
+ return new Response(JSON.stringify(value), {
76
+ status,
77
+ headers: {
78
+ 'content-type': 'application/json; charset=utf-8',
79
+ 'cache-control': 'no-store',
80
+ },
81
+ })
82
+ }
83
+
84
+ /** Absolute path of the harness home used for the plugin's own config file. */
85
+ function harnessHome() {
86
+ const configured = typeof process !== 'undefined' ? process.env?.DSH_HOME : undefined
87
+ if (typeof configured === 'string' && configured.trim() !== '') return configured.trim()
88
+ return join(homedir(), '.dsh')
89
+ }
90
+
91
+ /** Location of the optional credential file written by the installer. */
92
+ export const CONFIG_FILE = join(harnessHome(), 'dsh-plugin-yapi.json')
93
+
94
+ const BUNDLED_SKILL_NAME = 'yapi-doc'
95
+ const BUNDLED_SKILL_PROVIDER = 'yapi-doc-plugin'
96
+ const BUNDLED_SKILL_DESCRIPTION =
97
+ 'Read a YAPI interface document by ID, full URL, or path keyword. Use when a task needs an API method, path, headers, parameters, request body, response fields, or response example. The bundled CLI reads the local YAPI account and prints Markdown in one command.'
98
+ const BUNDLED_SKILL_WHEN =
99
+ 'The user mentions YAPI, an interface document ID (for example 10001), an API path, or asks for the request or response fields of an API.'
100
+
101
+ /**
102
+ * Register the packaged `yapi-doc` skill when the plugin was installed by
103
+ * `dsh plugin add` (no `~/.dsh/skills` copy present). The installer path still
104
+ * wins because the user skill root ranks above this bundled provider.
105
+ * @param {any} ctx - DSH host context (must carry the `skills` service).
106
+ */
107
+ function registerBundledSkill(ctx) {
108
+ const skillDir = join(HERE, 'skills', 'yapi-doc')
109
+ const skillPath = join(skillDir, 'SKILL.md')
110
+ if (!existsSync(skillPath)) return
111
+ const userSkill = join(harnessHome(), 'skills', BUNDLED_SKILL_NAME, 'SKILL.md')
112
+ if (existsSync(userSkill)) return
113
+
114
+ ctx.skills.registerProvider(function () {
115
+ return {
116
+ name: BUNDLED_SKILL_PROVIDER,
117
+ async list() {
118
+ return [
119
+ {
120
+ name: BUNDLED_SKILL_NAME,
121
+ description: BUNDLED_SKILL_DESCRIPTION,
122
+ whenToUse: BUNDLED_SKILL_WHEN,
123
+ source: 'bundled',
124
+ provider: BUNDLED_SKILL_PROVIDER,
125
+ rank: 1000,
126
+ path: skillPath,
127
+ },
128
+ ]
129
+ },
130
+ async get(candidate) {
131
+ if (!candidate || candidate.name !== BUNDLED_SKILL_NAME) return undefined
132
+ const raw = await readFile(skillPath, 'utf8')
133
+ const body = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/u, '').trim()
134
+ const content = body.split('$HOME/.dsh/skills/yapi-doc').join(skillDir)
135
+ return {
136
+ name: BUNDLED_SKILL_NAME,
137
+ description: BUNDLED_SKILL_DESCRIPTION,
138
+ whenToUse: BUNDLED_SKILL_WHEN,
139
+ invocation: { modelInvocable: true, userInvocable: true },
140
+ source: 'bundled',
141
+ provider: BUNDLED_SKILL_PROVIDER,
142
+ resourceBase: { kind: 'directory', path: skillDir },
143
+ path: skillPath,
144
+ content,
145
+ }
146
+ },
147
+ }
148
+ })
149
+ }
150
+
151
+ /**
152
+ * Read the installer-written config file. Missing or malformed files are not a
153
+ * startup failure: the panel reports the missing credential when it first asks
154
+ * for a document, which keeps the plugin visible and explains the fix.
155
+ * @returns {Record<string, unknown>} parsed object, or an empty object.
156
+ */
157
+ function readStoredConfig() {
158
+ try {
159
+ const parsed = JSON.parse(readFileSync(CONFIG_FILE, 'utf8'))
160
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
161
+ } catch {
162
+ return {}
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Atomically persist the account so the next start uses it. The temporary file
168
+ * is created with 0600 before the rename, so the password is never briefly
169
+ * readable by another user.
170
+ * @param {{ baseUrl: string, email: string, password: string, timeoutMs: number }} settings - verified account.
171
+ */
172
+ function writeConfigFile(settings) {
173
+ const directory = dirname(CONFIG_FILE)
174
+ mkdirSync(directory, { recursive: true, mode: 0o700 })
175
+ const temporary = `${CONFIG_FILE}.${String(process.pid)}.${String(Date.now())}.tmp`
176
+ const content = JSON.stringify(
177
+ {
178
+ baseUrl: settings.baseUrl,
179
+ email: settings.email,
180
+ password: settings.password,
181
+ timeoutMs: settings.timeoutMs,
182
+ },
183
+ null,
184
+ 2,
185
+ )
186
+ writeFileSync(temporary, content + '\n', { mode: 0o600 })
187
+ chmodSync(temporary, 0o600)
188
+ renameSync(temporary, CONFIG_FILE)
189
+ }
190
+
191
+ /**
192
+ * Resolve settings by precedence: plugin-row config → environment → config file.
193
+ * Keeping the row empty is what lets the market hot-mount the bundle patch
194
+ * (that path only accepts plain insert rows), while the file still carries the
195
+ * account for a fresh start.
196
+ * @param {Record<string, unknown>} config - loader-provided config.
197
+ * @returns {{ baseUrl: string, email: string, password: string, timeoutMs: number }} settings.
198
+ */
199
+ function readConfig(config) {
200
+ const row = config && typeof config === 'object' ? config : {}
201
+ const env = typeof process !== 'undefined' && process.env ? process.env : {}
202
+ const stored = readStoredConfig()
203
+ const timeout = Number(row.timeoutMs ?? env.YAPI_TIMEOUT_MS ?? stored.timeoutMs)
204
+ return {
205
+ baseUrl: normalizeBaseUrl(row.baseUrl ?? env.YAPI_BASE_URL ?? stored.baseUrl ?? ''),
206
+ email: String(row.email ?? env.YAPI_EMAIL ?? stored.email ?? '').trim(),
207
+ password: String(row.password ?? env.YAPI_PASSWORD ?? stored.password ?? ''),
208
+ timeoutMs: Number.isFinite(timeout) && timeout >= 1000 ? Math.min(timeout, 120_000) : 20_000,
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Create the in-memory YAPI session and its two JSON verbs.
214
+ * @param {{ baseUrl: string, email: string, password: string, timeoutMs: number }} settings - resolved config.
215
+ * @param {any} logger - DSH logger (optional methods).
216
+ * @returns {{ getJson: Function, ensureSession: Function, status: Function }} client face.
217
+ */
218
+ function createYapiClient(settings, logger) {
219
+ const { baseUrl, email, password, timeoutMs } = settings
220
+ let cookie = ''
221
+ let expiresAt = 0
222
+ let pendingLogin = null
223
+ let user = null
224
+ let lastLoginAt = 0
225
+
226
+ async function login() {
227
+ if (baseUrl === '') {
228
+ throw new YapiError(
229
+ '未配置 YAPI 地址。请在面板「账号设置」填写,或设置 YAPI_BASE_URL。',
230
+ 'NO_CONFIG',
231
+ )
232
+ }
233
+ if (email === '' || password === '') {
234
+ throw new YapiError(
235
+ `未配置 YAPI 账号。请编辑 ${CONFIG_FILE} 填写 baseUrl / email / password,或在面板「账号设置」中填写。`,
236
+ 'NO_CREDENTIALS',
237
+ )
238
+ }
239
+ let response
240
+ try {
241
+ response = await fetch(`${baseUrl}/api/user/login`, {
242
+ method: 'POST',
243
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
244
+ body: JSON.stringify({ email, password }),
245
+ signal: timeoutSignal(timeoutMs),
246
+ })
247
+ } catch (error) {
248
+ throw new YapiError(`无法连接 YAPI(${baseUrl}):${describe(error)}`, 'NETWORK')
249
+ }
250
+ const text = await response.text()
251
+ let payload = null
252
+ try {
253
+ payload = JSON.parse(text)
254
+ } catch {
255
+ payload = null
256
+ }
257
+ if (!payload || typeof payload !== 'object') {
258
+ throw new YapiError(`YAPI 登录响应不是 JSON(HTTP ${response.status}),请检查地址是否正确。`, 'LOGIN_BAD_RESPONSE')
259
+ }
260
+ if (payload.errcode !== 0) {
261
+ throw new YapiError(`YAPI 登录失败:${payload.errmsg || `错误码 ${String(payload.errcode)}`}`, 'LOGIN_FAILED')
262
+ }
263
+ const lines = typeof response.headers.getSetCookie === 'function' ? response.headers.getSetCookie() : []
264
+ const pairs = []
265
+ for (const line of lines) {
266
+ const pair = String(line).split(';')[0].trim()
267
+ if (pair.startsWith('_yapi_')) pairs.push(pair)
268
+ }
269
+ if (pairs.length === 0) {
270
+ throw new YapiError('YAPI 登录成功,但响应没有返回 _yapi_token Cookie。', 'LOGIN_NO_COOKIE')
271
+ }
272
+ cookie = pairs.join('; ')
273
+ // YAPI's token lives for seven days; refresh a day early so a stale cookie
274
+ // never reaches a query.
275
+ expiresAt = Date.now() + 6 * 24 * 60 * 60 * 1000
276
+ user = payload.data && typeof payload.data === 'object' ? payload.data : null
277
+ lastLoginAt = Date.now()
278
+ logger?.info?.(`[yapi] 登录成功:${user?.email || email}`)
279
+ return cookie
280
+ }
281
+
282
+ async function ensureSession(force) {
283
+ if (!force && cookie !== '' && Date.now() < expiresAt) return cookie
284
+ if (pendingLogin === null) {
285
+ pendingLogin = login().finally(() => {
286
+ pendingLogin = null
287
+ })
288
+ }
289
+ return pendingLogin
290
+ }
291
+
292
+ async function getJson(pathname, options = {}) {
293
+ const retry = options.retry !== false
294
+ await ensureSession(false)
295
+ let response
296
+ try {
297
+ response = await fetch(`${baseUrl}${pathname}`, {
298
+ method: 'GET',
299
+ headers: { cookie, accept: 'application/json' },
300
+ redirect: 'manual',
301
+ signal: timeoutSignal(timeoutMs),
302
+ })
303
+ } catch (error) {
304
+ throw new YapiError(`无法连接 YAPI(${baseUrl}):${describe(error)}`, 'NETWORK')
305
+ }
306
+
307
+ const retryAfterSessionLoss = async (code, message) => {
308
+ if (!retry) throw new YapiError(message, code)
309
+ cookie = ''
310
+ expiresAt = 0
311
+ await ensureSession(true)
312
+ return getJson(pathname, { retry: false })
313
+ }
314
+
315
+ if ([301, 302, 303, 307, 308].includes(response.status)) {
316
+ return retryAfterSessionLoss('SESSION_LOST', 'YAPI 会话已失效,重新登录后仍被重定向。')
317
+ }
318
+ if (response.status === 401 || response.status === 403) {
319
+ return retryAfterSessionLoss('SESSION_LOST', `YAPI 拒绝访问(HTTP ${response.status}),请确认账号有该项目的权限。`)
320
+ }
321
+ const text = await response.text()
322
+ let payload = null
323
+ try {
324
+ payload = JSON.parse(text)
325
+ } catch {
326
+ payload = null
327
+ }
328
+ if (!payload || typeof payload !== 'object') {
329
+ return retryAfterSessionLoss('BAD_RESPONSE', 'YAPI 返回了非 JSON 响应,可能是登录已失效或服务异常。')
330
+ }
331
+ if (payload.errcode !== 0) {
332
+ const code = Number(payload.errcode)
333
+ if ([40011, 40012, 40013].includes(code)) {
334
+ return retryAfterSessionLoss('SESSION_LOST', 'YAPI 会话已失效。')
335
+ }
336
+ throw new YapiError(payload.errmsg || `YAPI 错误码 ${code}`, `YAPI_${code}`)
337
+ }
338
+ return payload.data
339
+ }
340
+
341
+ return {
342
+ getJson,
343
+ ensureSession,
344
+ status: () => ({ baseUrl, loggedIn: cookie !== '', user, lastLoginAt }),
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Install the YAPI routes on the profile's authenticated connection channel.
350
+ * @param {any} ctx - DSH host context.
351
+ * @param {Record<string, unknown>} [config] - plugin row config.
352
+ */
353
+ export function apply(ctx, config = {}) {
354
+ registerBundledSkill(ctx)
355
+ // Mutable so POST /api/yapi/settings can swap the account without a restart.
356
+ const state = {
357
+ settings: readConfig(config),
358
+ client: null,
359
+ }
360
+ state.client = createYapiClient(state.settings, ctx.logger)
361
+ const projectCache = new Map()
362
+ const listCache = new Map()
363
+
364
+ /** Everything the panel may know about the account — never the password. */
365
+ function settingsView() {
366
+ const status = state.client.status()
367
+ return {
368
+ baseUrl: state.settings.baseUrl,
369
+ email: state.settings.email,
370
+ hasPassword: state.settings.password !== '',
371
+ loggedIn: status.loggedIn,
372
+ user: status.user ? { username: status.user.username ?? '', email: status.user.email ?? '' } : null,
373
+ configFile: CONFIG_FILE,
374
+ }
375
+ }
376
+
377
+ async function projectOf(projectId) {
378
+ const cached = projectCache.get(projectId)
379
+ if (cached && Date.now() - cached.at < 5 * 60 * 1000) return cached.data
380
+ const data = await state.client.getJson(`/api/project/get?id=${encodeURIComponent(projectId)}`)
381
+ projectCache.set(projectId, { at: Date.now(), data })
382
+ return data
383
+ }
384
+
385
+ async function interfacesOf(projectId) {
386
+ const cached = listCache.get(projectId)
387
+ if (cached && Date.now() - cached.at < 5 * 60 * 1000) return cached.list
388
+ const data = await state.client.getJson(
389
+ `/api/interface/list?project_id=${encodeURIComponent(projectId)}&page=1&limit=2000`,
390
+ )
391
+ const list = Array.isArray(data && data.list) ? data.list : []
392
+ listCache.set(projectId, { at: Date.now(), list })
393
+ return list
394
+ }
395
+
396
+ async function handleSearch(request) {
397
+ let body = null
398
+ try {
399
+ body = await request.json()
400
+ } catch {
401
+ return jsonResponse({ ok: false, error: { code: 'BAD_REQUEST', message: '请求体必须是 JSON。' } }, 400)
402
+ }
403
+ const projectId = Number(body && body.projectId)
404
+ if (!Number.isSafeInteger(projectId) || projectId <= 0) {
405
+ return jsonResponse(
406
+ { ok: false, error: { code: 'BAD_PROJECT', message: '请输入有效的项目 ID。' } },
407
+ 400,
408
+ )
409
+ }
410
+ const keyword = String((body && body.keyword) ?? '').trim()
411
+ if (keyword === '') {
412
+ return jsonResponse({ ok: true, data: { projectId, projectName: '', total: 0, items: [] } })
413
+ }
414
+ try {
415
+ const list = await interfacesOf(projectId)
416
+ let projectName = ''
417
+ try {
418
+ const project = await projectOf(projectId)
419
+ projectName = String((project && project.name) ?? '')
420
+ } catch (error) {
421
+ ctx.logger?.warn?.(`[yapi] 搜索时读取项目 ${projectId} 失败:${describe(error)}`)
422
+ }
423
+ const items = rankInterfaces(list, keyword, 30).map((item) => ({
424
+ id: Number(item._id),
425
+ method: String(item.method ?? '').toUpperCase(),
426
+ path: String(item.path ?? ''),
427
+ title: String(item.title ?? ''),
428
+ }))
429
+ return jsonResponse({ ok: true, data: { projectId, projectName, total: list.length, items } })
430
+ } catch (error) {
431
+ return jsonResponse({ ok: false, error: { code: error.code || 'UNKNOWN', message: describe(error) } }, 200)
432
+ }
433
+ }
434
+
435
+ async function handleInterface(request) {
436
+ let body = null
437
+ try {
438
+ body = await request.json()
439
+ } catch {
440
+ return jsonResponse({ ok: false, error: { code: 'BAD_REQUEST', message: '请求体必须是 JSON:{"id": 10001}' } }, 400)
441
+ }
442
+ const id = parseDocId(body && typeof body === 'object' ? body.id : body)
443
+ if (id === null) {
444
+ return jsonResponse({ ok: false, error: { code: 'BAD_ID', message: '请输入有效的文档 ID(例如 10001)或接口链接。' } }, 400)
445
+ }
446
+ try {
447
+ const raw = await state.client.getJson(`/api/interface/get?id=${id}`)
448
+ if (!raw || typeof raw !== 'object' || Number(raw._id) <= 0) {
449
+ throw new YapiError(`YAPI 没有返回文档 ${id} 的数据,可能已被删除。`, 'NO_DATA')
450
+ }
451
+ let project = null
452
+ const projectId = Number(raw && raw.project_id)
453
+ if (Number.isSafeInteger(projectId) && projectId > 0) {
454
+ try {
455
+ project = await projectOf(projectId)
456
+ } catch (error) {
457
+ ctx.logger?.warn?.(`[yapi] 读取项目 ${projectId} 失败:${describe(error)}`)
458
+ }
459
+ }
460
+ return jsonResponse({ ok: true, data: normalizeInterface(raw, project, state.settings.baseUrl) })
461
+ } catch (error) {
462
+ return jsonResponse({ ok: false, error: { code: error.code || 'UNKNOWN', message: describe(error) } }, 200)
463
+ }
464
+ }
465
+
466
+ async function handleHealth() {
467
+ try {
468
+ await state.client.ensureSession(false)
469
+ return jsonResponse({ ok: true, ...settingsView() })
470
+ } catch (error) {
471
+ return jsonResponse({ ok: false, error: { code: error.code || 'UNKNOWN', message: describe(error) } }, 200)
472
+ }
473
+ }
474
+
475
+ /**
476
+ * GET returns the current account; POST verifies the submitted account with a
477
+ * real YAPI login and only then persists it. A failed login changes nothing,
478
+ * so a typo cannot lock the panel out of a working account.
479
+ */
480
+ async function handleSettings(request) {
481
+ if (request.method === 'GET') {
482
+ return jsonResponse({ ok: true, ...settingsView() })
483
+ }
484
+ if (request.method !== 'POST') {
485
+ return jsonResponse({ ok: false, error: { code: 'METHOD_NOT_ALLOWED', message: '仅支持 GET / POST。' } }, 405)
486
+ }
487
+ let body = null
488
+ try {
489
+ body = await request.json()
490
+ } catch {
491
+ return jsonResponse({ ok: false, error: { code: 'BAD_REQUEST', message: '请求体必须是 JSON。' } }, 400)
492
+ }
493
+ const current = state.settings
494
+ const stored = readStoredConfig()
495
+ const baseUrl = normalizeBaseUrl((body && body.baseUrl) || current.baseUrl || stored.baseUrl || '')
496
+ if (baseUrl === '') {
497
+ return jsonResponse(
498
+ { ok: false, error: { code: 'NO_CONFIG', message: '请填写 YAPI 地址(例如 https://yapi.example.com)。' } },
499
+ 200,
500
+ )
501
+ }
502
+ const email = String((body && body.email) ?? current.email ?? stored.email ?? '').trim()
503
+ const typedPassword = body && typeof body.password === 'string' ? body.password : ''
504
+ const password =
505
+ typedPassword !== '' ? typedPassword : current.password || String(stored.password ?? '')
506
+ if (email === '' || password === '') {
507
+ return jsonResponse(
508
+ { ok: false, error: { code: 'NO_CREDENTIALS', message: '请填写账号和密码。' } },
509
+ 200,
510
+ )
511
+ }
512
+
513
+ const next = { baseUrl, email, password, timeoutMs: current.timeoutMs }
514
+ const probe = createYapiClient(next, ctx.logger)
515
+ try {
516
+ await probe.ensureSession(true)
517
+ } catch (error) {
518
+ return jsonResponse(
519
+ { ok: false, error: { code: error.code || 'LOGIN_FAILED', message: describe(error) } },
520
+ 200,
521
+ )
522
+ }
523
+ try {
524
+ writeConfigFile(next)
525
+ } catch (error) {
526
+ return jsonResponse(
527
+ {
528
+ ok: false,
529
+ error: { code: 'SAVE_FAILED', message: `登录成功,但写入配置文件失败:${describe(error)}` },
530
+ },
531
+ 200,
532
+ )
533
+ }
534
+
535
+ state.settings = next
536
+ state.client = probe
537
+ projectCache.clear()
538
+ listCache.clear()
539
+ const user = probe.status().user
540
+ ctx.logger?.info?.(`[yapi] 账号已更新:${user?.email || email}`)
541
+ return jsonResponse({ ok: true, ...settingsView() })
542
+ }
543
+
544
+ ctx.inject(['connection'], (scope) => {
545
+ const routes = [
546
+ { path: '/api/yapi/interface', methods: ['POST'], requestBody: 'buffered', fetch: handleInterface },
547
+ { path: '/api/yapi/search', methods: ['POST'], requestBody: 'buffered', fetch: handleSearch },
548
+ { path: '/api/yapi/health', methods: ['GET'], requestBody: 'buffered', fetch: handleHealth },
549
+ { path: '/api/yapi/settings', methods: ['GET', 'POST'], requestBody: 'buffered', fetch: handleSettings },
550
+ ]
551
+ for (const route of routes) scope.connection.fetch.register(route)
552
+ ctx.logger?.info?.(`[yapi] 已注册 ${routes.length} 条路由:/api/yapi/{interface,search,health,settings};配置 ${CONFIG_FILE}`)
553
+ })
554
+ }