dsh-dbhub-live 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.mjs ADDED
@@ -0,0 +1,1417 @@
1
+ // dsh-dbhub-live v2: PERSISTENT multi-source dbhub manager.
2
+ //
3
+ // Cordis namespace plugin (named exports, no default export). Installed as a
4
+ // dsh profile bundle: `dsh plugin --profile web add dsh-dbhub-live`.
5
+ //
6
+ // Architecture (B'):
7
+ // - ONE persistent `dbhub --transport stdio --config <generated dbhub.toml>`
8
+ // process, managed by this plugin (idle recycle, respawn on demand).
9
+ // - dbhub.toml is auto-generated from every registered workspace's connection
10
+ // config and hot-reloaded by dbhub itself. Users never maintain it.
11
+ // - Each workspace becomes a dbhub source; tools are registered per-workspace
12
+ // (`dbhub_execute_sql_<slug>` / `dbhub_search_objects_<slug>`) with the
13
+ // connection target (host:port/database, password masked) labeled in the
14
+ // tool description and result — picking a workspace = picking a tool name.
15
+ // - Ad-hoc temporary connections (`dbhub_query`/`dbhub_query_objects`): model
16
+ // passes a full dsn per request; each call is an independent throwaway
17
+ // dbhub, so two calls can query two different databases at once.
18
+ //
19
+ // Per-workspace connection sources (priority):
20
+ // 1. persisted explicit entry (`credentials.json`, user-provided or
21
+ // user-authorized collection) -> `dbhub_configure` tool
22
+ // 2. auto-discovery: `mise env` (mise.toml [env]) -> `.env` (DSN/DB_*)
23
+ // (evaluated at generation time, never persisted)
24
+ // 3. none: `dbhub_configure` asks the user, or — ONLY after explicit
25
+ // authorization — scans the workspace's common DB config files and shows
26
+ // candidates for confirmation. Scanning reads files that may contain
27
+ // passwords and costs tokens, so it is never silent.
28
+ //
29
+ // Data (credentials.json, dbhub.toml) lives in $DSH_HOME/storages/dsh-dbhub-live
30
+ // — outside the module tree, since the module may be installed under the
31
+ // profile's node_modules (pnpm-managed) which must not be written to.
32
+ // The process environment is deliberately NOT consulted for connections.
33
+
34
+ import { readFileSync, writeFileSync, readdirSync, existsSync, statSync, mkdirSync } from 'node:fs'
35
+ import { join, dirname, basename } from 'node:path'
36
+ import { fileURLToPath } from 'node:url'
37
+
38
+ const name = 'dsh-dbhub-live'
39
+ // Real-module Guard requires declared injection before touching `ctx.tools`;
40
+ // the timer mixin powers idle recycling.
41
+ const inject = ['tools', 'timer']
42
+
43
+ function dshHomeDir() {
44
+ if (process.env.DSH_HOME) return process.env.DSH_HOME
45
+ return join(process.env.USERPROFILE || process.env.HOME || '.', '.dsh')
46
+ }
47
+
48
+ const MODULE_DIR = dirname(fileURLToPath(import.meta.url))
49
+ const DATA_DIR = join(dshHomeDir(), 'storages', 'dsh-dbhub-live')
50
+ const STORE_PATH = join(DATA_DIR, 'credentials.json')
51
+ const TOML_PATH = join(DATA_DIR, 'dbhub.toml')
52
+ const IDLE_MS = 10 * 60 * 1000
53
+ try {
54
+ mkdirSync(DATA_DIR, { recursive: true })
55
+ } catch (e) { /* best-effort */ }
56
+
57
+ // ── persistence ──────────────────────────────────────────────────────────
58
+
59
+ function loadStore() {
60
+ try {
61
+ return JSON.parse(readFileSync(STORE_PATH, 'utf8')) || {}
62
+ } catch (e) {
63
+ return {}
64
+ }
65
+ }
66
+
67
+ function saveStore(store) {
68
+ try {
69
+ writeFileSync(STORE_PATH, JSON.stringify(store, null, 2))
70
+ } catch (e) {
71
+ /* best-effort */
72
+ }
73
+ }
74
+
75
+ const store = loadStore()
76
+
77
+ // ── managed dbhub runtime (auto-install for users without one) ────────────
78
+ // When no `dbhub` executable is present (PATH / mise / previous install), the
79
+ // plugin installs the MCP server once, on demand, into a private prefix under
80
+ // the storage dir, then reuses that binary on every later boot. This is
81
+ // deliberately an install-ONCE-at-init design (NOT `npx` on every spawn):
82
+ // the ad-hoc tools spawn a fresh dbhub process on EVERY call, so an npx-per-
83
+ // call approach would add several hundred ms to every query and break offline.
84
+ // The resolved executable path is persisted so later boots skip re-discovery.
85
+ const DBHUB_PACKAGE = process.env.DSH_DBHUB_PACKAGE || '@bytebase/dbhub'
86
+ // How often the AUTO-INSTALLED copy is refreshed to the latest version.
87
+ // Only our own managed install is refreshed — a dbhub the user installed
88
+ // himself (PATH / mise) is never touched. Override via DSH_DBHUB_UPDATE_DAYS
89
+ // (e.g. "0" disables auto-update; any positive number sets the day interval).
90
+ const DBHUB_UPDATE_MS = (() => {
91
+ const raw = process.env.DSH_DBHUB_UPDATE_DAYS
92
+ const n = raw !== undefined && raw !== '' && Number(raw) >= 0 ? Number(raw) : 7
93
+ return n * 24 * 60 * 60 * 1000
94
+ })()
95
+ const RUNTIME_DIR = join(DATA_DIR, 'dbhub-runtime')
96
+ const RUNTIME_PATH = join(DATA_DIR, 'runtime.json')
97
+
98
+ function loadRuntime() {
99
+ try {
100
+ return JSON.parse(readFileSync(RUNTIME_PATH, 'utf8')) || {}
101
+ } catch (e) {
102
+ return {}
103
+ }
104
+ }
105
+
106
+ function saveRuntime(r) {
107
+ try {
108
+ writeFileSync(RUNTIME_PATH, JSON.stringify(r, null, 2))
109
+ } catch (e) {
110
+ /* best-effort */
111
+ }
112
+ }
113
+
114
+ const runtime = loadRuntime()
115
+
116
+ // ── small utils ──────────────────────────────────────────────────────────
117
+
118
+ function slugify(title) {
119
+ const s = String(title || '')
120
+ .toLowerCase()
121
+ .replace(/[^a-z0-9_]+/g, '-')
122
+ .replace(/^-+|-+$/g, '')
123
+ return s || 'ws'
124
+ }
125
+
126
+ function tomlEscape(s) {
127
+ return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')
128
+ }
129
+
130
+ function sessionCwd(agent) {
131
+ try {
132
+ const s = agent && agent.session
133
+ if (!s) return undefined
134
+ const h = s.header
135
+ if (h && typeof h.cwd === 'string' && h.cwd) return h.cwd
136
+ if (typeof s.cwd === 'string' && s.cwd) return s.cwd
137
+ if (s.meta && typeof s.meta.cwd === 'string' && s.meta.cwd) return s.meta.cwd
138
+ } catch (e) {
139
+ /* fall through */
140
+ }
141
+ return undefined
142
+ }
143
+
144
+ function parseEnvFile(text) {
145
+ const map = {}
146
+ for (const raw of String(text).split(/\r?\n/)) {
147
+ const line = raw.trim()
148
+ if (!line || line.startsWith('#')) continue
149
+ const eq = line.indexOf('=')
150
+ if (eq < 0) continue
151
+ const key = line.slice(0, eq).trim()
152
+ let value = line.slice(eq + 1).trim()
153
+ if (!key) continue
154
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
155
+ value = value.slice(1, -1)
156
+ }
157
+ map[key] = value
158
+ }
159
+ return map
160
+ }
161
+
162
+ function parseEnvOutput(text) {
163
+ const map = {}
164
+ for (const raw of String(text).split(/\r?\n/)) {
165
+ const line = raw.trim()
166
+ if (!line || line.startsWith('#')) continue
167
+ const m = line.match(/^(?:\$Env:|export\s+)?([A-Za-z_][A-Za-z0-9_]*)=("(?:[^"\\]|\\.)*"|'(?:[^'])*'|[^ ]*)/)
168
+ if (!m) continue
169
+ let value = m[2]
170
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
171
+ value = value.slice(1, -1)
172
+ }
173
+ map[m[1]] = value
174
+ }
175
+ return map
176
+ }
177
+
178
+ // DB_* individual variables -> a DSN string (or undefined).
179
+ function dsnFromEnv(env) {
180
+ const has = (k) => env[k] !== undefined && env[k] !== null && String(env[k]).trim() !== ''
181
+ if (!has('DB_HOST') || !has('DB_USER') || !has('DB_NAME')) return undefined
182
+ const type = (has('DB_TYPE') ? String(env.DB_TYPE).trim() : 'mysql').toLowerCase()
183
+ const host = String(env.DB_HOST).trim()
184
+ const port = has('DB_PORT') ? String(env.DB_PORT).trim() : ''
185
+ const user = String(env.DB_USER).trim()
186
+ const password = has('DB_PASSWORD') ? String(env.DB_PASSWORD).trim() : ''
187
+ const database = String(env.DB_NAME).trim()
188
+ const enc = (s) => encodeURIComponent(s)
189
+ if (type === 'sqlite') return 'sqlite:///' + host
190
+ return type + '://' + enc(user) + ':' + enc(password) + '@' + host + (port ? ':' + port : '') + '/' + enc(database)
191
+ }
192
+
193
+ function dsnFromMap(map) {
194
+ if (map.DSN) return String(map.DSN).trim()
195
+ return dsnFromEnv(map)
196
+ }
197
+
198
+ async function runMiseEnv(subprocess, cwd, signal) {
199
+ if (!subprocess || !cwd) return undefined
200
+ let exe
201
+ try {
202
+ exe = await subprocess.resolveExecutable('mise', undefined, signal)
203
+ } catch (e) {
204
+ return undefined
205
+ }
206
+ let handle
207
+ try {
208
+ handle = subprocess.spawn({
209
+ argv: [exe, 'env'],
210
+ cwd,
211
+ stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'ignore' },
212
+ graceMs: 5000,
213
+ signal,
214
+ })
215
+ } catch (e) {
216
+ return undefined
217
+ }
218
+ let out = ''
219
+ if (handle.stdout) {
220
+ handle.stdout.on('data', (c) => {
221
+ out = (out + String(c)).slice(-131072)
222
+ })
223
+ }
224
+ try {
225
+ const outcome = await handle.done
226
+ if (outcome.exitCode !== 0) return undefined
227
+ return parseEnvOutput(out)
228
+ } catch (e) {
229
+ return undefined
230
+ } finally {
231
+ try {
232
+ handle.terminate()
233
+ } catch (e) {
234
+ /* ignore */
235
+ }
236
+ }
237
+ }
238
+
239
+ // ── workspaces & config generation ───────────────────────────────────────
240
+
241
+ // Workspaces: live registry first, then a direct read of the durable
242
+ // workspace registry file (robust at boot, when services may not be ready).
243
+ async function listWorkspaces(ctx) {
244
+ if (ctx) {
245
+ const registry = ctx.get('workspaceRegistry')
246
+ if (registry && typeof registry.list === 'function') {
247
+ try {
248
+ const list = await registry.list()
249
+ if (Array.isArray(list) && list.length > 0) {
250
+ return list.filter((w) => w && w.path).map((w) => ({ path: w.path, title: w.title || basename(w.path) }))
251
+ }
252
+ } catch (e) {
253
+ /* fall through */
254
+ }
255
+ }
256
+ }
257
+ try {
258
+ const wsFile = join(dshHomeDir(), 'storages', 'workspace.json')
259
+ const data = JSON.parse(readFileSync(wsFile, 'utf8'))
260
+ const table = data && data.tables && data.tables.workspaces
261
+ if (table) {
262
+ const out = []
263
+ for (const id of Object.keys(table)) {
264
+ const w = table[id]
265
+ if (w && w.path) out.push({ path: w.path, title: w.title || basename(w.path) })
266
+ }
267
+ if (out.length > 0) return out
268
+ }
269
+ } catch (e) {
270
+ /* fall through */
271
+ }
272
+ try {
273
+ const cwd = process.cwd()
274
+ if (cwd) return [{ path: cwd, title: basename(cwd) }]
275
+ } catch (e) {
276
+ /* fall through */
277
+ }
278
+ return []
279
+ }
280
+
281
+ async function resolveWorkspaceDsn(subprocess, fs, wsPath, signal) {
282
+ // 1) persisted explicit entry
283
+ const entry = store[wsPath]
284
+ if (entry && entry.dsn && typeof entry.dsn === 'string' && entry.dsn.trim()) {
285
+ return { dsn: entry.dsn.trim(), source: 'persisted(' + (entry.source || 'user') + ')' }
286
+ }
287
+ // 2) auto-discovery: mise env -> .env
288
+ const miseMap = await runMiseEnv(subprocess, wsPath, signal)
289
+ if (miseMap) {
290
+ const dsn = dsnFromMap(miseMap)
291
+ if (dsn) return { dsn, source: '工作区 mise env' }
292
+ }
293
+ try {
294
+ const target = await fs.resolve('.env', { cwd: wsPath, signal })
295
+ const map = parseEnvFile(await fs.readText(target, signal))
296
+ const dsn = dsnFromMap(map)
297
+ if (dsn) return { dsn, source: '工作目录 .env' }
298
+ } catch (e) {
299
+ /* no .env */
300
+ }
301
+ return undefined
302
+ }
303
+
304
+ function fingerprintOf(sources) {
305
+ return sources.map((s) => s.id + '|' + s.dsn).join('\n')
306
+ }
307
+
308
+ function generateToml(sources) {
309
+ const lines = ['# Auto-generated by dsh-dbhub-live — do not edit. Regenerated on demand.']
310
+ for (const s of sources) {
311
+ lines.push('[[sources]]')
312
+ lines.push('id = "' + tomlEscape(s.id) + '"')
313
+ lines.push('dsn = "' + tomlEscape(s.dsn) + '"')
314
+ }
315
+ return lines.join('\n') + '\n'
316
+ }
317
+
318
+ // ── dbhub executable (pin a modern version, prefer 1.2.x) ────────────────
319
+
320
+ function findDbhubExe() {
321
+ // 1) a previously managed / mise-installed binary is the fastest path
322
+ if (runtime.dbhubExe) {
323
+ try {
324
+ if (existsSync(runtime.dbhubExe) && statSync(runtime.dbhubExe).isFile()) return runtime.dbhubExe
325
+ } catch (e) {
326
+ runtime.dbhubExe = undefined
327
+ saveRuntime(runtime)
328
+ }
329
+ }
330
+ // 2) mise-managed install (`mise x npm:@bytebase/dbhub` or a manual install)
331
+ const dataDir = process.env.MISE_DATA_DIR
332
+ if (dataDir) {
333
+ try {
334
+ const base = join(dataDir, 'npm-bytebase-dbhub')
335
+ const versions = readdirSync(base).filter((v) => /^\d+\.\d+\.\d+$/.test(v))
336
+ const cmp = (a, b) => {
337
+ const pa = a.split('.').map(Number)
338
+ const pb = b.split('.').map(Number)
339
+ for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] - pb[i]
340
+ return 0
341
+ }
342
+ versions.sort(cmp)
343
+ for (let i = versions.length - 1; i >= 0; i--) {
344
+ for (const bin of ['dbhub.cmd', 'dbhub']) {
345
+ const p = join(base, versions[i], 'node_modules', '.bin', bin)
346
+ try {
347
+ if (existsSync(p) && statSync(p).isFile()) {
348
+ runtime.dbhubExe = p
349
+ saveRuntime(runtime)
350
+ return p
351
+ }
352
+ } catch (e) {
353
+ /* keep looking */
354
+ }
355
+ }
356
+ }
357
+ } catch (e) {
358
+ /* fall through */
359
+ }
360
+ }
361
+ return undefined
362
+ }
363
+
364
+ function dbhubExeInDir(dir) {
365
+ if (!dir) return undefined
366
+ for (const bin of ['dbhub.cmd', 'dbhub']) {
367
+ const p = join(dir, 'node_modules', '.bin', bin)
368
+ try {
369
+ if (existsSync(p) && statSync(p).isFile()) return p
370
+ } catch (e) {
371
+ /* keep looking */
372
+ }
373
+ }
374
+ return undefined
375
+ }
376
+
377
+ let installingDbhub // single in-flight install guard
378
+
379
+ // Install the dbhub MCP server once, into a private prefix under the storage
380
+ // dir. Uses `npm install --prefix <RUNTIME_DIR>` (deterministic, offline-safe
381
+ // afterwards) rather than `npx <pkg>` per call. Package and version can be
382
+ // overridden via DSH_DBHUB_PACKAGE; by default installs the latest.
383
+ async function installDbhub(subprocess, signal) {
384
+ if (installingDbhub) return installingDbhub
385
+ if (!subprocess) {
386
+ throw new Error('subprocess 服务不可用,无法自动安装 dbhub')
387
+ }
388
+ installingDbhub = (async () => {
389
+ let npmExe
390
+ try {
391
+ npmExe = await subprocess.resolveExecutable('npm', undefined, signal)
392
+ } catch (e) {
393
+ throw new Error('未找到 npm,无法自动安装 dbhub(请先安装 Node.js/npm,或手动把 dbhub 加入 PATH 后重试)')
394
+ }
395
+ const args = [
396
+ 'install',
397
+ DBHUB_PACKAGE,
398
+ '--prefix', RUNTIME_DIR,
399
+ '--no-save', '--no-fund', '--no-audit',
400
+ '--loglevel=error',
401
+ ]
402
+ let handle
403
+ try {
404
+ handle = subprocess.spawn({
405
+ argv: buildSpawnArgv(subprocess, npmExe, args),
406
+ cwd: DATA_DIR,
407
+ stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
408
+ graceMs: 180000,
409
+ signal,
410
+ })
411
+ } catch (e) {
412
+ throw new Error('启动 npm 安装失败: ' + String((e && e.message) || e))
413
+ }
414
+ let errTail = ''
415
+ if (handle.stderr) {
416
+ handle.stderr.on('data', (c) => {
417
+ errTail = (errTail + String(c)).slice(-2000)
418
+ })
419
+ }
420
+ try {
421
+ const outcome = await handle.done
422
+ if (outcome.exitCode !== 0 || outcome.signal) {
423
+ throw new Error(
424
+ '自动安装 dbhub 失败 (npm exit ' + outcome.exitCode + ')' + (errTail ? ': ' + errTail : ''),
425
+ )
426
+ }
427
+ } finally {
428
+ try {
429
+ handle.terminate()
430
+ } catch (e) {
431
+ /* ignore */
432
+ }
433
+ }
434
+ const exe = dbhubExeInDir(RUNTIME_DIR)
435
+ if (!exe) {
436
+ throw new Error('npm 安装完成,但未在 ' + RUNTIME_DIR + ' 下找到 dbhub 可执行文件')
437
+ }
438
+ runtime.dbhubInstallAt = Date.now()
439
+ console.log('[dsh-dbhub-live] 已自动安装 dbhub (' + DBHUB_PACKAGE + ') → ' + exe)
440
+ return exe
441
+ })()
442
+ try {
443
+ const exe = await installingDbhub
444
+ return exe
445
+ } finally {
446
+ installingDbhub = undefined
447
+ }
448
+ }
449
+
450
+ // Resolve the dbhub executable: persisted → mise → PATH → install-on-demand.
451
+ async function resolveDbhubExe(subprocess, signal) {
452
+ const cached = findDbhubExe()
453
+ if (cached) return cached
454
+ if (subprocess) {
455
+ try {
456
+ const exe = await subprocess.resolveExecutable('dbhub', undefined, signal)
457
+ runtime.dbhubExe = exe
458
+ saveRuntime(runtime)
459
+ return exe
460
+ } catch (e) {
461
+ /* not on PATH — fall through to install */
462
+ }
463
+ }
464
+ const installed = await installDbhub(subprocess, signal)
465
+ runtime.dbhubExe = installed
466
+ saveRuntime(runtime)
467
+ return installed
468
+ }
469
+
470
+ // Is the current executable one WE auto-installed (as opposed to a dbhub the
471
+ // user installed himself via PATH / mise)? Only ours is eligible for auto-
472
+ // update — we must never silently upgrade a user-managed binary.
473
+ function isAutoManagedExe() {
474
+ const exe = runtime.dbhubExe
475
+ if (!exe || typeof exe !== 'string') return false
476
+ const dir = RUNTIME_DIR.replace(/[\\/]+$/, '')
477
+ const e = exe.replace(/[\\/]+$/, '')
478
+ return e.toLowerCase().startsWith(dir.toLowerCase())
479
+ }
480
+
481
+ // Refresh the auto-installed dbhub to the latest version, but only when a
482
+ // configured interval has elapsed. Runs `npm install` into the SAME prefix,
483
+ // which upgrades the binary in place — the resolved path stays valid, so
484
+ // concurrently-running queries keep using a working dbhub throughout.
485
+ // Never blocks boot: callers should NOT await it (see apply()).
486
+ async function maybeRefreshDbhub(subprocess) {
487
+ if (!isAutoManagedExe()) return
488
+ if (!(runtime.dbhubInstallAt > 0)) return
489
+ if (DBHUB_UPDATE_MS <= 0) return // auto-update disabled
490
+ if (Date.now() - runtime.dbhubInstallAt < DBHUB_UPDATE_MS) return
491
+ try {
492
+ await installDbhub(subprocess, undefined)
493
+ console.log('[dsh-dbhub-live] dbhub 已自动更新到最新版')
494
+ } catch (e) {
495
+ // Non-fatal: keep using the existing (older) binary.
496
+ console.error('[dsh-dbhub-live] dbhub 自动更新失败(沿用现有版本): ' + String((e && e.message) || e))
497
+ }
498
+ }
499
+
500
+ // ── MCP client (persistent) ──────────────────────────────────────────────
501
+
502
+ function createMcpClient(handle, onStderr, onNotification) {
503
+ let buf = ''
504
+ let nextId = 1
505
+ let closed = false
506
+ const pending = new Map()
507
+ const settleAll = (err) => {
508
+ for (const p of pending.values()) p.reject(err)
509
+ pending.clear()
510
+ }
511
+ if (handle.stdout) {
512
+ handle.stdout.on('data', (chunk) => {
513
+ buf += String(chunk)
514
+ let idx
515
+ while ((idx = buf.indexOf('\n')) >= 0) {
516
+ const line = buf.slice(0, idx).trim()
517
+ buf = buf.slice(idx + 1)
518
+ if (!line) continue
519
+ let msg
520
+ try {
521
+ msg = JSON.parse(line)
522
+ } catch (e) {
523
+ continue
524
+ }
525
+ if (msg && typeof msg.id === 'number' && pending.has(msg.id)) {
526
+ const p = pending.get(msg.id)
527
+ pending.delete(msg.id)
528
+ if (msg.error) p.reject(new Error('MCP error: ' + JSON.stringify(msg.error)))
529
+ else p.resolve(msg.result)
530
+ } else if (msg && msg.method && !msg.id && onNotification) {
531
+ try {
532
+ onNotification(msg)
533
+ } catch (e) {
534
+ /* contain listener failures */
535
+ }
536
+ }
537
+ }
538
+ })
539
+ }
540
+ if (handle.stderr && onStderr) handle.stderr.on('data', onStderr)
541
+ handle.done.then((outcome) => {
542
+ closed = true
543
+ settleAll(
544
+ new Error(
545
+ 'dbhub exited (code ' + outcome.exitCode + ')' + (outcome.signal ? ', signal ' + outcome.signal : ''),
546
+ ),
547
+ )
548
+ }).catch((err) => {
549
+ closed = true
550
+ settleAll(err instanceof Error ? err : new Error(String(err)))
551
+ })
552
+ return {
553
+ get closed() {
554
+ return closed
555
+ },
556
+ request(method, params) {
557
+ const id = nextId++
558
+ return new Promise((resolve, reject) => {
559
+ if (!handle.stdin) {
560
+ reject(new Error('dbhub stdin unavailable'))
561
+ return
562
+ }
563
+ if (closed) {
564
+ reject(new Error('dbhub process is down (will auto-restart on next call)'))
565
+ return
566
+ }
567
+ pending.set(id, { resolve, reject })
568
+ try {
569
+ handle.stdin.write(
570
+ JSON.stringify(
571
+ params === undefined
572
+ ? { jsonrpc: '2.0', id, method }
573
+ : { jsonrpc: '2.0', id, method, params },
574
+ ) + '\n',
575
+ )
576
+ } catch (e) {
577
+ pending.delete(id)
578
+ reject(e)
579
+ }
580
+ })
581
+ },
582
+ notify(method, params) {
583
+ if (!handle.stdin || closed) return
584
+ try {
585
+ handle.stdin.write(
586
+ JSON.stringify(
587
+ params === undefined
588
+ ? { jsonrpc: '2.0', method }
589
+ : { jsonrpc: '2.0', method, params },
590
+ ) + '\n',
591
+ )
592
+ } catch (e) {
593
+ /* ignore */
594
+ }
595
+ },
596
+ }
597
+ }
598
+
599
+ // ── server lifecycle ─────────────────────────────────────────────────────
600
+
601
+ const server = {
602
+ handle: undefined,
603
+ client: undefined,
604
+ generatedFingerprint: undefined,
605
+ starting: undefined,
606
+ }
607
+
608
+ function terminateServer() {
609
+ const h = server.handle
610
+ server.handle = undefined
611
+ server.client = undefined
612
+ if (h) {
613
+ try {
614
+ h.terminate()
615
+ } catch (e) {
616
+ /* ignore */
617
+ }
618
+ }
619
+ }
620
+
621
+ function buildSpawnArgv(subprocess, exe, args) {
622
+ if (process.platform !== 'win32') return [exe, ...args]
623
+ if (!/\.(cmd|bat)$/i.test(exe)) return [exe, ...args]
624
+ const quote = (s) => (/[\s&|<>^"]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s)
625
+ return ['cmd.exe', '/c', [quote(exe), ...args.map(quote)].join(' ')]
626
+ }
627
+
628
+ async function spawnServer(subprocess, signal) {
629
+ let exe
630
+ try {
631
+ exe = await resolveDbhubExe(subprocess, signal)
632
+ } catch (e) {
633
+ return { error: '无法获取 dbhub: ' + String((e && e.message) || e) }
634
+ }
635
+ let handle
636
+ try {
637
+ handle = subprocess.spawn({
638
+ argv: buildSpawnArgv(subprocess, exe, ['--transport', 'stdio', '--config', TOML_PATH]),
639
+ cwd: DATA_DIR,
640
+ stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
641
+ graceMs: 3000,
642
+ signal,
643
+ })
644
+ } catch (e) {
645
+ return { error: '启动 dbhub 失败: ' + String((e && e.message) || e) }
646
+ }
647
+ let stderrTail = ''
648
+ const client = createMcpClient(handle, (c) => {
649
+ stderrTail = (stderrTail + String(c)).slice(-4000)
650
+ }, () => {
651
+ // notifications/tools/list_changed — re-sync happens lazily on next call
652
+ // (the tools already registered stay valid; new ones appear after respawn).
653
+ console.log('[dsh-dbhub-live] tools/list_changed received')
654
+ })
655
+ try {
656
+ await client.request('initialize', {
657
+ protocolVersion: '2025-03-26',
658
+ capabilities: {},
659
+ clientInfo: { name: 'dsh-dbhub', version: '2.0.0' },
660
+ })
661
+ client.notify('notifications/initialized')
662
+ } catch (e) {
663
+ let detail = String((e && e.message) || e)
664
+ if (stderrTail) detail += '\n[dbhub stderr] ' + stderrTail
665
+ try {
666
+ handle.terminate()
667
+ } catch (e2) {
668
+ /* ignore */
669
+ }
670
+ return { error: detail }
671
+ }
672
+ server.handle = handle
673
+ server.client = client
674
+ return {}
675
+ }
676
+
677
+ // Tools sync: register one ctx.tools def per dbhub tool, keyed by raw name.
678
+ const toolDisposers = new Map() // modelName -> disposer (server-synced tools)
679
+ const coreDisposers = new Map() // modelName -> disposer (plugin-owned tools, never synced away)
680
+
681
+ // Per-source tool naming + connection labeling.
682
+ // Each generated source gets its own suffixed tool set (`dbhub_<base>_<slug>`)
683
+ // even when dbhub reports single-source bare names, so a workspace's tools are
684
+ // never ambiguous. Every tool's description and result carry the masked
685
+ // connection target (host:port/database, password hidden), so a user can see
686
+ // exactly which environment (test/dev/prod) it points at before querying.
687
+ function syncToolsNow(ctx, subprocess, sources) {
688
+ if (!server.client) return Promise.resolve()
689
+ const sourceById = new Map()
690
+ for (const s of sources) sourceById.set(s.id, s)
691
+ return server.client.request('tools/list').then((res) => {
692
+ const tools = Array.isArray(res && res.tools) ? res.tools : []
693
+ // raw base name -> list of {slug, dbhubRawName, maskedDsn}
694
+ const bases = new Map() // base -> Map<slug, {rawName, dsnMasked}>
695
+ for (const t of tools) {
696
+ const raw = t && t.name
697
+ if (!raw || typeof raw !== 'string') continue
698
+ // split off a per-source slug suffix (dbhub suffix = our source id)
699
+ let base = raw
700
+ let slug
701
+ for (const id of sourceById.keys()) {
702
+ if (raw === id) { slug = id } else if (raw.endsWith('_' + id)) {
703
+ base = raw.slice(0, raw.length - id.length - 1)
704
+ slug = id
705
+ }
706
+ }
707
+ if (slug === undefined && sources.length === 1) {
708
+ // single source: dbhub emits bare names -> attribute to the only source
709
+ slug = sources[0].id
710
+ }
711
+ if (slug === undefined) continue
712
+ const src = sourceById.get(slug)
713
+ const dsnMasked = src ? maskDsn(src.dsn) : slug
714
+ if (!bases.has(base)) bases.set(base, new Map())
715
+ bases.get(base).set(slug, { rawName: raw, dsnMasked })
716
+ }
717
+ const seen = new Set()
718
+ const jobs = []
719
+ const inputByRaw = new Map(tools.map((t) => [t.name, t.inputSchema]))
720
+ const descByRaw = new Map(tools.map((t) => [t.name, t.description]))
721
+ for (const [base, bySlug] of bases) {
722
+ for (const [slug, info] of bySlug) {
723
+ const modelName = 'dbhub_' + base + '_' + slug
724
+ seen.add(modelName)
725
+ if (toolDisposers.has(modelName)) continue
726
+ const rawName = info.rawName
727
+ const dsnMasked = info.dsnMasked
728
+ const inputSchema = inputByRaw.get(rawName)
729
+ const desc = descByRaw.get(rawName)
730
+ jobs.push(
731
+ (async () => {
732
+ try {
733
+ const def = {
734
+ name: modelName,
735
+ description: (desc ? desc + ' ' : '') +
736
+ '(连接源:' + slug + ' → ' + dsnMasked + ';注意:这是该工作区配置的数据库环境,请确认是你要查的那套)',
737
+ parameters: (inputSchema && typeof inputSchema === 'object' && inputSchema.type === 'object')
738
+ ? inputSchema
739
+ : { type: 'object', properties: {}, required: [] },
740
+ timeoutMs: 60000,
741
+ output: {
742
+ schema: {},
743
+ render: (_args, value) => [
744
+ {
745
+ type: 'text',
746
+ text: String(value && value.text !== undefined ? value.text : JSON.stringify(value)),
747
+ },
748
+ ],
749
+ },
750
+ async execute(args, exec) {
751
+ return runOnServer(ctx, subprocess, rawName, args, exec, '{连接: ' + slug + ' → ' + dsnMasked + '}')
752
+ },
753
+ }
754
+ const disposer = ctx.tools.register(def)
755
+ toolDisposers.set(modelName, disposer)
756
+ } catch (e) {
757
+ console.error('[dsh-dbhub-live] register failed for ' + modelName + ': ' + String((e && e.message) || e))
758
+ }
759
+ })(),
760
+ )
761
+ }
762
+ }
763
+ // unregister tools whose source vanished
764
+ for (const [modelName, disposer] of [...toolDisposers]) {
765
+ if (!seen.has(modelName)) {
766
+ try {
767
+ disposer()
768
+ } catch (e) {
769
+ /* ignore */
770
+ }
771
+ toolDisposers.delete(modelName)
772
+ }
773
+ }
774
+ return Promise.all(jobs).then(() => {})
775
+ }).catch((e) => {
776
+ console.error('[dsh-dbhub-live] tools/list failed: ' + String((e && e.message) || e))
777
+ })
778
+ }
779
+
780
+ let idleDispose
781
+ function resetIdle(ctx) {
782
+ if (idleDispose) {
783
+ try {
784
+ idleDispose()
785
+ } catch (e) {
786
+ /* ignore */
787
+ }
788
+ idleDispose = undefined
789
+ }
790
+ if (!server.handle) return
791
+ idleDispose = ctx.timeout(() => {
792
+ idleDispose = undefined
793
+ console.log('[dsh-dbhub-live] idle — recycling dbhub process')
794
+ terminateServer()
795
+ }, IDLE_MS)
796
+ }
797
+
798
+ // Warm-call fast path: re-resolve configs only when the durable inputs change
799
+ // (workspace registry file, credentials store) — otherwise skip straight to a
800
+ // live-server check. Keeps repeated tool calls at ~0 overhead.
801
+ let lastInputMtime = 0
802
+
803
+ function configInputsMtime() {
804
+ let max = 0
805
+ for (const p of [join(dshHomeDir(), 'storages', 'workspace.json'), STORE_PATH]) {
806
+ try {
807
+ const m = statSync(p).mtimeMs
808
+ if (m > max) max = m
809
+ } catch (e) {
810
+ /* missing file is fine */
811
+ }
812
+ }
813
+ return max
814
+ }
815
+
816
+ async function ensureRunning(ctx, subprocess) {
817
+ const inputMtime = configInputsMtime()
818
+ const serverUp = server.client && !server.client.closed
819
+ if (serverUp && inputMtime <= lastInputMtime && server.generatedFingerprint !== undefined) {
820
+ resetIdle(ctx)
821
+ return
822
+ }
823
+ // Regenerate config when the workspace set or its resolved DSNs change.
824
+ let sources = []
825
+ try {
826
+ const workspaces = await listWorkspaces(ctx)
827
+ for (const ws of workspaces) {
828
+ const resolved = await resolveWorkspaceDsn(subprocess, ctx.get('fs'), ws.path, undefined)
829
+ if (resolved && resolved.dsn) {
830
+ sources.push({ id: slugify(ws.title) + '_' + shortHash(ws.path), dsn: resolved.dsn })
831
+ }
832
+ }
833
+ } catch (e) {
834
+ console.error('[dsh-dbhub-live] config generation failed: ' + String((e && e.message) || e))
835
+ }
836
+ lastInputMtime = inputMtime
837
+ const fp = fingerprintOf(sources)
838
+ if (fp !== server.generatedFingerprint) {
839
+ if (sources.length === 0) {
840
+ // No source yet — do NOT write an empty [[sources]] toml (dbhub treats
841
+ // it as fatal). Wait for dbhub_configure; the fast path above then retries.
842
+ console.log('[dsh-dbhub-live] no workspace sources yet — use dbhub_configure')
843
+ server.generatedFingerprint = fp
844
+ if (server.handle) terminateServer()
845
+ return
846
+ }
847
+ writeFileSync(TOML_PATH, generateToml(sources))
848
+ server.generatedFingerprint = fp
849
+ // config changed while a server is up -> restart is deterministic
850
+ // (dbhub also hot-reloads its config, but a respawn re-syncs cleanly).
851
+ if (server.handle) terminateServer()
852
+ }
853
+ if (server.client && !server.client.closed) {
854
+ resetIdle(ctx)
855
+ return
856
+ }
857
+ if (server.starting) return server.starting
858
+ server.starting = (async () => {
859
+ try {
860
+ const r = await spawnServer(subprocess, undefined)
861
+ if (r.error) throw new Error(r.error)
862
+ await syncToolsNow(ctx, subprocess, sources)
863
+ resetIdle(ctx)
864
+ } finally {
865
+ server.starting = undefined
866
+ }
867
+ })()
868
+ return server.starting
869
+ }
870
+
871
+ function shortHash(s) {
872
+ let h = 0
873
+ for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0
874
+ return (h >>> 0).toString(36)
875
+ }
876
+
877
+ async function runOnServer(ctx, subprocess, rawName, args, exec, label) {
878
+ try {
879
+ await ensureRunning(ctx, subprocess)
880
+ } catch (e) {
881
+ return { ok: false, text: 'dbhub 服务启动失败: ' + String((e && e.message) || e) }
882
+ }
883
+ if (!server.client) return { ok: false, text: 'dbhub 服务不可用' }
884
+ try {
885
+ const res = await server.client.request('tools/call', { name: rawName, arguments: args || {} })
886
+ const textOf = (r) => {
887
+ if (r && Array.isArray(r.content)) {
888
+ const parts = []
889
+ for (const b of r.content) if (b && typeof b.text === 'string') parts.push(b.text)
890
+ return parts.join('\n')
891
+ }
892
+ return JSON.stringify(r)
893
+ }
894
+ resetIdle(ctx)
895
+ const prefix = label ? label + '\n' : ''
896
+ if (res && res.isError) return { ok: false, text: 'dbhub 执行错误: ' + textOf(res) }
897
+ if (res && res.structuredContent !== undefined) {
898
+ return { ok: true, text: prefix + JSON.stringify(res.structuredContent, null, 2) }
899
+ }
900
+ return { ok: true, text: prefix + textOf(res) }
901
+ } catch (e) {
902
+ return { ok: false, text: String((e && e.message) || e) }
903
+ }
904
+ }
905
+
906
+ // ── ad-hoc temporary connection ──────────────────────────────────────────
907
+ // Each call spawns a fresh throwaway `dbhub --transport stdio --dsn <dsn>`,
908
+ // runs one MCP tools/call against an ad-hoc target (ip/account/password/db
909
+ // supplied by the model per request), then kills it. Independent per call, so
910
+ // two parallel calls can query two different databases at once. Nothing is
911
+ // persisted and the persistent multi-source server is untouched.
912
+ async function runAdhoc(subprocess, dsn, rawName, mcpArgs, exec) {
913
+ if (!dsn || typeof dsn !== 'string' || !dsn.trim()) {
914
+ return { ok: false, text: '缺少 dsn 参数(如 mysql://user:pass@host:3306/db)' }
915
+ }
916
+ let exe
917
+ try {
918
+ exe = await resolveDbhubExe(subprocess, exec.signal)
919
+ } catch (e) {
920
+ return { ok: false, text: '无法获取 dbhub: ' + String((e && e.message) || e) }
921
+ }
922
+ let handle
923
+ try {
924
+ handle = subprocess.spawn({
925
+ argv: buildSpawnArgv(subprocess, exe, ['--transport', 'stdio', '--dsn', dsn.trim()]),
926
+ cwd: DATA_DIR,
927
+ stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
928
+ graceMs: 3000,
929
+ signal: exec.signal,
930
+ })
931
+ } catch (e) {
932
+ return { ok: false, text: '启动临时 dbhub 失败: ' + String((e && e.message) || e) }
933
+ }
934
+ let stderrTail = ''
935
+ try {
936
+ const client = createMcpClient(handle, (c) => {
937
+ stderrTail = (stderrTail + String(c)).slice(-2000)
938
+ })
939
+ await client.request('initialize', {
940
+ protocolVersion: '2025-03-26',
941
+ capabilities: {},
942
+ clientInfo: { name: 'dsh-dbhub-adhoc', version: '1.0.0' },
943
+ })
944
+ client.notify('notifications/initialized')
945
+ const res = await client.request('tools/call', { name: rawName, arguments: mcpArgs })
946
+ const textOf = (r) => {
947
+ if (r && Array.isArray(r.content)) {
948
+ const parts = []
949
+ for (const b of r.content) if (b && typeof b.text === 'string') parts.push(b.text)
950
+ return parts.join('\n')
951
+ }
952
+ return JSON.stringify(r)
953
+ }
954
+ const prefix = '{临时连接: ' + maskDsn(dsn) + '}\n'
955
+ if (res && res.isError) return { ok: false, text: '临时连接执行错误: ' + textOf(res) }
956
+ if (res && res.structuredContent !== undefined) {
957
+ return { ok: true, text: prefix + JSON.stringify(res.structuredContent, null, 2) }
958
+ }
959
+ return { ok: true, text: prefix + textOf(res) }
960
+ } catch (e) {
961
+ let detail = String((e && e.message) || e)
962
+ if (stderrTail) detail += '\n[dbhub stderr] ' + stderrTail
963
+ return { ok: false, text: detail }
964
+ } finally {
965
+ try {
966
+ handle.terminate()
967
+ } catch (e) {
968
+ /* ignore */
969
+ }
970
+ try {
971
+ await handle.waitForExit(exec.signal)
972
+ } catch (e) {
973
+ /* ignore */
974
+ }
975
+ }
976
+ }
977
+
978
+ // ── authorized collection (tier 3) ───────────────────────────────────────
979
+
980
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'target', 'dist', 'build', '.idea', '.vscode', 'venv', '.venv', '__pycache__', '.dsh', '.mise', '.opencode', '.codex'])
981
+ const FILE_PATTERNS = [/\.env([.\w-]*)?$/, /application[-.\w]*\.(yml|yaml|properties)$/, /docker-compose[-.\w]*\.(yml|yaml)$/, /dbconfig\.properties$/, /jdbc\.properties$/, /database\.properties$/, /bootstrap[-.\w]*\.(yml|yaml)$/]
982
+
983
+ function walkForCandidates(root, budget) {
984
+ const found = []
985
+ const walk = (dir, depth) => {
986
+ if (depth > 2 || found.length >= 30 || budget.count >= 200) return
987
+ let entries
988
+ try {
989
+ entries = readdirSync(dir, { withFileTypes: true })
990
+ } catch (e) {
991
+ return
992
+ }
993
+ for (const en of entries) {
994
+ if (budget.count >= 200) return
995
+ budget.count++
996
+ if (SKIP_DIRS.has(en.name)) continue
997
+ const full = join(dir, en.name)
998
+ let isFile = en.isFile()
999
+ if (!isFile && en.isSymbolicLink()) {
1000
+ try {
1001
+ isFile = statSync(full).isFile()
1002
+ } catch (e) {
1003
+ continue
1004
+ }
1005
+ }
1006
+ if (isFile) {
1007
+ if (FILE_PATTERNS.some((p) => p.test(en.name))) found.push(full)
1008
+ } else if (en.isDirectory() || en.isSymbolicLink()) {
1009
+ walk(full, depth + 1)
1010
+ }
1011
+ }
1012
+ }
1013
+ walk(root, 0)
1014
+ return found
1015
+ }
1016
+
1017
+ function extractDsnCandidates(filePath, text) {
1018
+ const out = []
1019
+ const push = (dsn, via) => {
1020
+ let d = dsn
1021
+ if (d && typeof d === 'string') d = d.trim().replace(/^postgresql:\/\//i, 'postgres://')
1022
+ if (d && /^[a-z]+:\/\//i.test(d) && !out.some((o) => o.dsn === d)) {
1023
+ out.push({ dsn: d, via })
1024
+ }
1025
+ }
1026
+ for (const m of String(text).matchAll(/(?:jdbc:mysql|jdbc:postgresql|jdbc:sqlserver|jdbc:mariadb)[^"'\s,;)]*/gi)) {
1027
+ push(m[0].replace(/^jdbc:/i, ''), 'jdbc url')
1028
+ }
1029
+ for (const m of String(text).matchAll(/url\s*[:=]\s*["']?(jdbc:[^"'\s]+)["']?/gi)) {
1030
+ push(m[1].replace(/^jdbc:/i, ''), 'url=')
1031
+ }
1032
+ for (const m of String(text).matchAll(/datasource\.url\s*[:=]\s*["']?([^"'\s]+)["']?/gi)) {
1033
+ push(m[1], 'datasource.url')
1034
+ }
1035
+ for (const m of String(text).matchAll(/\b(mysql|postgres|postgresql|mariadb|sqlserver|sqlite)(?:\+ssl)?:\/\/[^"'\s,;)]+/gi)) {
1036
+ push(m[0], 'dsn')
1037
+ }
1038
+ const map = parseEnvFile(text)
1039
+ const grouped = dsnFromMap(map)
1040
+ if (grouped) push(grouped, 'env 变量组')
1041
+ const mysqlGroup = {
1042
+ DB_TYPE: map.MYSQL_TYPE || map.DB_TYPE,
1043
+ DB_HOST: map.MYSQL_HOST || map.PGHOST || map.DB_HOST,
1044
+ DB_PORT: map.MYSQL_PORT || map.PGPORT || map.DB_PORT,
1045
+ DB_USER: map.MYSQL_USER || map.PGUSER || map.DB_USER,
1046
+ DB_PASSWORD: map.MYSQL_PASSWORD || map.PGPASSWORD || map.DB_PASSWORD,
1047
+ DB_NAME: map.MYSQL_DATABASE || map.PGDATABASE || map.DB_NAME,
1048
+ }
1049
+ const g2 = dsnFromEnv(mysqlGroup)
1050
+ if (g2) push(g2, 'MYSQL_*/PG* 变量组')
1051
+ return out.slice(0, 6)
1052
+ }
1053
+
1054
+ async function askUser(userQuestions, agent, signal, questions) {
1055
+ if (!userQuestions || typeof userQuestions.ask !== 'function') return undefined
1056
+ try {
1057
+ return await userQuestions.ask({
1058
+ questions,
1059
+ ...(agent ? { agent } : {}),
1060
+ ...(signal ? { signal } : {}),
1061
+ })
1062
+ } catch (e) {
1063
+ return undefined
1064
+ }
1065
+ }
1066
+
1067
+ function answerItemOf(ans, id) {
1068
+ if (!ans || !Array.isArray(ans.answers)) return undefined
1069
+ return ans.answers.find((a) => a && a.id === id)
1070
+ }
1071
+
1072
+ // ── dbhub_configure tool body ────────────────────────────────────────────
1073
+
1074
+ async function runConfigure(ctx, subprocess, args, exec) {
1075
+ const userQuestions = ctx.get('userQuestions')
1076
+ const workspaces = await listWorkspaces(ctx)
1077
+ const currentCwd = sessionCwd(exec.agent)
1078
+ let target = workspaces.find((w) => w.path === (args.workspace || currentCwd))
1079
+ if (!target) {
1080
+ const byTitle = workspaces.find((w) => w.title === args.workspace)
1081
+ target = byTitle || (workspaces.find((w) => w.path === currentCwd) || workspaces[0])
1082
+ }
1083
+ if (!target) return { ok: false, text: '没有可用的工作区' }
1084
+ const wsPath = target.path
1085
+ const slug = slugify(target.title) + '_' + shortHash(wsPath)
1086
+
1087
+ // 1) explicit dsn argument
1088
+ if (typeof args.dsn === 'string' && args.dsn.trim() !== '') {
1089
+ store[wsPath] = { dsn: args.dsn.trim(), source: 'user', updatedAt: Date.now() }
1090
+ saveStore(store)
1091
+ await ensureRunning(ctx, subprocess)
1092
+ return { ok: true, text: '已持久化连接并注册工具(' + slug + ')。下次可直接使用 dbhub_execute_sql_' + slug + '。' }
1093
+ }
1094
+
1095
+ const ans = await askUser(userQuestions, exec.agent, exec.signal, [
1096
+ {
1097
+ id: 'mode',
1098
+ header: '配置工作区数据库连接:' + target.title,
1099
+ question: '未检测到自动配置(mise env/.env)。怎么提供连接信息?',
1100
+ options: [
1101
+ { label: '输入完整 DSN', description: '如 mysql://user:pass@host:3306/db' },
1102
+ { label: '填写分项(类型/主机/端口/账号/密码/库名)' },
1103
+ { label: '扫描项目配置文件(需授权,可能读取含密码的文件)' },
1104
+ { label: '取消' },
1105
+ ],
1106
+ },
1107
+ ])
1108
+ const mode = answerItemOf(ans, 'mode')
1109
+ const chosen = mode && mode.selected && mode.selected[0]
1110
+ if (!chosen || chosen === '取消') return { ok: false, text: '已取消,未做任何修改' }
1111
+
1112
+ if (chosen === '输入完整 DSN') {
1113
+ const ans2 = await askUser(userQuestions, exec.agent, exec.signal, [
1114
+ {
1115
+ id: 'dsn',
1116
+ header: '完整 DSN',
1117
+ question: '请输入数据库连接串',
1118
+ },
1119
+ ])
1120
+ const dsn = answerItemOf(ans2, 'dsn')
1121
+ const text = dsn && dsn.custom && dsn.custom.trim()
1122
+ if (!text) return { ok: false, text: '未提供 DSN,已取消' }
1123
+ store[wsPath] = { dsn: text, source: 'user', updatedAt: Date.now() }
1124
+ saveStore(store)
1125
+ await ensureRunning(ctx, subprocess)
1126
+ return { ok: true, text: '已持久化并注册工具(' + slug + ')。' }
1127
+ }
1128
+
1129
+ if (chosen === '填写分项(类型/主机/端口/账号/密码/库名)') {
1130
+ const ans2 = await askUser(userQuestions, exec.agent, exec.signal, [
1131
+ {
1132
+ id: 'type',
1133
+ header: '类型',
1134
+ question: '数据库类型',
1135
+ options: [{ label: 'mysql' }, { label: 'postgres' }, { label: 'mariadb' }, { label: 'sqlite' }, { label: 'sqlserver' }],
1136
+ },
1137
+ { id: 'host', header: '主机', question: '主机地址', options: [{ label: '留空(默认 localhost)' }] },
1138
+ { id: 'port', header: '端口', question: '端口(可留空)', options: [{ label: '留空' }] },
1139
+ { id: 'user', header: '账号', question: '用户名(可留空)', options: [{ label: '留空' }] },
1140
+ { id: 'password', header: '密码', question: '密码(可留空)', options: [{ label: '留空' }] },
1141
+ { id: 'db', header: '库名', question: '数据库名(可留空)', options: [{ label: '留空' }] },
1142
+ ])
1143
+ const val = (id) => {
1144
+ const item = answerItemOf(ans2, id)
1145
+ if (item && item.custom && item.custom.trim()) return item.custom.trim()
1146
+ if (item && item.selected && item.selected[0]) {
1147
+ const s = item.selected[0]
1148
+ return s.startsWith('留空') ? '' : s
1149
+ }
1150
+ return ''
1151
+ }
1152
+ const type = (val('type') || 'mysql').toLowerCase()
1153
+ const host = val('host')
1154
+ const port = val('port')
1155
+ const user = val('user')
1156
+ const password = val('password')
1157
+ const database = val('db')
1158
+ let dsn
1159
+ if (type === 'sqlite') dsn = 'sqlite:///' + (host || 'test.db')
1160
+ else dsn = type + '://' + encodeURIComponent(user) + ':' + encodeURIComponent(password) + '@' + (host || 'localhost') + (port ? ':' + port : '') + '/' + encodeURIComponent(database)
1161
+ store[wsPath] = { dsn, source: 'user', updatedAt: Date.now() }
1162
+ saveStore(store)
1163
+ await ensureRunning(ctx, subprocess)
1164
+ return { ok: true, text: '已持久化并注册工具(' + slug + ')。' }
1165
+ }
1166
+
1167
+ // scan path: authorization gate FIRST
1168
+ const auth = await askUser(userQuestions, exec.agent, exec.signal, [
1169
+ {
1170
+ id: 'auth',
1171
+ header: '授权扫描',
1172
+ question: '将扫描工作区项目中的常见数据库配置文件(.env / application*.yml / docker-compose / jdbc.properties 等)' +
1173
+ '并读取其中的连接信息(可能包含账号密码)。此操作会读取敏感文件且消耗较多 token,是否授权?',
1174
+ options: [
1175
+ { label: '授权扫描', description: '扫描后列出候选供你确认,确认才持久化' },
1176
+ { label: '取消' },
1177
+ ],
1178
+ },
1179
+ ])
1180
+ const authChosen = answerItemOf(auth, 'auth')
1181
+ const authOk = authChosen && authChosen.selected && authChosen.selected[0] === '授权扫描'
1182
+ if (!authOk) return { ok: false, text: '未授权扫描,已取消(可改用“输入 DSN”或“填写分项”)' }
1183
+
1184
+ const budget = { count: 0 }
1185
+ const files = walkForCandidates(wsPath, budget)
1186
+ if (files.length === 0) return { ok: false, text: '未找到可扫描的数据库配置文件(已跳过 node_modules/.git/target 等)。可改用“输入 DSN”或“填写分项”。' }
1187
+ const candidates = []
1188
+ for (const f of files) {
1189
+ let text
1190
+ try {
1191
+ const st = statSync(f)
1192
+ if (st.size > 64 * 1024) continue
1193
+ text = readFileSync(f, 'utf8')
1194
+ } catch (e) {
1195
+ continue
1196
+ }
1197
+ for (const c of extractDsnCandidates(f, text)) {
1198
+ const rel = f.replace(wsPath, '.').replace(/\\/g, '/')
1199
+ candidates.push({ label: rel + ' → ' + maskDsn(c.dsn), dsn: c.dsn, via: c.via })
1200
+ }
1201
+ }
1202
+ if (candidates.length === 0) return { ok: false, text: '扫描了 ' + files.length + ' 个文件,但未提取到数据库连接。可改用“输入 DSN”或“填写分项”。' }
1203
+ const pick = await askUser(userQuestions, exec.agent, exec.signal, [
1204
+ {
1205
+ id: 'pick',
1206
+ header: '选择连接',
1207
+ question: '扫描到以下候选(密码已打码),选择要使用的:',
1208
+ options: candidates.map((c) => ({ label: c.label, description: '来源: ' + c.via })),
1209
+ },
1210
+ ])
1211
+ const picked = answerItemOf(pick, 'pick')
1212
+ const label = picked && picked.selected && picked.selected[0]
1213
+ if (!label) return { ok: false, text: '未选择,已取消' }
1214
+ const match = candidates.find((c) => c.label === label)
1215
+ if (!match) return { ok: false, text: '选择无效' }
1216
+ store[wsPath] = { dsn: match.dsn, source: 'collected', updatedAt: Date.now() }
1217
+ saveStore(store)
1218
+ await ensureRunning(ctx, subprocess)
1219
+ return { ok: true, text: '已按你的确认持久化连接(来源: 项目文件扫描)并注册工具(' + slug + ')。' }
1220
+ }
1221
+
1222
+ function maskDsn(dsn) {
1223
+ try {
1224
+ const u = new URL(dsn.replace(/^jdbc:/i, ''))
1225
+ if (u.password) u.password = '****'
1226
+ return u.href
1227
+ } catch (e) {
1228
+ return dsn.replace(/(:\/\/[^:/@]+:)[^@]+(@)/, '$1****$2')
1229
+ }
1230
+ }
1231
+
1232
+ // ── tool builders ────────────────────────────────────────────────────────
1233
+
1234
+ function buildConfigureTool(ctx, subprocess) {
1235
+ return {
1236
+ name: 'dbhub_configure',
1237
+ description:
1238
+ '配置某个工作区的数据库连接(持久化)。参数:workspace(工作区路径或标题,默认当前会话工作区)、dsn(完整连接串,可选)。' +
1239
+ '未提供 dsn 时会询问用户:输入 DSN / 填写分项 / 授权扫描项目配置文件(读取 .env、application*.yml、docker-compose、jdbc.properties 等并列出候选供确认)。' +
1240
+ '配置后自动生成 dbhub.toml、重启常驻 dbhub 服务并注册该工作区的工具(dbhub_execute_sql_<工作区> 等)。' +
1241
+ '每个工作区只需配置一次,之后自动持久化;有 mise env/.env 自动配置的工作区无需调用本工具。',
1242
+ parameters: {
1243
+ type: 'object',
1244
+ properties: {
1245
+ workspace: { type: 'string', description: '工作区路径或标题;默认当前会话工作区' },
1246
+ dsn: { type: 'string', description: '完整数据库连接串;省略则询问用户或扫描' },
1247
+ },
1248
+ required: [],
1249
+ },
1250
+ timeoutMs: 120000,
1251
+ output: {
1252
+ schema: {},
1253
+ render: (_args, value) => [
1254
+ {
1255
+ type: 'text',
1256
+ text: String(value && value.text !== undefined ? value.text : JSON.stringify(value)),
1257
+ },
1258
+ ],
1259
+ },
1260
+ async execute(args, exec) {
1261
+ return runConfigure(ctx, subprocess, args, exec)
1262
+ },
1263
+ }
1264
+ }
1265
+
1266
+ function registerCoreTools(ctx, subprocess) {
1267
+ try {
1268
+ const def = buildConfigureTool(ctx, subprocess)
1269
+ const disposer = ctx.tools.register(def)
1270
+ coreDisposers.set('dbhub_configure', disposer)
1271
+ } catch (e) {
1272
+ console.error('[dsh-dbhub-live] register dbhub_configure failed: ' + String((e && e.message) || e))
1273
+ }
1274
+ // Ad-hoc temporary connections: model passes a full dsn per request; each
1275
+ // call is an independent throwaway dbhub, so two calls can hit two different
1276
+ // databases at once. Independent state -> safe to run concurrently.
1277
+ const adhocDefs = [
1278
+ {
1279
+ name: 'dbhub_query',
1280
+ description:
1281
+ '临时动态连接任意数据库并执行 SQL(每次调用独立临时连接,可同时查多个不同库)。' +
1282
+ '参数 dsn 必填:完整连接串,指定 ip/端口/账号/密码/库(如 mysql://user:pass@host:3306/db)。' +
1283
+ '适合一次性排查/对比不同环境;与持久配置的工作区工具互不影响。多语句用 ; 分隔。',
1284
+ parameters: {
1285
+ type: 'object',
1286
+ properties: {
1287
+ dsn: { type: 'string', description: '完整数据库连接串,如 mysql://root:pass@192.168.77.6:3306/tx_sd_jinengshu' },
1288
+ sql: { type: 'string', description: '要执行的 SQL(多语句用 ; 分隔)' },
1289
+ },
1290
+ required: ['dsn', 'sql'],
1291
+ },
1292
+ timeoutMs: 60000,
1293
+ isConcurrencySafe: () => true,
1294
+ output: {
1295
+ schema: {},
1296
+ render: (_args, value) => [
1297
+ { type: 'text', text: String(value && value.text !== undefined ? value.text : JSON.stringify(value)) },
1298
+ ],
1299
+ },
1300
+ async execute(args, exec) {
1301
+ return runAdhoc(subprocess, args.dsn, 'execute_sql', { sql: args.sql }, exec)
1302
+ },
1303
+ },
1304
+ {
1305
+ name: 'dbhub_query_objects',
1306
+ description:
1307
+ '临时动态连接并搜索数据库对象(表/视图/列/索引等),每次调用独立临时连接。' +
1308
+ '注意:dbhub 的 search_objects 仅对 sqlite 开放(🔒),MySQL/PostgreSQL 等请用 dbhub_query 直接查(如 SHOW TABLES)。' +
1309
+ '参数 dsn 必填(ip/端口/账号/密码/库)。',
1310
+ parameters: {
1311
+ type: 'object',
1312
+ properties: {
1313
+ dsn: { type: 'string', description: '完整数据库连接串,如 mysql://root:pass@192.168.77.6:3306/tx_sd_jinengshu' },
1314
+ object_type: { type: 'string', enum: ['schema', 'table', 'view', 'column', 'procedure', 'function', 'index'], description: '对象类型' },
1315
+ pattern: { type: 'string', description: 'LIKE 模式' },
1316
+ schema: { type: 'string', description: '限定 schema' },
1317
+ table: { type: 'string', description: '限定表(需 schema)' },
1318
+ detail_level: { type: 'string', enum: ['names', 'summary', 'full'], description: '详细程度' },
1319
+ limit: { type: 'integer', description: '最大结果数' },
1320
+ },
1321
+ required: ['dsn', 'object_type'],
1322
+ },
1323
+ timeoutMs: 60000,
1324
+ isConcurrencySafe: () => true,
1325
+ output: {
1326
+ schema: {},
1327
+ render: (_args, value) => [
1328
+ { type: 'text', text: String(value && value.text !== undefined ? value.text : JSON.stringify(value)) },
1329
+ ],
1330
+ },
1331
+ async execute(args, exec) {
1332
+ const mcp = {}
1333
+ for (const k of ['object_type', 'pattern', 'schema', 'table', 'detail_level', 'limit']) {
1334
+ if (args[k] !== undefined && args[k] !== null) mcp[k] = args[k]
1335
+ }
1336
+ return runAdhoc(subprocess, args.dsn, 'search_objects', mcp, exec)
1337
+ },
1338
+ },
1339
+ ]
1340
+ for (const def of adhocDefs) {
1341
+ try {
1342
+ const disposer = ctx.tools.register(def)
1343
+ coreDisposers.set(def.name, disposer)
1344
+ } catch (e) {
1345
+ console.error('[dsh-dbhub-live] register ' + def.name + ' failed: ' + String((e && e.message) || e))
1346
+ }
1347
+ }
1348
+ }
1349
+
1350
+ // ── orphan cleanup ───────────────────────────────────────────────────────
1351
+
1352
+ // Best-effort: on startup, kill any dbhub process still pointing at OUR
1353
+ // dbhub.toml but not owned by this running instance (left over from a
1354
+ // hard-killed previous dsh where the subprocess service could not dispose).
1355
+ // Matches only the unique config path, so unrelated dbhub processes are safe.
1356
+ // Windows-only (dbhub runs as node.exe); non-fatal on failure.
1357
+ async function cleanupOrphans(subprocess) {
1358
+ if (process.platform !== 'win32') return
1359
+ let ps
1360
+ try {
1361
+ ps = await subprocess.resolveExecutable('powershell.exe', undefined, undefined)
1362
+ } catch (e) {
1363
+ try {
1364
+ ps = await subprocess.resolveExecutable('pwsh.exe', undefined, undefined)
1365
+ } catch (e2) {
1366
+ return
1367
+ }
1368
+ }
1369
+ const script =
1370
+ 'Get-CimInstance Win32_Process | Where-Object { $_.Name -eq "node.exe" -and ' +
1371
+ '$_.CommandLine -like "*dsh-dbhub-live\\dbhub.toml*" } | ' +
1372
+ 'ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }'
1373
+ let handle
1374
+ try {
1375
+ handle = subprocess.spawn({
1376
+ argv: [ps, '-NoProfile', '-NonInteractive', '-Command', script],
1377
+ cwd: DATA_DIR,
1378
+ stdio: { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' },
1379
+ graceMs: 5000,
1380
+ })
1381
+ await handle.done
1382
+ } catch (e) {
1383
+ console.error('[dsh-dbhub-live] orphan cleanup failed: ' + String((e && e.message) || e))
1384
+ }
1385
+ }
1386
+
1387
+ // ── apply ────────────────────────────────────────────────────────────────
1388
+
1389
+ async function apply(ctx) {
1390
+ const subprocess = ctx.get('subprocess')
1391
+ registerCoreTools(ctx, subprocess)
1392
+ if (!subprocess) {
1393
+ console.error('[dsh-dbhub-live] subprocess unavailable — per-source tools deferred')
1394
+ return
1395
+ }
1396
+ // Fire-and-forget: do NOT block dsh web boot on dbhub init (workspace
1397
+ // resolution + spawn). Clean up orphans first, then start the server.
1398
+ // dbhub_configure is available immediately.
1399
+ (async () => {
1400
+ try {
1401
+ await cleanupOrphans(subprocess)
1402
+ } catch (e) {
1403
+ /* contained */
1404
+ }
1405
+ await ensureRunning(ctx, subprocess)
1406
+ console.log('[dsh-dbhub-live] dbhub 服务已加载(' + (coreDisposers.size + toolDisposers.size) + ' 个工具)')
1407
+ // Background, non-blocking: refresh the auto-installed dbhub if its update
1408
+ // interval has elapsed. Fails silently — the current binary stays in use.
1409
+ maybeRefreshDbhub(subprocess).catch((e) => {
1410
+ console.error('[dsh-dbhub-live] dbhub 自动更新检查失败: ' + String((e && e.message) || e))
1411
+ })
1412
+ })().catch((e) => {
1413
+ console.error('[dsh-dbhub-live] initial start failed: ' + String((e && e.message) || e))
1414
+ })
1415
+ }
1416
+
1417
+ export { name, inject, apply }