dsh-plugin-yapi 0.0.0-stage → 1.4.2

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,592 @@
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_PROVIDER = 'yapi-doc-plugin'
95
+ const BUNDLED_SKILLS = [
96
+ {
97
+ name: 'yapi-doc',
98
+ dir: 'yapi-doc',
99
+ description:
100
+ '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.',
101
+ whenToUse:
102
+ '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.',
103
+ },
104
+ {
105
+ name: 'yapi-sync',
106
+ dir: 'yapi-sync',
107
+ description:
108
+ 'Sync the yapi-doc and yapi-sync skills into every detected AI tool (Trae, WorkBuddy, Qoder CN, GitHub Copilot, CodeBuddy, Codex/Agent Skills, DSH, Claude Code, Cursor, Gemini CLI, Amp, OpenCode and more). Use when the user asks to install, update, or sync the YAPI skill across tools.',
109
+ whenToUse:
110
+ 'The user asks to install, update, or sync the YAPI skill or CLI across local AI tools.',
111
+ },
112
+ ]
113
+
114
+ /** Packaged skills whose user-level copy is absent. */
115
+ function bundledSkillDefs() {
116
+ return BUNDLED_SKILLS.map(function (skill) {
117
+ return Object.assign({}, skill, {
118
+ skillDir: join(HERE, 'skills', skill.dir),
119
+ skillPath: join(HERE, 'skills', skill.dir, 'SKILL.md'),
120
+ })
121
+ }).filter(function (skill) {
122
+ return (
123
+ existsSync(skill.skillPath) &&
124
+ !existsSync(join(harnessHome(), 'skills', skill.name, 'SKILL.md'))
125
+ )
126
+ })
127
+ }
128
+
129
+ /**
130
+ * Register packaged `yapi-doc` / `yapi-sync` skills when the plugin was
131
+ * installed by `dsh plugin add` (no `~/.dsh/skills` copy present). The user
132
+ * skill root still wins because it ranks above this bundled provider.
133
+ * @param {any} ctx - DSH host context (must carry the `skills` service).
134
+ */
135
+ function registerBundledSkill(ctx) {
136
+ const defs = bundledSkillDefs()
137
+ if (defs.length === 0) return
138
+
139
+ ctx.skills.registerProvider(function () {
140
+ return {
141
+ name: BUNDLED_SKILL_PROVIDER,
142
+ async list() {
143
+ return defs.map(function (skill) {
144
+ return {
145
+ name: skill.name,
146
+ description: skill.description,
147
+ whenToUse: skill.whenToUse,
148
+ source: 'bundled',
149
+ provider: BUNDLED_SKILL_PROVIDER,
150
+ rank: 1000,
151
+ path: skill.skillPath,
152
+ }
153
+ })
154
+ },
155
+ async get(candidate) {
156
+ const skill = defs.find(function (item) {
157
+ return item.name === (candidate && candidate.name)
158
+ })
159
+ if (skill === undefined) return undefined
160
+ const raw = await readFile(skill.skillPath, 'utf8')
161
+ const body = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/u, '').trim()
162
+ const content = body.split('$HOME/.dsh/skills/yapi-doc').join(join(HERE, 'skills', 'yapi-doc'))
163
+ return {
164
+ name: skill.name,
165
+ description: skill.description,
166
+ whenToUse: skill.whenToUse,
167
+ invocation: { modelInvocable: true, userInvocable: true },
168
+ source: 'bundled',
169
+ provider: BUNDLED_SKILL_PROVIDER,
170
+ resourceBase: { kind: 'directory', path: skill.skillDir },
171
+ path: skill.skillPath,
172
+ content,
173
+ }
174
+ },
175
+ }
176
+ })
177
+ }
178
+
179
+ /**
180
+ * Read the installer-written config file. Missing or malformed files are not a
181
+ * startup failure: the panel reports the missing credential when it first asks
182
+ * for a document, which keeps the plugin visible and explains the fix.
183
+ * @returns {Record<string, unknown>} parsed object, or an empty object.
184
+ */
185
+ function readStoredConfig() {
186
+ try {
187
+ const parsed = JSON.parse(readFileSync(CONFIG_FILE, 'utf8'))
188
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
189
+ } catch {
190
+ return {}
191
+ }
192
+ }
193
+
194
+ /** YAPI login mode; unknown values fall back to the normal password login. */
195
+ function normalizeAuthType(value) {
196
+ return String(value ?? '').trim().toLowerCase() === 'ldap' ? 'ldap' : 'normal'
197
+ }
198
+
199
+ /**
200
+ * Atomically persist the account so the next start uses it. The temporary file
201
+ * is created with 0600 before the rename, so the password is never briefly
202
+ * readable by another user.
203
+ * @param {{ baseUrl: string, email: string, password: string, authType: string, timeoutMs: number }} settings - verified account.
204
+ */
205
+ function writeConfigFile(settings) {
206
+ const directory = dirname(CONFIG_FILE)
207
+ mkdirSync(directory, { recursive: true, mode: 0o700 })
208
+ const temporary = `${CONFIG_FILE}.${String(process.pid)}.${String(Date.now())}.tmp`
209
+ const content = JSON.stringify(
210
+ {
211
+ baseUrl: settings.baseUrl,
212
+ email: settings.email,
213
+ password: settings.password,
214
+ authType: settings.authType,
215
+ timeoutMs: settings.timeoutMs,
216
+ },
217
+ null,
218
+ 2,
219
+ )
220
+ writeFileSync(temporary, content + '\n', { mode: 0o600 })
221
+ chmodSync(temporary, 0o600)
222
+ renameSync(temporary, CONFIG_FILE)
223
+ }
224
+
225
+ /**
226
+ * Resolve settings by precedence: plugin-row config → environment → config file.
227
+ * Keeping the row empty is what lets the market hot-mount the bundle patch
228
+ * (that path only accepts plain insert rows), while the file still carries the
229
+ * account for a fresh start.
230
+ * @param {Record<string, unknown>} config - loader-provided config.
231
+ * @returns {{ baseUrl: string, email: string, password: string, authType: string, timeoutMs: number }} settings.
232
+ */
233
+ function readConfig(config) {
234
+ const row = config && typeof config === 'object' ? config : {}
235
+ const env = typeof process !== 'undefined' && process.env ? process.env : {}
236
+ const stored = readStoredConfig()
237
+ const timeout = Number(row.timeoutMs ?? env.YAPI_TIMEOUT_MS ?? stored.timeoutMs)
238
+ return {
239
+ baseUrl: normalizeBaseUrl(row.baseUrl ?? env.YAPI_BASE_URL ?? stored.baseUrl ?? ''),
240
+ email: String(row.email ?? env.YAPI_EMAIL ?? stored.email ?? '').trim(),
241
+ password: String(row.password ?? env.YAPI_PASSWORD ?? stored.password ?? ''),
242
+ authType: normalizeAuthType(row.authType ?? env.YAPI_AUTH_TYPE ?? stored.authType ?? 'normal'),
243
+ timeoutMs: Number.isFinite(timeout) && timeout >= 1000 ? Math.min(timeout, 120_000) : 20_000,
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Create the in-memory YAPI session and its two JSON verbs.
249
+ * @param {{ baseUrl: string, email: string, password: string, timeoutMs: number }} settings - resolved config.
250
+ * @param {any} logger - DSH logger (optional methods).
251
+ * @returns {{ getJson: Function, ensureSession: Function, status: Function }} client face.
252
+ */
253
+ function createYapiClient(settings, logger) {
254
+ const { baseUrl, email, password, authType, timeoutMs } = settings
255
+ let cookie = ''
256
+ let expiresAt = 0
257
+ let pendingLogin = null
258
+ let user = null
259
+ let lastLoginAt = 0
260
+
261
+ async function login() {
262
+ if (baseUrl === '') {
263
+ throw new YapiError(
264
+ '未配置 YAPI 地址。请在面板「账号设置」填写,或设置 YAPI_BASE_URL。',
265
+ 'NO_CONFIG',
266
+ )
267
+ }
268
+ if (email === '' || password === '') {
269
+ throw new YapiError(
270
+ `未配置 YAPI 账号。请编辑 ${CONFIG_FILE} 填写 baseUrl / email / password,或在面板「账号设置」中填写。`,
271
+ 'NO_CREDENTIALS',
272
+ )
273
+ }
274
+ let response
275
+ try {
276
+ const loginPath = authType === 'ldap' ? '/api/user/login_by_ldap' : '/api/user/login'
277
+ response = await fetch(`${baseUrl}${loginPath}`, {
278
+ method: 'POST',
279
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
280
+ body: JSON.stringify({ email, password }),
281
+ signal: timeoutSignal(timeoutMs),
282
+ })
283
+ } catch (error) {
284
+ throw new YapiError(`无法连接 YAPI(${baseUrl}):${describe(error)}`, 'NETWORK')
285
+ }
286
+ const text = await response.text()
287
+ let payload = null
288
+ try {
289
+ payload = JSON.parse(text)
290
+ } catch {
291
+ payload = null
292
+ }
293
+ if (!payload || typeof payload !== 'object') {
294
+ throw new YapiError(`YAPI 登录响应不是 JSON(HTTP ${response.status}),请检查地址是否正确。`, 'LOGIN_BAD_RESPONSE')
295
+ }
296
+ if (payload.errcode !== 0) {
297
+ throw new YapiError(`YAPI 登录失败:${payload.errmsg || `错误码 ${String(payload.errcode)}`}`, 'LOGIN_FAILED')
298
+ }
299
+ const lines = typeof response.headers.getSetCookie === 'function' ? response.headers.getSetCookie() : []
300
+ const pairs = []
301
+ for (const line of lines) {
302
+ const pair = String(line).split(';')[0].trim()
303
+ if (pair.startsWith('_yapi_')) pairs.push(pair)
304
+ }
305
+ if (pairs.length === 0) {
306
+ throw new YapiError('YAPI 登录成功,但响应没有返回 _yapi_token Cookie。', 'LOGIN_NO_COOKIE')
307
+ }
308
+ cookie = pairs.join('; ')
309
+ // YAPI's token lives for seven days; refresh a day early so a stale cookie
310
+ // never reaches a query.
311
+ expiresAt = Date.now() + 6 * 24 * 60 * 60 * 1000
312
+ user = payload.data && typeof payload.data === 'object' ? payload.data : null
313
+ lastLoginAt = Date.now()
314
+ logger?.info?.(`[yapi] 登录成功:${user?.email || email}`)
315
+ return cookie
316
+ }
317
+
318
+ async function ensureSession(force) {
319
+ if (!force && cookie !== '' && Date.now() < expiresAt) return cookie
320
+ if (pendingLogin === null) {
321
+ pendingLogin = login().finally(() => {
322
+ pendingLogin = null
323
+ })
324
+ }
325
+ return pendingLogin
326
+ }
327
+
328
+ async function getJson(pathname, options = {}) {
329
+ const retry = options.retry !== false
330
+ await ensureSession(false)
331
+ let response
332
+ try {
333
+ response = await fetch(`${baseUrl}${pathname}`, {
334
+ method: 'GET',
335
+ headers: { cookie, accept: 'application/json' },
336
+ redirect: 'manual',
337
+ signal: timeoutSignal(timeoutMs),
338
+ })
339
+ } catch (error) {
340
+ throw new YapiError(`无法连接 YAPI(${baseUrl}):${describe(error)}`, 'NETWORK')
341
+ }
342
+
343
+ const retryAfterSessionLoss = async (code, message) => {
344
+ if (!retry) throw new YapiError(message, code)
345
+ cookie = ''
346
+ expiresAt = 0
347
+ await ensureSession(true)
348
+ return getJson(pathname, { retry: false })
349
+ }
350
+
351
+ if ([301, 302, 303, 307, 308].includes(response.status)) {
352
+ return retryAfterSessionLoss('SESSION_LOST', 'YAPI 会话已失效,重新登录后仍被重定向。')
353
+ }
354
+ if (response.status === 401 || response.status === 403) {
355
+ return retryAfterSessionLoss('SESSION_LOST', `YAPI 拒绝访问(HTTP ${response.status}),请确认账号有该项目的权限。`)
356
+ }
357
+ const text = await response.text()
358
+ let payload = null
359
+ try {
360
+ payload = JSON.parse(text)
361
+ } catch {
362
+ payload = null
363
+ }
364
+ if (!payload || typeof payload !== 'object') {
365
+ return retryAfterSessionLoss('BAD_RESPONSE', 'YAPI 返回了非 JSON 响应,可能是登录已失效或服务异常。')
366
+ }
367
+ if (payload.errcode !== 0) {
368
+ const code = Number(payload.errcode)
369
+ if ([40011, 40012, 40013].includes(code)) {
370
+ return retryAfterSessionLoss('SESSION_LOST', 'YAPI 会话已失效。')
371
+ }
372
+ throw new YapiError(payload.errmsg || `YAPI 错误码 ${code}`, `YAPI_${code}`)
373
+ }
374
+ return payload.data
375
+ }
376
+
377
+ return {
378
+ getJson,
379
+ ensureSession,
380
+ status: () => ({ baseUrl, loggedIn: cookie !== '', user, lastLoginAt }),
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Install the YAPI routes on the profile's authenticated connection channel.
386
+ * @param {any} ctx - DSH host context.
387
+ * @param {Record<string, unknown>} [config] - plugin row config.
388
+ */
389
+ export function apply(ctx, config = {}) {
390
+ registerBundledSkill(ctx)
391
+ // Mutable so POST /api/yapi/settings can swap the account without a restart.
392
+ const state = {
393
+ settings: readConfig(config),
394
+ client: null,
395
+ }
396
+ state.client = createYapiClient(state.settings, ctx.logger)
397
+ const projectCache = new Map()
398
+ const listCache = new Map()
399
+
400
+ /** Everything the panel may know about the account — never the password. */
401
+ function settingsView() {
402
+ const status = state.client.status()
403
+ return {
404
+ baseUrl: state.settings.baseUrl,
405
+ email: state.settings.email,
406
+ authType: state.settings.authType,
407
+ hasPassword: state.settings.password !== '',
408
+ loggedIn: status.loggedIn,
409
+ user: status.user ? { username: status.user.username ?? '', email: status.user.email ?? '' } : null,
410
+ configFile: CONFIG_FILE,
411
+ }
412
+ }
413
+
414
+ async function projectOf(projectId) {
415
+ const cached = projectCache.get(projectId)
416
+ if (cached && Date.now() - cached.at < 5 * 60 * 1000) return cached.data
417
+ const data = await state.client.getJson(`/api/project/get?id=${encodeURIComponent(projectId)}`)
418
+ projectCache.set(projectId, { at: Date.now(), data })
419
+ return data
420
+ }
421
+
422
+ async function interfacesOf(projectId) {
423
+ const cached = listCache.get(projectId)
424
+ if (cached && Date.now() - cached.at < 5 * 60 * 1000) return cached.list
425
+ const data = await state.client.getJson(
426
+ `/api/interface/list?project_id=${encodeURIComponent(projectId)}&page=1&limit=2000`,
427
+ )
428
+ const list = Array.isArray(data && data.list) ? data.list : []
429
+ listCache.set(projectId, { at: Date.now(), list })
430
+ return list
431
+ }
432
+
433
+ async function handleSearch(request) {
434
+ let body = null
435
+ try {
436
+ body = await request.json()
437
+ } catch {
438
+ return jsonResponse({ ok: false, error: { code: 'BAD_REQUEST', message: '请求体必须是 JSON。' } }, 400)
439
+ }
440
+ const projectId = Number(body && body.projectId)
441
+ if (!Number.isSafeInteger(projectId) || projectId <= 0) {
442
+ return jsonResponse(
443
+ { ok: false, error: { code: 'BAD_PROJECT', message: '请输入有效的项目 ID。' } },
444
+ 400,
445
+ )
446
+ }
447
+ const keyword = String((body && body.keyword) ?? '').trim()
448
+ if (keyword === '') {
449
+ return jsonResponse({ ok: true, data: { projectId, projectName: '', total: 0, items: [] } })
450
+ }
451
+ try {
452
+ const list = await interfacesOf(projectId)
453
+ let projectName = ''
454
+ try {
455
+ const project = await projectOf(projectId)
456
+ projectName = String((project && project.name) ?? '')
457
+ } catch (error) {
458
+ ctx.logger?.warn?.(`[yapi] 搜索时读取项目 ${projectId} 失败:${describe(error)}`)
459
+ }
460
+ const items = rankInterfaces(list, keyword, 30).map((item) => ({
461
+ id: Number(item._id),
462
+ method: String(item.method ?? '').toUpperCase(),
463
+ path: String(item.path ?? ''),
464
+ title: String(item.title ?? ''),
465
+ }))
466
+ return jsonResponse({ ok: true, data: { projectId, projectName, total: list.length, items } })
467
+ } catch (error) {
468
+ return jsonResponse({ ok: false, error: { code: error.code || 'UNKNOWN', message: describe(error) } }, 200)
469
+ }
470
+ }
471
+
472
+ async function handleInterface(request) {
473
+ let body = null
474
+ try {
475
+ body = await request.json()
476
+ } catch {
477
+ return jsonResponse({ ok: false, error: { code: 'BAD_REQUEST', message: '请求体必须是 JSON:{"id": 10001}' } }, 400)
478
+ }
479
+ const id = parseDocId(body && typeof body === 'object' ? body.id : body)
480
+ if (id === null) {
481
+ return jsonResponse({ ok: false, error: { code: 'BAD_ID', message: '请输入有效的文档 ID(例如 10001)或接口链接。' } }, 400)
482
+ }
483
+ try {
484
+ const raw = await state.client.getJson(`/api/interface/get?id=${id}`)
485
+ if (!raw || typeof raw !== 'object' || Number(raw._id) <= 0) {
486
+ throw new YapiError(`YAPI 没有返回文档 ${id} 的数据,可能已被删除。`, 'NO_DATA')
487
+ }
488
+ let project = null
489
+ const projectId = Number(raw && raw.project_id)
490
+ if (Number.isSafeInteger(projectId) && projectId > 0) {
491
+ try {
492
+ project = await projectOf(projectId)
493
+ } catch (error) {
494
+ ctx.logger?.warn?.(`[yapi] 读取项目 ${projectId} 失败:${describe(error)}`)
495
+ }
496
+ }
497
+ return jsonResponse({ ok: true, data: normalizeInterface(raw, project, state.settings.baseUrl) })
498
+ } catch (error) {
499
+ return jsonResponse({ ok: false, error: { code: error.code || 'UNKNOWN', message: describe(error) } }, 200)
500
+ }
501
+ }
502
+
503
+ async function handleHealth() {
504
+ try {
505
+ await state.client.ensureSession(false)
506
+ return jsonResponse({ ok: true, ...settingsView() })
507
+ } catch (error) {
508
+ return jsonResponse({ ok: false, error: { code: error.code || 'UNKNOWN', message: describe(error) } }, 200)
509
+ }
510
+ }
511
+
512
+ /**
513
+ * GET returns the current account; POST verifies the submitted account with a
514
+ * real YAPI login and only then persists it. A failed login changes nothing,
515
+ * so a typo cannot lock the panel out of a working account.
516
+ */
517
+ async function handleSettings(request) {
518
+ if (request.method === 'GET') {
519
+ return jsonResponse({ ok: true, ...settingsView() })
520
+ }
521
+ if (request.method !== 'POST') {
522
+ return jsonResponse({ ok: false, error: { code: 'METHOD_NOT_ALLOWED', message: '仅支持 GET / POST。' } }, 405)
523
+ }
524
+ let body = null
525
+ try {
526
+ body = await request.json()
527
+ } catch {
528
+ return jsonResponse({ ok: false, error: { code: 'BAD_REQUEST', message: '请求体必须是 JSON。' } }, 400)
529
+ }
530
+ const current = state.settings
531
+ const stored = readStoredConfig()
532
+ const baseUrl = normalizeBaseUrl((body && body.baseUrl) || current.baseUrl || stored.baseUrl || '')
533
+ if (baseUrl === '') {
534
+ return jsonResponse(
535
+ { ok: false, error: { code: 'NO_CONFIG', message: '请填写 YAPI 地址(例如 https://yapi.example.com)。' } },
536
+ 200,
537
+ )
538
+ }
539
+ const email = String((body && body.email) ?? current.email ?? stored.email ?? '').trim()
540
+ const typedPassword = body && typeof body.password === 'string' ? body.password : ''
541
+ const password =
542
+ typedPassword !== '' ? typedPassword : current.password || String(stored.password ?? '')
543
+ if (email === '' || password === '') {
544
+ return jsonResponse(
545
+ { ok: false, error: { code: 'NO_CREDENTIALS', message: '请填写账号和密码。' } },
546
+ 200,
547
+ )
548
+ }
549
+
550
+ const authType = normalizeAuthType((body && body.authType) ?? current.authType ?? stored.authType ?? 'normal')
551
+ const next = { baseUrl, email, password, authType, timeoutMs: current.timeoutMs }
552
+ const probe = createYapiClient(next, ctx.logger)
553
+ try {
554
+ await probe.ensureSession(true)
555
+ } catch (error) {
556
+ return jsonResponse(
557
+ { ok: false, error: { code: error.code || 'LOGIN_FAILED', message: describe(error) } },
558
+ 200,
559
+ )
560
+ }
561
+ try {
562
+ writeConfigFile(next)
563
+ } catch (error) {
564
+ return jsonResponse(
565
+ {
566
+ ok: false,
567
+ error: { code: 'SAVE_FAILED', message: `登录成功,但写入配置文件失败:${describe(error)}` },
568
+ },
569
+ 200,
570
+ )
571
+ }
572
+
573
+ state.settings = next
574
+ state.client = probe
575
+ projectCache.clear()
576
+ listCache.clear()
577
+ const user = probe.status().user
578
+ ctx.logger?.info?.(`[yapi] 账号已更新:${user?.email || email}`)
579
+ return jsonResponse({ ok: true, ...settingsView() })
580
+ }
581
+
582
+ ctx.inject(['connection'], (scope) => {
583
+ const routes = [
584
+ { path: '/api/yapi/interface', methods: ['POST'], requestBody: 'buffered', fetch: handleInterface },
585
+ { path: '/api/yapi/search', methods: ['POST'], requestBody: 'buffered', fetch: handleSearch },
586
+ { path: '/api/yapi/health', methods: ['GET'], requestBody: 'buffered', fetch: handleHealth },
587
+ { path: '/api/yapi/settings', methods: ['GET', 'POST'], requestBody: 'buffered', fetch: handleSettings },
588
+ ]
589
+ for (const route of routes) scope.connection.fetch.register(route)
590
+ ctx.logger?.info?.(`[yapi] 已注册 ${routes.length} 条路由:/api/yapi/{interface,search,health,settings};配置 ${CONFIG_FILE}`)
591
+ })
592
+ }