dsh-dbhub-live 5.2.0 → 6.0.0-rc.1

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 (57) hide show
  1. package/README.en.md +21 -18
  2. package/README.md +21 -18
  3. package/lib/adhoc.mjs +108 -12
  4. package/lib/capability.mjs +97 -13
  5. package/lib/client.js +705 -315
  6. package/lib/config.mjs +490 -99
  7. package/lib/i18n.mjs +68 -2
  8. package/lib/index.mjs +386 -260
  9. package/lib/mcp.mjs +245 -49
  10. package/lib/meta.mjs +25 -17
  11. package/lib/options.mjs +11 -33
  12. package/lib/runtime.mjs +80 -11
  13. package/lib/state.mjs +112 -117
  14. package/lib/toml.mjs +10 -0
  15. package/lib/tools.mjs +118 -24
  16. package/lib/update.mjs +12 -2
  17. package/package.json +4 -20
  18. package/AGENTS.md +0 -116
  19. package/doc/ARCHITECTURE.md +0 -108
  20. package/doc/CLIENT.md +0 -124
  21. package/doc/DESENSITIZE.md +0 -56
  22. package/doc/DEVELOPMENT.md +0 -139
  23. package/doc/IMPACT-MATRIX.md +0 -36
  24. package/doc/RELEASE.md +0 -93
  25. package/doc/REQUIREMENTS.md +0 -177
  26. package/doc/images/chat-query.png +0 -0
  27. package/doc/images/row-editor.png +0 -0
  28. package/doc/images/settings-page.png +0 -0
  29. package/scripts/desensitize-check.mjs +0 -331
  30. package/scripts/desensitize.config.mjs +0 -255
  31. package/scripts/docs-check.mjs +0 -214
  32. package/scripts/release-preflight.mjs +0 -180
  33. package/scripts/render-check.mjs +0 -1366
  34. package/test/adhoc-toml.test.mjs +0 -365
  35. package/test/auto-dsn.test.mjs +0 -126
  36. package/test/bridge.test.mjs +0 -227
  37. package/test/capability.test.mjs +0 -231
  38. package/test/client-format.test.mjs +0 -515
  39. package/test/configure-options.test.mjs +0 -244
  40. package/test/configure-ssh.test.mjs +0 -232
  41. package/test/copy-from.test.mjs +0 -155
  42. package/test/env-options.test.mjs +0 -456
  43. package/test/i18n.test.mjs +0 -37
  44. package/test/init.test.mjs +0 -163
  45. package/test/meta.test.mjs +0 -234
  46. package/test/options.test.mjs +0 -160
  47. package/test/policy.test.mjs +0 -132
  48. package/test/resolve-source.test.mjs +0 -82
  49. package/test/resolve-workspace.test.mjs +0 -115
  50. package/test/row-cache.test.mjs +0 -237
  51. package/test/ssh-probe.test.mjs +0 -834
  52. package/test/state.test.mjs +0 -85
  53. package/test/test-op-fastpath.test.mjs +0 -208
  54. package/test/toml.test.mjs +0 -352
  55. package/test/update.test.mjs +0 -270
  56. package/test/util.test.mjs +0 -275
  57. package/test/zero-knowledge.test.mjs +0 -121
package/README.en.md CHANGED
@@ -15,7 +15,7 @@ Install it and you can simply tell the AI "show me the last 10 rows of the order
15
15
  <details>
16
16
  <summary>📷 Preview: Settings → DBHub Database Tools</summary>
17
17
 
18
- ![DBHub Database Tools configure page](doc/images/settings-page.png)
18
+ ![DBHub Database Tools configure page](https://raw.githubusercontent.com/mr-mihu/dsh-dbhub-live/dev/doc/images/settings-page.png)
19
19
 
20
20
  </details>
21
21
 
@@ -36,23 +36,21 @@ Supports **MySQL · PostgreSQL · MariaDB · SQLite · SQL Server**.
36
36
  <!-- release-test:versions:begin -->
37
37
  ## Supported version range
38
38
 
39
- **From DSH 0.1.5 (lines 0.1.5 / 0.1.6 / 0.1.7 / 0.2.0 / 0.2.1, prereleases included)** — 6 declared versions verified (last verified 2026-10-24).
39
+ **From DSH 0.2.1 (lines 0.2.1 / 0.2.2, prereleases included)** — 4 declared versions not all verified yet (last verified 2026-10-24).
40
40
 
41
41
  <details>
42
42
  <summary>Per-version verification details (expand)</summary>
43
43
 
44
44
  | DSH version | UI | Boot | Hard checks | Result | Last verified |
45
45
  | --- | --- | --- | --- | --- | --- |
46
- | `0.1.5-rc.3` | legacy | 39.8s | 19 | ⚠️ pass with notes · manual | 2026-10-24 · 91642fa2 |
47
- | `0.1.6-alpha.2` | legacy | 13.1s | 19 | ⚠️ pass with notes · manual | 2026-10-24 · 91642fa2 |
48
- | `0.1.7-rc.2` | legacy | 14.8s | 20 | ⚠️ pass with notes · manual | 2026-10-24 · 91642fa2 |
49
- | `0.2.0-rc.2` | legacy | 14.8s | 20 | ⚠️ pass with notes · manual | 2026-10-24 · 91642fa2 |
50
- | `0.2.1-alpha.1` | modern | 15.2s | 20 | ⚠️ pass with notes · manual | 2026-10-24 · 91642fa2 |
51
46
  | `0.2.1-rc.2` | modern | 16.9s | 20 | ⚠️ pass with notes · manual | 2026-10-24 · 91642fa2 |
47
+ | `0.2.1-rc.5` | modern | — | — | — | 未验证 |
48
+ | `0.2.2-rc.1` | modern | — | — | — | 未验证 |
49
+ | `0.2.2-rc.2` | modern | — | — | — | 未验证 |
52
50
 
53
51
  </details>
54
52
 
55
- Full evidence, review rules and known drift live in [doc/SUPPORT-MATRIX.md](doc/SUPPORT-MATRIX.md).
53
+ Full evidence, review rules and known drift live in [doc/SUPPORT-MATRIX.md](https://github.com/mr-mihu/dsh-dbhub-live/blob/dev/doc/SUPPORT-MATRIX.md).
56
54
  <!-- release-test:versions:end -->
57
55
 
58
56
  ## Installation
@@ -73,7 +71,7 @@ On desktop you can also **install entirely through the UI**: **left menu → Plu
73
71
 
74
72
  Once installed, **restart `dsh web`** and open **Settings → DBHub Database Tools** to see the configure page (there is also a sidebar shortcut, and the Configure control on the plugin's row on the Plugins page — all three entries lead to the same page).
75
73
 
76
- > Requirements: every `dsh` release line from 0.1.5 on (0.2.x included); **the new UI needs 0.2.1+** (0.2.1 prereleases included) — the wide full page, the version/update header and the inline read-only switch only render on 0.2.1 and later; **0.1.x is still supported but keeps the old layout and behaviour**. Node.js ≥ 18 is recommended locally (it is used to install dbhub automatically). **Read-only mode and SSH tunnels need dbhub 1.4.0+** — the plugin detects this and gives you upgrade guidance. Per-version verification results live in [doc/SUPPORT-MATRIX.md](doc/SUPPORT-MATRIX.md) (generated by the release test, which must pass before every release).
74
+ > Requirements: **DSH 0.2.1 or later** (0.2.1 prereleases included; **0.1.x and 0.2.0 are no longer supported**). On an older host the plugin does not go blank: the configure page shows one bilingual notice naming the required and the running version, and every tool, connection and setting keeps working — but upgrade DSH soon. Node.js ≥ 18 is recommended locally (it is used to install dbhub automatically). **Read-only mode and SSH tunnels need dbhub 1.4.0+** — the plugin detects this and gives you upgrade guidance. Per-version verification results live in [doc/SUPPORT-MATRIX.md](https://github.com/mr-mihu/dsh-dbhub-live/blob/dev/doc/SUPPORT-MATRIX.md) (generated by the release test, which must pass before every release).
77
75
 
78
76
  ## Updating
79
77
 
@@ -89,7 +87,7 @@ Then **restart `dsh web`**.
89
87
 
90
88
  **No need to remember version numbers**: the configure page header shows the plugin name and the current version; when a newer version exists a quiet "**↑ Upgrade to <latest>**" text link appears and **clicking it opens a panel where you copy** `dsh-dbhub-live@<latest>`; the "Check for updates" button next to it checks on demand. Opening the page also triggers one automatic check (throttled, so it does not hit the network on every open). Checks use the **npm registry first, with GitHub Releases as the fallback**; release notes live in [GitHub Releases](https://github.com/mr-mihu/dsh-dbhub-live/releases).
91
89
 
92
- > The version/update hint is part of the new UI and needs plugin 5.2.0+ and DSH 0.2.1+; on 0.1.x it is not shown (see "Configure Page").
90
+ > The version/update hint needs plugin 5.2.0+ and DSH 0.2.1+ (see "Configure Page").
93
91
 
94
92
  ## Quick Start
95
93
 
@@ -105,7 +103,7 @@ AI: … (query results)
105
103
  <details>
106
104
  <summary>📷 In action: ask the AI, get the query results</summary>
107
105
 
108
- ![Asking the AI and getting query results](doc/images/chat-query.png)
106
+ ![Asking the AI and getting query results](https://raw.githubusercontent.com/mr-mihu/dsh-dbhub-live/dev/doc/images/chat-query.png)
109
107
 
110
108
  </details>
111
109
 
@@ -149,11 +147,11 @@ While the plugin is enabled it contributes a **system-prompt section** (about 40
149
147
  <details>
150
148
  <summary>📷 Row editor: read-only mode and SSH tunnel</summary>
151
149
 
152
- ![Connection row and "Test SSH tunnel"](doc/images/row-editor.png)
150
+ ![Connection row and "Test SSH tunnel"](https://raw.githubusercontent.com/mr-mihu/dsh-dbhub-live/dev/doc/images/row-editor.png)
153
151
 
154
152
  </details>
155
153
 
156
- The same component offers **three layouts**, picked automatically from the host surface and the window width (the new UI needs plugin 5.2.0+ and **DSH 0.2.1+**; on DSH 0.1.x the old layout and behaviour are kept, see the end of this section):
154
+ The same component offers **three layouts**, picked automatically from the host surface and the window width (needs plugin 5.2.0+ and **DSH 0.2.1+**, see the end of this section):
157
155
 
158
156
  | Layout | How to reach it | Shape |
159
157
  | --- | --- | --- |
@@ -167,12 +165,13 @@ The same component offers **three layouts**, picked automatically from the host
167
165
  - **Connection list**: organised by workspace. When every workspace has a single environment the rows render as flat cards; as soon as one workspace owns several environments they fold into per-workspace groups (click a group head to expand; the most recently used one is open by default). Each connection takes one line: environment name · type / host / port / **database** · **read-only switch** · the `source` handle (click it to copy) · test / edit / delete. In a narrow window the host and port are truncated first, while **the database name always stays visible**.
168
166
  - **Settings (DSH-native style, deliberately quiet)**: on the wide page the four controls collapse into **one compact settings line** — `Plugin settings · Enable plugin [enabled] · Sidebar entry [shown] · Access policy [on] · Auto-update [7] days [save] · ⓘ details`; the modal and the phone use compact stacked rows. Fonts and switches are kept small so the **connection list** stays the main content. The controls are DSH's own switches (theme/skin aware, with state words); every explanatory paragraph lives in "ⓘ Details & diagnostics".
169
167
  - **Read-only switch (outside the edit form in all three layouts)**: right next to the datasource on every connection row there is a small "RO" toggle — one click flips it (open lock + "Writable" = writable; closed lock + "RO" = read-only) without opening the edit form; the modal and the phone layout have it too. The tick-box in the edit form stays as well; both are the same state. **The connection string itself no longer carries a lock icon** — it used to read like "this row needs a password" and clashed with the read-only lock.
170
- - **Add / edit / delete**: use ✏ on a row to change the connection string or the environment name; "+ Add connection" accepts Chinese environment names. **Saving onto an environment name that already exists asks "overwrite?" first** — cancel and not a single byte is written.
171
- - **Connection test**: runs `SELECT 1` over **the effective connection that environment will really use** (read-only / tunnel included) and shows the elapsed time while it runs. The result is one-shot feedback: nothing is persisted and no connection state changes.
168
+ - **Add / edit / delete**: use ✏ on a row to change the connection string or the environment name; "+ Add connection" accepts Chinese environment names. **Saving onto an environment name that already exists asks "overwrite?" first** — cancel and not a single byte is written. **Deleting asks first too** (it cannot be undone; cancelling writes nothing either).
169
+ - **A failed write is always visible**: when a save / switch / delete is refused (401, offline, a Host refusal) the page shows an error line and **never** claims "Saved ✓"; fixing it and clicking again is the retry. A failed plugin-state read shows its own error line plus "Retry" instead of silently turning into "no connections". "Auto-update interval" only accepts a whole number of days from 0 to 365 — an empty box or a fraction is rejected on the spot and no request is sent.
170
+ - **Connection test**: runs `SELECT 1` over **the effective connection that environment will really use** (read-only / tunnel included) and shows the elapsed time while it runs. The result is one-shot feedback: nothing is persisted and no connection state changes. When the command never reaches the Host (bridge 401 / offline) it says "the test command never reached the host" immediately instead of reporting a timeout 30 seconds later.
172
171
 
173
172
  **"Show the sidebar entry" is not disabling the plugin**: switching it off merely hides the sidebar shortcut — the plugin keeps working and the model can still query; use "Disable" to stop the plugin as a whole (a disabled plugin always hides the entry). In either state, **the settings page and the Plugins row's Configure control always stay reachable**, so the change can be undone at any time.
174
173
 
175
- **Compatibility path for DSH 0.1.x**: the header version / upgrade entry, the wide full page, the row read-only chip and the settings rows all render only on **DSH 0.2.1+** (prereleases included); on 0.1.x the plugin keeps its previous layout and behaviour and **shows no upgrade hint** — that is deliberate, not a defect.
174
+ **When DSH is too old**: the configure page gains one warning at the top (naming "DSH 0.2.1 or later" and the running version); the page, connections, read-only switches and tools all keep working — the plugin is not broken, the host just needs an upgrade.
176
175
 
177
176
  ## 🔒 Read-only Mode and SSH Tunnels
178
177
 
@@ -238,14 +237,18 @@ On desktop you can also uninstall directly on the **left menu → Plugins** page
238
237
  | The read-only / SSH controls are greyed out, or dbhub is reported as too old | Both need dbhub 1.4.0+: upgrade dbhub, or delete `~/.dsh/storages/dsh-dbhub-live` so the plugin installs a version that satisfies it. |
239
238
  | A connection test fails and you cannot tell why | Read the **end** of the message (the diagnostic is the tail of dbhub's output and marks the layer: [SSH layer] / [database layer] / [plugin layer]). **A [database layer] report means the tunnel is already up**; with a tunnel configured, press "Test SSH tunnel" first to verify the SSH layer on its own. |
240
239
  | The configure page is not visible | Check the plugin is installed: restart `dsh web` on the web side; on desktop use the Configure control on the Plugins row or the DBHub entry in the left menu. The page only shows in the Web / desktop UI and does not affect tool usage in terminal environments. |
241
- | The old UI is still shown after upgrading | The new UI needs **DSH 0.2.1+** (prereleases included) and plugin 5.2.0+; restart after upgrading dsh. On DSH 0.1.x the plugin deliberately keeps the old layout and behaviour and shows no update hint. |
240
+ | The configure page says the DSH version is too old | The plugin needs **DSH 0.2.1+** (prereleases included); upgrade dsh and restart. The tools and the page keep working until you do — the notice is only a reminder. |
242
241
  | No "Upgrade to …" hint appears in the header | You may already be on the latest version, or this check could not reach the npm registry / GitHub Releases; press "Check for updates" to retry. A failed check is only a hint and does not affect plugin functionality. |
243
242
  | How do I upgrade on desktop | Uninstall on the **left menu → Plugins** page, then reinstall the latest version; connections and credentials live in `~/.dsh/storages/dsh-dbhub-live` and are still there after the reinstall. |
244
- | After a dsh upgrade the plugin is reported as incompatible | Upgrade the plugin to 5.0.0+ (it supports both 0.1.x and 0.2.x); forcing it through with `allow-version` buys nothing. |
243
+ | After a dsh upgrade the plugin is reported as incompatible | Check that dsh is 0.2.1 or later, then upgrade the plugin to the latest version; forcing it through with `allow-version` buys nothing. |
245
244
  | Automatic dbhub updates are not wanted | Set "Auto-update interval (days)" to `0` on the configure page and save, or set `DSH_DBHUB_UPDATE_DAYS=0`. |
246
245
 
247
246
  **Environment variables** (only these two, not exposed in the UI): `DSH_DBHUB_PACKAGE` (the package that gets auto-installed, default `@bytebase/dbhub`), `DSH_DBHUB_UPDATE_DAYS` (the seed for the auto-update interval, default `7`, overridden by the value saved on the configure page).
248
247
 
248
+ ## Changelog
249
+
250
+ Highlights of every released version live in [doc/CHANGELOG.md](https://github.com/mr-mihu/dsh-dbhub-live/blob/dev/doc/CHANGELOG.md); per-release notes are on [GitHub Releases](https://github.com/mr-mihu/dsh-dbhub-live/releases).
251
+
249
252
  ## License
250
253
 
251
254
  [MIT](./LICENSE)
package/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
  <details>
16
16
  <summary>📷 界面预览:设置 → DBHub 数据库工具</summary>
17
17
 
18
- ![DBHub 数据库工具配置页](doc/images/settings-page.png)
18
+ ![DBHub 数据库工具配置页](https://raw.githubusercontent.com/mr-mihu/dsh-dbhub-live/dev/doc/images/settings-page.png)
19
19
 
20
20
  </details>
21
21
 
@@ -36,23 +36,21 @@
36
36
  <!-- release-test:versions:begin -->
37
37
  ## 支持的版本范围
38
38
 
39
- **DSH 0.1.5 起(0.1.5 / 0.1.6 / 0.1.7 / 0.2.0 / 0.2.1 共 5 条版本线,含预发布)** — 6 个声明版本已实测通过(最近验证 2026-10-24)。
39
+ **DSH 0.2.1 起(0.2.1 / 0.2.2 共 2 条版本线,含预发布)** — 4 个声明版本尚未全部通过(最近验证 2026-10-24)。
40
40
 
41
41
  <details>
42
42
  <summary>逐版本实测明细(点开查看)</summary>
43
43
 
44
44
  | DSH 版本 | 界面 | 启动 | 硬检查 | 结论 | 最近验证 |
45
45
  | --- | --- | --- | --- | --- | --- |
46
- | `0.1.5-rc.3` | 旧版 | 39.8s | 19 | ⚠️ 通过(有告警) · 手工装配 | 2026-10-24 · 91642fa2 |
47
- | `0.1.6-alpha.2` | 旧版 | 13.1s | 19 | ⚠️ 通过(有告警) · 手工装配 | 2026-10-24 · 91642fa2 |
48
- | `0.1.7-rc.2` | 旧版 | 14.8s | 20 | ⚠️ 通过(有告警) · 手工装配 | 2026-10-24 · 91642fa2 |
49
- | `0.2.0-rc.2` | 旧版 | 14.8s | 20 | ⚠️ 通过(有告警) · 手工装配 | 2026-10-24 · 91642fa2 |
50
- | `0.2.1-alpha.1` | 新版 | 15.2s | 20 | ⚠️ 通过(有告警) · 手工装配 | 2026-10-24 · 91642fa2 |
51
46
  | `0.2.1-rc.2` | 新版 | 16.9s | 20 | ⚠️ 通过(有告警) · 手工装配 | 2026-10-24 · 91642fa2 |
47
+ | `0.2.1-rc.5` | 新版 | — | — | — | 未验证 |
48
+ | `0.2.2-rc.1` | 新版 | — | — | — | 未验证 |
49
+ | `0.2.2-rc.2` | 新版 | — | — | — | 未验证 |
52
50
 
53
51
  </details>
54
52
 
55
- 完整证据、判定规则与已知偏差见 [doc/SUPPORT-MATRIX.md](doc/SUPPORT-MATRIX.md)。
53
+ 完整证据、判定规则与已知偏差见 [doc/SUPPORT-MATRIX.md](https://github.com/mr-mihu/dsh-dbhub-live/blob/dev/doc/SUPPORT-MATRIX.md)。
56
54
  <!-- release-test:versions:end -->
57
55
 
58
56
  ## 安装
@@ -73,7 +71,7 @@ npx @deepseek-ai/dsh plugin --profile desktop add dsh-dbhub-live
73
71
 
74
72
  装完 web 端**重启 `dsh web`**,打开 **设置 → DBHub 数据库工具** 即可看到配置页(也有左侧栏快捷入口和插件页那行的「配置」,三个入口是同一个页面)。
75
73
 
76
- > 环境要求:`dsh` 0.1.5 起的全部版本线(含 0.2.x);**新界面需要 0.2.1+**(含 0.2.1 的预发布版)——宽屏整页、页头版本与升级提示、行内只读开关都只在 0.2.1 及以上出现;**0.1.x 仍受支持,但保持旧版布局与行为**。建议本机有 Node.js ≥ 18(用于自动安装 dbhub)。**只读模式与 SSH 隧道需要 dbhub 1.4.0+**,插件会自动检测并给出升级指引。每个版本的实测记录见 [doc/SUPPORT-MATRIX.md](doc/SUPPORT-MATRIX.md)(由发布测试自动生成,发版前必跑)。
74
+ > 环境要求:**DSH 0.2.1 或更高版本**(含 0.2.1 的预发布版;**0.1.x 与 0.2.0 不再支持**)。宿主版本过旧时插件不会变成空白:配置页顶部会显示一条中英双语提示(写明所需版本与当前版本),工具、连接与设置照常可用——但请尽快升级 DSH。建议本机有 Node.js ≥ 18(用于自动安装 dbhub)。**只读模式与 SSH 隧道需要 dbhub 1.4.0+**,插件会自动检测并给出升级指引。每个版本的实测记录见 [doc/SUPPORT-MATRIX.md](https://github.com/mr-mihu/dsh-dbhub-live/blob/dev/doc/SUPPORT-MATRIX.md)(由发布测试自动生成,发版前必跑)。
77
75
 
78
76
  ## 更新
79
77
 
@@ -89,7 +87,7 @@ dsh plugin --profile web up dsh-dbhub-live@<最新版本号>
89
87
 
90
88
  **不用自己记版本号**:配置页页头显示插件名与当前版本,检测到更新时会出现一行低调的「**↑ 可升级到 <latest>**」文字链接,**点开后在面板里点一下即可复制** `dsh-dbhub-live@<latest>`;旁边的「检查更新」按钮可以随时手动检查。打开页面时也会自动检查一次(有节流,不会每次打开都打网络)。检查以 **npm registry 为主、GitHub Releases 为兜底**,发布说明见 [GitHub Releases](https://github.com/mr-mihu/dsh-dbhub-live/releases)。
91
89
 
92
- > 版本与升级提示属于新界面,需要插件 5.2.0+ 且 DSH 0.2.1+;0.1.x 上不显示(见「配置页」)。
90
+ > 版本与升级提示需要插件 5.2.0+ 且 DSH 0.2.1+(见「配置页」)。
93
91
 
94
92
  ## 快速开始
95
93
 
@@ -105,7 +103,7 @@ AI:…(返回查询结果)
105
103
  <details>
106
104
  <summary>📷 实际效果:向 AI 提问,直接拿到查询结果</summary>
107
105
 
108
- ![向 AI 提问并拿到查询结果](doc/images/chat-query.png)
106
+ ![向 AI 提问并拿到查询结果](https://raw.githubusercontent.com/mr-mihu/dsh-dbhub-live/dev/doc/images/chat-query.png)
109
107
 
110
108
  </details>
111
109
 
@@ -149,11 +147,11 @@ AI:…(返回查询结果)
149
147
  <details>
150
148
  <summary>📷 连接行编辑器:只读模式与 SSH 隧道</summary>
151
149
 
152
- ![连接行与「测试 SSH 隧道」](doc/images/row-editor.png)
150
+ ![连接行与「测试 SSH 隧道」](https://raw.githubusercontent.com/mr-mihu/dsh-dbhub-live/dev/doc/images/row-editor.png)
153
151
 
154
152
  </details>
155
153
 
156
- 同一套组件提供**三种布局**,按承载位与窗口宽度自动切换(新界面需要插件 5.2.0+ 且 **DSH 0.2.1+**;DSH 0.1.x 上保持旧版布局与行为,见本节末尾):
154
+ 同一套组件提供**三种布局**,按承载位与窗口宽度自动切换(需要插件 5.2.0+ 且 **DSH 0.2.1+**,见本节末尾):
157
155
 
158
156
  | 布局 | 进入方式 | 形态 |
159
157
  | --- | --- | --- |
@@ -167,12 +165,13 @@ AI:…(返回查询结果)
167
165
  - **连接列表**:按工作区组织。所有工作区都只有一个环境时用平铺卡片;任一工作区有多个环境时按工作区分组折叠(点组头展开,默认展开最近使用的那个)。每个连接只占一行:环境名 · 类型 / 主机 / 端口 / **库名** · **只读开关** · `source` 句柄(点它即复制) · 测试 / 修改 / 删除。窄窗口下先截断主机端口,**库名始终可见**。
168
166
  - **设置区(DSH 主体样式,刻意弱化)**:宽屏把四项收成**一行紧凑设置条**——`插件设置 · 启用插件[启用] · 侧边栏入口[显示] · 访问策略[开启] · 自动更新[7]天[保存] · ⓘ说明`;弹窗与手机是紧凑设置行。字号与开关都收小,让**连接列表**成为页面主体。控件用 DSH 自己的开关(跟随主题 / 皮肤,带汉字状态词);解释性长文字都在「ⓘ 说明」里。
169
167
  - **只读开关(三种布局都在外面)**:每条连接行的数据源旁边就是该环境的「只读」小开关,点一下即改(开锁图标 + 「可写」= 可写;闭锁图标 + 「只读」= 已只读),不必打开编辑表单;弹窗 / 手机同样可用。编辑表单里的勾选也保留,两处是同一个状态。**连接串本身不再带锁形图标**——过去容易被误读成"需要密码",也和只读锁冲突。
170
- - **增删改**:行内 ✏ 改连接串或环境名;「+ 添加连接」支持中文环境名。**同名环境保存前会先问「是否覆盖」**,取消则一个字都不写。
171
- - **连接测试**:按该环境**真正会被使用的有效连接**(含只读 / 隧道)执行 `SELECT 1`,测试中显示已耗时。结果是一次性反馈,不持久化、不改变连接状态。
168
+ - **增删改**:行内 ✏ 改连接串或环境名;「+ 添加连接」支持中文环境名。**同名环境保存前会先问「是否覆盖」**,取消则一个字都不写。**删除也要先确认**(这条不可撤销,取消同样零写入)。
169
+ - **写失败一定看得见**:保存 / 开关 / 删除被拒(401、离线、宿主拒绝)时页面上会出现一行错误提示,**不会**再显示「已保存 ✓」,改好后再点一次就是重试;读取不到插件状态时也会给出错误行 + 「重试」,不会悄悄变成"没有连接"。「自动更新间隔」只接受 0–365 的整数天,留空或填小数会当场提示、不发请求。
170
+ - **连接测试**:按该环境**真正会被使用的有效连接**(含只读 / 隧道)执行 `SELECT 1`,测试中显示已耗时。结果是一次性反馈,不持久化、不改变连接状态。命令没能送到宿主(例如桥接 401 / 离线)时会立刻说「测试命令未能送达宿主」,而不是等 30 秒后报「测试超时」。
172
171
 
173
172
  **「在侧边栏显示入口」不是禁用插件**:关掉它只是隐藏左侧栏的快捷入口,插件照常工作、模型照常可以查询;想整体停用才用「禁用」(禁用后入口一律隐藏)。无论哪种状态,**设置页与插件页那行的「配置」始终能进**,随时可以撤销。
174
173
 
175
- **DSH 0.1.x 的兼容路径**:页头版本 / 升级提示、宽屏整页、行内只读开关与开关样式的设置区都只在 **DSH 0.2.1+**(含预发布版)渲染;0.1.x 上插件保持原有布局与行为,**不显示升级提示**——这是刻意保留的,不是缺陷。
174
+ **DSH 版本过低时**:配置页顶部会多出一条警告(写明「需要 DSH 0.2.1 或更高版本」与本机版本),页面、连接、只读开关与工具全部照常工作——这不是"插件坏了",只需升级 DSH。
176
175
 
177
176
  ## 🔒 只读模式与 SSH 隧道
178
177
 
@@ -238,14 +237,18 @@ dsh plugin --profile desktop remove dsh-dbhub-live
238
237
  | 只读 / SSH 控件是灰的,或提示 dbhub 版本过旧 | 两项都需要 dbhub 1.4.0+:升级 dbhub,或删除 `~/.dsh/storages/dsh-dbhub-live` 让插件自动装一个满足要求的版本。 |
239
238
  | 连接测试失败,看不出原因 | 读提示的**末尾**(诊断给的是 dbhub 输出的尾部,会标出【SSH 层】/【数据库层】/【插件层】)。**报【数据库层】就说明隧道已经通了**;有隧道时先点「测试 SSH 隧道」把 SSH 层单独验一遍。 |
240
239
  | 看不到配置页 | 确认插件已安装:web 端重启过 `dsh web`;桌面端在「插件」页该行点「配置」,或从左侧菜单的 DBHub 入口进。配置页只在 Web / 桌面界面显示,终端环境下不影响工具使用。 |
241
- | 升级到新版本后仍是旧界面 | 新界面需要 **DSH 0.2.1+**(含其预发布版)且插件 5.2.0+;升级 dsh 后重启。DSH 0.1.x 上插件有意保持旧布局与行为,也不显示升级提示。 |
240
+ | 配置页顶部提示「DSH 版本过旧」 | 插件最低支持 **DSH 0.2.1**(含其预发布版);升级 dsh 后重启即可。升级前工具与页面仍可用,提示只是提醒。 |
242
241
  | 页头没有出现「升级到 …」提示 | 可能已是最新版本,或这次检查没连上 npm registry / GitHub Releases;点「检查更新」重试即可。检查失败只是提示,不影响插件功能。 |
243
242
  | 桌面端怎么升级 | 在 **左侧菜单 → 插件** 页卸载后重装最新版本;连接与凭据在 `~/.dsh/storages/dsh-dbhub-live`,重装后仍在。 |
244
- | 升级 dsh 后提示插件与本版不兼容 | 升级插件到 5.0.0+(同时支持 0.1.x 与 0.2.x);用 `allow-version` 强行豁免没有意义。 |
243
+ | 升级 dsh 后提示插件与本版不兼容 | 先确认 dsh ≥ 0.2.1,再把插件升级到最新版;用 `allow-version` 强行豁免没有意义。 |
245
244
  | 不希望自动更新 dbhub | 配置页「自动更新间隔(天)」填 `0` 保存,或设环境变量 `DSH_DBHUB_UPDATE_DAYS=0`。 |
246
245
 
247
246
  **环境变量**(仅这两项,不在 UI 暴露):`DSH_DBHUB_PACKAGE`(自动安装的包名,默认 `@bytebase/dbhub`)、`DSH_DBHUB_UPDATE_DAYS`(自动更新间隔种子,默认 `7`,被配置页保存的值覆盖)。
248
247
 
248
+ ## 版本历史
249
+
250
+ 每个正式版本的变更摘要见 [doc/CHANGELOG.md](https://github.com/mr-mihu/dsh-dbhub-live/blob/dev/doc/CHANGELOG.md);逐版本的发布说明在 [GitHub Releases](https://github.com/mr-mihu/dsh-dbhub-live/releases)。
251
+
249
252
  ## 许可证
250
253
 
251
254
  [MIT](./LICENSE)
package/lib/adhoc.mjs CHANGED
@@ -18,12 +18,14 @@
18
18
  // file holds placeholders only; the real secrets
19
19
  // ride the CHILD's environment (see toml.mjs).
20
20
 
21
- import { DATA_DIR, connLabel, scrubSecrets, normalizeSshOptions, expandHome, withDefaultSshKey, DEFAULT_SSH_KEY_PATH } from './config.mjs'
21
+ import { DATA_DIR, connLabel, scrubSecrets, normalizeSshOptions, expandHome, withDefaultSshKey, DEFAULT_SSH_KEY_PATH, warnOnce } from './config.mjs'
22
22
  import { buildSpawnArgv, resolveDbhubExe } from './runtime.mjs'
23
- import { createMcpClient, disabledMessage } from './mcp.mjs'
23
+ import { createMcpClient, disabledMessage, MCP_INIT_TIMEOUT_MS, MCP_CALL_TIMEOUT_MS } from './mcp.mjs'
24
24
  import { buildDbhubToml, writeTempToml, removeTempToml } from './toml.mjs'
25
25
  import { detectDbhubCapabilities } from './capability.mjs'
26
+ import { readPluginVersion } from './meta.mjs'
26
27
  import { currentT } from './i18n.mjs'
28
+ import { StringDecoder } from 'node:string_decoder'
27
29
  import { existsSync } from 'node:fs'
28
30
  import { connect } from 'node:net'
29
31
  import * as state from './state.mjs'
@@ -70,6 +72,14 @@ export function secretsOf(conn) {
70
72
  export const PROBE_TIMEOUT_MS = 25000
71
73
  export const PROBE_TIMEOUT_SSH_MS = 45000
72
74
 
75
+ /**
76
+ * How long a finished call waits for the child to actually exit before it stops
77
+ * waiting and simply warns. `waitForExit` never settles when a descendant escaped
78
+ * the managed process group (the host documents that as out of its control), and
79
+ * an unbounded await there would hang the call the timeouts above just bounded.
80
+ */
81
+ export const EXIT_WAIT_MS = 5000
82
+
73
83
  /** Host-side probe cap for one connection (tunnelled connections get more). */
74
84
  export function hostTimeoutFor(conn) {
75
85
  return needsToml(conn) && normalizeConn(conn).ssh ? PROBE_TIMEOUT_SSH_MS : PROBE_TIMEOUT_MS
@@ -383,11 +393,18 @@ export async function probeSshTunnel(subprocess, ssh, timeoutMs) {
383
393
 
384
394
  /**
385
395
  * Run ONE MCP tool call against a disposable dbhub process.
396
+ *
397
+ * Both requests carry a request-level budget (see lib/mcp.mjs): `exec.signal` only
398
+ * fires on a user cancel / turn interruption, so a dbhub that is alive but silent
399
+ * would otherwise hold this call — and the whole conversation round — forever.
400
+ * `exec.mcpTimeoutMs` shrinks the CALL budget (the robustness test proves the
401
+ * timeout path in milliseconds); production passes no value.
402
+ *
386
403
  * @param subprocess - host subprocess service.
387
404
  * @param conn - `{ dsn, ro, ssh }` (a bare DSN string means "no options").
388
405
  * @param rawName - the MCP tool name (`execute_sql` / `search_objects`).
389
406
  * @param mcpArgs - the tool arguments.
390
- * @param exec - `{ signal, agent? }`.
407
+ * @param exec - `{ signal, agent?, mcpTimeoutMs? }`.
391
408
  * @param label - optional pre-localized header line.
392
409
  * @returns `{ ok, text }` (already scrubbed and metadata-labelled).
393
410
  */
@@ -422,7 +439,14 @@ export async function runAdhoc(subprocess, conn, rawName, mcpArgs, exec, label)
422
439
  const built = buildDbhubToml({ dsn: c.dsn, ro: c.ro, ssh: c.ssh })
423
440
  tomlPath = writeTempToml(built.text)
424
441
  // Secrets travel in the CHILD's environment; the file only holds `${VAR}`.
425
- childEnv = { ...process.env, ...built.env }
442
+ // ONLY the generated names: spreading `process.env` here would hand the
443
+ // spawn a scrubbed-away ambient environment all over again (the host
444
+ // subprocess service strips `*KEY|PASSWORD|SECRET|TOKEN*` and `DSH_*` from
445
+ // the inherited env, and merges an explicit `env` AFTER that scrub), so the
446
+ // child — and every third-party package inside dbhub — could read the
447
+ // agent's own API keys. Absent names simply resolve from the scrubbed
448
+ // parent env the service provides.
449
+ childEnv = { ...built.env }
426
450
  argv = ['--transport', 'stdio', '--config', tomlPath]
427
451
  } catch (e) {
428
452
  removeTempToml(tomlPath)
@@ -447,17 +471,30 @@ export async function runAdhoc(subprocess, conn, rawName, mcpArgs, exec, label)
447
471
  return { ok: false, text: currentT('result.dbhubSpawnFailed', { msg: String((e && e.message) || e) }) }
448
472
  }
449
473
  let stderrTail = ''
474
+ const callTimeoutMs = Number.isFinite(exec && exec.mcpTimeoutMs) && exec.mcpTimeoutMs > 0
475
+ ? exec.mcpTimeoutMs
476
+ : MCP_CALL_TIMEOUT_MS
450
477
  try {
478
+ // Decode across chunk boundaries: dbhub's banner and connection errors are
479
+ // often non-ASCII, and a split multibyte character would surface as U+FFFD in
480
+ // the diagnostic tail the user has to read.
481
+ const stderrDecoder = new StringDecoder('utf8')
451
482
  const client = createMcpClient(handle, (chunk) => {
452
- stderrTail = (stderrTail + String(chunk)).slice(-2000)
483
+ const piece = typeof chunk === 'string' ? chunk : stderrDecoder.write(chunk)
484
+ stderrTail = (stderrTail + piece).slice(-2000)
453
485
  })
454
486
  await client.request('initialize', {
455
487
  protocolVersion: '2025-03-26',
456
488
  capabilities: {},
457
- clientInfo: { name: 'dsh-dbhub-adhoc', version: '3.0.0' },
458
- })
489
+ // dbhub logs this in its own diagnostics. The literal '3.0.0' that used to
490
+ // sit here named a version this plugin never was — a report from a user on
491
+ // 5.x pointed the maintainer at the wrong code. `meta` is a LEAF, so reading
492
+ // the installed version here cannot create a cycle; a detached copy of the
493
+ // package (no readable package.json) degrades to '0.0.0'.
494
+ clientInfo: { name: 'dsh-dbhub-adhoc', version: readPluginVersion() || '0.0.0' },
495
+ }, { timeoutMs: MCP_INIT_TIMEOUT_MS })
459
496
  client.notify('notifications/initialized')
460
- const res = await client.request('tools/call', { name: rawName, arguments: mcpArgs })
497
+ const res = await client.request('tools/call', { name: rawName, arguments: mcpArgs }, { timeoutMs: callTimeoutMs })
461
498
  const textOf = (r) => {
462
499
  if (r && Array.isArray(r.content)) {
463
500
  const parts = []
@@ -469,16 +506,45 @@ export async function runAdhoc(subprocess, conn, rawName, mcpArgs, exec, label)
469
506
  // Zero-knowledge surface: the header carries metadata only (host/port/db),
470
507
  // and every line — including dbhub's own error text — is scrubbed before
471
508
  // it can reach the model (the SSH password / passphrase included).
472
- const prefix = (label !== undefined && label !== null
509
+ // The prefix itself goes through the same scrubber: it is built from
510
+ // `connLabel` AND from a caller-supplied `label`, and a caller that ever
511
+ // pastes a DSN into it must not bypass the gate the body passes through.
512
+ const header = label !== undefined && label !== null
473
513
  ? String(label)
474
- : currentT('label.adhocConn', { dsn: connLabel(c.dsn) })) + '\n'
514
+ : currentT('label.adhocConn', { dsn: connLabel(c.dsn) })
515
+ const prefix = scrubSecrets(header, c.dsn, extraSecrets) + '\n'
475
516
  if (res && res.isError) return { ok: false, text: prefix + scrubSecrets(textOf(res), c.dsn, extraSecrets) }
476
517
  if (res && res.structuredContent !== undefined) {
477
518
  return { ok: true, text: prefix + scrubSecrets(JSON.stringify(res.structuredContent, null, 2), c.dsn, extraSecrets) }
478
519
  }
479
520
  return { ok: true, text: prefix + scrubSecrets(textOf(res), c.dsn, extraSecrets) }
480
521
  } catch (e) {
481
- let detail = String((e && e.message) || e)
522
+ const code = e && e.code
523
+ let detail
524
+ if (code === 'MCP_TIMEOUT' || code === 'MCP_TOO_LARGE') {
525
+ // Kill the wedged process BEFORE the report is assembled: `finally` runs
526
+ // after this frame, and a dbhub that ignored one request will ignore the
527
+ // next. The temp TOML is still removed by `finally`.
528
+ try {
529
+ handle.terminate()
530
+ } catch (err) {
531
+ /* ignore */
532
+ }
533
+ }
534
+ if (code === 'MCP_TIMEOUT') {
535
+ const sec = Math.max(1, Math.round((Number(e.timeoutMs) || 0) / 1000))
536
+ detail = currentT('result.mcpTimeout', { sec: String(sec) })
537
+ } else if (code === 'MCP_TOO_LARGE') {
538
+ const limitBytes = Number(e.limitBytes) || 0
539
+ detail = currentT('result.mcpTooLarge', {
540
+ what: currentT(e.limitKind === 'line' ? 'result.mcpTooLargeLine' : 'result.mcpTooLargeTotal'),
541
+ limit: String(Math.round((limitBytes / (1024 * 1024)) * 10) / 10) + ' MiB',
542
+ })
543
+ } else {
544
+ detail = String((e && e.message) || e)
545
+ }
546
+ // Never echo a query/params/DSN: only the localized sentence plus (already
547
+ // length-capped) stderr, which the scrubber below rewrites.
482
548
  if (stderrTail) detail += '\n[dbhub stderr] ' + stderrTail
483
549
  return { ok: false, text: scrubSecrets(detail, c.dsn, extraSecrets) }
484
550
  } finally {
@@ -487,10 +553,40 @@ export async function runAdhoc(subprocess, conn, rawName, mcpArgs, exec, label)
487
553
  } catch (e) {
488
554
  /* ignore */
489
555
  }
556
+ let exited
557
+ // The host contract answers `false` when the signal was already aborted and
558
+ // the process is STILL alive. The return value used to be dropped, so the
559
+ // temp config was deleted as if the child had exited (Windows: EBUSY) and
560
+ // the only trace was a once-per-process warnOnce.
561
+ //
562
+ // The wait itself is bounded: a descendant that escaped the managed process
563
+ // group can make `waitForExit` never settle, which would hang the very call
564
+ // this batch set out to make bounded. A timeout here is treated exactly like
565
+ // `false` (try once more, then warn).
566
+ let exitTimer = null
567
+ const exitWaitMs = Number.isFinite(exec && exec.exitWaitMs) && exec.exitWaitMs > 0
568
+ ? exec.exitWaitMs
569
+ : EXIT_WAIT_MS
490
570
  try {
491
- await handle.waitForExit(exec.signal)
571
+ exited = await Promise.race([
572
+ Promise.resolve(handle.waitForExit(exec.signal)).catch(() => undefined),
573
+ new Promise((resolve) => {
574
+ exitTimer = setTimeout(() => resolve('timeout'), exitWaitMs)
575
+ }),
576
+ ])
492
577
  } catch (e) {
493
578
  /* ignore */
579
+ } finally {
580
+ if (exitTimer) clearTimeout(exitTimer)
581
+ }
582
+ if (exited === false || exited === 'timeout') {
583
+ try {
584
+ handle.terminate()
585
+ } catch (e) {
586
+ /* ignore */
587
+ }
588
+ // No path, no DSN: just the fact (said once per process).
589
+ warnOnce('adhoc-exit-pending', currentT('result.processStillRunning'))
494
590
  }
495
591
  // Best effort: the temp file holds no secret, but it must not accumulate.
496
592
  removeTempToml(tomlPath)
@@ -12,12 +12,43 @@
12
12
  // Detection is deliberately file-based (no extra process spawn): the executable
13
13
  // we resolved lives inside a package directory (`…/node_modules/.bin/dbhub.cmd`
14
14
  // next to `…/node_modules/@bytebase/dbhub/package.json`) or under mise's
15
- // `npm-bytebase-dbhub/<version>/` layout, and both carry the version.
15
+ // `npm-bytebase-dbhub/<version>/` layout, or it is the classic global layout
16
+ // (`<prefix>/bin/dbhub` + `<prefix>/lib/node_modules/@bytebase/dbhub`), and all
17
+ // of them carry the version. An UNKNOWN version still allows both features
18
+ // (R62); the point of covering the standard layouts is that "unknown" stays the
19
+ // exception instead of being the default answer.
16
20
  // Pure module: `node:fs` + `node:path` only, no Cordis service.
17
21
 
18
- import { existsSync, readFileSync, statSync } from 'node:fs'
22
+ import { existsSync, readFileSync, statSync, realpathSync } from 'node:fs'
19
23
  import { dirname, join, resolve } from 'node:path'
20
24
 
25
+ // Process-wide detection cache, keyed by `<absolute exe path>\0<mtimeMs>`. The
26
+ // answer for one executable is immutable for the life of the process, while a
27
+ // TOML call used to re-walk up to 18 candidate manifests with readFileSync on
28
+ // EVERY tool call (the guard runs before each read-only / tunnelled spawn). The
29
+ // mtime keeps an in-place upgrade (`npm install` over the old prefix) from
30
+ // serving a stale version, and one statSync is microseconds next to that walk.
31
+ const DETECTION_CACHE = new Map()
32
+
33
+ // Production reads the real fs; tests swap single primitives to COUNT the probes
34
+ // (the same seam config.mjs uses for its storage writers). Nothing here reads a
35
+ // hook unless a test installed one. `realpathSync` resolves a shim that is a
36
+ // SYMLINK (nvm / volta / pnpm, and npm -g's `bin/dbhub` link) to the package
37
+ // directory it really lives in.
38
+ const NODE_FS = { existsSync, readFileSync, statSync, realpathSync }
39
+ let fsHooks = NODE_FS
40
+
41
+ /**
42
+ * Replace a subset of the file primitives used by the version probe.
43
+ * @param hooks - `{ existsSync?, readFileSync?, statSync?, realpathSync? }`, or
44
+ * `null` to restore node:fs. Unlisted primitives keep the real implementation.
45
+ * The detection cache is dropped so a swapped fs can never be served a hit.
46
+ */
47
+ export function setFsHooks(hooks) {
48
+ fsHooks = hooks ? { ...NODE_FS, ...hooks } : NODE_FS
49
+ DETECTION_CACHE.clear()
50
+ }
51
+
21
52
  /** Minimum dbhub version whose TOML dialect this plugin generates. */
22
53
  export const DBHUB_MIN_VERSION = '1.4.0'
23
54
  /** Versions below this predate the TOML tool model entirely. */
@@ -62,11 +93,27 @@ export function versionFromMisePath(exe) {
62
93
  return parseVersion(m[1]) ? m[1] : ''
63
94
  }
64
95
 
96
+ /**
97
+ * The real path of an executable, or the given path when it cannot be resolved.
98
+ *
99
+ * A dbhub reached through a link must be walked UP from its TARGET: npm -g puts
100
+ * `bin/dbhub` as a symlink into `lib/node_modules`, and nvm / volta / pnpm shim
101
+ * the same way, so the link's own directory contains no manifest at all.
102
+ */
103
+ function realExe(raw) {
104
+ try {
105
+ return fsHooks.realpathSync(raw)
106
+ } catch (e) {
107
+ return raw
108
+ }
109
+ }
110
+
65
111
  /**
66
112
  * Walk up from the executable (bounded) looking for the owning package.json and
67
113
  * read its version. Works for the auto-managed install
68
- * (`<RUNTIME_DIR>/node_modules/@bytebase/dbhub/package.json`) and for any npm /
69
- * pnpm / mise layout that keeps the manifest above the shim.
114
+ * (`<RUNTIME_DIR>/node_modules/@bytebase/dbhub/package.json`), for any npm /
115
+ * pnpm / mise layout that keeps the manifest above the shim, and for the
116
+ * classic global layout (`<prefix>/bin/dbhub` + `<prefix>/lib/node_modules/…`).
70
117
  * @returns a version string, or `''`.
71
118
  */
72
119
  export function versionFromPackageJson(exe) {
@@ -74,25 +121,36 @@ export function versionFromPackageJson(exe) {
74
121
  if (!raw) return ''
75
122
  let dir
76
123
  try {
77
- dir = dirname(resolve(raw))
124
+ dir = dirname(realExe(resolve(raw)))
78
125
  } catch (e) {
79
126
  return ''
80
127
  }
81
128
  for (let i = 0; i < 6 && dir; i++) {
82
- // Three layouts matter, and all of them must be probed at EVERY level:
129
+ // Every layout matters, and all of them must be probed at EVERY level:
83
130
  // · `<pkg>/package.json` — the mise shim package that pins dbhub;
84
- // · `<dir>/node_modules/@bytebase/dbhub/package.json` — what
131
+ // · `<dir>/node_modules/{@bytebase/dbhub,dbhub}/package.json` — what
85
132
  // `npm install @bytebase/dbhub --prefix <dir>` produces, i.e. the
86
- // DEFAULT auto-managed install (and `npm -g` too);
87
- // · `<dir>/node_modules/dbhub/package.json` — an unscoped install.
133
+ // DEFAULT auto-managed install;
134
+ // · `<dir>/lib/node_modules/…` — the npm GLOBAL / nvm / volta prefix
135
+ // (`<prefix>/bin/dbhub` next to `<prefix>/lib/node_modules/…`). This
136
+ // candidate is what keeps a default `npm i -g` install from resolving
137
+ // to '' : an unknown version is ALLOWED by design (R62), so the
138
+ // `< 1.0.0` refusal was unreachable and a `[[tools]] readonly` TOML was
139
+ // handed to a dbhub that ignores it — looking read-only while every
140
+ // write still went through;
141
+ // · `<dir>/../../lib/node_modules/…` — the same prefix seen from a shim
142
+ // that sits one directory deeper (`<prefix>/bin/<sub>/dbhub`).
88
143
  for (const candidate of [
89
144
  join(dir, 'package.json'),
90
145
  join(dir, 'node_modules', '@bytebase', 'dbhub', 'package.json'),
91
146
  join(dir, 'node_modules', 'dbhub', 'package.json'),
147
+ join(dir, 'lib', 'node_modules', '@bytebase', 'dbhub', 'package.json'),
148
+ join(dir, 'lib', 'node_modules', 'dbhub', 'package.json'),
149
+ join(dir, '..', '..', 'lib', 'node_modules', '@bytebase', 'dbhub', 'package.json'),
92
150
  ]) {
93
151
  try {
94
- if (!existsSync(candidate) || !statSync(candidate).isFile()) continue
95
- const doc = JSON.parse(readFileSync(candidate, 'utf8'))
152
+ if (!fsHooks.existsSync(candidate) || !fsHooks.statSync(candidate).isFile()) continue
153
+ const doc = JSON.parse(fsHooks.readFileSync(candidate, 'utf8'))
96
154
  const version = parseVersion(doc && doc.version) ? String(doc.version) : ''
97
155
  if (!version) continue
98
156
  // Only trust a manifest that IS dbhub: an unrelated package.json above
@@ -139,19 +197,45 @@ export function capabilitiesOf(rawVersion) {
139
197
  return { version, readonlyTools: true, ssh: true, warning: '' }
140
198
  }
141
199
 
200
+ /**
201
+ * Detection cache key: the path plus the executable's mtime (0 when it cannot be
202
+ * stat'ed, which keeps the key stable for a layout whose shim does not exist).
203
+ */
204
+ function cacheKeyOf(raw) {
205
+ let stamp = 0
206
+ if (raw) {
207
+ try {
208
+ stamp = Number(fsHooks.statSync(raw).mtimeMs) || 0
209
+ } catch (e) {
210
+ stamp = 0
211
+ }
212
+ }
213
+ return raw + '\u0000' + String(stamp)
214
+ }
215
+
142
216
  /**
143
217
  * Detect the capabilities of one resolved dbhub executable.
144
218
  * Never throws and never blocks: an unresolvable version is reported as
145
219
  * `unknown` and the features stay enabled (the caller only *uses* this on
146
220
  * read-only / SSH environments, and an genuine incompatibility surfaces as a
147
221
  * clear dbhub error).
222
+ *
223
+ * Cached per executable (see DETECTION_CACHE); a copy is returned so a caller can
224
+ * never poison the cache.
148
225
  * @param exe - resolved dbhub executable path (may be undefined at boot).
149
226
  * @returns `{ version, readonlyTools, ssh, warning }`.
150
227
  */
151
228
  export function detectDbhubCapabilities(exe) {
229
+ const raw = String(exe === undefined || exe === null ? '' : exe).trim()
230
+ const key = cacheKeyOf(raw)
231
+ const hit = DETECTION_CACHE.get(key)
232
+ if (hit) return { ...hit }
233
+ let value
152
234
  try {
153
- return capabilitiesOf(detectVersion(exe))
235
+ value = capabilitiesOf(detectVersion(raw))
154
236
  } catch (e) {
155
- return { version: '', readonlyTools: true, ssh: true, warning: 'unknown' }
237
+ value = { version: '', readonlyTools: true, ssh: true, warning: 'unknown' }
156
238
  }
239
+ DETECTION_CACHE.set(key, value)
240
+ return { ...value }
157
241
  }