dsh-dbhub-live 3.0.0 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/config.mjs CHANGED
@@ -15,7 +15,6 @@ export const STORE_PATH = join(DATA_DIR, 'credentials.json')
15
15
  export const TOML_PATH = join(DATA_DIR, 'dbhub.toml')
16
16
  export const RUNTIME_DIR = join(DATA_DIR, 'dbhub-runtime')
17
17
  export const RUNTIME_PATH = join(DATA_DIR, 'runtime.json')
18
- export const IDLE_MS = 10 * 60 * 1000
19
18
 
20
19
  // Ensure the storage directory exists once at load; every writer below
21
20
  // (store, runtime, toml, managed dbhub prefix) assumes it is present.
@@ -55,11 +54,14 @@ function defineOwn(target, key, value) {
55
54
 
56
55
  /**
57
56
  * Normalize a credentials document (per-workspace entries + the enabled
58
- * toggle) so legacy or hand-edited files cannot poison initialization:
59
- * non-boolean `enabled`, non-object entries and empty-DSN entries are
60
- * dropped; the authoritative `dsn` is trimmed. Unknown entry fields are
61
- * preserved so a newer version's writes are never stripped by an older
62
- * loader (forward compatibility).
57
+ * toggle) so legacy or hand-edited files cannot poison initialization.
58
+ * Store model (v2): each workspace entry is `{ environments: { <env>: {
59
+ * dsn, source?, updatedAt? } } }`; a v1 entry `{ dsn, source?, updatedAt? }`
60
+ * is migrated into `environments.default` automatically. Non-boolean
61
+ * `enabled`, non-object entries, empty-DSN entries and empty environment maps
62
+ * are dropped; DSNs are trimmed. Unknown fields are preserved so a newer
63
+ * version's writes are never stripped by an older loader (forward
64
+ * compatibility).
63
65
  * @param raw - parsed JSON document (any shape).
64
66
  * @returns the normalized plain object.
65
67
  */
@@ -67,16 +69,113 @@ export function normalizeStore(raw) {
67
69
  const out = {}
68
70
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out
69
71
  if (typeof raw.enabled === 'boolean') defineOwn(out, 'enabled', raw.enabled)
72
+
73
+ const normalizeEnv = (env) => {
74
+ const clean = {}
75
+ if (!env || typeof env !== 'object' || Array.isArray(env)) return
76
+ const dsn = typeof env.dsn === 'string' ? env.dsn.trim() : ''
77
+ if (!dsn) return
78
+ defineOwn(clean, 'dsn', dsn)
79
+ if (typeof env.source === 'string' && env.source) defineOwn(clean, 'source', env.source)
80
+ if (typeof env.updatedAt === 'number' && Number.isFinite(env.updatedAt)) {
81
+ defineOwn(clean, 'updatedAt', env.updatedAt)
82
+ }
83
+ for (const [k, v] of Object.entries(env)) {
84
+ if (k === 'dsn' || k === 'source' || k === 'updatedAt') continue
85
+ defineOwn(clean, k, v) // forward-compatible unknown env fields
86
+ }
87
+ return clean
88
+ }
89
+
70
90
  for (const [key, entry] of Object.entries(raw)) {
71
91
  if (key === 'enabled') continue
72
92
  if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
73
- const dsn = typeof entry.dsn === 'string' ? entry.dsn.trim() : ''
74
- if (!dsn) continue
75
- defineOwn(out, key, { ...entry, dsn })
93
+ if (Object.prototype.hasOwnProperty.call(entry, 'environments')) {
94
+ // v2: per-environment map
95
+ const environments = {}
96
+ if (entry.environments && typeof entry.environments === 'object' && !Array.isArray(entry.environments)) {
97
+ for (const [env, envEntry] of Object.entries(entry.environments)) {
98
+ const clean = normalizeEnv(envEntry)
99
+ if (clean) defineOwn(environments, env, clean)
100
+ }
101
+ }
102
+ if (Object.keys(environments).length > 0) defineOwn(out, key, { environments })
103
+ } else {
104
+ // v1 migration: a bare dsn entry becomes the 'default' environment
105
+ const clean = normalizeEnv(entry)
106
+ if (clean) defineOwn(out, key, { environments: { default: clean } })
107
+ }
76
108
  }
77
109
  return out
78
110
  }
79
111
 
112
+ /**
113
+ * List every persisted workspace environment as flat rows for the card.
114
+ * @param store - the (normalized) credentials store.
115
+ * @returns rows [{ wsPath, env, dsn, source?, updatedAt?, persisted: true }].
116
+ */
117
+ export function listWorkspaceEnvironments(store) {
118
+ const rows = []
119
+ for (const [wsPath, entry] of Object.entries(store || {})) {
120
+ if (wsPath === 'enabled') continue
121
+ if (!entry || typeof entry !== 'object' || !entry.environments || typeof entry.environments !== 'object') continue
122
+ for (const [env, envEntry] of Object.entries(entry.environments)) {
123
+ if (!envEntry || typeof envEntry.dsn !== 'string' || !envEntry.dsn.trim()) continue
124
+ rows.push({
125
+ wsPath,
126
+ env,
127
+ dsn: envEntry.dsn.trim(),
128
+ ...(typeof envEntry.source === 'string' && envEntry.source ? { source: envEntry.source } : {}),
129
+ ...(typeof envEntry.updatedAt === 'number' && Number.isFinite(envEntry.updatedAt) ? { updatedAt: envEntry.updatedAt } : {}),
130
+ persisted: true,
131
+ })
132
+ }
133
+ }
134
+ return rows
135
+ }
136
+
137
+ /**
138
+ * Persist one workspace environment (creates or overwrites). The workspace
139
+ * entry becomes `{ environments: { <env>: { dsn, source, updatedAt } } }`.
140
+ * @returns the stored environment name ('default' when blank).
141
+ */
142
+ export function setWorkspaceEnv(wsPath, env, dsn, source) {
143
+ const entry = store[wsPath] && typeof store[wsPath] === 'object' && !Array.isArray(store[wsPath])
144
+ ? store[wsPath]
145
+ : {}
146
+ const environments = entry.environments && typeof entry.environments === 'object' && !Array.isArray(entry.environments)
147
+ ? { ...entry.environments }
148
+ : {}
149
+ const name = env === undefined || env === null || String(env).trim() === ''
150
+ ? 'default'
151
+ : String(env).trim()
152
+ const cleanDsn = String(dsn).trim()
153
+ if (!cleanDsn) throw new TypeError('DSN 不能为空')
154
+ environments[name] = { dsn: cleanDsn, source: String(source || 'user'), updatedAt: Date.now() }
155
+ store[wsPath] = { environments }
156
+ saveStore(store)
157
+ return name
158
+ }
159
+
160
+ /**
161
+ * Remove one persisted workspace environment; when it was the last one the
162
+ * whole workspace entry is dropped. Removing an auto-derived 'default' (which
163
+ * is never persisted) is a no-op.
164
+ * @returns whether anything was removed.
165
+ */
166
+ export function removeWorkspaceEnv(wsPath, env) {
167
+ const entry = store[wsPath]
168
+ if (!entry || typeof entry !== 'object' || !entry.environments || typeof entry.environments !== 'object') return false
169
+ const name = env === undefined || env === null || String(env).trim() === '' ? 'default' : String(env).trim()
170
+ if (!Object.prototype.hasOwnProperty.call(entry.environments, name)) return false
171
+ const environments = { ...entry.environments }
172
+ delete environments[name]
173
+ if (Object.keys(environments).length === 0) delete store[wsPath]
174
+ else store[wsPath] = { environments }
175
+ saveStore(store)
176
+ return true
177
+ }
178
+
80
179
  /** Normalize the runtime document (dbhubExe / dbhubInstallAt) the same way. */
81
180
  export function normalizeRuntime(raw) {
82
181
  const out = {}
@@ -342,13 +441,9 @@ export async function runMiseEnv(subprocess, cwd, signal) {
342
441
  }
343
442
  }
344
443
 
345
- // Resolve one workspace's DSN: persisted explicit entry first, then
346
- // auto-discovery (mise env -> .env). Never consults the process environment.
347
- export async function resolveWorkspaceDsn(subprocess, fs, wsPath, signal) {
348
- const entry = store[wsPath]
349
- if (entry && entry.dsn && typeof entry.dsn === 'string' && entry.dsn.trim()) {
350
- return { dsn: entry.dsn.trim(), source: 'persisted(' + (entry.source || 'user') + ')' }
351
- }
444
+ // Auto-discovery (mise env -> .env) supplies the workspace's 'default'
445
+ // environment only. Never consults the process environment.
446
+ async function resolveAutoDsn(subprocess, fs, wsPath, signal) {
352
447
  const miseMap = await runMiseEnv(subprocess, wsPath, signal)
353
448
  if (miseMap) {
354
449
  const dsn = dsnFromMap(miseMap)
@@ -363,4 +458,35 @@ export async function resolveWorkspaceDsn(subprocess, fs, wsPath, signal) {
363
458
  /* no .env */
364
459
  }
365
460
  return undefined
461
+ }
462
+
463
+ /**
464
+ * Resolve one workspace's connection rows, one per environment: every
465
+ * persisted environment (v2 `environments.<env>`) plus, when no persisted
466
+ * `default` overrides it, the auto-discovered 'default'. Auto-discovery is
467
+ * never persisted — it is re-evaluated at every resolve (the card marks it
468
+ * as such).
469
+ * @returns rows [{ env, dsn, source }].
470
+ */
471
+ export async function resolveWorkspaceEnvs(subprocess, fs, wsPath, signal) {
472
+ const entry = store[wsPath]
473
+ const persisted = entry && entry.environments && typeof entry.environments === 'object'
474
+ ? entry.environments
475
+ : {}
476
+ const out = []
477
+ for (const [env, envEntry] of Object.entries(persisted)) {
478
+ if (envEntry && typeof envEntry.dsn === 'string' && envEntry.dsn.trim()) {
479
+ out.push({ env, dsn: envEntry.dsn.trim(), source: 'persisted(' + (envEntry.source || 'user') + ')' })
480
+ }
481
+ }
482
+ if (!Object.prototype.hasOwnProperty.call(persisted, 'default')) {
483
+ const auto = await resolveAutoDsn(subprocess, fs, wsPath, signal)
484
+ if (auto) out.push({ env: 'default', dsn: auto.dsn, source: auto.source })
485
+ }
486
+ return out
487
+ }
488
+
489
+ /** Environment slug for tool/source naming; 'default' stays bare. */
490
+ export function envSlug(env) {
491
+ return env === 'default' ? '' : slugify(env) || 'env'
366
492
  }
package/lib/i18n.mjs ADDED
@@ -0,0 +1,139 @@
1
+ // dsh-dbhub-live: host-side locale support.
2
+ //
3
+ // The dsh UI language preference lives in the user settings document under
4
+ // the 'locale' namespace ('preference': 'zh' | 'en'; absence delegates to the
5
+ // browser and defaults to zh here). Every user/model-facing string the Host
6
+ // produces — tool results, error feedback, milestone logs, the status-card
7
+ // error source — runs through `currentT` so reports match the dsh UI
8
+ // language. Tool SCHEMA descriptions stay pinned Chinese (model-facing
9
+ // product copy; they are static per registration).
10
+
11
+ const ZH = {
12
+ 'log.hostApply': 'host apply',
13
+ 'log.nsRegistered': '状态命名空间已注册: dsh-dbhub-live',
14
+ 'log.loaded': 'dbhub 服务已加载({n} 个工具)',
15
+ 'log.readyNoSources': '插件就绪(暂无工作区数据源,可用 dbhub_configure 配置后自动启动服务)',
16
+ 'log.initFailed': '初始化失败(状态卡片可见最近错误),下次调用自动重试',
17
+ 'log.disabledBoot': '插件处于禁用状态,未自动初始化(可在 设置 → 插件 → dsh-dbhub-live 开启)',
18
+ 'log.opSaved': '卡片配置:{title} 环境 {env} 已保存',
19
+ 'log.opRemoved': '卡片配置:{title} 环境 {env} 已删除',
20
+ 'log.opNothing': '卡片配置:{title} 无可删除的环境记录(自动发现项不会持久化)',
21
+ 'log.opNoWs': '配置操作失败:找不到工作区 "{ws}"',
22
+ 'log.opBad': '忽略非法的配置操作: {raw}',
23
+ 'log.opUnknown': '忽略未知配置操作: {op}',
24
+ 'log.syncApplied': '配置操作已生效:dbhub 工具已重新同步({n} 个)',
25
+ 'log.syncLater': '配置操作后工具重同步失败(下次调用自动重试): {msg}',
26
+ 'log.stateSyncFail': '状态同步失败: {msg}',
27
+ 'log.updateCheckFailed': 'dbhub 自动更新检查失败: {msg}',
28
+ 'result.disabled': 'dsh-dbhub-live 已禁用:所有 dbhub 工具暂不可用。请在 设置 → 插件 → dsh-dbhub-live 中重新开启。',
29
+ 'result.dsnMissing': '缺少 dsn 参数(如 mysql://user:pass@host:3306/db)',
30
+ 'result.masked': '检测到脱敏密码 `****`:dbhub_query / dbhub_query_objects 需要真实凭据,打码连接串不能直连。要查询已注册的环境,请改用对应的常驻工具(先用 dbhub_list_sources 查 source 值);若确需临时直连,请提供真实密码的完整 DSN。',
31
+ 'result.noSource': '找不到数据源 "{src}":请先调用 dbhub_list_sources 查看可用 source 值(如 <工作区> 或 <工作区>_<环境>)。',
32
+ 'result.sourcesIntro': '⚠️ 下面每个连接串都是脱敏显示(密码 ****),仅供识别,不要把它们拷进 dbhub_query 直连;查询某环境请用 dbhub_execute_sql / dbhub_search_objects,source 参数取该行的「source 值」。\n已注册连接源 {n} 个:',
33
+ 'result.srcRow': '- {title}({badge})|环境 {env}|连接 {dsn}(打码,仅显示)|来源 {src}|source 值 {srcId}',
34
+ 'result.sourcesEmpty': '暂无已注册的连接源(可用 dbhub_configure 或 设置 → 插件 → dsh-dbhub-live → 工作区连接 添加)。',
35
+ 'result.badgeSaved': '已保存',
36
+ 'result.badgeAuto': '自动',
37
+ 'result.savedOk': '已持久化连接并注册工具({slug},环境 {env})。下次可直接使用 dbhub_execute_sql_{tool}。',
38
+ 'result.sevSaveFail': '连接已持久化,但 dbhub 服务启动失败(下次调用会自动重试并注册工具)。见 dsh web 日志。',
39
+ 'result.serverStartFail': 'dbhub 服务启动失败: {msg}',
40
+ 'result.serverUnavailable': 'dbhub 服务不可用',
41
+ 'result.noWslabel': '没有可用的工作区',
42
+ 'result.modeCanceled': '已取消,未做任何修改',
43
+ 'result.noDsn': '未提供 DSN,已取消',
44
+ 'result.scanDenied': '未授权扫描,已取消(可改用“输入 DSN”或“填写分项”)',
45
+ 'result.scanEmpty': '未找到可扫描的数据库配置文件(已跳过 node_modules/.git/target 等)。可改用“输入 DSN”或“填写分项”。',
46
+ 'result.scanNone': '扫描了 {n} 个文件,但未提取到数据库连接。可改用“输入 DSN”或“填写分项”。',
47
+ 'result.scanPicked': '已按你的确认持久化连接(来源: 项目文件扫描,环境 {env})并注册工具({slug})。',
48
+ 'label.conn': '{连接: {src} → {dsn}}',
49
+ 'label.adhocConn': '{临时连接: {dsn}}',
50
+ }
51
+
52
+ const EN = {
53
+ 'log.hostApply': 'host apply',
54
+ 'log.nsRegistered': 'settings namespace registered: dsh-dbhub-live',
55
+ 'log.loaded': 'dbhub server loaded ({n} tools)',
56
+ 'log.readyNoSources': 'ready (no workspace sources yet — configure one with dbhub_configure)',
57
+ 'log.initFailed': 'init failed (see the recent error on the status card); retried on next call',
58
+ 'log.disabledBoot': 'plugin disabled — not initialized (enable it in Settings → Plugins → dsh-dbhub-live)',
59
+ 'log.opSaved': 'card config: {title} environment {env} saved',
60
+ 'log.opRemoved': 'card config: {title} environment {env} removed',
61
+ 'log.opNothing': 'card config: {title} has no persisted environment to remove (auto-discovered rows are not persisted)',
62
+ 'log.opNoWs': 'config operation failed: workspace "{ws}" not found',
63
+ 'log.opBad': 'ignoring invalid config operation: {raw}',
64
+ 'log.opUnknown': 'ignoring unknown config operation: {op}',
65
+ 'log.syncApplied': 'config applied: dbhub tools re-synced ({n})',
66
+ 'log.syncLater': 'tool re-sync failed after config (auto-retried on next call): {msg}',
67
+ 'log.stateSyncFail': 'status sync failed: {msg}',
68
+ 'log.updateCheckFailed': 'dbhub auto-update check failed: {msg}',
69
+ 'result.disabled': 'dsh-dbhub-live is disabled: dbhub tools are unavailable. Re-enable it in Settings → Plugins → dsh-dbhub-live.',
70
+ 'result.dsnMissing': 'missing dsn argument (e.g. mysql://user:pass@host:3306/db)',
71
+ 'result.masked': 'masked password `****` detected: dbhub_query / dbhub_query_objects need real credentials. To query a registered environment use the persistent tools (run dbhub_list_sources for the source value); for a one-off connection pass a full DSN with the real password.',
72
+ 'result.noSource': 'source "{src}" not found: run dbhub_list_sources first to see the available source values (e.g. <workspace> or <workspace>_<env>).',
73
+ 'result.sourcesIntro': '⚠️ Every DSN below is masked (password ****) and only for identification — do not copy one into dbhub_query. To query an environment use dbhub_execute_sql / dbhub_search_objects with the row\u2019s source value.\n{n} registered source(s):',
74
+ 'result.srcRow': '- {title} ({badge}) | env {env} | dsn {dsn} (masked) | source {src} | source value {srcId}',
75
+ 'result.sourcesEmpty': 'no registered sources yet (add one with dbhub_configure or Settings → Plugins → dsh-dbhub-live → Workspace connections).',
76
+ 'result.badgeSaved': 'saved',
77
+ 'result.badgeAuto': 'auto',
78
+ 'result.savedOk': 'connection persisted and tools registered ({slug}, env {env}). Next time use dbhub_execute_sql_{tool}.',
79
+ 'result.sevSaveFail': 'connection persisted but the dbhub server failed to start (auto-retried and re-registered on next call). See the dsh web log.',
80
+ 'result.serverStartFail': 'dbhub server failed to start: {msg}',
81
+ 'result.serverUnavailable': 'dbhub server unavailable',
82
+ 'result.noWslabel': 'no workspace available',
83
+ 'result.modeCanceled': 'cancelled — nothing changed',
84
+ 'result.noDsn': 'no DSN provided — cancelled',
85
+ 'result.scanDenied': 'scan not authorized — cancelled (use "enter DSN" or "fill in fields" instead)',
86
+ 'result.scanEmpty': 'no database config files found (node_modules/.git/target etc. are skipped). Use "enter DSN" or "fill in fields" instead.',
87
+ 'result.scanNone': 'scanned {n} files but extracted no database connection. Use "enter DSN" or "fill in fields" instead.',
88
+ 'result.scanPicked': 'connection persisted from your selection (source: project file scan, env {env}) and tools registered ({slug}).',
89
+ 'label.conn': '{connection: {src} → {dsn}}',
90
+ 'label.adhocConn': '{adhoc: {dsn}}',
91
+ }
92
+
93
+ let localeProvider = () => 'zh'
94
+ let cached = { at: 0, value: 'zh' }
95
+
96
+ /** Install the locale provider (set from apply with the settings service). */
97
+ export function setLocaleProvider(fn) {
98
+ localeProvider = fn
99
+ cached = { at: 0, value: 'zh' } // invalidate so the next read refreshes
100
+ }
101
+
102
+ /** Current locale (cached up to 1s; provider errors fall back to zh). */
103
+ export function currentLocale() {
104
+ const now = Date.now()
105
+ if (now - cached.at >= 1000) {
106
+ let value = 'zh'
107
+ try {
108
+ value = localeProvider() === 'en' ? 'en' : 'zh'
109
+ } catch (e) {
110
+ value = 'zh'
111
+ }
112
+ cached = { at: now, value }
113
+ }
114
+ return cached.value
115
+ }
116
+
117
+ /** Translate a key for the CURRENT locale; falls back to zh then the key. */
118
+ export function currentT(key, params) {
119
+ const dict = currentLocale() === 'en' ? EN : ZH
120
+ let s = dict[key] ?? ZH[key] ?? key
121
+ if (params) {
122
+ for (const [k, v] of Object.entries(params)) {
123
+ s = s.split('{' + k + '}').join(String(v))
124
+ }
125
+ }
126
+ return s
127
+ }
128
+
129
+ /** Translate for an explicit locale (pure — unit-testable). */
130
+ export function tFor(locale, key, params) {
131
+ const dict = locale === 'en' ? EN : ZH
132
+ let s = dict[key] ?? ZH[key] ?? key
133
+ if (params) {
134
+ for (const [k, v] of Object.entries(params)) {
135
+ s = s.split('{' + k + '}').join(String(v))
136
+ }
137
+ }
138
+ return s
139
+ }
package/lib/index.mjs CHANGED
@@ -31,22 +31,28 @@ import {
31
31
  } from './runtime.mjs'
32
32
  import {
33
33
  ensureRunning, terminateServer, isServerUp, activeToolCount, refreshToolCount,
34
+ collectSources, latestSummaries, configInputsMtime,
34
35
  } from './mcp.mjs'
35
36
  import { registerCoreTools } from './tools.mjs'
37
+ import {
38
+ listWorkspaces, setWorkspaceEnv, removeWorkspaceEnv,
39
+ } from './config.mjs'
40
+ import { setLocaleProvider, currentT } from './i18n.mjs'
36
41
  import * as state from './state.mjs'
42
+ import * as options from './options.mjs'
37
43
 
38
44
  export const name = 'dsh-dbhub-live'
39
45
  // Real-module Guard requires declared injection before touching `ctx.tools`;
40
46
  // the timer mixin powers idle recycling.
41
47
  export const inject = ['tools', 'timer']
42
48
 
43
- // ── settings namespace: live status mirror for the browser card ───────────
49
+ // ── settings namespace: live status mirror + configurable options ─────────
44
50
 
45
51
  // The namespace must match the settings-namespace pattern
46
52
  // (^[a-z][a-z0-9-]*$) — derived from @deepseek-ai/dsh-settings'
47
53
  // `settingsNamespace()` brand without importing it, so a plugin installed as a
48
54
  // local-directory link (whose bare imports resolve from the source tree) can
49
- // never fail to boot over a status accessory.
55
+ // never fail to boot over a settings accessory.
50
56
  const STATUS_NS = 'dsh-dbhub-live'
51
57
 
52
58
  const STATUS_DEFAULTS = {
@@ -55,6 +61,13 @@ const STATUS_DEFAULTS = {
55
61
  toolCount: 0,
56
62
  lastError: '',
57
63
  mode: 'lazy',
64
+ updateIntervalDays: 7,
65
+ idleMinutes: 10,
66
+ // Workspace-connection summaries ride as a JSON string so the schema never
67
+ // has to validate their arbitrary shape; `configOp` is the one-way command
68
+ // channel from the card (host consumes it and republishes without it).
69
+ workspaces: '[]',
70
+ configOp: '',
58
71
  }
59
72
 
60
73
  // Prefer the real schemastery schema (standard path, present in every dsh
@@ -71,6 +84,10 @@ try {
71
84
  toolCount: z.number().min(0).default(STATUS_DEFAULTS.toolCount),
72
85
  lastError: z.string().default(STATUS_DEFAULTS.lastError),
73
86
  mode: z.string().default(STATUS_DEFAULTS.mode),
87
+ updateIntervalDays: z.number().min(0).default(STATUS_DEFAULTS.updateIntervalDays),
88
+ idleMinutes: z.number().min(0).default(STATUS_DEFAULTS.idleMinutes),
89
+ workspaces: z.string().default(STATUS_DEFAULTS.workspaces),
90
+ configOp: z.string().default(STATUS_DEFAULTS.configOp),
74
91
  })
75
92
  } catch (e) {
76
93
  const fallbackSchema = (value) => {
@@ -85,40 +102,138 @@ try {
85
102
  console.warn('[dsh-dbhub-live] @deepseek-ai/schemastery 不可解析,使用内置最小状态 schema(状态卡片功能不受影响)')
86
103
  }
87
104
 
88
- // Mirrors the state machine into the settings namespace when a settings
89
- // provider is mounted (the Plugins tab dispatches our card from this
90
- // namespace). The namespace's `enabled` field is how the card's toggle writes
91
- // back; the host store remains the authoritative source and converges the
92
- // document at boot. Everything is wired on the injected settings fiber so it
93
- // rolls back with this plugin.
105
+ // Resolve a workspace selector (path or title) from the card ops.
106
+ async function resolveWorkspaceRef(ref) {
107
+ const workspaces = await listWorkspaces(undefined)
108
+ const wanted = String(ref || '').trim()
109
+ if (wanted) {
110
+ const byPath = workspaces.find((w) => w.path === wanted)
111
+ if (byPath) return byPath
112
+ const byTitle = workspaces.find((w) => w.title === wanted)
113
+ if (byTitle) return byTitle
114
+ }
115
+ return undefined
116
+ }
117
+
118
+ // One card command: { op: 'add'|'remove', workspace, env, dsn? }
119
+ async function handleConfigOp(rawOp, ctx, subprocess) {
120
+ let op
121
+ try {
122
+ op = JSON.parse(String(rawOp || ''))
123
+ } catch (e) {
124
+ console.warn('[dsh-dbhub-live] ' + currentT('log.opBad', { raw: String(rawOp) }))
125
+ return
126
+ }
127
+ if (!op || typeof op !== 'object') return
128
+ const ws = await resolveWorkspaceRef(op.workspace)
129
+ if (!ws) {
130
+ console.warn('[dsh-dbhub-live] ' + currentT('log.opNoWs', { ws: String(op.workspace || '') }))
131
+ return
132
+ }
133
+ try {
134
+ if (op.op === 'add' || op.op === 'set') {
135
+ const dsn = typeof op.dsn === 'string' ? op.dsn.trim() : ''
136
+ if (!dsn) throw new TypeError('DSN 不能为空')
137
+ const envName = setWorkspaceEnv(ws.path, op.env, dsn, op.source === 'collected' ? 'collected' : 'user')
138
+ console.log('[dsh-dbhub-live] ' + currentT('log.opSaved', { title: ws.title, env: envName }))
139
+ } else if (op.op === 'remove') {
140
+ const removed = removeWorkspaceEnv(ws.path, op.env)
141
+ if (!removed) console.log('[dsh-dbhub-live] ' + currentT('log.opNothing', { title: ws.title }))
142
+ else console.log('[dsh-dbhub-live] ' + currentT('log.opRemoved', { title: ws.title, env: String(op.env || 'default') }))
143
+ } else {
144
+ console.warn('[dsh-dbhub-live] ' + currentT('log.opUnknown', { op: String(op.op) }))
145
+ }
146
+ } catch (e) {
147
+ console.warn('[dsh-dbhub-live] ' + currentT('log.opNoWs', { ws: String((e && e.message) || e) }))
148
+ }
149
+ // Refresh the summaries cache AND make the running server re-sync its tool
150
+ // registrations NOW (the credentials mtime changed, the toml regenerates,
151
+ // the server restarts and every per-environment tool — including a newly
152
+ // added `_<env>` name — registers immediately). Without this, a card edit
153
+ // would only surface as a tool on the next unrelated call.
154
+ await collectSources(ctx, subprocess)
155
+ try {
156
+ await ensureRunning(ctx, subprocess)
157
+ console.log('[dsh-dbhub-live] ' + currentT('log.syncApplied', { n: activeToolCount() }))
158
+ } catch (e) {
159
+ console.warn('[dsh-dbhub-live] ' + currentT('log.syncLater', { msg: String((e && e.message) || e) }))
160
+ }
161
+ refreshToolCount()
162
+ }
163
+
164
+ // Mirrors the state machine + configurable options into the settings
165
+ // namespace when a settings provider is mounted (the Plugins tab dispatches
166
+ // our card from this namespace). The namespace's `enabled` field is how the
167
+ // card's toggle writes back, and the config fields (`dbhubPackage`,
168
+ // `updateIntervalDays`, `idleMinutes`) are how the card edits options. The
169
+ // host store remains the authoritative source and converges the document at
170
+ // boot. Everything is wired on the injected settings fiber so it rolls back
171
+ // with this plugin.
94
172
  function wireStatusNamespace(ctx) {
95
173
  ctx.inject(['settings'], (sctx) => {
96
- let scope
97
- try {
98
- scope = sctx.settings.register(STATUS_NS, STATUS_SCHEMA, { base: {} })
99
- } catch (e) {
100
- console.error('[dsh-dbhub-live] 状态命名空间注册失败: ' + String((e && e.message) || e))
101
- return
102
- }
103
- const publish = () => {
104
- scope.replace(state.snapshot()).catch((e) => {
105
- console.warn('[dsh-dbhub-live] 状态同步失败: ' + String((e && e.message) || e))
106
- })
174
+ // The whole callback runs inside one guard: a wiring failure must surface
175
+ // in the log instead of silently killing the status surface (bootstrap
176
+ // publishes, configOp commands and live summaries all live here).
177
+ const wrap = (fn) => {
178
+ try {
179
+ return fn()
180
+ } catch (e) {
181
+ console.error('[dsh-dbhub-live] 状态接线异常: ' + String((e && e.stack) || e))
182
+ return undefined
183
+ }
107
184
  }
108
- const stopWatch = scope.watch((next) => {
109
- const nextEnabled = next && typeof next === 'object' && typeof next.enabled === 'boolean'
110
- ? next.enabled
111
- : undefined
112
- if (nextEnabled !== undefined && nextEnabled !== state.isEnabled()) {
113
- state.setEnabled(nextEnabled)
185
+ wrap(() => {
186
+ const subprocess = ctx.get('subprocess')
187
+ const scope = sctx.settings.register(STATUS_NS, STATUS_SCHEMA, { base: {} })
188
+ console.log('[dsh-dbhub-live] ' + currentT('log.nsRegistered'))
189
+ // Precedence: user settings > process environment > built-in defaults.
190
+ // Capture the user layer BEFORE our first publish, so env-seeded options
191
+ // are only overridden by fields the user actually saved.
192
+ const preBootUser = (() => {
193
+ try {
194
+ const found = sctx.settings.describe().find((d) => d && d.ns === STATUS_NS)
195
+ return found && found.user && typeof found.user === 'object' ? found.user : {}
196
+ } catch (e) {
197
+ return {}
198
+ }
199
+ })()
200
+ options.applyPatch(preBootUser)
201
+ const publish = () => {
202
+ const merged = {
203
+ ...state.snapshot(),
204
+ ...options.snapshot(),
205
+ serverUp: isServerUp(),
206
+ workspaces: JSON.stringify(latestSummaries()),
207
+ }
208
+ scope.replace(merged).catch((e) => {
209
+ console.warn('[dsh-dbhub-live] 状态同步失败: ' + String((e && e.message) || e))
210
+ })
114
211
  }
212
+ // Fresh summaries at attach, then mirror every state change.
213
+ collectSources(ctx, subprocess).then(publish, () => publish())
214
+ const stopWatch = scope.watch((next) => {
215
+ const value = next && typeof next === 'object' ? next : {}
216
+ if (typeof value.enabled === 'boolean' && value.enabled !== state.isEnabled()) {
217
+ state.setEnabled(value.enabled)
218
+ }
219
+ // Config edits from the card land here; applying them republishes the
220
+ // merged snapshot (the republish is a no-op for unchanged fields).
221
+ if (options.applyPatch(value)) publish()
222
+ // One-way workspace-config command.
223
+ if (typeof value.configOp === 'string' && value.configOp.trim() !== '' && value.configOp !== '{}') {
224
+ const op = value.configOp
225
+ handleConfigOp(op, ctx, subprocess).then(publish, () => publish())
226
+ }
227
+ })
228
+ const stopStatus = state.subscribe(publish)
229
+ const stopOptions = options.subscribe(publish)
230
+ sctx.effect(() => () => {
231
+ stopWatch()
232
+ stopStatus()
233
+ stopOptions()
234
+ })
235
+ publish()
115
236
  })
116
- const unsubscribe = state.subscribe(publish)
117
- sctx.effect(() => () => {
118
- stopWatch()
119
- unsubscribe()
120
- })
121
- publish()
122
237
  })
123
238
  }
124
239
 
@@ -144,27 +259,27 @@ function startLazyInit(ctx, subprocess) {
144
259
  state.clearError()
145
260
  state.setPhase('running')
146
261
  refreshToolCount()
147
- console.log('[dsh-dbhub-live] dbhub 服务已加载(' + activeToolCount() + ' 个工具)')
262
+ console.log('[dsh-dbhub-live] ' + currentT('log.loaded', { n: activeToolCount() }))
148
263
  // Background, non-blocking: refresh the auto-installed dbhub if its
149
264
  // update interval has elapsed. Fails silently — the current binary
150
265
  // stays in use.
151
266
  maybeRefreshDbhub(subprocess).catch((e) => {
152
- console.error('[dsh-dbhub-live] dbhub 自动更新检查失败: ' + String((e && e.message) || e))
267
+ console.error('[dsh-dbhub-live] ' + currentT('log.updateCheckFailed', { msg: String((e && e.message) || e) }))
153
268
  })
154
269
  } else if (state.getState().phase !== 'error') {
155
270
  // No workspace data source yet (or the server was recycled): the
156
271
  // plugin is healthy and waiting — mark it running, not initializing.
157
272
  state.clearError()
158
273
  state.setPhase('running')
159
- console.log('[dsh-dbhub-live] 插件就绪(暂无工作区数据源,可用 dbhub_configure 配置后自动启动服务)')
274
+ console.log('[dsh-dbhub-live] ' + currentT('log.readyNoSources'))
160
275
  } else {
161
276
  // A real start failure was already recorded into the state machine;
162
277
  // the next tool call retries automatically.
163
- console.error('[dsh-dbhub-live] 初始化失败(状态卡片可见最近错误),下次调用自动重试')
278
+ console.error('[dsh-dbhub-live] ' + currentT('log.initFailed'))
164
279
  }
165
280
  } catch (e) {
166
281
  state.recordError(String((e && e.message) || e))
167
- console.error('[dsh-dbhub-live] 初始化失败: ' + String((e && e.message) || e))
282
+ console.error('[dsh-dbhub-live] ' + currentT('log.initFailed') + ': ' + String((e && e.message) || e))
168
283
  } finally {
169
284
  initInFlight = undefined
170
285
  }
@@ -175,6 +290,19 @@ function startLazyInit(ctx, subprocess) {
175
290
  // ── apply ─────────────────────────────────────────────────────────────────
176
291
 
177
292
  export async function apply(ctx) {
293
+ console.log('[dsh-dbhub-live] ' + currentT('log.hostApply'))
294
+ // Host-side feedback follows the dsh UI language (settings.locale.preference).
295
+ setLocaleProvider(() => {
296
+ try {
297
+ const settings = ctx.get('settings')
298
+ if (!settings) return 'zh'
299
+ const entry = settings.describe().find((d) => d && d.ns === 'locale')
300
+ const preference = entry && entry.value && entry.value.preference
301
+ return preference === 'en' ? 'en' : 'zh'
302
+ } catch (e) {
303
+ return 'zh'
304
+ }
305
+ })
178
306
  const subprocess = ctx.get('subprocess')
179
307
  registerCoreTools(ctx, subprocess)
180
308
  refreshToolCount()
@@ -212,6 +340,6 @@ export async function apply(ctx) {
212
340
  /* recorded in state */
213
341
  })
214
342
  } else {
215
- console.log('[dsh-dbhub-live] 插件处于禁用状态,未自动初始化(可在 设置 → 插件 → dsh-dbhub-live 开启)')
343
+ console.log('[dsh-dbhub-live] ' + currentT('log.disabledBoot'))
216
344
  }
217
345
  }