dsh-dbhub-live 2.0.0 → 3.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +97 -0
- package/LICENSE +21 -21
- package/README.en.md +162 -0
- package/README.md +162 -110
- package/cordis.patch.yml +8 -8
- package/doc/REQUIREMENTS.md +65 -0
- package/lib/adhoc.mjs +97 -0
- package/lib/client.js +604 -0
- package/lib/collect.mjs +118 -0
- package/lib/config.mjs +492 -0
- package/lib/i18n.mjs +139 -0
- package/lib/index.mjs +345 -1417
- package/lib/mcp.mjs +462 -0
- package/lib/options.mjs +94 -0
- package/lib/runtime.mjs +255 -0
- package/lib/state.mjs +108 -0
- package/lib/tools.mjs +462 -0
- package/package.json +61 -39
- package/test/adhoc.test.mjs +29 -0
- package/test/client-format.test.mjs +88 -0
- package/test/i18n.test.mjs +27 -0
- package/test/init.test.mjs +163 -0
- package/test/options.test.mjs +41 -0
- package/test/state.test.mjs +85 -0
- package/test/util.test.mjs +96 -0
package/lib/mcp.mjs
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
// dsh-dbhub-live: persistent multi-source dbhub server + MCP client.
|
|
2
|
+
//
|
|
3
|
+
// ONE persistent `dbhub --transport stdio --config <generated dbhub.toml>`
|
|
4
|
+
// process, managed here: idle recycle, respawn on demand, lazy init. The toml
|
|
5
|
+
// is auto-generated from every registered workspace's connection config and
|
|
6
|
+
// hot-reloaded by dbhub itself — users never maintain it.
|
|
7
|
+
//
|
|
8
|
+
// This module also owns the per-workspace tool registrations and their
|
|
9
|
+
// disposers (the source of truth for the registered-tool count).
|
|
10
|
+
|
|
11
|
+
import { statSync } from 'node:fs'
|
|
12
|
+
import { join, basename } from 'node:path'
|
|
13
|
+
import {
|
|
14
|
+
DATA_DIR, dshHomeDir, STORE_PATH, TOML_PATH,
|
|
15
|
+
store, listWorkspaces, listWorkspaceEnvironments, resolveWorkspaceEnvs,
|
|
16
|
+
slugify, envSlug, shortHash,
|
|
17
|
+
fingerprintOf, generateToml, maskDsn, writeToml,
|
|
18
|
+
} from './config.mjs'
|
|
19
|
+
import { buildSpawnArgv, resolveDbhubExe } from './runtime.mjs'
|
|
20
|
+
import { currentT } from './i18n.mjs'
|
|
21
|
+
import * as options from './options.mjs'
|
|
22
|
+
import * as state from './state.mjs'
|
|
23
|
+
|
|
24
|
+
// ── MCP client (JSON-RPC 2.0 over stdio) ─────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export function createMcpClient(handle, onStderr, onNotification) {
|
|
27
|
+
let buf = ''
|
|
28
|
+
let nextId = 1
|
|
29
|
+
let closed = false
|
|
30
|
+
const pending = new Map()
|
|
31
|
+
const settleAll = (err) => {
|
|
32
|
+
for (const p of pending.values()) p.reject(err)
|
|
33
|
+
pending.clear()
|
|
34
|
+
}
|
|
35
|
+
if (handle.stdout) {
|
|
36
|
+
handle.stdout.on('data', (chunk) => {
|
|
37
|
+
buf += String(chunk)
|
|
38
|
+
let idx
|
|
39
|
+
while ((idx = buf.indexOf('\n')) >= 0) {
|
|
40
|
+
const line = buf.slice(0, idx).trim()
|
|
41
|
+
buf = buf.slice(idx + 1)
|
|
42
|
+
if (!line) continue
|
|
43
|
+
let msg
|
|
44
|
+
try {
|
|
45
|
+
msg = JSON.parse(line)
|
|
46
|
+
} catch (e) {
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
if (msg && typeof msg.id === 'number' && pending.has(msg.id)) {
|
|
50
|
+
const p = pending.get(msg.id)
|
|
51
|
+
pending.delete(msg.id)
|
|
52
|
+
if (msg.error) p.reject(new Error('MCP error: ' + JSON.stringify(msg.error)))
|
|
53
|
+
else p.resolve(msg.result)
|
|
54
|
+
} else if (msg && msg.method && !msg.id && onNotification) {
|
|
55
|
+
try {
|
|
56
|
+
onNotification(msg)
|
|
57
|
+
} catch (e) {
|
|
58
|
+
/* contain listener failures */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
if (handle.stderr && onStderr) handle.stderr.on('data', onStderr)
|
|
65
|
+
handle.done.then((outcome) => {
|
|
66
|
+
closed = true
|
|
67
|
+
settleAll(
|
|
68
|
+
new Error(
|
|
69
|
+
'dbhub exited (code ' + outcome.exitCode + ')' + (outcome.signal ? ', signal ' + outcome.signal : ''),
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
}).catch((err) => {
|
|
73
|
+
closed = true
|
|
74
|
+
settleAll(err instanceof Error ? err : new Error(String(err)))
|
|
75
|
+
})
|
|
76
|
+
return {
|
|
77
|
+
get closed() {
|
|
78
|
+
return closed
|
|
79
|
+
},
|
|
80
|
+
request(method, params) {
|
|
81
|
+
const id = nextId++
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
if (!handle.stdin) {
|
|
84
|
+
reject(new Error('dbhub stdin unavailable'))
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
if (closed) {
|
|
88
|
+
reject(new Error('dbhub process is down (will auto-restart on next call)'))
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
pending.set(id, { resolve, reject })
|
|
92
|
+
try {
|
|
93
|
+
handle.stdin.write(
|
|
94
|
+
JSON.stringify(
|
|
95
|
+
params === undefined
|
|
96
|
+
? { jsonrpc: '2.0', id, method }
|
|
97
|
+
: { jsonrpc: '2.0', id, method, params },
|
|
98
|
+
) + '\n',
|
|
99
|
+
)
|
|
100
|
+
} catch (e) {
|
|
101
|
+
pending.delete(id)
|
|
102
|
+
reject(e)
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
},
|
|
106
|
+
notify(method, params) {
|
|
107
|
+
if (!handle.stdin || closed) return
|
|
108
|
+
try {
|
|
109
|
+
handle.stdin.write(
|
|
110
|
+
JSON.stringify(
|
|
111
|
+
params === undefined
|
|
112
|
+
? { jsonrpc: '2.0', method }
|
|
113
|
+
: { jsonrpc: '2.0', method, params },
|
|
114
|
+
) + '\n',
|
|
115
|
+
)
|
|
116
|
+
} catch (e) {
|
|
117
|
+
/* ignore */
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── server lifecycle ──────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
const server = {
|
|
126
|
+
handle: undefined,
|
|
127
|
+
client: undefined,
|
|
128
|
+
generatedFingerprint: undefined,
|
|
129
|
+
starting: undefined,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function terminateServer() {
|
|
133
|
+
const h = server.handle
|
|
134
|
+
server.handle = undefined
|
|
135
|
+
server.client = undefined
|
|
136
|
+
if (h) {
|
|
137
|
+
try {
|
|
138
|
+
h.terminate()
|
|
139
|
+
} catch (e) {
|
|
140
|
+
/* ignore */
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function spawnServer(subprocess, signal) {
|
|
146
|
+
let exe
|
|
147
|
+
try {
|
|
148
|
+
exe = await resolveDbhubExe(subprocess, signal)
|
|
149
|
+
} catch (e) {
|
|
150
|
+
return { error: '无法获取 dbhub: ' + String((e && e.message) || e) }
|
|
151
|
+
}
|
|
152
|
+
let handle
|
|
153
|
+
try {
|
|
154
|
+
handle = subprocess.spawn({
|
|
155
|
+
argv: buildSpawnArgv(subprocess, exe, ['--transport', 'stdio', '--config', TOML_PATH]),
|
|
156
|
+
cwd: DATA_DIR,
|
|
157
|
+
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
|
|
158
|
+
graceMs: 3000,
|
|
159
|
+
signal,
|
|
160
|
+
})
|
|
161
|
+
} catch (e) {
|
|
162
|
+
return { error: '启动 dbhub 失败: ' + String((e && e.message) || e) }
|
|
163
|
+
}
|
|
164
|
+
let stderrTail = ''
|
|
165
|
+
const client = createMcpClient(handle, (c) => {
|
|
166
|
+
stderrTail = (stderrTail + String(c)).slice(-4000)
|
|
167
|
+
}, () => {
|
|
168
|
+
// notifications/tools/list_changed — re-sync happens lazily on next call
|
|
169
|
+
// (the tools already registered stay valid; new ones appear after respawn).
|
|
170
|
+
console.log('[dsh-dbhub-live] tools/list_changed received')
|
|
171
|
+
})
|
|
172
|
+
try {
|
|
173
|
+
await client.request('initialize', {
|
|
174
|
+
protocolVersion: '2025-03-26',
|
|
175
|
+
capabilities: {},
|
|
176
|
+
clientInfo: { name: 'dsh-dbhub', version: '3.0.0' },
|
|
177
|
+
})
|
|
178
|
+
client.notify('notifications/initialized')
|
|
179
|
+
} catch (e) {
|
|
180
|
+
let detail = String((e && e.message) || e)
|
|
181
|
+
if (stderrTail) detail += '\n[dbhub stderr] ' + stderrTail
|
|
182
|
+
try {
|
|
183
|
+
handle.terminate()
|
|
184
|
+
} catch (e2) {
|
|
185
|
+
/* ignore */
|
|
186
|
+
}
|
|
187
|
+
return { error: detail }
|
|
188
|
+
}
|
|
189
|
+
server.handle = handle
|
|
190
|
+
server.client = client
|
|
191
|
+
return {}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── tool registry (single-slot) ────────────────────────────────────────────
|
|
195
|
+
//
|
|
196
|
+
// Context budget: there is exactly ONE set of dbhub tools, period. Work
|
|
197
|
+
// workspaces × environments are NOT expanded into per-source tools (that
|
|
198
|
+
// would inject 2N tool declarations into every model turn); instead the
|
|
199
|
+
// source-parameterized tools dbhub_execute_sql / dbhub_search_objects resolve
|
|
200
|
+
// their `source` argument at call time against the live source list.
|
|
201
|
+
const toolDisposers = new Map() // modelName -> disposer (plugin-owned tools)
|
|
202
|
+
const coreDisposers = toolDisposers
|
|
203
|
+
|
|
204
|
+
/** Total registered dbhub tools (constant set: configure/list/execute/search/query/query_objects). */
|
|
205
|
+
export function activeToolCount() {
|
|
206
|
+
return coreDisposers.size
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function refreshToolCount() {
|
|
210
|
+
state.setToolCount(activeToolCount())
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Resolve a `source` reference (from dbhub_list_sources) to a live source row.
|
|
215
|
+
* Accepts: the exact source id (`<title>_<hash>[_<env>]`), the display titles
|
|
216
|
+
* `<title>` / `<title> <env>` / `<title>_<env>` / `<title>.<env>`.
|
|
217
|
+
* @returns the matched {@link collectSources} row + its source id, or
|
|
218
|
+
* `undefined` when nothing matches.
|
|
219
|
+
*/
|
|
220
|
+
export async function resolveSource(ctx, subprocess, sourceRef) {
|
|
221
|
+
const wanted = String(sourceRef || '').trim().toLowerCase()
|
|
222
|
+
if (!wanted) return undefined
|
|
223
|
+
const { rows } = await collectSources(ctx, subprocess)
|
|
224
|
+
const keyed = rows.map((r, i) => {
|
|
225
|
+
const title = slugify(r.title)
|
|
226
|
+
const env = r.env === 'default' ? '' : envSlug(r.env)
|
|
227
|
+
const id = title + '_' + shortHash(r.wsPath) + (env ? '_' + env : '')
|
|
228
|
+
return { row: r, id, keys: new Set([id, title, env ? title + '_' + env : title, env ? title + ' ' + r.env : title, env ? title + '.' + r.env : title, String(i)]) }
|
|
229
|
+
})
|
|
230
|
+
return keyed.find((k) => k.keys.has(wanted) || k.id === wanted || (wanted.includes(k.id)))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Human label for a source row ({连接: <title> → masked dsn}). */
|
|
234
|
+
export function sourceLabel(row) {
|
|
235
|
+
return row ? '{连接: ' + row.title + ' → ' + maskDsn(row.dsn) + '}' : ''
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── warm-call fast path & lazy ensure ─────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
let idleDispose
|
|
241
|
+
function resetIdle(ctx) {
|
|
242
|
+
if (idleDispose) {
|
|
243
|
+
try {
|
|
244
|
+
idleDispose()
|
|
245
|
+
} catch (e) {
|
|
246
|
+
/* ignore */
|
|
247
|
+
}
|
|
248
|
+
idleDispose = undefined
|
|
249
|
+
}
|
|
250
|
+
if (!server.handle) return
|
|
251
|
+
const idleMs = (options.get('idleMinutes') || 10) * 60 * 1000
|
|
252
|
+
idleDispose = ctx.timeout(() => {
|
|
253
|
+
idleDispose = undefined
|
|
254
|
+
console.log('[dsh-dbhub-live] idle — recycling dbhub process')
|
|
255
|
+
terminateServer()
|
|
256
|
+
}, idleMs)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Re-resolve configs only when the durable inputs change (workspace registry
|
|
260
|
+
// file, credentials store) — otherwise skip straight to a live-server check.
|
|
261
|
+
// Keeps repeated tool calls at ~0 overhead.
|
|
262
|
+
let lastInputMtime = 0
|
|
263
|
+
|
|
264
|
+
// Live connection-row cache: refreshed by every collectSources run so the
|
|
265
|
+
// settings publish path can mirror the card without an extra walk.
|
|
266
|
+
let lastRowCache = []
|
|
267
|
+
|
|
268
|
+
/** Masked connection rows for the card (never leaks passwords over the wire). */
|
|
269
|
+
export function summarizeRows(rows) {
|
|
270
|
+
return rows.map((r) => {
|
|
271
|
+
const srcId = slugify(r.title) + '_' + shortHash(r.wsPath) + (r.env === 'default' ? '' : '_' + envSlug(r.env))
|
|
272
|
+
return {
|
|
273
|
+
title: r.title,
|
|
274
|
+
path: r.wsPath,
|
|
275
|
+
env: r.env,
|
|
276
|
+
dsn: maskDsn(r.dsn),
|
|
277
|
+
source: r.source,
|
|
278
|
+
persisted: !!r.persisted,
|
|
279
|
+
srcId,
|
|
280
|
+
}
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The last collected summary set (empty until the first walk). */
|
|
285
|
+
export function latestSummaries() {
|
|
286
|
+
return summarizeRows(lastRowCache)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** mtime of the durable config inputs (workspace registry + credentials). */
|
|
290
|
+
export function configInputsMtime() {
|
|
291
|
+
let max = 0
|
|
292
|
+
for (const p of [join(dshHomeDir(), 'storages', 'workspace.json'), STORE_PATH]) {
|
|
293
|
+
try {
|
|
294
|
+
const m = statSync(p).mtimeMs
|
|
295
|
+
if (m > max) max = m
|
|
296
|
+
} catch (e) {
|
|
297
|
+
/* missing file is fine */
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return max
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Walk every workspace × environment and produce the live connection rows
|
|
305
|
+
* (for the card's masked summaries) and the dbhub source list (for the toml
|
|
306
|
+
* and per-workspace tool names). Shared by `ensureRunning` and the settings
|
|
307
|
+
* publish path so the card always mirrors what the server would load.
|
|
308
|
+
* Environment naming: the 'default' env keeps the legacy bare source id
|
|
309
|
+
* (`<title>_<hash>`); extra environments append `_<envSlug>`.
|
|
310
|
+
* @returns `{ rows, sources }` — rows: [{ wsPath, title, env, dsn, source,
|
|
311
|
+
* persisted }]; sources: [{ id, dsn }].
|
|
312
|
+
*/
|
|
313
|
+
export async function collectSources(ctx, subprocess) {
|
|
314
|
+
const rows = []
|
|
315
|
+
const sources = []
|
|
316
|
+
try {
|
|
317
|
+
const workspaces = await listWorkspaces(ctx)
|
|
318
|
+
const persisted = listWorkspaceEnvironments(store)
|
|
319
|
+
const byPath = new Map()
|
|
320
|
+
for (const p of persisted) {
|
|
321
|
+
if (!byPath.has(p.wsPath)) byPath.set(p.wsPath, [])
|
|
322
|
+
byPath.get(p.wsPath).push(p)
|
|
323
|
+
}
|
|
324
|
+
for (const ws of workspaces) {
|
|
325
|
+
const title = ws.title || basename(ws.path)
|
|
326
|
+
const baseId = slugify(title) + '_' + shortHash(ws.path)
|
|
327
|
+
const resolved = await resolveWorkspaceEnvs(subprocess, ctx.get('fs'), ws.path, undefined)
|
|
328
|
+
const persistedEnvs = new Set((byPath.get(ws.path) || []).map((p) => p.env))
|
|
329
|
+
for (const r of resolved) {
|
|
330
|
+
const slug = envSlug(r.env)
|
|
331
|
+
const id = baseId + (slug ? '_' + slug : '')
|
|
332
|
+
rows.push({ wsPath: ws.path, title, env: r.env, dsn: r.dsn, source: r.source, persisted: persistedEnvs.has(r.env) })
|
|
333
|
+
sources.push({ id, dsn: r.dsn })
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
} catch (e) {
|
|
337
|
+
console.error('[dsh-dbhub-live] config generation failed: ' + String((e && e.message) || e))
|
|
338
|
+
}
|
|
339
|
+
lastRowCache = rows
|
|
340
|
+
return { rows, sources }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Make sure the persistent server is up and synced, initializing lazily on
|
|
345
|
+
* first use. Concurrent callers share one in-flight start. Regenerates the
|
|
346
|
+
* toml whenever the workspace set or its resolved DSNs change.
|
|
347
|
+
* @returns a promise settling when the server is ready (never rejects; the
|
|
348
|
+
* caller reads `server.client` to learn readiness).
|
|
349
|
+
*/
|
|
350
|
+
export async function ensureRunning(ctx, subprocess) {
|
|
351
|
+
if (!state.isEnabled()) return
|
|
352
|
+
const inputMtime = configInputsMtime()
|
|
353
|
+
const serverUp = server.client && !server.client.closed
|
|
354
|
+
if (serverUp && inputMtime <= lastInputMtime && server.generatedFingerprint !== undefined) {
|
|
355
|
+
resetIdle(ctx)
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
const { sources } = await collectSources(ctx, subprocess)
|
|
359
|
+
lastInputMtime = inputMtime
|
|
360
|
+
const fp = fingerprintOf(sources)
|
|
361
|
+
// Never spawn with zero sources: dbhub treats an empty [[sources]] toml as
|
|
362
|
+
// fatal, so without a configured workspace the server must stay down and the
|
|
363
|
+
// fast path above retries as soon as a source appears (mtime/fingerprint).
|
|
364
|
+
if (sources.length === 0) {
|
|
365
|
+
if (fp !== server.generatedFingerprint) {
|
|
366
|
+
console.log('[dsh-dbhub-live] no workspace sources yet — use dbhub_configure')
|
|
367
|
+
server.generatedFingerprint = ''
|
|
368
|
+
}
|
|
369
|
+
if (server.handle) terminateServer()
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
if (fp !== server.generatedFingerprint) {
|
|
373
|
+
if (!writeToml(generateToml(sources))) {
|
|
374
|
+
// A blocked/unwritable storage dir is a real init failure: surface it in
|
|
375
|
+
// the state machine (phase 'error' + message) and retry on next call.
|
|
376
|
+
throw new Error('写入 dbhub.toml 失败(配置目录不可写)')
|
|
377
|
+
}
|
|
378
|
+
server.generatedFingerprint = fp
|
|
379
|
+
// config changed while a server is up -> restart is deterministic
|
|
380
|
+
// (dbhub also hot-reloads its config, but a respawn re-syncs cleanly).
|
|
381
|
+
if (server.handle) terminateServer()
|
|
382
|
+
}
|
|
383
|
+
if (server.client && !server.client.closed) {
|
|
384
|
+
resetIdle(ctx)
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
if (server.starting) {
|
|
388
|
+
try {
|
|
389
|
+
await server.starting
|
|
390
|
+
} catch (e) {
|
|
391
|
+
/* the owner below recorded the error */
|
|
392
|
+
}
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
server.starting = (async () => {
|
|
396
|
+
try {
|
|
397
|
+
state.setPhase('initializing')
|
|
398
|
+
const r = await spawnServer(subprocess, undefined)
|
|
399
|
+
if (r.error) throw new Error(r.error)
|
|
400
|
+
state.clearError()
|
|
401
|
+
state.setPhase('running')
|
|
402
|
+
refreshToolCount()
|
|
403
|
+
resetIdle(ctx)
|
|
404
|
+
} catch (e) {
|
|
405
|
+
state.recordError(String((e && e.message) || e))
|
|
406
|
+
throw e
|
|
407
|
+
} finally {
|
|
408
|
+
server.starting = undefined
|
|
409
|
+
}
|
|
410
|
+
})()
|
|
411
|
+
try {
|
|
412
|
+
await server.starting
|
|
413
|
+
} catch (e) {
|
|
414
|
+
/* failure recorded + surfaced through runOnServer's {ok:false} */
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ── MCP call over the persistent server ───────────────────────────────────
|
|
419
|
+
|
|
420
|
+
export async function runOnServer(ctx, subprocess, rawName, args, exec, label) {
|
|
421
|
+
if (!state.isEnabled()) {
|
|
422
|
+
return { ok: false, text: disabledMessage() }
|
|
423
|
+
}
|
|
424
|
+
try {
|
|
425
|
+
await ensureRunning(ctx, subprocess)
|
|
426
|
+
} catch (e) {
|
|
427
|
+
return { ok: false, text: currentT('result.serverStartFail', { msg: String((e && e.message) || e) }) }
|
|
428
|
+
}
|
|
429
|
+
if (!server.client) return { ok: false, text: currentT('result.serverUnavailable') }
|
|
430
|
+
try {
|
|
431
|
+
const res = await server.client.request('tools/call', { name: rawName, arguments: args || {} })
|
|
432
|
+
const textOf = (r) => {
|
|
433
|
+
if (r && Array.isArray(r.content)) {
|
|
434
|
+
const parts = []
|
|
435
|
+
for (const b of r.content) if (b && typeof b.text === 'string') parts.push(b.text)
|
|
436
|
+
return parts.join('\n')
|
|
437
|
+
}
|
|
438
|
+
return JSON.stringify(r)
|
|
439
|
+
}
|
|
440
|
+
resetIdle(ctx)
|
|
441
|
+
const prefix = label ? label + '\n' : ''
|
|
442
|
+
if (res && res.isError) return { ok: false, text: textOf(res) }
|
|
443
|
+
if (res && res.structuredContent !== undefined) {
|
|
444
|
+
return { ok: true, text: prefix + JSON.stringify(res.structuredContent, null, 2) }
|
|
445
|
+
}
|
|
446
|
+
return { ok: true, text: prefix + textOf(res) }
|
|
447
|
+
} catch (e) {
|
|
448
|
+
return { ok: false, text: String((e && e.message) || e) }
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Shared "plugin disabled" copy for every dbhub tool (locale-aware).
|
|
453
|
+
export function disabledMessage() {
|
|
454
|
+
return currentT('result.disabled')
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Whether the persistent server currently answers calls. */
|
|
458
|
+
export function isServerUp() {
|
|
459
|
+
return !!(server.client && !server.client.closed)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export { coreDisposers, refreshToolCount }
|
package/lib/options.mjs
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// dsh-dbhub-live: deployment-tunable options.
|
|
2
|
+
//
|
|
3
|
+
// Editable through the Settings -> Plugins -> dsh-dbhub-live card. Precedence:
|
|
4
|
+
// user settings (settings document) > process environment > built-in default
|
|
5
|
+
// Environment variables only seed the boot values; once the settings document
|
|
6
|
+
// claims a field (first publish writes the section), that field is owned by
|
|
7
|
+
// the settings UI until it is unset — so an env change no longer applies to a
|
|
8
|
+
// claimed field. This mirrors how the dsh settings system treats user layers.
|
|
9
|
+
|
|
10
|
+
const BUILTIN_DEFAULTS = {
|
|
11
|
+
dbhubPackage: '@bytebase/dbhub',
|
|
12
|
+
updateIntervalDays: 7,
|
|
13
|
+
idleMinutes: 10,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function envNumber(name, fallback) {
|
|
17
|
+
const raw = process.env[name]
|
|
18
|
+
if (raw === undefined || raw === '') return fallback
|
|
19
|
+
const n = Number(raw)
|
|
20
|
+
return Number.isFinite(n) && n >= 0 ? n : fallback
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const options = {
|
|
24
|
+
dbhubPackage: process.env.DSH_DBHUB_PACKAGE || BUILTIN_DEFAULTS.dbhubPackage,
|
|
25
|
+
updateIntervalDays: envNumber('DSH_DBHUB_UPDATE_DAYS', BUILTIN_DEFAULTS.updateIntervalDays),
|
|
26
|
+
idleMinutes: BUILTIN_DEFAULTS.idleMinutes,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const listeners = new Set()
|
|
30
|
+
|
|
31
|
+
function emit() {
|
|
32
|
+
for (const fn of [...listeners]) {
|
|
33
|
+
try {
|
|
34
|
+
fn()
|
|
35
|
+
} catch (e) {
|
|
36
|
+
/* contain listener failures */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Subscribe to option changes; returns the disposer. */
|
|
42
|
+
export function subscribe(fn) {
|
|
43
|
+
listeners.add(fn)
|
|
44
|
+
return () => listeners.delete(fn)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Detached JSON snapshot of the settings-exposed options. `dbhubPackage` is
|
|
49
|
+
* deliberately excluded: the auto-install package stays an environment-seeded
|
|
50
|
+
* internal knob (DSH_DBHUB_PACKAGE / built-in default), not a settings field.
|
|
51
|
+
*/
|
|
52
|
+
export function snapshot() {
|
|
53
|
+
return {
|
|
54
|
+
updateIntervalDays: options.updateIntervalDays,
|
|
55
|
+
idleMinutes: options.idleMinutes,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Read one option by id ('dbhubPackage' is internal-only).
|
|
61
|
+
* @param id - 'dbhubPackage' | 'updateIntervalDays' | 'idleMinutes'.
|
|
62
|
+
* @returns the current value.
|
|
63
|
+
*/
|
|
64
|
+
export function get(id) {
|
|
65
|
+
return options[id]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Per-field validators for the settings-exposed options; 'dbhubPackage' is
|
|
69
|
+
// deliberately not patchable (it stays an env/internal knob).
|
|
70
|
+
const VALIDATORS = {
|
|
71
|
+
updateIntervalDays: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0,
|
|
72
|
+
idleMinutes: (v) => typeof v === 'number' && Number.isFinite(v) && v > 0,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Apply a validated patch onto the options.
|
|
77
|
+
* @param patch - partial option values (prefixes the env seed at boot).
|
|
78
|
+
* @returns whether any option actually changed.
|
|
79
|
+
*/
|
|
80
|
+
export function applyPatch(patch) {
|
|
81
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return false
|
|
82
|
+
let changed = false
|
|
83
|
+
for (const [key, validator] of Object.entries(VALIDATORS)) {
|
|
84
|
+
const value = patch[key]
|
|
85
|
+
if (value === undefined) continue
|
|
86
|
+
if (!validator(value)) continue
|
|
87
|
+
const next = key === 'dbhubPackage' ? String(value).trim() : value
|
|
88
|
+
if (next === options[key]) continue
|
|
89
|
+
options[key] = next
|
|
90
|
+
changed = true
|
|
91
|
+
}
|
|
92
|
+
if (changed) emit()
|
|
93
|
+
return changed
|
|
94
|
+
}
|