dsh-dbhub-live 3.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/lib/mcp.mjs CHANGED
@@ -9,13 +9,16 @@
9
9
  // disposers (the source of truth for the registered-tool count).
10
10
 
11
11
  import { statSync } from 'node:fs'
12
- import { join } from 'node:path'
12
+ import { join, basename } from 'node:path'
13
13
  import {
14
- DATA_DIR, dshHomeDir, STORE_PATH, TOML_PATH, IDLE_MS,
15
- store, listWorkspaces, resolveWorkspaceDsn, slugify, shortHash,
14
+ DATA_DIR, dshHomeDir, STORE_PATH, TOML_PATH,
15
+ store, listWorkspaces, listWorkspaceEnvironments, resolveWorkspaceEnvs,
16
+ slugify, envSlug, shortHash,
16
17
  fingerprintOf, generateToml, maskDsn, writeToml,
17
18
  } from './config.mjs'
18
19
  import { buildSpawnArgv, resolveDbhubExe } from './runtime.mjs'
20
+ import { currentT } from './i18n.mjs'
21
+ import * as options from './options.mjs'
19
22
  import * as state from './state.mjs'
20
23
 
21
24
  // ── MCP client (JSON-RPC 2.0 over stdio) ─────────────────────────────────
@@ -188,120 +191,48 @@ async function spawnServer(subprocess, signal) {
188
191
  return {}
189
192
  }
190
193
 
191
- // ── per-workspace tool sync ───────────────────────────────────────────────
192
-
193
- // Tools sync: register one ctx.tools def per dbhub tool, keyed by raw name.
194
- const toolDisposers = new Map() // modelName -> disposer (server-synced tools)
195
- const coreDisposers = new Map() // modelName -> disposer (plugin-owned tools, never synced away)
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
196
203
 
197
- /** Total registered dbhub tools (core + per-workspace). */
204
+ /** Total registered dbhub tools (constant set: configure/list/execute/search/query/query_objects). */
198
205
  export function activeToolCount() {
199
- return coreDisposers.size + toolDisposers.size
206
+ return coreDisposers.size
200
207
  }
201
208
 
202
209
  function refreshToolCount() {
203
210
  state.setToolCount(activeToolCount())
204
211
  }
205
212
 
206
- // Per-source tool naming + connection labeling.
207
- // Each generated source gets its own suffixed tool set (`dbhub_<base>_<slug>`)
208
- // even when dbhub reports single-source bare names, so a workspace's tools are
209
- // never ambiguous. Every tool's description and result carry the masked
210
- // connection target (host:port/database, password hidden), so a user can see
211
- // exactly which environment (test/dev/prod) it points at before querying.
212
- function syncToolsNow(ctx, subprocess, sources) {
213
- if (!server.client) return Promise.resolve()
214
- const sourceById = new Map()
215
- for (const s of sources) sourceById.set(s.id, s)
216
- return server.client.request('tools/list').then((res) => {
217
- const tools = Array.isArray(res && res.tools) ? res.tools : []
218
- // raw base name -> list of {slug, dbhubRawName, maskedDsn}
219
- const bases = new Map() // base -> Map<slug, {rawName, dsnMasked}>
220
- for (const t of tools) {
221
- const raw = t && t.name
222
- if (!raw || typeof raw !== 'string') continue
223
- // split off a per-source slug suffix (dbhub suffix = our source id)
224
- let base = raw
225
- let slug
226
- for (const id of sourceById.keys()) {
227
- if (raw === id) { slug = id } else if (raw.endsWith('_' + id)) {
228
- base = raw.slice(0, raw.length - id.length - 1)
229
- slug = id
230
- }
231
- }
232
- if (slug === undefined && sources.length === 1) {
233
- // single source: dbhub emits bare names -> attribute to the only source
234
- slug = sources[0].id
235
- }
236
- if (slug === undefined) continue
237
- const src = sourceById.get(slug)
238
- const dsnMasked = src ? maskDsn(src.dsn) : slug
239
- if (!bases.has(base)) bases.set(base, new Map())
240
- bases.get(base).set(slug, { rawName: raw, dsnMasked })
241
- }
242
- const seen = new Set()
243
- const jobs = []
244
- const inputByRaw = new Map(tools.map((t) => [t.name, t.inputSchema]))
245
- const descByRaw = new Map(tools.map((t) => [t.name, t.description]))
246
- for (const [base, bySlug] of bases) {
247
- for (const [slug, info] of bySlug) {
248
- const modelName = 'dbhub_' + base + '_' + slug
249
- seen.add(modelName)
250
- if (toolDisposers.has(modelName)) continue
251
- const rawName = info.rawName
252
- const dsnMasked = info.dsnMasked
253
- const inputSchema = inputByRaw.get(rawName)
254
- const desc = descByRaw.get(rawName)
255
- jobs.push(
256
- (async () => {
257
- try {
258
- const def = {
259
- name: modelName,
260
- description: (desc ? desc + ' ' : '') +
261
- '(连接源:' + slug + ' → ' + dsnMasked + ';注意:这是该工作区配置的数据库环境,请确认是你要查的那套)',
262
- parameters: (inputSchema && typeof inputSchema === 'object' && inputSchema.type === 'object')
263
- ? inputSchema
264
- : { type: 'object', properties: {}, required: [] },
265
- timeoutMs: 60000,
266
- output: {
267
- schema: {},
268
- render: (_args, value) => [
269
- {
270
- type: 'text',
271
- text: String(value && value.text !== undefined ? value.text : JSON.stringify(value)),
272
- },
273
- ],
274
- },
275
- async execute(args, exec) {
276
- return runOnServer(ctx, subprocess, rawName, args, exec, '{连接: ' + slug + ' → ' + dsnMasked + '}')
277
- },
278
- }
279
- const disposer = ctx.tools.register(def)
280
- toolDisposers.set(modelName, disposer)
281
- } catch (e) {
282
- console.error('[dsh-dbhub-live] register failed for ' + modelName + ': ' + String((e && e.message) || e))
283
- }
284
- })(),
285
- )
286
- }
287
- }
288
- // unregister tools whose source vanished
289
- for (const [modelName, disposer] of [...toolDisposers]) {
290
- if (!seen.has(modelName)) {
291
- try {
292
- disposer()
293
- } catch (e) {
294
- /* ignore */
295
- }
296
- toolDisposers.delete(modelName)
297
- }
298
- }
299
- return Promise.all(jobs).then(() => {
300
- refreshToolCount()
301
- })
302
- }).catch((e) => {
303
- console.error('[dsh-dbhub-live] tools/list failed: ' + String((e && e.message) || e))
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)]) }
304
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) + '}' : ''
305
236
  }
306
237
 
307
238
  // ── warm-call fast path & lazy ensure ─────────────────────────────────────
@@ -317,11 +248,12 @@ function resetIdle(ctx) {
317
248
  idleDispose = undefined
318
249
  }
319
250
  if (!server.handle) return
251
+ const idleMs = (options.get('idleMinutes') || 10) * 60 * 1000
320
252
  idleDispose = ctx.timeout(() => {
321
253
  idleDispose = undefined
322
254
  console.log('[dsh-dbhub-live] idle — recycling dbhub process')
323
255
  terminateServer()
324
- }, IDLE_MS)
256
+ }, idleMs)
325
257
  }
326
258
 
327
259
  // Re-resolve configs only when the durable inputs change (workspace registry
@@ -329,7 +261,33 @@ function resetIdle(ctx) {
329
261
  // Keeps repeated tool calls at ~0 overhead.
330
262
  let lastInputMtime = 0
331
263
 
332
- function configInputsMtime() {
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() {
333
291
  let max = 0
334
292
  for (const p of [join(dshHomeDir(), 'storages', 'workspace.json'), STORE_PATH]) {
335
293
  try {
@@ -342,6 +300,46 @@ function configInputsMtime() {
342
300
  return max
343
301
  }
344
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
+
345
343
  /**
346
344
  * Make sure the persistent server is up and synced, initializing lazily on
347
345
  * first use. Concurrent callers share one in-flight start. Regenerates the
@@ -357,18 +355,7 @@ export async function ensureRunning(ctx, subprocess) {
357
355
  resetIdle(ctx)
358
356
  return
359
357
  }
360
- let sources = []
361
- try {
362
- const workspaces = await listWorkspaces(ctx)
363
- for (const ws of workspaces) {
364
- const resolved = await resolveWorkspaceDsn(subprocess, ctx.get('fs'), ws.path, undefined)
365
- if (resolved && resolved.dsn) {
366
- sources.push({ id: slugify(ws.title) + '_' + shortHash(ws.path), dsn: resolved.dsn })
367
- }
368
- }
369
- } catch (e) {
370
- console.error('[dsh-dbhub-live] config generation failed: ' + String((e && e.message) || e))
371
- }
358
+ const { sources } = await collectSources(ctx, subprocess)
372
359
  lastInputMtime = inputMtime
373
360
  const fp = fingerprintOf(sources)
374
361
  // Never spawn with zero sources: dbhub treats an empty [[sources]] toml as
@@ -410,7 +397,6 @@ export async function ensureRunning(ctx, subprocess) {
410
397
  state.setPhase('initializing')
411
398
  const r = await spawnServer(subprocess, undefined)
412
399
  if (r.error) throw new Error(r.error)
413
- await syncToolsNow(ctx, subprocess, sources)
414
400
  state.clearError()
415
401
  state.setPhase('running')
416
402
  refreshToolCount()
@@ -438,9 +424,9 @@ export async function runOnServer(ctx, subprocess, rawName, args, exec, label) {
438
424
  try {
439
425
  await ensureRunning(ctx, subprocess)
440
426
  } catch (e) {
441
- return { ok: false, text: 'dbhub 服务启动失败: ' + String((e && e.message) || e) }
427
+ return { ok: false, text: currentT('result.serverStartFail', { msg: String((e && e.message) || e) }) }
442
428
  }
443
- if (!server.client) return { ok: false, text: 'dbhub 服务不可用' }
429
+ if (!server.client) return { ok: false, text: currentT('result.serverUnavailable') }
444
430
  try {
445
431
  const res = await server.client.request('tools/call', { name: rawName, arguments: args || {} })
446
432
  const textOf = (r) => {
@@ -453,7 +439,7 @@ export async function runOnServer(ctx, subprocess, rawName, args, exec, label) {
453
439
  }
454
440
  resetIdle(ctx)
455
441
  const prefix = label ? label + '\n' : ''
456
- if (res && res.isError) return { ok: false, text: 'dbhub 执行错误: ' + textOf(res) }
442
+ if (res && res.isError) return { ok: false, text: textOf(res) }
457
443
  if (res && res.structuredContent !== undefined) {
458
444
  return { ok: true, text: prefix + JSON.stringify(res.structuredContent, null, 2) }
459
445
  }
@@ -463,9 +449,9 @@ export async function runOnServer(ctx, subprocess, rawName, args, exec, label) {
463
449
  }
464
450
  }
465
451
 
466
- // Shared "plugin disabled" copy for every dbhub tool.
452
+ // Shared "plugin disabled" copy for every dbhub tool (locale-aware).
467
453
  export function disabledMessage() {
468
- return 'dsh-dbhub-live 已禁用:所有 dbhub 工具暂不可用。请在 设置 → 插件 → dsh-dbhub-live 中重新开启。'
454
+ return currentT('result.disabled')
469
455
  }
470
456
 
471
457
  /** Whether the persistent server currently answers calls. */
@@ -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
+ }
package/lib/runtime.mjs CHANGED
@@ -11,17 +11,12 @@
11
11
  import { existsSync, statSync, readdirSync, mkdirSync } from 'node:fs'
12
12
  import { join } from 'node:path'
13
13
  import { DATA_DIR, RUNTIME_DIR, runtime, saveRuntime, ensureStorageDir } from './config.mjs'
14
+ import * as options from './options.mjs'
14
15
 
15
- // How often the AUTO-INSTALLED copy is refreshed to the latest version.
16
- // Only our own managed install is refreshed — a dbhub the user installed
17
- // himself (PATH / mise) is never touched. Override via DSH_DBHUB_UPDATE_DAYS
18
- // (e.g. "0" disables auto-update; any positive number sets the day interval).
19
- const DBHUB_PACKAGE = process.env.DSH_DBHUB_PACKAGE || '@bytebase/dbhub'
20
- const DBHUB_UPDATE_MS = (() => {
21
- const raw = process.env.DSH_DBHUB_UPDATE_DAYS
22
- const n = raw !== undefined && raw !== '' && Number(raw) >= 0 ? Number(raw) : 7
23
- return n * 24 * 60 * 60 * 1000
24
- })()
16
+ // Auto-update interval and install package are deployment-tunable options
17
+ // (Settings -> Plugins -> dsh-dbhub-live); only our own managed install is
18
+ // ever refreshed — a dbhub the user installed himself (PATH / mise) is never
19
+ // touched.
25
20
 
26
21
  // ── executable lookup ─────────────────────────────────────────────────────
27
22
 
@@ -97,8 +92,8 @@ let installingDbhub // single in-flight install guard
97
92
 
98
93
  // Install the dbhub MCP server once, into a private prefix under the storage
99
94
  // dir. Uses `npm install --prefix <RUNTIME_DIR>` (deterministic, offline-safe
100
- // afterwards) rather than `npx <pkg>` per call. Package and version can be
101
- // overridden via DSH_DBHUB_PACKAGE; by default installs the latest.
95
+ // afterwards) rather than `npx <pkg>` per call. The package/version is a
96
+ // configurable option (default installs the latest).
102
97
  async function installDbhub(subprocess, signal) {
103
98
  if (installingDbhub) return installingDbhub
104
99
  if (!subprocess) {
@@ -115,9 +110,10 @@ async function installDbhub(subprocess, signal) {
115
110
  if (!ensureStorageDir()) {
116
111
  throw new Error('无法创建 dbhub 运行时目录 ' + DATA_DIR + '(写入被拦截)')
117
112
  }
113
+ const dbhubPackage = options.get('dbhubPackage') || '@bytebase/dbhub'
118
114
  const args = [
119
115
  'install',
120
- DBHUB_PACKAGE,
116
+ dbhubPackage,
121
117
  '--prefix', RUNTIME_DIR,
122
118
  '--no-save', '--no-fund', '--no-audit',
123
119
  '--loglevel=error',
@@ -159,7 +155,7 @@ async function installDbhub(subprocess, signal) {
159
155
  throw new Error('npm 安装完成,但未在 ' + RUNTIME_DIR + ' 下找到 dbhub 可执行文件')
160
156
  }
161
157
  runtime.dbhubInstallAt = Date.now()
162
- console.log('[dsh-dbhub-live] 已自动安装 dbhub (' + DBHUB_PACKAGE + ') → ' + exe)
158
+ console.log('[dsh-dbhub-live] 已自动安装 dbhub (' + dbhubPackage + ') → ' + exe)
163
159
  return exe
164
160
  })()
165
161
  try {
@@ -209,8 +205,10 @@ export function isAutoManagedExe() {
209
205
  export async function maybeRefreshDbhub(subprocess) {
210
206
  if (!isAutoManagedExe()) return
211
207
  if (!(runtime.dbhubInstallAt > 0)) return
212
- if (DBHUB_UPDATE_MS <= 0) return // auto-update disabled
213
- if (Date.now() - runtime.dbhubInstallAt < DBHUB_UPDATE_MS) return
208
+ const intervalDays = options.get('updateIntervalDays')
209
+ if (!(intervalDays > 0)) return // auto-update disabled
210
+ const intervalMs = intervalDays * 24 * 60 * 60 * 1000
211
+ if (Date.now() - runtime.dbhubInstallAt < intervalMs) return
214
212
  try {
215
213
  await installDbhub(subprocess, undefined)
216
214
  console.log('[dsh-dbhub-live] dbhub 已自动更新到最新版')