dsh-dbhub-live 5.0.1 → 5.2.0-dev.3
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 +95 -209
- package/README.en.md +251 -169
- package/README.md +251 -169
- package/doc/ARCHITECTURE.md +108 -0
- package/doc/CLIENT.md +124 -0
- package/doc/DESENSITIZE.md +56 -0
- package/doc/DEVELOPMENT.md +139 -0
- package/doc/IMPACT-MATRIX.md +36 -0
- package/doc/RELEASE.md +93 -0
- package/doc/REQUIREMENTS.md +177 -105
- package/doc/images/chat-query.png +0 -0
- package/doc/images/row-editor.png +0 -0
- package/doc/images/settings-page.png +0 -0
- package/lib/adhoc.mjs +412 -34
- package/lib/capability.mjs +157 -0
- package/lib/client.js +1243 -80
- package/lib/config.mjs +370 -21
- package/lib/i18n.mjs +119 -4
- package/lib/index.mjs +401 -43
- package/lib/mcp.mjs +496 -391
- package/lib/meta.mjs +267 -0
- package/lib/options.mjs +25 -5
- package/lib/policy.mjs +92 -0
- package/lib/toml.mjs +188 -0
- package/lib/tools.mjs +1093 -827
- package/lib/update.mjs +244 -0
- package/package.json +34 -10
- package/scripts/desensitize-check.mjs +10 -3
- package/scripts/desensitize.config.mjs +255 -237
- package/scripts/docs-check.mjs +214 -0
- package/scripts/release-preflight.mjs +180 -0
- package/scripts/render-check.mjs +1366 -843
- package/test/adhoc-toml.test.mjs +365 -0
- package/test/bridge.test.mjs +227 -185
- package/test/capability.test.mjs +231 -0
- package/test/client-format.test.mjs +515 -352
- package/test/configure-options.test.mjs +244 -0
- package/test/configure-ssh.test.mjs +232 -0
- package/test/copy-from.test.mjs +155 -0
- package/test/env-options.test.mjs +456 -0
- package/test/meta.test.mjs +234 -0
- package/test/options.test.mjs +160 -123
- package/test/policy.test.mjs +132 -0
- package/test/row-cache.test.mjs +237 -138
- package/test/ssh-probe.test.mjs +834 -0
- package/test/test-op-fastpath.test.mjs +208 -0
- package/test/toml.test.mjs +352 -0
- package/test/update.test.mjs +270 -0
- package/test/util.test.mjs +17 -0
- package/test/zero-knowledge.test.mjs +120 -78
package/lib/index.mjs
CHANGED
|
@@ -39,14 +39,26 @@ import {
|
|
|
39
39
|
import {
|
|
40
40
|
activeToolCount, refreshToolCount,
|
|
41
41
|
collectSources, latestSummaries, hasRowCache, createRowMirror,
|
|
42
|
+
cachedRows, resolveTestDsn,
|
|
42
43
|
} from './mcp.mjs'
|
|
43
44
|
import { registerCoreTools } from './tools.mjs'
|
|
44
|
-
import {
|
|
45
|
+
import { registerDbhubPolicy } from './policy.mjs'
|
|
46
|
+
import { probeConnection, probeSshTunnel, hostTimeoutFor, PROBE_TIMEOUT_SSH_MS, defaultKeyNoteOf } from './adhoc.mjs'
|
|
47
|
+
import { sweepTempToml } from './toml.mjs'
|
|
48
|
+
import { detectDbhubCapabilities } from './capability.mjs'
|
|
49
|
+
import { resolveDbhubExe } from './runtime.mjs'
|
|
45
50
|
import { createBridgeRoutes, BRIDGE_BASE } from './bridge.mjs'
|
|
46
51
|
import {
|
|
47
|
-
listWorkspaces, setWorkspaceEnv,
|
|
52
|
+
store, listWorkspaces, setWorkspaceEnv, setWorkspaceEnvOptions, removeWorkspaceEnv,
|
|
53
|
+
renameWorkspaceEnv, normalizeEnvName, listWorkspaceEnvironments, resolveWorkspaceEnvs,
|
|
54
|
+
normalizeSshOptions, mergeSshOptions,
|
|
48
55
|
} from './config.mjs'
|
|
49
56
|
import { setLocaleProvider, currentT } from './i18n.mjs'
|
|
57
|
+
// Version metadata (plugin/dsh/runtime) and the plugin self-update check are
|
|
58
|
+
// leaf modules (node builtins only), so importing them here cannot create a
|
|
59
|
+
// dependency cycle.
|
|
60
|
+
import { versionMeta } from './meta.mjs'
|
|
61
|
+
import { createUpdateChecker } from './update.mjs'
|
|
50
62
|
import * as state from './state.mjs'
|
|
51
63
|
import * as options from './options.mjs'
|
|
52
64
|
|
|
@@ -71,11 +83,19 @@ const STATUS_DEFAULTS = {
|
|
|
71
83
|
lastError: '',
|
|
72
84
|
mode: 'oneshot',
|
|
73
85
|
updateIntervalDays: 7,
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
86
|
+
// The user's own choice to hide the sidebar shortcut while the plugin keeps
|
|
87
|
+
// working. The browser ANDs it with `enabled`: disabling the plugin hides the
|
|
88
|
+
// entry no matter what this says, and hiding the entry never disables anything
|
|
89
|
+
// (the settings page and the Plugins row stay reachable either way).
|
|
78
90
|
showSidebarEntry: true,
|
|
91
|
+
// The user's own choice to let the plugin contribute its database-access
|
|
92
|
+
// policy to the system prompt (default ON). It removes that section only:
|
|
93
|
+
// every tool keeps working, and a disabled plugin contributes nothing anyway.
|
|
94
|
+
announceAccessPolicy: true,
|
|
95
|
+
// Upstream dbhub capability probe (JSON string: {version, readonlyTools, ssh,
|
|
96
|
+
// warning}). The UI uses it to disable the read-only / SSH controls with a
|
|
97
|
+
// reason instead of letting the user configure something that cannot work.
|
|
98
|
+
capabilities: '',
|
|
79
99
|
// Workspace-connection summaries ride as a JSON string so the schema never
|
|
80
100
|
// has to validate their arbitrary shape; `configOp` is the one-way command
|
|
81
101
|
// channel from the card (host consumes it and republishes without it).
|
|
@@ -86,6 +106,14 @@ const STATUS_DEFAULTS = {
|
|
|
86
106
|
// persisted anywhere — the card only shows it while its own pending nonce
|
|
87
107
|
// matches (a reload drops the pending map, so the result cannot stick).
|
|
88
108
|
testResult: '',
|
|
109
|
+
// Version / update metadata (lib/meta.mjs, lib/update.mjs). `update` rides as
|
|
110
|
+
// a JSON string like `workspaces` — it is '' until the card asks for its first
|
|
111
|
+
// check, so boot never waits on the network. Nothing here is secret.
|
|
112
|
+
pluginVersion: '',
|
|
113
|
+
dshVersion: '',
|
|
114
|
+
dshModern: false,
|
|
115
|
+
runtime: 'web',
|
|
116
|
+
update: '',
|
|
89
117
|
}
|
|
90
118
|
|
|
91
119
|
// Prefer the real schemastery schema (standard path, present in every dsh
|
|
@@ -110,9 +138,16 @@ try {
|
|
|
110
138
|
mode: z.string().default(STATUS_DEFAULTS.mode),
|
|
111
139
|
updateIntervalDays: z.number().min(0).default(STATUS_DEFAULTS.updateIntervalDays),
|
|
112
140
|
showSidebarEntry: z.boolean().default(STATUS_DEFAULTS.showSidebarEntry),
|
|
141
|
+
announceAccessPolicy: z.boolean().default(STATUS_DEFAULTS.announceAccessPolicy),
|
|
142
|
+
capabilities: z.string().default(STATUS_DEFAULTS.capabilities),
|
|
113
143
|
workspaces: z.string().default(STATUS_DEFAULTS.workspaces),
|
|
114
144
|
configOp: z.string().default(STATUS_DEFAULTS.configOp),
|
|
115
145
|
testResult: z.string().default(STATUS_DEFAULTS.testResult),
|
|
146
|
+
pluginVersion: z.string().default(STATUS_DEFAULTS.pluginVersion),
|
|
147
|
+
dshVersion: z.string().default(STATUS_DEFAULTS.dshVersion),
|
|
148
|
+
dshModern: z.boolean().default(STATUS_DEFAULTS.dshModern),
|
|
149
|
+
runtime: z.string().default(STATUS_DEFAULTS.runtime),
|
|
150
|
+
update: z.string().default(STATUS_DEFAULTS.update),
|
|
116
151
|
})
|
|
117
152
|
// schemastery 3.18.2 (shipped with dsh 0.1.6) has no `.volatile()`; the guard
|
|
118
153
|
// keeps the declaration valid there, where the fields arrive as plain values.
|
|
@@ -123,6 +158,7 @@ try {
|
|
|
123
158
|
CONFIG_SCHEMA = z.object({
|
|
124
159
|
updateIntervalDays: volatileField(z.number().min(0)),
|
|
125
160
|
showSidebarEntry: volatileField(z.boolean()),
|
|
161
|
+
announceAccessPolicy: volatileField(z.boolean()),
|
|
126
162
|
})
|
|
127
163
|
} catch (e) {
|
|
128
164
|
const fallbackSchema = (value) => {
|
|
@@ -177,11 +213,11 @@ async function resolveWorkspaceRef(ref, ctx) {
|
|
|
177
213
|
return workspaces[0] || undefined
|
|
178
214
|
}
|
|
179
215
|
|
|
180
|
-
// Hard cap for one card connection test
|
|
181
|
-
// SELECT 1
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
216
|
+
// Hard cap for one card connection test: the probe is a throwaway dbhub +
|
|
217
|
+
// SELECT 1, and the tiered budget comes from adhoc.mjs (`hostTimeoutFor`) — a
|
|
218
|
+
// tunnelled probe gets more room because SSH setup is slower. It must stay
|
|
219
|
+
// comfortably BELOW the client-side watchdog (which is tiered to match) so the
|
|
220
|
+
// {nonce, ok, message} report always wins the race against "测试超时未返回".
|
|
185
221
|
|
|
186
222
|
// Backstop: even if the underlying dbhub spawn never settles (subprocess-
|
|
187
223
|
// service hiccup), the card ALWAYS receives a report inside the probe budget
|
|
@@ -201,13 +237,20 @@ function withProbeTimeout(promise, ms) {
|
|
|
201
237
|
})
|
|
202
238
|
}
|
|
203
239
|
|
|
204
|
-
// One card connection test: { op: 'test', workspace, env, nonce }
|
|
205
|
-
//
|
|
206
|
-
//
|
|
240
|
+
// One card connection test: { op: 'test', workspace, env, nonce } (+ optional
|
|
241
|
+
// `dsn` / `readOnly` / `ssh` for a PRE-SAVE validation of unsaved options).
|
|
242
|
+
// Resolves the REAL DSN host-side (the card only ever holds the masked summary),
|
|
243
|
+
// then probes it through an isolated ad-hoc connection — the persistent server is
|
|
207
244
|
// never restarted or otherwise touched, and a failure only ever means a red
|
|
208
245
|
// line on the card. The compact {nonce, ok, message} report goes back through
|
|
209
246
|
// `reportTest` (the transient namespace `testResult` field); nothing is
|
|
210
247
|
// persisted.
|
|
248
|
+
//
|
|
249
|
+
// F4: resolving WHICH dsn to probe must not re-walk every workspace. A persisted
|
|
250
|
+
// row is read straight from the store, a row the card already displays comes
|
|
251
|
+
// from the in-memory mirror, and only a genuinely never-walked environment
|
|
252
|
+
// triggers the discovery walk. Two milestone logs carry the two segments so the
|
|
253
|
+
// split is observable on a real machine.
|
|
211
254
|
async function handleTestOp(op, ctx, subprocess, ws, reportTest) {
|
|
212
255
|
const nonce = typeof op.nonce === 'string' ? op.nonce : ''
|
|
213
256
|
const report = (ok, message) => {
|
|
@@ -218,6 +261,22 @@ async function handleTestOp(op, ctx, subprocess, ws, reportTest) {
|
|
|
218
261
|
/* the callback is ours; never let reporting break the op chain */
|
|
219
262
|
}
|
|
220
263
|
}
|
|
264
|
+
// Unsaved options the card wants validated together with the connection (the
|
|
265
|
+
// SSH panel and the read-only switch both use this).
|
|
266
|
+
const opHasOptions = op.readOnly !== undefined || (op.ssh && typeof op.ssh === 'object')
|
|
267
|
+
const opOptions = {
|
|
268
|
+
ro: op.readOnly === true,
|
|
269
|
+
ssh: op.ssh && typeof op.ssh === 'object' ? op.ssh : null,
|
|
270
|
+
}
|
|
271
|
+
const probeOnce = async (conn, envName, wsTitle, tag) => {
|
|
272
|
+
const timeoutMs = hostTimeoutFor(conn)
|
|
273
|
+
const started = Date.now()
|
|
274
|
+
const res = await withProbeTimeout(probeConnection(subprocess, conn, timeoutMs), timeoutMs)
|
|
275
|
+
console.log('[dsh-dbhub-live] ' + currentT('log.testProbeMs', { ms: String(Date.now() - started) }))
|
|
276
|
+
report(res.ok, res.message)
|
|
277
|
+
if (!res.ok) console.error('[dsh-dbhub-live] ' + currentT('log.testDone', { title: wsTitle, env: envName, result: 'FAIL' + tag + ' — ' + res.message }))
|
|
278
|
+
else console.log('[dsh-dbhub-live] ' + currentT('log.testDone', { title: wsTitle, env: envName, result: 'OK' + tag + ' — ' + res.message }))
|
|
279
|
+
}
|
|
221
280
|
try {
|
|
222
281
|
// Pre-save validation path: {op:'test', dsn, nonce, workspace, env} — the
|
|
223
282
|
// card gates the "add/save" button on a live probe, so an unpersisted DSN
|
|
@@ -226,49 +285,204 @@ async function handleTestOp(op, ctx, subprocess, ws, reportTest) {
|
|
|
226
285
|
// user typed (the masked-card flow still works: `dsn` is left blank and
|
|
227
286
|
// we look up the row by workspace + env like before).
|
|
228
287
|
const inlineDsn = typeof op.dsn === 'string' && op.dsn.trim() ? op.dsn.trim() : ''
|
|
229
|
-
if (
|
|
230
|
-
|
|
231
|
-
|
|
288
|
+
if (!ws) {
|
|
289
|
+
report(false, currentT('log.opNoWs', { ws: String(op.workspace || '') }))
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
const envName = normalizeEnvName(op.env)
|
|
293
|
+
// ── SSH-layer-only test ({op:'test', kind:'ssh'}) ────────────────────────
|
|
294
|
+
// Answers "is it the tunnel or the database?": reachability of the bastion,
|
|
295
|
+
// the local key file, and whether dbhub can bring the tunnel up at all. The
|
|
296
|
+
// ssh block comes from the form (unsaved) or, for a saved row, from the
|
|
297
|
+
// store — resolved WITHOUT a discovery walk.
|
|
298
|
+
if (op.kind === 'ssh') {
|
|
299
|
+
const fromCard = op.ssh && typeof op.ssh === 'object' ? op.ssh : null
|
|
300
|
+
let ssh = fromCard
|
|
301
|
+
{
|
|
302
|
+
// Same "blank secret = keep the stored one" rule as the options write:
|
|
303
|
+
// testing a saved tunnel from the row editor must not fail just because
|
|
304
|
+
// the form (correctly) refuses to echo the password back.
|
|
305
|
+
const saved = listWorkspaceEnvironments(store).find((r) => r.wsPath === ws.path && r.env === envName)
|
|
306
|
+
ssh = mergeSshOptions(ssh || (saved ? saved.ssh : null), saved ? saved.ssh : null)
|
|
307
|
+
}
|
|
308
|
+
console.log('[dsh-dbhub-live] ' + currentT('log.testStart', { title: ws.title, env: envName }) + ' (SSH)')
|
|
309
|
+
if (!ssh) {
|
|
310
|
+
report(false, currentT('result.sshTestNoConfig'))
|
|
232
311
|
return
|
|
233
312
|
}
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
313
|
+
const started = Date.now()
|
|
314
|
+
const res = await withProbeTimeout(probeSshTunnel(subprocess, ssh, PROBE_TIMEOUT_SSH_MS), PROBE_TIMEOUT_SSH_MS)
|
|
315
|
+
console.log('[dsh-dbhub-live] ' + currentT('log.testProbeMs', { ms: String(Date.now() - started) }))
|
|
316
|
+
// The merge above may have filled the conventional identity path, which the
|
|
317
|
+
// probe can no longer detect on its own — so the note is computed against
|
|
318
|
+
// what the CARD sent (only when it sent a tunnel block at all).
|
|
319
|
+
const note = fromCard ? defaultKeyNoteOf(fromCard, ssh) : ''
|
|
320
|
+
report(res.ok, res.message + note)
|
|
321
|
+
const line = currentT('log.testDone', { title: ws.title, env: envName, result: (res.ok ? 'OK' : 'FAIL') + '(SSH) — ' + res.message + note })
|
|
322
|
+
if (res.ok) console.log('[dsh-dbhub-live] ' + line)
|
|
323
|
+
else console.error('[dsh-dbhub-live] ' + line)
|
|
240
324
|
return
|
|
241
325
|
}
|
|
242
|
-
if (
|
|
243
|
-
|
|
326
|
+
if (inlineDsn) {
|
|
327
|
+
console.log('[dsh-dbhub-live] ' + currentT('log.testStart', { title: ws.title, env: envName }) + ' (预校验)')
|
|
328
|
+
await probeOnce({ dsn: inlineDsn, ro: opOptions.ro, ssh: opOptions.ssh }, envName, ws.title, '(预校验)')
|
|
244
329
|
return
|
|
245
330
|
}
|
|
246
|
-
const envName = String(op.env == null ? '' : op.env).trim() || 'default'
|
|
247
331
|
console.log('[dsh-dbhub-live] ' + currentT('log.testStart', { title: ws.title, env: envName }))
|
|
248
|
-
|
|
249
|
-
const
|
|
250
|
-
|
|
332
|
+
// ── F4 fast path: store → already-cached row → (only then) a real walk ──
|
|
333
|
+
const resolveStarted = Date.now()
|
|
334
|
+
const storeRows = listWorkspaceEnvironments(store)
|
|
335
|
+
let picked = resolveTestDsn({
|
|
336
|
+
wsPath: ws.path,
|
|
337
|
+
env: envName,
|
|
338
|
+
storeRows,
|
|
339
|
+
cachedRows: cachedRows(),
|
|
340
|
+
hasCache: hasRowCache(),
|
|
341
|
+
})
|
|
342
|
+
if (!picked) {
|
|
343
|
+
await collectSources(ctx, subprocess)
|
|
344
|
+
// Re-read the store: a card write can land while the walk runs, and the
|
|
345
|
+
// post-walk answer must not be older than the walk itself.
|
|
346
|
+
picked = resolveTestDsn({
|
|
347
|
+
wsPath: ws.path,
|
|
348
|
+
env: envName,
|
|
349
|
+
storeRows: listWorkspaceEnvironments(store),
|
|
350
|
+
cachedRows: cachedRows(),
|
|
351
|
+
hasCache: hasRowCache(),
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
console.log('[dsh-dbhub-live] ' + currentT('log.testWalkMs', {
|
|
355
|
+
ms: String(Date.now() - resolveStarted),
|
|
356
|
+
from: picked ? picked.from : 'walk-miss',
|
|
357
|
+
}))
|
|
358
|
+
if (!picked) {
|
|
251
359
|
report(false, currentT('result.testNoRow'))
|
|
252
360
|
console.log('[dsh-dbhub-live] ' + currentT('log.testDone', { title: ws.title, env: envName, result: 'FAIL — ' + currentT('result.testNoRow') }))
|
|
253
361
|
return
|
|
254
362
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
363
|
+
// An unsaved option (or tunnel) the card is validating wins over the stored
|
|
364
|
+
// one; otherwise the row's own options are what execution would use.
|
|
365
|
+
const conn = opHasOptions
|
|
366
|
+
? { dsn: picked.dsn, ro: opOptions.ro, ssh: opOptions.ssh }
|
|
367
|
+
: { dsn: picked.dsn, ro: picked.ro, ssh: picked.ssh }
|
|
368
|
+
await probeOnce(conn, envName, ws.title, '')
|
|
259
369
|
} catch (e) {
|
|
260
370
|
console.error('[dsh-dbhub-live] 卡片连接测试异常: ' + String((e && e.stack) || e))
|
|
261
371
|
report(false, String((e && e.message) || e))
|
|
262
372
|
}
|
|
263
373
|
}
|
|
264
374
|
|
|
265
|
-
// One card command: { op: 'add'|'remove',
|
|
266
|
-
// { op: 'test',
|
|
375
|
+
// One card command: { op: 'add'|'set'|'options'|'rename'|'remove', … } or
|
|
376
|
+
// { op: 'test', … } (connection probe, zero side effects).
|
|
267
377
|
// `publishFast` mirrors a completed write from the cached rows right away; the
|
|
268
378
|
// caller publishes the walked result afterwards. Returns the applied patches
|
|
269
379
|
// (empty for a probe or a no-op) so the caller can mirror them immediately too.
|
|
270
380
|
// `rawOp` is the encoded command the settings-document channel carries and the
|
|
271
381
|
// decoded object the HTTP bridge carries.
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Translate the option fields of one card command into a store patch.
|
|
385
|
+
* `undefined` when the command carries no option information at all — which
|
|
386
|
+
* means "preserve whatever the entry already had" (D4).
|
|
387
|
+
* `ssh: null` is the explicit "clear the tunnel" signal, so it must survive as
|
|
388
|
+
* a defined null rather than being dropped.
|
|
389
|
+
*/
|
|
390
|
+
function optionsPatchOf(op) {
|
|
391
|
+
const patch = {}
|
|
392
|
+
// Only a real boolean counts: a malformed `readOnly: 'yes'` must be a no-op
|
|
393
|
+
// rather than silently CLEARING a user's safety switch.
|
|
394
|
+
if (op && op.readOnly === true) patch.readOnly = true
|
|
395
|
+
else if (op && op.readOnly === false) patch.readOnly = false
|
|
396
|
+
if (op && op.ssh !== undefined) patch.ssh = op.ssh === null ? null : op.ssh
|
|
397
|
+
return Object.keys(patch).length > 0 ? patch : undefined
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** One persisted store row by workspace path × environment (host-side). */
|
|
401
|
+
function findRow(wsPath, env) {
|
|
402
|
+
const name = normalizeEnvName(env)
|
|
403
|
+
return listWorkspaceEnvironments(store).find((r) => r.wsPath === wsPath && r.env === name)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Apply one options-only card command (the read-only switch and/or the SSH
|
|
408
|
+
* tunnel) and return the mirror patches.
|
|
409
|
+
*
|
|
410
|
+
* The write never touches the DSN — the UI has no connection data to offer when
|
|
411
|
+
* a user flips a checkbox, and demanding one would make them re-type a password.
|
|
412
|
+
* An environment that only AUTO-DISCOVERY knows has no store row to hold the
|
|
413
|
+
* options, so it is promoted to a persisted row from the DSN the walk just
|
|
414
|
+
* resolved (D10) — and strictly on this explicit action (listing, querying and
|
|
415
|
+
* publishing never promote anything).
|
|
416
|
+
*
|
|
417
|
+
* Exported for the unit tests: it is pure with respect to its inputs, takes the
|
|
418
|
+
* auto-discovery lookup as a parameter, and performs no publishing of its own.
|
|
419
|
+
*
|
|
420
|
+
* @param op - `{ env, readOnly?, ssh?, newEnv? }`.
|
|
421
|
+
* @param ws - `{ path, title }` of the already-resolved workspace.
|
|
422
|
+
* @param lookupAuto - `async (envName) => ({ env, dsn } | undefined)`.
|
|
423
|
+
* @returns `{ patches, row?, error? }`.
|
|
424
|
+
*/
|
|
425
|
+
export async function applyOptionsOp(op, ws, lookupAuto) {
|
|
426
|
+
const patches = []
|
|
427
|
+
const patch = optionsPatchOf(op)
|
|
428
|
+
const requestedName = normalizeEnvName(op.env)
|
|
429
|
+
// "Blank secret = keep the stored value": the card cannot echo a password back,
|
|
430
|
+
// so an edit that only touches the host/user/port arrives without one. Merge
|
|
431
|
+
// with the stored block BEFORE validating, or a legitimate edit would be
|
|
432
|
+
// rejected as an incomplete tunnel.
|
|
433
|
+
if (patch && patch.ssh) {
|
|
434
|
+
const existing = findRow(ws.path, requestedName)
|
|
435
|
+
patch.ssh = mergeSshOptions(patch.ssh, existing ? existing.ssh : null)
|
|
436
|
+
if (!normalizeSshOptions(patch.ssh)) return { patches: [], error: 'badSsh' }
|
|
437
|
+
}
|
|
438
|
+
let envName = requestedName
|
|
439
|
+
// An options write may ALSO rename: one atomic command, so the two changes
|
|
440
|
+
// cannot race on the single `configOp` field. The rename runs first and the
|
|
441
|
+
// options then land on the new name.
|
|
442
|
+
const newEnv = op.newEnv === undefined || op.newEnv === null ? '' : normalizeEnvName(op.newEnv)
|
|
443
|
+
if (newEnv && newEnv !== requestedName) {
|
|
444
|
+
const moved = renameWorkspaceEnv(ws.path, requestedName, newEnv)
|
|
445
|
+
if (moved) {
|
|
446
|
+
envName = moved
|
|
447
|
+
patches.push({ op: 'rename', path: ws.path, env: moved, from: requestedName })
|
|
448
|
+
console.log('[dsh-dbhub-live] ' + currentT('log.opRenamed', { title: ws.title, from: requestedName, to: moved }))
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
let name = setWorkspaceEnvOptions(ws.path, envName, patch || {})
|
|
452
|
+
if (!name) {
|
|
453
|
+
let auto
|
|
454
|
+
try {
|
|
455
|
+
auto = await lookupAuto(envName)
|
|
456
|
+
} catch (e) {
|
|
457
|
+
auto = undefined
|
|
458
|
+
}
|
|
459
|
+
if (!auto) {
|
|
460
|
+
// A rename that already landed is still a real store change: report it
|
|
461
|
+
// rather than dropping the patch (the card would keep showing the old
|
|
462
|
+
// name until some later walk).
|
|
463
|
+
return { patches, error: 'needConnFirst' }
|
|
464
|
+
}
|
|
465
|
+
name = setWorkspaceEnv(ws.path, envName, auto.dsn, 'promoted', patch)
|
|
466
|
+
const promotedRow = findRow(ws.path, name)
|
|
467
|
+
patches.push({
|
|
468
|
+
op: 'add', path: ws.path, title: ws.title, env: name, dsn: auto.dsn, source: 'promoted',
|
|
469
|
+
ro: promotedRow ? promotedRow.ro : false, ssh: promotedRow ? promotedRow.ssh : null,
|
|
470
|
+
})
|
|
471
|
+
}
|
|
472
|
+
const row = findRow(ws.path, name)
|
|
473
|
+
patches.push({
|
|
474
|
+
op: 'options', path: ws.path, env: name,
|
|
475
|
+
ro: row ? row.ro : false, ssh: row ? row.ssh : null,
|
|
476
|
+
})
|
|
477
|
+
const roText = row && row.ro ? 'on' : 'off'
|
|
478
|
+
const sshText = row && row.ssh ? (row.ssh.host + ':' + row.ssh.port) : 'off'
|
|
479
|
+
console.log('[dsh-dbhub-live] ' + currentT('log.opOptions', { title: ws.title, env: name, ro: roText, ssh: sshText }))
|
|
480
|
+
if (row && row.ssh) {
|
|
481
|
+
console.log('[dsh-dbhub-live] ' + currentT('log.sshTunnelUp', { title: ws.title, env: name, host: row.ssh.host, port: String(row.ssh.port), user: row.ssh.user }))
|
|
482
|
+
}
|
|
483
|
+
return { patches, row }
|
|
484
|
+
}
|
|
485
|
+
|
|
272
486
|
async function handleConfigOp(rawOp, ctx, subprocess, reportTest, publishFast) {
|
|
273
487
|
const patches = []
|
|
274
488
|
let op
|
|
@@ -305,9 +519,25 @@ async function handleConfigOp(rawOp, ctx, subprocess, reportTest, publishFast) {
|
|
|
305
519
|
}
|
|
306
520
|
}
|
|
307
521
|
const source = op.source === 'collected' ? 'collected' : 'user'
|
|
308
|
-
const
|
|
309
|
-
|
|
522
|
+
const opts = optionsPatchOf(op)
|
|
523
|
+
const envName = setWorkspaceEnv(ws.path, op.env, dsn, source, opts)
|
|
524
|
+
const row = findRow(ws.path, envName)
|
|
525
|
+
patches.push({
|
|
526
|
+
op: 'add', path: ws.path, title: ws.title, env: envName, dsn, source,
|
|
527
|
+
ro: row ? row.ro : false, ssh: row ? row.ssh : null,
|
|
528
|
+
})
|
|
310
529
|
console.log('[dsh-dbhub-live] ' + currentT('log.opSaved', { title: ws.title, env: envName }))
|
|
530
|
+
} else if (op.op === 'options') {
|
|
531
|
+
const result = await applyOptionsOp(op, ws, async (envName) => {
|
|
532
|
+
const envs = await resolveWorkspaceEnvs(subprocess, ctx.get('fs'), ws.path, undefined)
|
|
533
|
+
return envs.find((r) => r.env === envName)
|
|
534
|
+
})
|
|
535
|
+
if (result.error === 'needConnFirst') {
|
|
536
|
+
console.warn('[dsh-dbhub-live] ' + currentT('result.needConnFirst') + '(' + ws.title + ' / ' + normalizeEnvName(op.env) + ')')
|
|
537
|
+
} else if (result.error === 'badSsh') {
|
|
538
|
+
console.warn('[dsh-dbhub-live] SSH 配置无效(缺少主机/用户/凭据或为多跳),已忽略:' + String(op.env || 'default'))
|
|
539
|
+
}
|
|
540
|
+
patches.push(...result.patches)
|
|
311
541
|
} else if (op.op === 'rename') {
|
|
312
542
|
const from = op.env
|
|
313
543
|
const to = op.newEnv
|
|
@@ -341,6 +571,22 @@ async function handleConfigOp(rawOp, ctx, subprocess, reportTest, publishFast) {
|
|
|
341
571
|
return patches
|
|
342
572
|
}
|
|
343
573
|
|
|
574
|
+
// Card commands arrive as an object (the HTTP bridge posts `{ op: <command> }`)
|
|
575
|
+
// or as a JSON string (on 0.1.x the settings document carries the single string
|
|
576
|
+
// field `configOp`). Normalize both shapes in one place so the early
|
|
577
|
+
// `updateCheck` branch in runOp can read the command; anything unparsable falls
|
|
578
|
+
// through to handleConfigOp, which owns the "bad op" warning.
|
|
579
|
+
function opObjectOf(rawOp) {
|
|
580
|
+
if (rawOp && typeof rawOp === 'object') return rawOp
|
|
581
|
+
if (typeof rawOp !== 'string') return undefined
|
|
582
|
+
try {
|
|
583
|
+
const parsed = JSON.parse(rawOp)
|
|
584
|
+
return parsed && typeof parsed === 'object' ? parsed : undefined
|
|
585
|
+
} catch (e) {
|
|
586
|
+
return undefined
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
344
590
|
// ── live host core: one merged view, fanned out to every active channel ───
|
|
345
591
|
//
|
|
346
592
|
// Publishing is channel-independent: the 0.1.x settings namespace and the HTTP
|
|
@@ -355,18 +601,39 @@ function createHostCore(ctx) {
|
|
|
355
601
|
// the latest one; the card surfaces it only for a nonce it itself dispatched,
|
|
356
602
|
// so a page reload silently drops any stale report.
|
|
357
603
|
let lastTestResult = ''
|
|
604
|
+
// Upstream dbhub capability probe (JSON string; '' until the background probe
|
|
605
|
+
// finishes). The UI disables the read-only / SSH controls with a reason when
|
|
606
|
+
// the installed dbhub predates the TOML dialect.
|
|
607
|
+
let capabilities = ''
|
|
358
608
|
// The card's own writes are held here until a walk that started after them
|
|
359
609
|
// lands (see createRowMirror): a slow walk that began before a write would
|
|
360
610
|
// otherwise publish a list without it, which reads as "the row appeared and
|
|
361
611
|
// then vanished".
|
|
362
612
|
const mirror = createRowMirror()
|
|
363
613
|
const sinks = new Set()
|
|
614
|
+
// Version metadata and the plugin self-update checker are Host-lifetime
|
|
615
|
+
// singletons: the installed version cannot change under a running process,
|
|
616
|
+
// and one checker instance is what makes the throttle and the in-flight
|
|
617
|
+
// dedupe work (a per-request instance would hit the registries on every
|
|
618
|
+
// publish). `updateJson` is the browser-facing snapshot — a JSON string, ''
|
|
619
|
+
// until the card asks for its first check, so boot never waits on a network.
|
|
620
|
+
const meta = versionMeta()
|
|
621
|
+
const updateChecker = createUpdateChecker({ currentVersion: meta.pluginVersion })
|
|
622
|
+
let updateJson = ''
|
|
623
|
+
// checkedAt of the snapshot already written to the log: a throttled check
|
|
624
|
+
// just reuses the last result, and logging it again on every page mount would
|
|
625
|
+
// turn the milestone line into noise.
|
|
626
|
+
let lastLoggedCheckAt = -1
|
|
364
627
|
|
|
365
628
|
const view = () => ({
|
|
366
629
|
...state.snapshot(),
|
|
367
630
|
...options.snapshot(),
|
|
631
|
+
// pluginVersion / dshVersion / dshModern / runtime — plain strings/booleans.
|
|
632
|
+
...meta,
|
|
368
633
|
workspaces: JSON.stringify(latestSummaries()),
|
|
369
634
|
testResult: lastTestResult,
|
|
635
|
+
capabilities: capabilities,
|
|
636
|
+
update: updateJson,
|
|
370
637
|
// The browser half needs this to address the plugin's own settings form
|
|
371
638
|
// (`ctx.configForms.get(entryId)`) for option writes; '' when the entry
|
|
372
639
|
// cannot be resolved, in which case it falls back to POST /options.
|
|
@@ -446,16 +713,80 @@ function createHostCore(ctx) {
|
|
|
446
713
|
// A probe changes NO rows: mirror it, do not re-walk the workspaces.
|
|
447
714
|
publishMirror()
|
|
448
715
|
}
|
|
716
|
+
/** Record the upstream capability probe and republish the (cheap) mirror. */
|
|
717
|
+
const setCapabilities = (cap) => {
|
|
718
|
+
try {
|
|
719
|
+
capabilities = JSON.stringify(cap || {})
|
|
720
|
+
} catch (e) {
|
|
721
|
+
capabilities = ''
|
|
722
|
+
}
|
|
723
|
+
publishMirror()
|
|
724
|
+
}
|
|
725
|
+
/** Capabilities currently advertised to the card (a plain object). */
|
|
726
|
+
const capabilitiesOf = () => {
|
|
727
|
+
try {
|
|
728
|
+
const parsed = JSON.parse(capabilities || '{}')
|
|
729
|
+
return parsed && typeof parsed === 'object' ? parsed : {}
|
|
730
|
+
} catch (e) {
|
|
731
|
+
return {}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
// Answer the card's {op:'updateCheck'} command. The checker owns the throttle
|
|
735
|
+
// and the in-flight dedupe, so repeated clicks/reloads cost one request burst;
|
|
736
|
+
// `force` bypasses the throttle (the card's explicit "check now"). The result
|
|
737
|
+
// is mirrored into `update` and published, then logged once — check() resolves
|
|
738
|
+
// on every failure and the guard here only covers a future regression.
|
|
739
|
+
const runUpdateCheck = async (force) => {
|
|
740
|
+
let snap
|
|
741
|
+
try {
|
|
742
|
+
snap = await updateChecker.check(force === true)
|
|
743
|
+
} catch (e) {
|
|
744
|
+
snap = {
|
|
745
|
+
latest: '',
|
|
746
|
+
latestSpec: '',
|
|
747
|
+
hasUpdate: false,
|
|
748
|
+
checkedAt: Date.now(),
|
|
749
|
+
source: '',
|
|
750
|
+
error: String((e && e.message) || e),
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
updateJson = JSON.stringify(snap)
|
|
754
|
+
publishMirror()
|
|
755
|
+
// One line per ACTUAL check; a throttled reuse keeps the previous line.
|
|
756
|
+
if (snap.checkedAt === lastLoggedCheckAt) return snap
|
|
757
|
+
lastLoggedCheckAt = snap.checkedAt
|
|
758
|
+
const current = meta.pluginVersion || '?'
|
|
759
|
+
if (snap.error) {
|
|
760
|
+
console.warn('[dsh-dbhub-live] ' + currentT('log.appUpdateFailed', { msg: snap.error, current }))
|
|
761
|
+
} else if (snap.hasUpdate) {
|
|
762
|
+
console.log('[dsh-dbhub-live] ' + currentT('log.appUpdate', { current, latest: snap.latest, source: snap.source }))
|
|
763
|
+
} else {
|
|
764
|
+
console.log('[dsh-dbhub-live] ' + currentT('log.appUpToDate', {
|
|
765
|
+
current,
|
|
766
|
+
latest: snap.latest || '?',
|
|
767
|
+
source: snap.source || '?',
|
|
768
|
+
}))
|
|
769
|
+
}
|
|
770
|
+
return snap
|
|
771
|
+
}
|
|
449
772
|
// One card command, from whichever channel carried it. The caller owns the
|
|
450
773
|
// follow-up republish; a write is already mirrored by publishFast.
|
|
451
774
|
const runOp = async (rawOp) => {
|
|
775
|
+
// `updateCheck` is not a workspace command: no rows change and it touches the
|
|
776
|
+
// network, so it is intercepted BEFORE handleConfigOp (which would reject an
|
|
777
|
+
// unknown op). It still resolves [] — a command that patches no row.
|
|
778
|
+
const command = opObjectOf(rawOp)
|
|
779
|
+
if (command && command.op === 'updateCheck') {
|
|
780
|
+
await runUpdateCheck(command.force === true)
|
|
781
|
+
return []
|
|
782
|
+
}
|
|
452
783
|
const patches = await handleConfigOp(rawOp, ctx, subprocessOf(), reportTest, publishFast)
|
|
453
784
|
if (Array.isArray(patches) && patches.length > 0) publish()
|
|
454
785
|
else publishMirror()
|
|
455
786
|
return patches
|
|
456
787
|
}
|
|
457
788
|
|
|
458
|
-
return { view, onPublish, publish, publishMirror, publishFast, reportTest, runOp }
|
|
789
|
+
return { view, onPublish, publish, publishMirror, publishFast, reportTest, runOp, setCapabilities, capabilitiesOf }
|
|
459
790
|
}
|
|
460
791
|
|
|
461
792
|
// ── channel 1: the settings namespace (dsh 0.1.6 and older only) ──────────
|
|
@@ -612,10 +943,12 @@ let bootInFlight
|
|
|
612
943
|
|
|
613
944
|
// Fire-and-forget one bookkeeping run: mark the plugin 'running' (there is no
|
|
614
945
|
// server to start — each tool call spawns its own disposable dbhub), refresh
|
|
615
|
-
// the tool count,
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
|
|
946
|
+
// the tool count, sweep any generated-config leftovers from a crash, probe the
|
|
947
|
+
// upstream dbhub capabilities for the UI, and check for a dbhub auto-update in
|
|
948
|
+
// the background (fails silently; the current binary stays in use). Concurrent
|
|
949
|
+
// calls share the in-flight run. Failures land in the state machine, never block
|
|
950
|
+
// boot.
|
|
951
|
+
function startBackgroundBoot(ctx, subprocess, core) {
|
|
619
952
|
if (!state.isEnabled() || !subprocess) return undefined
|
|
620
953
|
if (bootInFlight) return bootInFlight
|
|
621
954
|
bootInFlight = (async () => {
|
|
@@ -624,6 +957,27 @@ function startBackgroundBoot(ctx, subprocess) {
|
|
|
624
957
|
state.setPhase('running')
|
|
625
958
|
refreshToolCount()
|
|
626
959
|
console.log('[dsh-dbhub-live] ' + currentT('log.loaded', { n: activeToolCount() }))
|
|
960
|
+
// Stale generated TOML files (a crash between write and delete) hold no
|
|
961
|
+
// secrets, but they must not accumulate in the storage dir.
|
|
962
|
+
try {
|
|
963
|
+
const swept = sweepTempToml()
|
|
964
|
+
if (swept > 0) console.log('[dsh-dbhub-live] ' + currentT('log.tomlSwept', { n: String(swept) }))
|
|
965
|
+
} catch (e) {
|
|
966
|
+
/* sweeping is best effort */
|
|
967
|
+
}
|
|
968
|
+
// Capability probe: purely informational (the UI disables what cannot
|
|
969
|
+
// work). Never fatal, and it also resolves/installs the executable early
|
|
970
|
+
// so the first real call does not pay for it.
|
|
971
|
+
resolveDbhubExe(subprocess).then((exe) => {
|
|
972
|
+
const cap = detectDbhubCapabilities(exe)
|
|
973
|
+
if (core && typeof core.setCapabilities === 'function') core.setCapabilities(cap)
|
|
974
|
+
console.log('[dsh-dbhub-live] dbhub 能力探测: version=' + (cap.version || 'unknown')
|
|
975
|
+
+ ' readonly=' + (cap.readonlyTools ? 'yes' : 'no')
|
|
976
|
+
+ ' ssh=' + (cap.ssh ? 'yes' : 'no')
|
|
977
|
+
+ (cap.warning ? ' warning=' + cap.warning : ''))
|
|
978
|
+
}).catch((e) => {
|
|
979
|
+
console.warn('[dsh-dbhub-live] dbhub 能力探测失败: ' + String((e && e.message) || e))
|
|
980
|
+
})
|
|
627
981
|
maybeRefreshDbhub(subprocess).catch((e) => {
|
|
628
982
|
console.error('[dsh-dbhub-live] ' + currentT('log.updateCheckFailed', { msg: String((e && e.message) || e) }))
|
|
629
983
|
})
|
|
@@ -655,6 +1009,10 @@ export async function apply(ctx, config) {
|
|
|
655
1009
|
})
|
|
656
1010
|
const subprocess = ctx.get('subprocess')
|
|
657
1011
|
registerCoreTools(ctx, subprocess)
|
|
1012
|
+
// The system-prompt policy is what keeps database access on these tools (and
|
|
1013
|
+
// out of psql/scripts); it is optional by design and contributes nothing
|
|
1014
|
+
// while the plugin is disabled.
|
|
1015
|
+
registerDbhubPolicy(ctx)
|
|
658
1016
|
refreshToolCount()
|
|
659
1017
|
|
|
660
1018
|
// One core, two channels: every state/option change replays the walk, and
|
|
@@ -690,7 +1048,7 @@ export async function apply(ctx, config) {
|
|
|
690
1048
|
return
|
|
691
1049
|
}
|
|
692
1050
|
if (state.isEnabled()) {
|
|
693
|
-
startBackgroundBoot(ctx, subprocess).catch(() => {
|
|
1051
|
+
startBackgroundBoot(ctx, subprocess, core).catch(() => {
|
|
694
1052
|
/* recorded in state */
|
|
695
1053
|
})
|
|
696
1054
|
} else {
|