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.
Files changed (50) hide show
  1. package/AGENTS.md +95 -209
  2. package/README.en.md +251 -169
  3. package/README.md +251 -169
  4. package/doc/ARCHITECTURE.md +108 -0
  5. package/doc/CLIENT.md +124 -0
  6. package/doc/DESENSITIZE.md +56 -0
  7. package/doc/DEVELOPMENT.md +139 -0
  8. package/doc/IMPACT-MATRIX.md +36 -0
  9. package/doc/RELEASE.md +93 -0
  10. package/doc/REQUIREMENTS.md +177 -105
  11. package/doc/images/chat-query.png +0 -0
  12. package/doc/images/row-editor.png +0 -0
  13. package/doc/images/settings-page.png +0 -0
  14. package/lib/adhoc.mjs +412 -34
  15. package/lib/capability.mjs +157 -0
  16. package/lib/client.js +1243 -80
  17. package/lib/config.mjs +370 -21
  18. package/lib/i18n.mjs +119 -4
  19. package/lib/index.mjs +401 -43
  20. package/lib/mcp.mjs +496 -391
  21. package/lib/meta.mjs +267 -0
  22. package/lib/options.mjs +25 -5
  23. package/lib/policy.mjs +92 -0
  24. package/lib/toml.mjs +188 -0
  25. package/lib/tools.mjs +1093 -827
  26. package/lib/update.mjs +244 -0
  27. package/package.json +34 -10
  28. package/scripts/desensitize-check.mjs +10 -3
  29. package/scripts/desensitize.config.mjs +255 -237
  30. package/scripts/docs-check.mjs +214 -0
  31. package/scripts/release-preflight.mjs +180 -0
  32. package/scripts/render-check.mjs +1366 -843
  33. package/test/adhoc-toml.test.mjs +365 -0
  34. package/test/bridge.test.mjs +227 -185
  35. package/test/capability.test.mjs +231 -0
  36. package/test/client-format.test.mjs +515 -352
  37. package/test/configure-options.test.mjs +244 -0
  38. package/test/configure-ssh.test.mjs +232 -0
  39. package/test/copy-from.test.mjs +155 -0
  40. package/test/env-options.test.mjs +456 -0
  41. package/test/meta.test.mjs +234 -0
  42. package/test/options.test.mjs +160 -123
  43. package/test/policy.test.mjs +132 -0
  44. package/test/row-cache.test.mjs +237 -138
  45. package/test/ssh-probe.test.mjs +834 -0
  46. package/test/test-op-fastpath.test.mjs +208 -0
  47. package/test/toml.test.mjs +352 -0
  48. package/test/update.test.mjs +270 -0
  49. package/test/util.test.mjs +17 -0
  50. 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 { probeConnection } from './adhoc.mjs'
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, removeWorkspaceEnv, renameWorkspaceEnv, normalizeEnvName,
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
- // Whether the browser half contributes its own sidebar entry; the settings
75
- // page renders the switch (the page itself is a settings section and the
76
- // plugin's row on the Plugins page, so hiding the entry never locks anyone
77
- // out of the configuration).
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 (the probe is a throwaway dbhub +
181
- // SELECT 1; the timeout also kills the spawned process when it fires). Must be
182
- // comfortably BELOW the client-side watchdog so the {nonce, ok, message}
183
- // report always wins the race against "测试超时未返回" (15s == 15s lost races).
184
- const TEST_TIMEOUT_MS = 25000
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 }. Resolves
205
- // the REAL DSN host-side (the card only ever holds the masked summary), then
206
- // probes it through an isolated ad-hoc connection — the persistent server is
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 (inlineDsn) {
230
- if (!ws) {
231
- report(false, currentT('log.opNoWs', { ws: String(op.workspace || '') }))
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 envName = String(op.env == null ? '' : op.env).trim() || 'default'
235
- console.log('[dsh-dbhub-live] ' + currentT('log.testStart', { title: ws.title, env: envName }) + ' (预校验)')
236
- const res = await withProbeTimeout(probeConnection(subprocess, inlineDsn, TEST_TIMEOUT_MS), TEST_TIMEOUT_MS)
237
- report(res.ok, res.message)
238
- if (!res.ok) console.error('[dsh-dbhub-live] ' + currentT('log.testDone', { title: ws.title, env: envName, result: 'FAIL(预校验) — ' + res.message }))
239
- else console.log('[dsh-dbhub-live] ' + currentT('log.testDone', { title: ws.title, env: envName, result: 'OK — ' + res.message }))
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 (!ws) {
243
- report(false, currentT('log.opNoWs', { ws: String(op.workspace || '') }))
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
- const { rows } = await collectSources(ctx, subprocess)
249
- const row = rows.find((r) => r.wsPath === ws.path && r.env === envName)
250
- if (!row) {
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
- const res = await withProbeTimeout(probeConnection(subprocess, row.dsn, TEST_TIMEOUT_MS), TEST_TIMEOUT_MS)
256
- report(res.ok, res.message)
257
- if (!res.ok) console.error('[dsh-dbhub-live] ' + currentT('log.testDone', { title: ws.title, env: envName, result: 'FAIL — ' + res.message }))
258
- else console.log('[dsh-dbhub-live] ' + currentT('log.testDone', { title: ws.title, env: envName, result: 'OK — ' + res.message }))
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', workspace, env, dsn? } or
266
- // { op: 'test', workspace, env, nonce } (connection probe, zero side effects).
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 envName = setWorkspaceEnv(ws.path, op.env, dsn, source)
309
- patches.push({ op: 'add', path: ws.path, title: ws.title, env: envName, dsn, source })
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, and check for a dbhub auto-update in the background (fails
616
- // silently; the current binary stays in use). Concurrent calls share the
617
- // in-flight run. Failures land in the state machine, never block boot.
618
- function startBackgroundBoot(ctx, subprocess) {
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 {