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/LICENSE +21 -0
- package/README.md +110 -0
- package/cordis.patch.yml +8 -0
- package/lib/index.mjs +1417 -0
- package/package.json +39 -0
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 }
|