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.
@@ -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
+ }