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/runtime.mjs
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// dsh-dbhub-live: managed dbhub executable resolution.
|
|
2
|
+
//
|
|
3
|
+
// When no `dbhub` executable is present (PATH / mise / previous install), the
|
|
4
|
+
// plugin installs the MCP server once, on demand, into a private prefix under
|
|
5
|
+
// the storage dir, then reuses that binary on every later boot. This is
|
|
6
|
+
// deliberately an install-ONCE-at-init design (NOT `npx` on every spawn):
|
|
7
|
+
// the ad-hoc tools spawn a fresh dbhub process on EVERY call, so an npx-per-
|
|
8
|
+
// call approach would add several hundred ms to every query and break offline.
|
|
9
|
+
// The resolved executable path is persisted so later boots skip re-discovery.
|
|
10
|
+
|
|
11
|
+
import { existsSync, statSync, readdirSync, mkdirSync } from 'node:fs'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { DATA_DIR, RUNTIME_DIR, runtime, saveRuntime, ensureStorageDir } from './config.mjs'
|
|
14
|
+
import * as options from './options.mjs'
|
|
15
|
+
|
|
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.
|
|
20
|
+
|
|
21
|
+
// ── executable lookup ─────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
function findDbhubExe() {
|
|
24
|
+
// 1) a previously managed / mise-installed binary is the fastest path
|
|
25
|
+
if (runtime.dbhubExe) {
|
|
26
|
+
try {
|
|
27
|
+
if (existsSync(runtime.dbhubExe) && statSync(runtime.dbhubExe).isFile()) return runtime.dbhubExe
|
|
28
|
+
} catch (e) {
|
|
29
|
+
runtime.dbhubExe = undefined
|
|
30
|
+
saveRuntime(runtime)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// 2) mise-managed install (`mise x npm:@bytebase/dbhub` or a manual install)
|
|
34
|
+
const dataDir = process.env.MISE_DATA_DIR
|
|
35
|
+
if (dataDir) {
|
|
36
|
+
try {
|
|
37
|
+
const base = join(dataDir, 'npm-bytebase-dbhub')
|
|
38
|
+
const versions = readdirSync(base).filter((v) => /^\d+\.\d+\.\d+$/.test(v))
|
|
39
|
+
const cmp = (a, b) => {
|
|
40
|
+
const pa = a.split('.').map(Number)
|
|
41
|
+
const pb = b.split('.').map(Number)
|
|
42
|
+
for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] - pb[i]
|
|
43
|
+
return 0
|
|
44
|
+
}
|
|
45
|
+
versions.sort(cmp)
|
|
46
|
+
for (let i = versions.length - 1; i >= 0; i--) {
|
|
47
|
+
for (const bin of ['dbhub.cmd', 'dbhub']) {
|
|
48
|
+
const p = join(base, versions[i], 'node_modules', '.bin', bin)
|
|
49
|
+
try {
|
|
50
|
+
if (existsSync(p) && statSync(p).isFile()) {
|
|
51
|
+
runtime.dbhubExe = p
|
|
52
|
+
saveRuntime(runtime)
|
|
53
|
+
return p
|
|
54
|
+
}
|
|
55
|
+
} catch (e) {
|
|
56
|
+
/* keep looking */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch (e) {
|
|
61
|
+
/* fall through */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function dbhubExeInDir(dir) {
|
|
68
|
+
if (!dir) return undefined
|
|
69
|
+
for (const bin of ['dbhub.cmd', 'dbhub']) {
|
|
70
|
+
const p = join(dir, 'node_modules', '.bin', bin)
|
|
71
|
+
try {
|
|
72
|
+
if (existsSync(p) && statSync(p).isFile()) return p
|
|
73
|
+
} catch (e) {
|
|
74
|
+
/* keep looking */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Windows `cmd.exe /c` wrapping for .cmd/.bat shims: the subprocess service
|
|
81
|
+
// spawns argv directly, so a shim needs a cmd shell to run inside.
|
|
82
|
+
export function buildSpawnArgv(subprocess, exe, args) {
|
|
83
|
+
if (process.platform !== 'win32') return [exe, ...args]
|
|
84
|
+
if (!/\.(cmd|bat)$/i.test(exe)) return [exe, ...args]
|
|
85
|
+
const quote = (s) => (/[\s&|<>^"]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s)
|
|
86
|
+
return ['cmd.exe', '/c', [quote(exe), ...args.map(quote)].join(' ')]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── auto-install ──────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
let installingDbhub // single in-flight install guard
|
|
92
|
+
|
|
93
|
+
// Install the dbhub MCP server once, into a private prefix under the storage
|
|
94
|
+
// dir. Uses `npm install --prefix <RUNTIME_DIR>` (deterministic, offline-safe
|
|
95
|
+
// afterwards) rather than `npx <pkg>` per call. The package/version is a
|
|
96
|
+
// configurable option (default installs the latest).
|
|
97
|
+
async function installDbhub(subprocess, signal) {
|
|
98
|
+
if (installingDbhub) return installingDbhub
|
|
99
|
+
if (!subprocess) {
|
|
100
|
+
throw new Error('subprocess 服务不可用,无法自动安装 dbhub')
|
|
101
|
+
}
|
|
102
|
+
installingDbhub = (async () => {
|
|
103
|
+
let npmExe
|
|
104
|
+
try {
|
|
105
|
+
npmExe = await subprocess.resolveExecutable('npm', undefined, signal)
|
|
106
|
+
} catch (e) {
|
|
107
|
+
throw new Error('未找到 npm,无法自动安装 dbhub(请先安装 Node.js/npm,或手动把 dbhub 加入 PATH 后重试)')
|
|
108
|
+
}
|
|
109
|
+
// The storage dir may have been cleaned while running; npm needs it as cwd.
|
|
110
|
+
if (!ensureStorageDir()) {
|
|
111
|
+
throw new Error('无法创建 dbhub 运行时目录 ' + DATA_DIR + '(写入被拦截)')
|
|
112
|
+
}
|
|
113
|
+
const dbhubPackage = options.get('dbhubPackage') || '@bytebase/dbhub'
|
|
114
|
+
const args = [
|
|
115
|
+
'install',
|
|
116
|
+
dbhubPackage,
|
|
117
|
+
'--prefix', RUNTIME_DIR,
|
|
118
|
+
'--no-save', '--no-fund', '--no-audit',
|
|
119
|
+
'--loglevel=error',
|
|
120
|
+
]
|
|
121
|
+
let handle
|
|
122
|
+
try {
|
|
123
|
+
handle = subprocess.spawn({
|
|
124
|
+
argv: buildSpawnArgv(subprocess, npmExe, args),
|
|
125
|
+
cwd: DATA_DIR,
|
|
126
|
+
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
|
|
127
|
+
graceMs: 180000,
|
|
128
|
+
signal,
|
|
129
|
+
})
|
|
130
|
+
} catch (e) {
|
|
131
|
+
throw new Error('启动 npm 安装失败: ' + String((e && e.message) || e))
|
|
132
|
+
}
|
|
133
|
+
let errTail = ''
|
|
134
|
+
if (handle.stderr) {
|
|
135
|
+
handle.stderr.on('data', (c) => {
|
|
136
|
+
errTail = (errTail + String(c)).slice(-2000)
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
const outcome = await handle.done
|
|
141
|
+
if (outcome.exitCode !== 0 || outcome.signal) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
'自动安装 dbhub 失败 (npm exit ' + outcome.exitCode + ')' + (errTail ? ': ' + errTail : ''),
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
} finally {
|
|
147
|
+
try {
|
|
148
|
+
handle.terminate()
|
|
149
|
+
} catch (e) {
|
|
150
|
+
/* ignore */
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const exe = dbhubExeInDir(RUNTIME_DIR)
|
|
154
|
+
if (!exe) {
|
|
155
|
+
throw new Error('npm 安装完成,但未在 ' + RUNTIME_DIR + ' 下找到 dbhub 可执行文件')
|
|
156
|
+
}
|
|
157
|
+
runtime.dbhubInstallAt = Date.now()
|
|
158
|
+
console.log('[dsh-dbhub-live] 已自动安装 dbhub (' + dbhubPackage + ') → ' + exe)
|
|
159
|
+
return exe
|
|
160
|
+
})()
|
|
161
|
+
try {
|
|
162
|
+
const exe = await installingDbhub
|
|
163
|
+
return exe
|
|
164
|
+
} finally {
|
|
165
|
+
installingDbhub = undefined
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Resolve the dbhub executable: persisted → mise → PATH → install-on-demand.
|
|
170
|
+
export async function resolveDbhubExe(subprocess, signal) {
|
|
171
|
+
const cached = findDbhubExe()
|
|
172
|
+
if (cached) return cached
|
|
173
|
+
if (subprocess) {
|
|
174
|
+
try {
|
|
175
|
+
const exe = await subprocess.resolveExecutable('dbhub', undefined, signal)
|
|
176
|
+
runtime.dbhubExe = exe
|
|
177
|
+
saveRuntime(runtime)
|
|
178
|
+
return exe
|
|
179
|
+
} catch (e) {
|
|
180
|
+
/* not on PATH — fall through to install */
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const installed = await installDbhub(subprocess, signal)
|
|
184
|
+
runtime.dbhubExe = installed
|
|
185
|
+
saveRuntime(runtime)
|
|
186
|
+
return installed
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Is the current executable one WE auto-installed (as opposed to a dbhub the
|
|
190
|
+
// user installed himself via PATH / mise)? Only ours is eligible for auto-
|
|
191
|
+
// update — we must never silently upgrade a user-managed binary.
|
|
192
|
+
export function isAutoManagedExe() {
|
|
193
|
+
const exe = runtime.dbhubExe
|
|
194
|
+
if (!exe || typeof exe !== 'string') return false
|
|
195
|
+
const dir = RUNTIME_DIR.replace(/[\\/]+$/, '')
|
|
196
|
+
const e = exe.replace(/[\\/]+$/, '')
|
|
197
|
+
return e.toLowerCase().startsWith(dir.toLowerCase())
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Refresh the auto-installed dbhub to the latest version, but only when a
|
|
201
|
+
// configured interval has elapsed. Runs `npm install` into the SAME prefix,
|
|
202
|
+
// which upgrades the binary in place — the resolved path stays valid, so
|
|
203
|
+
// concurrently-running queries keep using a working dbhub throughout.
|
|
204
|
+
// Never blocks boot: callers should NOT await it.
|
|
205
|
+
export async function maybeRefreshDbhub(subprocess) {
|
|
206
|
+
if (!isAutoManagedExe()) return
|
|
207
|
+
if (!(runtime.dbhubInstallAt > 0)) 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
|
|
212
|
+
try {
|
|
213
|
+
await installDbhub(subprocess, undefined)
|
|
214
|
+
console.log('[dsh-dbhub-live] dbhub 已自动更新到最新版')
|
|
215
|
+
} catch (e) {
|
|
216
|
+
// Non-fatal: keep using the existing (older) binary.
|
|
217
|
+
console.error('[dsh-dbhub-live] dbhub 自动更新失败(沿用现有版本): ' + String((e && e.message) || e))
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Best-effort: on startup, kill any dbhub process still pointing at OUR
|
|
222
|
+
// dbhub.toml but not owned by this running instance (left over from a
|
|
223
|
+
// hard-killed previous dsh where the subprocess service could not dispose).
|
|
224
|
+
// Matches only the unique config path, so unrelated dbhub processes are safe.
|
|
225
|
+
// Windows-only (dbhub runs as node.exe); non-fatal on failure.
|
|
226
|
+
export async function cleanupOrphans(subprocess) {
|
|
227
|
+
if (process.platform !== 'win32') return
|
|
228
|
+
let ps
|
|
229
|
+
try {
|
|
230
|
+
ps = await subprocess.resolveExecutable('powershell.exe', undefined, undefined)
|
|
231
|
+
} catch (e) {
|
|
232
|
+
try {
|
|
233
|
+
ps = await subprocess.resolveExecutable('pwsh.exe', undefined, undefined)
|
|
234
|
+
} catch (e2) {
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const script =
|
|
239
|
+
'Get-CimInstance Win32_Process | Where-Object { $_.Name -eq "node.exe" -and ' +
|
|
240
|
+
'$_.CommandLine -like "*dsh-dbhub-live\\dbhub.toml*" } | ' +
|
|
241
|
+
'ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }'
|
|
242
|
+
let handle
|
|
243
|
+
try {
|
|
244
|
+
handle = subprocess.spawn({
|
|
245
|
+
argv: [ps, '-NoProfile', '-NonInteractive', '-Command', script],
|
|
246
|
+
cwd: DATA_DIR,
|
|
247
|
+
stdio: { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' },
|
|
248
|
+
graceMs: 5000,
|
|
249
|
+
})
|
|
250
|
+
await handle.done
|
|
251
|
+
} catch (e) {
|
|
252
|
+
console.error('[dsh-dbhub-live] orphan cleanup failed: ' + String((e && e.message) || e))
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
package/lib/state.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// dsh-dbhub-live: plugin runtime state machine.
|
|
2
|
+
//
|
|
3
|
+
// The single source of truth for the status surface:
|
|
4
|
+
// enabled - the enable/disable toggle, persisted in the credentials store
|
|
5
|
+
// so the choice survives restarts (the settings-document mirror
|
|
6
|
+
// converges to this value at boot);
|
|
7
|
+
// phase - 'initializing' | 'running' | 'error' | 'disabled'
|
|
8
|
+
// toolCount - number of currently registered dbhub tools
|
|
9
|
+
// lastError - most recent init/run error text (shown while phase === 'error')
|
|
10
|
+
// mode - deployment mode label ('lazy': init deferred to first use)
|
|
11
|
+
//
|
|
12
|
+
// The module owns only the state; orchestration (starting/stopping the dbhub
|
|
13
|
+
// process, publishing to the settings namespace) subscribes to changes.
|
|
14
|
+
|
|
15
|
+
import { store, saveStore } from './config.mjs'
|
|
16
|
+
|
|
17
|
+
const state = {
|
|
18
|
+
enabled: store.enabled !== false,
|
|
19
|
+
phase: store.enabled !== false ? 'initializing' : 'disabled',
|
|
20
|
+
toolCount: 0,
|
|
21
|
+
lastError: '',
|
|
22
|
+
mode: 'lazy',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const listeners = new Set()
|
|
26
|
+
|
|
27
|
+
function emit() {
|
|
28
|
+
for (const fn of [...listeners]) {
|
|
29
|
+
try {
|
|
30
|
+
fn()
|
|
31
|
+
} catch (e) {
|
|
32
|
+
/* contain listener failures */
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Subscribe to every state change; returns the disposer. */
|
|
38
|
+
export function subscribe(fn) {
|
|
39
|
+
listeners.add(fn)
|
|
40
|
+
return () => listeners.delete(fn)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Detached JSON snapshot of the current state (safe to persist/wire). */
|
|
44
|
+
export function snapshot() {
|
|
45
|
+
return {
|
|
46
|
+
enabled: state.enabled,
|
|
47
|
+
phase: state.phase,
|
|
48
|
+
toolCount: state.toolCount,
|
|
49
|
+
lastError: state.lastError,
|
|
50
|
+
mode: state.mode,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Flip the enable/disable toggle. Persists to the store and moves the phase
|
|
56
|
+
* to 'disabled' when turning off; turning on leaves the phase for the caller's
|
|
57
|
+
* lazy-init kick (orchestration subscribes and starts it).
|
|
58
|
+
* @param value - the next enabled state.
|
|
59
|
+
* @returns whether the state actually changed.
|
|
60
|
+
*/
|
|
61
|
+
export function setEnabled(value) {
|
|
62
|
+
const next = Boolean(value)
|
|
63
|
+
if (next === state.enabled) return false
|
|
64
|
+
state.enabled = next
|
|
65
|
+
store.enabled = next
|
|
66
|
+
saveStore(store)
|
|
67
|
+
// Turning off parks the plugin; turning on moves to 'initializing' — the
|
|
68
|
+
// orchestration subscription sees the change and starts the lazy init.
|
|
69
|
+
state.phase = next ? 'initializing' : 'disabled'
|
|
70
|
+
emit()
|
|
71
|
+
return true
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isEnabled() {
|
|
75
|
+
return state.enabled
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function setPhase(phase) {
|
|
79
|
+
if (state.phase === phase) return
|
|
80
|
+
state.phase = phase
|
|
81
|
+
emit()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Record an init/run failure: phase -> 'error' and remember the message. */
|
|
85
|
+
export function recordError(message) {
|
|
86
|
+
state.lastError = String(message || '')
|
|
87
|
+
state.phase = 'error'
|
|
88
|
+
emit()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Clear the remembered error after a successful init; caller sets the phase. */
|
|
92
|
+
export function clearError() {
|
|
93
|
+
if (!state.lastError) return
|
|
94
|
+
state.lastError = ''
|
|
95
|
+
emit()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function setToolCount(count) {
|
|
99
|
+
const next = Math.max(0, Number(count) || 0)
|
|
100
|
+
if (state.toolCount === next) return
|
|
101
|
+
state.toolCount = next
|
|
102
|
+
emit()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Raw state access for orchestration (read-only usage). */
|
|
106
|
+
export function getState() {
|
|
107
|
+
return state
|
|
108
|
+
}
|