dsh-dbhub-live 3.1.2 → 3.1.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 CHANGED
@@ -34,6 +34,7 @@ cordis.patch.yml bundle patch:`name: dsh-dbhub-live` 挂载本包
34
34
  - **工具声明数量恒定(上下文预算红线)**:**绝不为每个工作区 × 环境注册独有工具**(否则 2N 个工具声明每轮进模型上下文)。常驻查询只有 `dbhub_execute_sql(source, …)` / `dbhub_search_objects(source, …)` 两个声明,调用时 `resolveSource` 按 source 值(`标题_hash[_环境]`,来自 `dbhub_list_sources`)解析到真实连接并调用 `dbhub_<base>_<sourceId>`。曾实现过的 per-source 注册(syncToolsNow/兜底/升级)已移除,勿回恢复。核心工具全集固定:configure / list_sources / execute_sql / search_objects / query / query_objects。
35
35
  - **schema 弹性依赖**:优先用真实 `@deepseek-ai/schemastery` schema(`await import`);解析失败时降级为 `lib/index.mjs` 内建的最小 callable schema(`schema(v)` 合默认值 + `toJSON()`),保证**链路安装(`dsh plugin add <本地目录>`,Node ESM 按源码真实路径解析裸导入)下插件照样启动、卡片照常工作**。tarball/npm 安装(真实目录在 profile node_modules 下)走真实 schemastery 路径。不要把这个 import 改回静态顶层 import——会重新引入链路安装时启动失败。
36
36
  - **懒加载**:`apply()` 只注册核心工具 + 后台异步初始化(`startLazyInit`);任何工具调用先 `ensureRunning`(共享 `server.starting`,并发调用自动排队等待);启动失败进入 `phase:'error'` 并记录 `lastError`,下次调用自动重试。**无工作区数据源时恒不拉起 dbhub 进程**(空 `[[sources]]` toml 对 dbhub 是致命的;空指纹若被当成“已同步”会在二次调用时绕过 toml 直接 spawn——`ensureRunning` 的 `sources.length === 0` 早退必须在指纹判断之外)。
37
+ - **多源故障隔离(toml 每源 `lazy = true`)**:dbhub 对 eager 源是**启动串行全连、一源拒连整进程 Fatal exit**(3.1.2 事故:一个测试环境库连不上 → 所有环境工具全挂)。`generateToml` 因此给每个源写 `lazy = true`(dbhub ≥1.2 原生特性,老版本会静默忽略退回 eager):启动只登记不连库,`execute_sql_<id>` 等工具名照常注册;某源连不上只在**该 source 的调用**返回 `SOURCE_UNREACHABLE` 类 isError 文本(`runOnServer` 已给 isError 也加环境标签),进程与其他源不受影响。副作用:卡片 🟢 只代表服务在线,不代表每个库可达(`dbhub_list_sources` 与工具描述已注明)。
37
38
  - **启用/禁用**:`state.setEnabled` 持久化到 `credentials.json`(权威值);禁用时立即 `terminateServer()` 释放进程,所有工具 execute 首行返回「插件已禁用」;重新启用触发懒加载初始化。
38
39
  - **密码脱敏**:`maskDsn` 是唯一出口——工具描述、结果前缀、扫描候选一律走它。
39
40
 
package/README.en.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  ## ✨ Features
16
16
 
17
- - **Persistent multi-source service** — a single background dbhub service connects to multiple sources; connections are reused and queries are faster.
17
+ - **Persistent multi-source service** — a single background dbhub service connects to multiple sources; connections are reused and queries are faster. Each source connects lazily, so one unreachable environment never takes the others down (fault isolation).
18
18
  - **Per-workspace tools** — `dbhub_execute_sql_<workspace>` / `dbhub_search_objects_<workspace>`; the tool name is the workspace, multiple workspaces never mix up, and connections are clearly labeled (passwords masked).
19
19
  - **Ad-hoc dynamic connections** — `dbhub_query` / `dbhub_query_objects` connect to any database independently on each call and can query several databases in parallel, handy for cross-environment diagnosis.
20
20
  - **Lazy-load startup** — plugin startup does not block the GUI and tools are available immediately; environment initialization is deferred to the first call (queries during initialization automatically wait until ready).
@@ -40,6 +40,9 @@ dsh plugin --profile web add dsh-dbhub-live
40
40
 
41
41
  # Option 2: invoke dsh via npx (no global dsh installation required)
42
42
  npx @deepseek-ai/dsh plugin --profile web add dsh-dbhub-live
43
+
44
+ # Update to a specific version (pin the currently published version so pnpm doesn't skip with "Already up to date")
45
+ dsh plugin --profile web update dsh-dbhub-live@3.1.3
43
46
  ```
44
47
 
45
48
  After installing, **restart `dsh web`** for it to take effect (you can then see the status card at Settings → Plugins → dsh-dbhub-live).
@@ -86,6 +89,8 @@ If a workspace already has `mise env` or `.env` (`DSN` / `DB_*`), the plugin dis
86
89
 
87
90
  Settings → Plugins → dsh-dbhub-live: the plugin syncs its running state and configuration to the Web settings panel in real time (only visible on the `dsh web` side). The card uses the **single-row collapsible** style (consistent with the other plugin settings cards):
88
91
 
92
+ > All card copy (name, status, config fields, buttons, connection rows) follows the dsh UI language (Chinese / English — Settings → General → Language); model-facing errors, feedback and the host logs follow it as well.
93
+
89
94
  **Collapsed (default)**: one row shows the status badge (🟢 running / 🟡 initializing / 🔴 error / ⚪ disabled), environment count, and the **enable/disable switch** — you can check the status and toggle without expanding.
90
95
 
91
96
  **Expanded** shows three blocks —
@@ -150,6 +155,7 @@ dsh plugin --profile web remove dsh-dbhub-live
150
155
  | Symptom | Fix |
151
156
  | --- | --- |
152
157
  | "Cannot locate dbhub" on first use | Make sure npm is present and online; offline, install `dbhub` manually and add it to PATH. |
158
+ | A query to one environment fails with `SOURCE_UNREACHABLE` / connection refused | Persistent connections are established lazily **per source with fault isolation**: one unreachable environment (test DB down, VPN off) only makes that environment's queries fail — other environments and the plugin keep working. Fix host/port/network and retry. |
153
159
  | Status card shows 🔴 error | Check "Most recent error" in the status card and the `dsh web` logs; a crashed process is auto-restarted on the next call. |
154
160
  | Tools say "plugin disabled" | Open Settings → Plugins → dsh-dbhub-live and click "Enable". |
155
161
  | Status card not visible | Confirm the plugin is installed and restart `dsh web`; the card only shows in the Web settings panel (`dsh web`) — on terminal environments without the panel, tool usage is unaffected. |
package/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  ## ✨ 特性
16
16
 
17
- - **常驻多源服务** — 后台一个 dbhub 服务同时接入多个数据源,连接复用、查询更快。
17
+ - **常驻多源服务** — 后台一个 dbhub 服务同时接入多个数据源,连接复用、查询更快;连接按源懒建立,单个环境数据库不可达不影响其他环境(故障隔离)。
18
18
  - **按工作区工具** — `dbhub_execute_sql_<工作区>` / `dbhub_search_objects_<工作区>`,工具名即工作区,多工作区不混淆,连接标注(密码打码)清晰可见。
19
19
  - **临时动态连接** — `dbhub_query` / `dbhub_query_objects` 每次独立连任意库,可并行查多个不同库,便于跨环境排查。
20
20
  - **懒加载启动** — 插件启动不阻塞 GUI,工具立即可用;环境初始化推迟到首次调用(初始化期间查询自动等待就绪)。
@@ -40,6 +40,9 @@ dsh plugin --profile web add dsh-dbhub-live
40
40
 
41
41
  # 方式二:通过 npx 调用 dsh(无需本机全局安装 dsh)
42
42
  npx @deepseek-ai/dsh plugin --profile web add dsh-dbhub-live
43
+
44
+ # 更新到指定版本(推荐写明当前发布的版本号,避免 pnpm 判「Already up to date」跳过)
45
+ dsh plugin --profile web update dsh-dbhub-live@3.1.3
43
46
  ```
44
47
 
45
48
  安装后**重启 `dsh web`** 生效(重启后到 设置 → 插件 → dsh-dbhub-live 可看到状态卡片)。
@@ -86,6 +89,8 @@ dbhub_query dsn=mysql://user:pass@127.0.0.1:3306/mydb sql="SHOW TABLES;"
86
89
 
87
90
  设置 → 插件 → dsh-dbhub-live:插件会把运行状态与配置实时同步到 Web 设置面板(仅 `dsh web` 端可见)。卡片为**整行折叠**样式(与其它插件设置卡一致):
88
91
 
92
+ > 卡片全部文案(名称、状态、配置项、按钮、连接行)跟随 dsh 界面语言(中 / 英,设置 → 通用 → 语言)切换;模型的报错/反馈与宿主日志同样跟随。
93
+
89
94
  **折叠态(默认)**:一行显示 运行状态徽章(🟢 运行中 / 🟡 初始化中 / 🔴 异常 / ⚪ 已禁用)、环境数量、**启用/禁用开关**——不展开也能看状态、切开关。
90
95
 
91
96
  **展开后**分三块——
@@ -150,6 +155,7 @@ dsh plugin --profile web remove dsh-dbhub-live
150
155
  | 现象 | 处理 |
151
156
  | --- | --- |
152
157
  | 首次使用报「无法获取 dbhub」 | 确认本机有 npm 且能联网;离线可手动安装 `dbhub` 并加入 PATH。 |
158
+ | 某个环境的查询报 `SOURCE_UNREACHABLE` / 连接被拒 | 常驻连接为**按需建立、环境间故障隔离**:某个环境的数据库不可达(如测试库停机、VPN 未连)只会让该环境的查询返回连接错误,其他环境与插件整体不受影响。核对该环境的主机/端口/网络后重试即可。 |
153
159
  | 状态卡片显示 🔴 异常 | 查看状态卡片中的「最近错误」与 `dsh web` 日志;进程异常退出时插件会在下次调用自动重启。 |
154
160
  | 工具显示「插件已禁用」 | 打开 设置 → 插件 → dsh-dbhub-live 卡片,点击「启用」。 |
155
161
  | 看不到状态卡片 | 确认插件已安装并重启 `dsh web`;状态卡片只在 Web 设置面板(`dsh web`)显示,在无设置面板的终端环境下不影响工具使用。 |
@@ -26,10 +26,11 @@
26
26
 
27
27
  ### 3. 运行时保障
28
28
  - 常驻连接复用,提升多次查询效率;空闲一段时间自动回收。
29
+ - **多环境故障隔离**:连接按环境懒建立——某个环境的数据库不可达(测试库停机、网络不通等)只影响该环境的查询(返回明确的连接错误),其余环境与服务整体照常可用。
29
30
  - 首次使用若缺少依赖则自动安装,之后定期自动更新;用户自行安装的版本不受影响。
30
31
  - 服务异常退出后,下次调用自动恢复,无需人工干预。
31
32
 
32
- ## 二、本次新增功能
33
+ ## 二、v3.1 新增功能(当前已发布:3.1.3)
33
34
 
34
35
  ### 1. 懒加载启动优化
35
36
  - 插件启动不阻塞 GUI,工具立即可用。
@@ -38,12 +39,13 @@
38
39
  ### 2. 插件状态管理
39
40
  - **启用 / 禁用开关**:关闭后所有 dbhub 相关工具立即返回「插件已禁用」友好提示,无需重启;再次开启立即可用。
40
41
  - **运行状态**:运行中 / 初始化中 / 异常 三态,实时反映插件可用性。
41
- - **工具数量**:当前已注册工具总数实时展示。
42
+ - **工具数量**:当前已注册工具总数实时展示(恒定 6 个声明,不随环境增减)。
42
43
  - **错误摘要**:最近一次初始化或运行错误简要信息,异常时展示。
43
44
 
44
45
  ### 3. 浏览器状态卡片(设置在插件的状态面板)
45
- - **整行折叠样式**(与其余插件设置卡一致):折叠态一行展示运行状态徽章(🟢 / 🟡 / 🔴 / ⚪)、环境数量与 **启用 / 禁用开关**(不展开即可看状态、切开关);展开后分「状态 / 配置 / 工作区连接」三块。
46
- - 状态块为辅助信息:常驻进程状态、工作模式(懒加载)、**工具声明数(固定 6,非重点)**、环境计数(N 个 · 已保存 M)、最近错误(红色展示)。
46
+ - **整行折叠样式**(与其余插件设置卡一致):折叠态一行展示插件显示名、运行状态徽章(🟢 / 🟡 / 🔴 / ⚪)、环境数量与 **启用 / 禁用开关**(不展开即可看状态、切开关);展开后分「状态 / 配置 / 工作区连接」三块(新增连接表单并入工作区连接块)。
47
+ - 状态块为辅助信息且**随宽度自适应多列**:常驻进程状态、工作模式(懒加载)、**工具声明数(固定 6,非重点)**、环境计数(N 个 · 已保存 M)、最近错误(红色展示)。
48
+ - 配置块置于**分组框**内(保存按钮归属清晰):自动更新间隔、空闲回收时长 + 保存。
47
49
  - 工作区连接块逐行展示:工作区名、环境名、**source 值**(等宽字体,供 `dbhub_execute_sql` 使用)、打码连接串、来源徽章(已保存 / 自动),支持修改 / 删除 / 新增。
48
50
  - 启用/禁用开关即时生效。
49
51
 
@@ -62,4 +64,10 @@
62
64
  - **变更即时生效**:卡片新增/修改/删除连接后,常驻 dbhub 服务立即重启,下一次查询即可使用新环境的 `source` 值,无需重启实例。
63
65
  - **连接源枚举工具**:`dbhub_list_sources` 列出全部已注册连接源(工作区 × 环境、打码连接串、来源与对应 source 值),避免把「测试环境」误当成默认(生产)库。
64
66
  - **脱敏连接串不可直连**:`dbhub_list_sources` 中展示的连接串密码为 `****`(仅识别用途);`dbhub_query` / `dbhub_query_objects` 检测到脱敏密码时会直接拒绝并引导使用常驻工具(内置真实凭据),避免用 `****` 白跑一次失败连接。
65
- - 自动配置与已保存配置并存展示;自动发现与实际不符时可手动修改覆盖。
67
+ - 自动配置与已保存配置并存展示;自动发现与实际不符时可手动修改覆盖。
68
+
69
+ ### 6. 多语言(跟随 dsh 界面语言)
70
+ - 卡片的**全部界面文案**(插件显示名、状态徽章与网格、配置项与按钮、工作区连接行、空态与占位符)随 dsh 主体界面语言切换(中 / 英)。
71
+ - 模型侧反馈(工具结果、报错与异常提示、脱敏/引导文案)与宿主日志同样跟随 dsh 语言设置;中文为默认与兜底。
72
+ - 工具的**描述文案**保持中文(产品约定:模型可见文案统一中文,便于跨会话一致)。
73
+ - 双语 README(中文 `README.md` 为唯一真源,英文 `README.en.md` 派生)。
package/lib/config.mjs CHANGED
@@ -292,12 +292,21 @@ export function fingerprintOf(sources) {
292
292
  return sources.map((s) => s.id + '|' + s.dsn).join('\n')
293
293
  }
294
294
 
295
+ // Every source is emitted `lazy = true`: dbhub connects it on first use
296
+ // instead of eagerly at startup. dbhub's eager connect is all-or-nothing —
297
+ // ONE unreachable environment (ECONNREFUSED etc.) kills the whole process
298
+ // fatally, taking every other workspace/env tool down with it. Lazy sources
299
+ // register at startup regardless of reachability (execute_sql_<id> etc. are
300
+ // built from the config source list), and a connect failure only ever
301
+ // surfaces as an isError result on that source's own call. Older dbhub
302
+ // builds strip the unknown key and fall back to the previous eager behavior.
295
303
  export function generateToml(sources) {
296
304
  const lines = ['# Auto-generated by dsh-dbhub-live — do not edit. Regenerated on demand.']
297
305
  for (const s of sources) {
298
306
  lines.push('[[sources]]')
299
307
  lines.push('id = "' + tomlEscape(s.id) + '"')
300
308
  lines.push('dsn = "' + tomlEscape(s.dsn) + '"')
309
+ lines.push('lazy = true')
301
310
  }
302
311
  return lines.join('\n') + '\n'
303
312
  }
package/lib/i18n.mjs CHANGED
@@ -29,7 +29,7 @@ const ZH = {
29
29
  'result.dsnMissing': '缺少 dsn 参数(如 mysql://user:pass@host:3306/db)',
30
30
  'result.masked': '检测到脱敏密码 `****`:dbhub_query / dbhub_query_objects 需要真实凭据,打码连接串不能直连。要查询已注册的环境,请改用对应的常驻工具(先用 dbhub_list_sources 查 source 值);若确需临时直连,请提供真实密码的完整 DSN。',
31
31
  'result.noSource': '找不到数据源 "{src}":请先调用 dbhub_list_sources 查看可用 source 值(如 <工作区> 或 <工作区>_<环境>)。',
32
- 'result.sourcesIntro': '⚠️ 下面每个连接串都是脱敏显示(密码 ****),仅供识别,不要把它们拷进 dbhub_query 直连;查询某环境请用 dbhub_execute_sql / dbhub_search_objects,source 参数取该行的「source 值」。\n已注册连接源 {n} 个:',
32
+ 'result.sourcesIntro': '⚠️ 下面每个连接串都是脱敏显示(密码 ****),仅供识别,不要把它们拷进 dbhub_query 直连;查询某环境请用 dbhub_execute_sql / dbhub_search_objects,source 参数取该行的「source 值」。\n已注册连接源 {n} 个(连接为按需建立,列出不代表数据库当前可达):',
33
33
  'result.srcRow': '- {title}({badge})|环境 {env}|连接 {dsn}(打码,仅显示)|来源 {src}|source 值 {srcId}',
34
34
  'result.sourcesEmpty': '暂无已注册的连接源(可用 dbhub_configure 或 设置 → 插件 → dsh-dbhub-live → 工作区连接 添加)。',
35
35
  'result.badgeSaved': '已保存',
@@ -70,7 +70,7 @@ const EN = {
70
70
  'result.dsnMissing': 'missing dsn argument (e.g. mysql://user:pass@host:3306/db)',
71
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
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):',
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) (connections are lazy — being listed does not mean the database is currently reachable):',
74
74
  'result.srcRow': '- {title} ({badge}) | env {env} | dsn {dsn} (masked) | source {src} | source value {srcId}',
75
75
  'result.sourcesEmpty': 'no registered sources yet (add one with dbhub_configure or Settings → Plugins → dsh-dbhub-live → Workspace connections).',
76
76
  'result.badgeSaved': 'saved',
package/lib/mcp.mjs CHANGED
@@ -3,7 +3,10 @@
3
3
  // ONE persistent `dbhub --transport stdio --config <generated dbhub.toml>`
4
4
  // process, managed here: idle recycle, respawn on demand, lazy init. The toml
5
5
  // is auto-generated from every registered workspace's connection config and
6
- // hot-reloaded by dbhub itself — users never maintain it.
6
+ // hot-reloaded by dbhub itself — users never maintain it. Every generated
7
+ // source is `lazy = true` (see config.mjs/generateToml): the server starts
8
+ // even when one environment's database is unreachable, and the failure only
9
+ // surfaces as an error on that source's own calls.
7
10
  //
8
11
  // This module also owns the per-workspace tool registrations and their
9
12
  // disposers (the source of truth for the registered-tool count).
@@ -439,7 +442,9 @@ export async function runOnServer(ctx, subprocess, rawName, args, exec, label) {
439
442
  }
440
443
  resetIdle(ctx)
441
444
  const prefix = label ? label + '\n' : ''
442
- if (res && res.isError) return { ok: false, text: textOf(res) }
445
+ // Label the failure too: a lazy source connects on first use, so a dead
446
+ // database surfaces HERE — the model must see which environment failed.
447
+ if (res && res.isError) return { ok: false, text: prefix + textOf(res) }
443
448
  if (res && res.structuredContent !== undefined) {
444
449
  return { ok: true, text: prefix + JSON.stringify(res.structuredContent, null, 2) }
445
450
  }
package/lib/tools.mjs CHANGED
@@ -224,7 +224,8 @@ function buildListSourcesTool(ctx, subprocess) {
224
224
  '列出当前已注册的全部数据库连接源(每个 = 工作区 × 环境)及其对应的常驻查询工具名。' +
225
225
  '注意:列表中的连接串是脱敏显示(密码为 ****),仅供识别环境,绝不能拷进 dbhub_query / dbhub_query_objects 直连(会失败);' +
226
226
  '查询某个环境请直接调用该行给出的 dbhub_execute_sql_<工作区>[_<环境>] 工具(已内置真实凭据)。' +
227
- '当用户提到某个环境(如 test/测试)但你不确定它是否已注册时,先调用本工具确认环境名,再调用对应环境的工具,避免误用默认(生产)库。无参数。',
227
+ '当用户提到某个环境(如 test/测试)但你不确定它是否已注册时,先调用本工具确认环境名,再调用对应环境的工具,避免误用默认(生产)库。' +
228
+ '常驻连接为懒建立:本工具能列出的 source 不代表数据库当前可达,若某环境库不可达,只有对该 source 的调用会返回连接错误(不影响其他环境)。无参数。',
228
229
  parameters: {
229
230
  type: 'object',
230
231
  properties: {},
@@ -322,7 +323,8 @@ export function registerCoreTools(ctx, subprocess) {
322
323
  name: 'dbhub_execute_sql',
323
324
  description:
324
325
  '在指定数据源的常驻连接上执行 SQL。source 必填:数据源标识,先调用 dbhub_list_sources 查看全部可用值与对应环境(如 <工作区> 为默认环境,<工作区>_<环境> 为命名环境,例如通过 _test 指向测试库)。' +
325
- 'sql:要执行的 SQL(多语句用 ; 分隔)。结果自动标注所连环境。',
326
+ 'sql:要执行的 SQL(多语句用 ; 分隔)。结果自动标注所连环境。' +
327
+ '连接为按需建立(该 source 首次调用时才真正连库):某个环境的数据库不可达只会让该 source 的调用返回连接错误,不影响其他环境查询与常驻服务存活。',
326
328
  parameters: {
327
329
  type: 'object',
328
330
  properties: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dbhub-live",
3
- "version": "3.1.2",
3
+ "version": "3.1.3",
4
4
  "description": "DSH 数据库 MCP 桥接插件:常驻多源 dbhub 服务 + source 参数化常驻工具 + 临时动态连接 + 懒加载、浏览器状态卡片与可配置参数。",
5
5
  "type": "module",
6
6
  "main": "lib/index.mjs",
@@ -1,96 +1,112 @@
1
- // Unit tests for the pure utilities in lib/config.mjs and lib/collect.mjs.
2
- // Test isolation: these modules derive the storage directory from DSH_HOME at
3
- // import time, so point DSH_HOME at a temp dir BEFORE importing them.
4
-
5
- import test from 'node:test'
6
- import assert from 'node:assert/strict'
7
- import { mkdtempSync, rmSync, existsSync } from 'node:fs'
8
- import { tmpdir } from 'node:os'
9
- import { join } from 'node:path'
10
-
11
- const home = mkdtempSync(join(tmpdir(), 'dsh-dbhub-test-'))
12
- process.env.DSH_HOME = home
13
-
14
- const cfg = await import('../lib/config.mjs')
15
-
16
- test.after(() => {
17
- rmSync(home, { recursive: true, force: true })
18
- })
19
-
20
- test('slugify normalizes titles', () => {
21
- assert.equal(cfg.slugify('MyApp'), 'myapp')
22
- assert.equal(cfg.slugify('hbtx-server-master_1hn7yaw'), 'hbtx-server-master_1hn7yaw')
23
- assert.equal(cfg.slugify('A B!/c'), 'a-b-c')
24
- assert.equal(cfg.slugify(''), 'ws')
25
- assert.equal(cfg.slugify(undefined), 'ws')
26
- })
27
-
28
- test('tomlEscape escapes backslash, quotes and newlines', () => {
29
- assert.equal(cfg.tomlEscape('a"b'), 'a\\"b')
30
- assert.equal(cfg.tomlEscape('a\\b'), 'a\\\\b')
31
- assert.equal(cfg.tomlEscape('a\nb'), 'a\\nb')
32
- })
33
-
34
- test('shortHash is stable and hex-ish', () => {
35
- assert.equal(cfg.shortHash('abc'), cfg.shortHash('abc'))
36
- assert.notEqual(cfg.shortHash('abc'), cfg.shortHash('another'))
37
- assert.match(cfg.shortHash('abc'), /^[a-z0-9]+$/)
38
- assert.ok(cfg.shortHash('abc').length > 0)
39
- })
40
-
41
- test('maskDsn hides passwords, keeps host/db', () => {
42
- assert.equal(cfg.maskDsn('mysql://root:secret@10.0.0.1:3306/mydb'),
43
- 'mysql://root:****@10.0.0.1:3306/mydb')
44
- assert.equal(cfg.maskDsn('postgres://u:p@h/db'), 'postgres://u:****@h/db')
45
- // no password -> unchanged
46
- assert.equal(cfg.maskDsn('sqlite:///C:/data/x.db'), 'sqlite:///C:/data/x.db')
47
- // unparseable DSN falls back to regex masking
48
- assert.equal(cfg.maskDsn('mysql://root:secret@h:3306/d'), 'mysql://root:****@h:3306/d')
49
- })
50
-
51
- test('dsnFromEnv joins DB_* variables and handles sqlite', () => {
52
- const env = { DB_HOST: '10.0.0.1', DB_PORT: '3307', DB_USER: 'root', DB_PASSWORD: 'pw', DB_NAME: 'tx_zdsf_main_pro' }
53
- assert.equal(cfg.dsnFromEnv(env), 'mysql://root:pw@10.0.0.1:3307/tx_zdsf_main_pro')
54
- assert.equal(cfg.dsnFromEnv({ DB_HOST: 'a', DB_USER: 'b', DB_NAME: 'c', DB_TYPE: 'sqlite' }), 'sqlite:///a')
55
- assert.equal(cfg.dsnFromEnv({ DB_HOST: 'a' }), undefined)
56
- })
57
-
58
- test('dsnFromMap prefers explicit DSN', () => {
59
- assert.equal(cfg.dsnFromMap({ DSN: 'postgres://u@h/db' }), 'postgres://u@h/db')
60
- assert.equal(cfg.dsnFromMap({ DB_HOST: 'h', DB_USER: 'u', DB_NAME: 'd' }), 'mysql://u:@h/d')
61
- })
62
-
63
- test('parseEnvFile skips comments/quotes', () => {
64
- const map = cfg.parseEnvFile('# c\nDSN="mysql://u@h/d"\nEMPTY=\nFOO=bar')
65
- assert.equal(map.DSN, 'mysql://u@h/d')
66
- assert.equal(map.FOO, 'bar')
67
- assert.equal(map.EMPTY, '')
68
- })
69
-
70
- test('generateToml and fingerprintOf', () => {
71
- const sources = [{ id: 'ws_abc', dsn: 'mysql://u@h/d' }]
72
- const toml = cfg.generateToml(sources)
73
- assert.match(toml, /^# Auto-generated/)
74
- assert.match(toml, /id = "ws_abc"/)
75
- assert.match(toml, /dsn = "mysql:\/\/u@h\/d"/)
76
- assert.notEqual(cfg.fingerprintOf(sources), cfg.fingerprintOf([{ id: 'ws_abc', dsn: 'other' }]))
77
- })
78
-
79
- test('collect: walkForCandidates skips noise dirs and finds config files', async () => {
80
- const { walkForCandidates, extractDsnCandidates } = await import('../lib/collect.mjs')
81
- const { writeFileSync, mkdirSync } = await import('node:fs')
82
- const root = join(home, 'proj')
83
- mkdirSync(join(root, 'node_modules'), { recursive: true })
84
- mkdirSync(join(root, 'src'), { recursive: true })
85
- writeFileSync(join(root, '.env'), 'DSN=mysql://root:pw@10.0.0.1:3306/appdb\n')
86
- writeFileSync(join(root, 'node_modules', 'x.env'), 'DSN=mysql://nope@x/y\n')
87
- writeFileSync(join(root, 'src', 'application.yml'), 'url: jdbc:mysql://1.2.3.4:3306/biz?user=u&password=p\n')
88
-
89
- const found = walkForCandidates(root, { count: 0 })
90
- assert.ok(found.includes(join(root, '.env')))
91
- assert.ok(found.includes(join(root, 'src', 'application.yml')))
92
- assert.ok(!found.some((f) => f.includes('node_modules')))
93
-
94
- const candidates = extractDsnCandidates(join(root, '.env'), 'DSN=mysql://root:pw@10.0.0.1:3306/appdb\n')
95
- assert.ok(candidates.some((c) => c.dsn === 'mysql://root:pw@10.0.0.1:3306/appdb'))
1
+ // Unit tests for the pure utilities in lib/config.mjs and lib/collect.mjs.
2
+ // Test isolation: these modules derive the storage directory from DSH_HOME at
3
+ // import time, so point DSH_HOME at a temp dir BEFORE importing them.
4
+
5
+ import test from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+ import { mkdtempSync, rmSync, existsSync } from 'node:fs'
8
+ import { tmpdir } from 'node:os'
9
+ import { join } from 'node:path'
10
+
11
+ const home = mkdtempSync(join(tmpdir(), 'dsh-dbhub-test-'))
12
+ process.env.DSH_HOME = home
13
+
14
+ const cfg = await import('../lib/config.mjs')
15
+
16
+ test.after(() => {
17
+ rmSync(home, { recursive: true, force: true })
18
+ })
19
+
20
+ test('slugify normalizes titles', () => {
21
+ assert.equal(cfg.slugify('MyApp'), 'myapp')
22
+ assert.equal(cfg.slugify('hbtx-server-master_1hn7yaw'), 'hbtx-server-master_1hn7yaw')
23
+ assert.equal(cfg.slugify('A B!/c'), 'a-b-c')
24
+ assert.equal(cfg.slugify(''), 'ws')
25
+ assert.equal(cfg.slugify(undefined), 'ws')
26
+ })
27
+
28
+ test('tomlEscape escapes backslash, quotes and newlines', () => {
29
+ assert.equal(cfg.tomlEscape('a"b'), 'a\\"b')
30
+ assert.equal(cfg.tomlEscape('a\\b'), 'a\\\\b')
31
+ assert.equal(cfg.tomlEscape('a\nb'), 'a\\nb')
32
+ })
33
+
34
+ test('shortHash is stable and hex-ish', () => {
35
+ assert.equal(cfg.shortHash('abc'), cfg.shortHash('abc'))
36
+ assert.notEqual(cfg.shortHash('abc'), cfg.shortHash('another'))
37
+ assert.match(cfg.shortHash('abc'), /^[a-z0-9]+$/)
38
+ assert.ok(cfg.shortHash('abc').length > 0)
39
+ })
40
+
41
+ test('maskDsn hides passwords, keeps host/db', () => {
42
+ assert.equal(cfg.maskDsn('mysql://root:secret@10.0.0.1:3306/mydb'),
43
+ 'mysql://root:****@10.0.0.1:3306/mydb')
44
+ assert.equal(cfg.maskDsn('postgres://u:p@h/db'), 'postgres://u:****@h/db')
45
+ // no password -> unchanged
46
+ assert.equal(cfg.maskDsn('sqlite:///C:/data/x.db'), 'sqlite:///C:/data/x.db')
47
+ // unparseable DSN falls back to regex masking
48
+ assert.equal(cfg.maskDsn('mysql://root:secret@h:3306/d'), 'mysql://root:****@h:3306/d')
49
+ })
50
+
51
+ test('dsnFromEnv joins DB_* variables and handles sqlite', () => {
52
+ const env = { DB_HOST: '10.0.0.1', DB_PORT: '3307', DB_USER: 'root', DB_PASSWORD: 'pw', DB_NAME: 'tx_zdsf_main_pro' }
53
+ assert.equal(cfg.dsnFromEnv(env), 'mysql://root:pw@10.0.0.1:3307/tx_zdsf_main_pro')
54
+ assert.equal(cfg.dsnFromEnv({ DB_HOST: 'a', DB_USER: 'b', DB_NAME: 'c', DB_TYPE: 'sqlite' }), 'sqlite:///a')
55
+ assert.equal(cfg.dsnFromEnv({ DB_HOST: 'a' }), undefined)
56
+ })
57
+
58
+ test('dsnFromMap prefers explicit DSN', () => {
59
+ assert.equal(cfg.dsnFromMap({ DSN: 'postgres://u@h/db' }), 'postgres://u@h/db')
60
+ assert.equal(cfg.dsnFromMap({ DB_HOST: 'h', DB_USER: 'u', DB_NAME: 'd' }), 'mysql://u:@h/d')
61
+ })
62
+
63
+ test('parseEnvFile skips comments/quotes', () => {
64
+ const map = cfg.parseEnvFile('# c\nDSN="mysql://u@h/d"\nEMPTY=\nFOO=bar')
65
+ assert.equal(map.DSN, 'mysql://u@h/d')
66
+ assert.equal(map.FOO, 'bar')
67
+ assert.equal(map.EMPTY, '')
68
+ })
69
+
70
+ test('generateToml and fingerprintOf', () => {
71
+ const sources = [{ id: 'ws_abc', dsn: 'mysql://u@h/d' }]
72
+ const toml = cfg.generateToml(sources)
73
+ assert.match(toml, /^# Auto-generated/)
74
+ assert.match(toml, /id = "ws_abc"/)
75
+ assert.match(toml, /dsn = "mysql:\/\/u@h\/d"/)
76
+ assert.match(toml, /lazy = true/)
77
+ assert.notEqual(cfg.fingerprintOf(sources), cfg.fingerprintOf([{ id: 'ws_abc', dsn: 'other' }]))
78
+ })
79
+
80
+ test('generateToml emits lazy = true for EVERY source (one dead env must not kill the server)', () => {
81
+ const sources = [
82
+ { id: 'hbtx_1hn7yaw', dsn: 'mysql://root:pw@10.253.0.3:3307/tx_zdsf_main_pro' },
83
+ { id: 'hbtx_1hn7yaw_test', dsn: 'mysql://root:pw@10.253.0.6:1688/db_zdsf' },
84
+ { id: 'other_2', dsn: 'sqlite:///C:/data/x.db' },
85
+ ]
86
+ const blocks = cfg.generateToml(sources).split('[[sources]]').slice(1)
87
+ assert.equal(blocks.length, 3)
88
+ for (const b of blocks) {
89
+ assert.match(b, /id = ".+"/)
90
+ assert.match(b, /dsn = ".+"/)
91
+ assert.match(b, /lazy = true/)
92
+ }
93
+ })
94
+
95
+ test('collect: walkForCandidates skips noise dirs and finds config files', async () => {
96
+ const { walkForCandidates, extractDsnCandidates } = await import('../lib/collect.mjs')
97
+ const { writeFileSync, mkdirSync } = await import('node:fs')
98
+ const root = join(home, 'proj')
99
+ mkdirSync(join(root, 'node_modules'), { recursive: true })
100
+ mkdirSync(join(root, 'src'), { recursive: true })
101
+ writeFileSync(join(root, '.env'), 'DSN=mysql://root:pw@10.0.0.1:3306/appdb\n')
102
+ writeFileSync(join(root, 'node_modules', 'x.env'), 'DSN=mysql://nope@x/y\n')
103
+ writeFileSync(join(root, 'src', 'application.yml'), 'url: jdbc:mysql://1.2.3.4:3306/biz?user=u&password=p\n')
104
+
105
+ const found = walkForCandidates(root, { count: 0 })
106
+ assert.ok(found.includes(join(root, '.env')))
107
+ assert.ok(found.includes(join(root, 'src', 'application.yml')))
108
+ assert.ok(!found.some((f) => f.includes('node_modules')))
109
+
110
+ const candidates = extractDsnCandidates(join(root, '.env'), 'DSN=mysql://root:pw@10.0.0.1:3306/appdb\n')
111
+ assert.ok(candidates.some((c) => c.dsn === 'mysql://root:pw@10.0.0.1:3306/appdb'))
96
112
  })