dsh-dbhub-live 5.1.0 → 5.2.0-dev.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +116 -254
- package/README.en.md +96 -11
- package/README.md +97 -12
- package/doc/ARCHITECTURE.md +108 -0
- package/doc/CLIENT.md +124 -0
- package/doc/DESENSITIZE.md +56 -0
- package/doc/DEVELOPMENT.md +139 -0
- package/doc/IMPACT-MATRIX.md +36 -0
- package/doc/RELEASE.md +93 -0
- package/doc/REQUIREMENTS.md +41 -3
- package/lib/client.js +704 -27
- package/lib/config.mjs +3 -0
- package/lib/i18n.mjs +14 -4
- package/lib/index.mjs +107 -0
- package/lib/meta.mjs +267 -0
- package/lib/options.mjs +16 -1
- package/lib/policy.mjs +92 -0
- package/lib/tools.mjs +4 -1
- package/lib/update.mjs +244 -0
- package/package.json +32 -9
- package/scripts/desensitize-check.mjs +10 -3
- package/scripts/desensitize.config.mjs +18 -10
- package/scripts/docs-check.mjs +214 -0
- package/scripts/release-preflight.mjs +180 -0
- package/scripts/render-check.mjs +286 -2
- package/test/client-format.test.mjs +89 -5
- package/test/meta.test.mjs +234 -0
- package/test/options.test.mjs +28 -15
- package/test/policy.test.mjs +132 -0
- package/test/update.test.mjs +270 -0
package/lib/config.mjs
CHANGED
|
@@ -586,6 +586,9 @@ export function normalizePrefs(raw) {
|
|
|
586
586
|
// plugin's enabled state: hiding the shortcut does not turn the plugin off,
|
|
587
587
|
// and the client ANDs the two (a disabled plugin contributes no entry at all).
|
|
588
588
|
if (typeof raw.showSidebarEntry === 'boolean') defineOwn(out, 'showSidebarEntry', raw.showSidebarEntry)
|
|
589
|
+
// The user's own "announce the database-access policy to the model" preference.
|
|
590
|
+
// Turning it off removes one system-prompt section; no tool changes behavior.
|
|
591
|
+
if (typeof raw.announceAccessPolicy === 'boolean') defineOwn(out, 'announceAccessPolicy', raw.announceAccessPolicy)
|
|
589
592
|
return out
|
|
590
593
|
}
|
|
591
594
|
|
package/lib/i18n.mjs
CHANGED
|
@@ -12,6 +12,7 @@ const ZH = {
|
|
|
12
12
|
'log.hostApply': 'host apply',
|
|
13
13
|
'log.nsRegistered': '状态命名空间已注册: dsh-dbhub-live',
|
|
14
14
|
'log.loaded': 'dbhub 插件已加载({n} 个工具)',
|
|
15
|
+
'log.policyRegistered': '模型侧访问策略已注册(system prompt 段 {name})',
|
|
15
16
|
'log.initFailed': '后台初始化失败(状态卡片可见最近错误)',
|
|
16
17
|
'log.disabledBoot': '插件处于禁用状态(可在 插件 → dsh-dbhub-live → 配置 开启)',
|
|
17
18
|
'log.opSaved': '卡片配置:{title} 环境 {env} 已保存',
|
|
@@ -25,6 +26,11 @@ const ZH = {
|
|
|
25
26
|
'log.syncApplied': '配置已生效:连接摘要已刷新({n} 个工具声明)',
|
|
26
27
|
'log.stateSyncFail': '状态同步失败: {msg}',
|
|
27
28
|
'log.updateCheckFailed': 'dbhub 自动更新检查失败: {msg}',
|
|
29
|
+
// Plugin self-update check (the card's {op:'updateCheck'} command). Host log
|
|
30
|
+
// only — the browser-side wording lives in lib/client.js.
|
|
31
|
+
'log.appUpdate': '发现新版本:本插件 {current} → {latest}(来源 {source})',
|
|
32
|
+
'log.appUpToDate': '已是最新版本:本插件 {current}(最新 {latest},来源 {source})',
|
|
33
|
+
'log.appUpdateFailed': '插件更新检查失败:{msg}(当前版本 {current})',
|
|
28
34
|
'log.testStart': '卡片连接测试:{title} 环境 {env}',
|
|
29
35
|
'log.testDone': '卡片连接测试完成:{title} 环境 {env} → {result}',
|
|
30
36
|
'result.testOk': '连接成功(SELECT 1,{ms} ms)',
|
|
@@ -42,7 +48,7 @@ const ZH = {
|
|
|
42
48
|
'result.needWorkspace': '当前会话的工作区不在已注册工作区列表中,无法确定要在哪个工作区配置连接。请让用户确认后用 workspace 参数显式指定(路径或标题):\n{list}',
|
|
43
49
|
'result.unknownWorkspace': '找不到工作区 "{ws}"(不会自动改用其他工作区,以免配置到错误的位置)。可用的工作区:\n{list}',
|
|
44
50
|
'result.noDsnViaModel': '出于安全原因,不能把密码/完整连接串作为 dbhub_configure 的参数传入(否则会进入模型上下文)。插件会在界面弹出密码输入框——请引导用户输入,或让用户在 插件 → dsh-dbhub-live → 配置 中修改。',
|
|
45
|
-
'result.authHint': '提示:可能是凭据不正确或连接信息(主机/端口/库)有误。可调用 dbhub_configure 更新该环境({src})的连接——密码通过界面输入,不会经过模型;也可让用户在 插件 → dsh-dbhub-live → 配置
|
|
51
|
+
'result.authHint': '提示:可能是凭据不正确或连接信息(主机/端口/库)有误。可调用 dbhub_configure 更新该环境({src})的连接——密码通过界面输入,不会经过模型;也可让用户在 插件 → dsh-dbhub-live → 配置 中修改。**不要为了连上这个库改用命令行客户端或脚本直连**(凭据只能由用户在插件里输入)。',
|
|
46
52
|
'result.sourcesIntro': '已注册连接源 {n} 个,分属 {m} 个工作区(仅元数据:类型/主机/端口/库;密码与账号永不显示)。**每条 source 只属于一个工作区,不同工作区的相似连接是不同目标,不要混用。**',
|
|
47
53
|
'result.wsCurrentHead': '【当前工作区】(优先使用这里的 source):',
|
|
48
54
|
'result.wsGroup': '- {title}({path})',
|
|
@@ -96,7 +102,7 @@ const ZH = {
|
|
|
96
102
|
'result.scanNone': '扫描了 {n} 个文件,但未提取到数据库连接。可改用“输入 DSN”或“填写分项”。',
|
|
97
103
|
'result.scanPicked': '已按你的确认持久化连接(来源: 项目文件扫描,环境 {env})。查询时用 dbhub_execute_sql,source 值 = {srcId}。',
|
|
98
104
|
// ── 5.1: environment-level read-only mode ──
|
|
99
|
-
'result.readOnlyHint': '提示:该环境已开启**只读模式**(由用户在配置页设置),dbhub 只放行只读语句(MySQL 为 select/with/explain/show/describe/desc,其他连接器略有不同),写操作会被拒绝(READONLY_VIOLATION)。如需写入,请让用户在 **设置 → DBHub
|
|
105
|
+
'result.readOnlyHint': '提示:该环境已开启**只读模式**(由用户在配置页设置),dbhub 只放行只读语句(MySQL 为 select/with/explain/show/describe/desc,其他连接器略有不同),写操作会被拒绝(READONLY_VIOLATION)。如需写入,请让用户在 **设置 → DBHub 数据库工具**(或插件页该行的「配置」)里关闭只读——模型只能开启,不能自行关闭。**不要改用 psql/mysql 等命令行客户端、临时脚本或任何其它途径绕过本插件完成这次写入(包括换用别的数据库插件)**:只读是用户设定的安全边界,绕过等于自行提权;若这次写入确实必要,把影响与理由告诉用户,由用户决定。',
|
|
100
106
|
'result.readOnlyUserOnly': '出于安全考虑,**关闭只读模式只能由用户操作**(模型只能开启、不能解除,否则等于自行提权)。请让用户在 **设置 → DBHub 数据库工具**(或插件页该行的「配置」)里关闭该环境的只读开关。',
|
|
101
107
|
'result.readOnlyOn': '已开启只读模式(环境 {env}):写操作会被 dbhub 拒绝(READONLY_VIOLATION)。',
|
|
102
108
|
'result.readOnlyOff': '已关闭只读模式(环境 {env})。',
|
|
@@ -156,6 +162,7 @@ const EN = {
|
|
|
156
162
|
'log.hostApply': 'host apply',
|
|
157
163
|
'log.nsRegistered': 'settings namespace registered: dsh-dbhub-live',
|
|
158
164
|
'log.loaded': 'dbhub plugin loaded ({n} tools)',
|
|
165
|
+
'log.policyRegistered': 'model-facing access policy registered (system prompt section {name})',
|
|
159
166
|
'log.initFailed': 'background init failed (see the recent error on the status card)',
|
|
160
167
|
'log.disabledBoot': 'plugin disabled (enable it in Plugins → dsh-dbhub-live → Configure)',
|
|
161
168
|
'log.opSaved': 'card config: {title} environment {env} saved',
|
|
@@ -169,6 +176,9 @@ const EN = {
|
|
|
169
176
|
'log.syncApplied': 'config applied: connection summaries refreshed ({n} tool declarations)',
|
|
170
177
|
'log.stateSyncFail': 'status sync failed: {msg}',
|
|
171
178
|
'log.updateCheckFailed': 'dbhub auto-update check failed: {msg}',
|
|
179
|
+
'log.appUpdate': 'update available: plugin {current} -> {latest} (source {source})',
|
|
180
|
+
'log.appUpToDate': 'plugin is up to date: {current} (latest {latest}, source {source})',
|
|
181
|
+
'log.appUpdateFailed': 'plugin update check failed: {msg} (current {current})',
|
|
172
182
|
'log.testStart': 'card connection test: {title} env {env}',
|
|
173
183
|
'log.testDone': 'card connection test finished: {title} env {env} → {result}',
|
|
174
184
|
'result.testOk': 'connection OK (SELECT 1, {ms} ms)',
|
|
@@ -186,7 +196,7 @@ const EN = {
|
|
|
186
196
|
'result.needWorkspace': 'The session workspace is not among the registered workspaces, so the target workspace is unknown. Ask the user, then pass workspace explicitly (path or title):\n{list}',
|
|
187
197
|
'result.unknownWorkspace': 'workspace "{ws}" not found (the plugin will NOT silently switch to another workspace and configure the wrong place). Available workspaces:\n{list}',
|
|
188
198
|
'result.noDsnViaModel': 'For security, a password or full DSN cannot be passed as a dbhub_configure argument (it would land in the model context). The plugin prompts for the password in the UI — ask the user to enter it, or have them edit it in Plugins → dsh-dbhub-live → Configure.',
|
|
189
|
-
'result.authHint': 'Hint: the credentials are likely incorrect, or the connection details (host/port/database) are wrong. You can run dbhub_configure to update this source ({src}) — the password is entered in the UI, never through the model; the user can also edit it in Plugins → dsh-dbhub-live → Configure.',
|
|
199
|
+
'result.authHint': 'Hint: the credentials are likely incorrect, or the connection details (host/port/database) are wrong. You can run dbhub_configure to update this source ({src}) — the password is entered in the UI, never through the model; the user can also edit it in Plugins → dsh-dbhub-live → Configure. **Do not switch to a command-line client or script to reach this database** — credentials are entered by the user in the plugin, never by the model.',
|
|
190
200
|
'result.sourcesIntro': 'Registered source(s): {n} across {m} workspace(s) (metadata only — type/host/port/database; passwords and usernames are never shown). **Every source belongs to exactly ONE workspace; look-alike connections in different workspaces are different targets — never treat them as the same.**',
|
|
191
201
|
'result.wsCurrentHead': '[CURRENT WORKSPACE] (prefer the sources here):',
|
|
192
202
|
'result.wsGroup': '- {title} ({path})',
|
|
@@ -240,7 +250,7 @@ const EN = {
|
|
|
240
250
|
'result.scanNone': 'scanned {n} files but extracted no database connection. Use "enter DSN" or "fill in fields" instead.',
|
|
241
251
|
'result.scanPicked': 'connection persisted from your selection (source: project file scan, env {env}). Query with dbhub_execute_sql; source value = {srcId}.',
|
|
242
252
|
// ── 5.1: environment-level read-only mode ──
|
|
243
|
-
'result.readOnlyHint': 'Note: this environment has **read-only mode** enabled (set by the user on the configuration page). dbhub only allows read statements (on MySQL: select/with/explain/show/describe/desc; other connectors differ slightly) and rejects writes with READONLY_VIOLATION. To write, ask the user to turn read-only off in **Settings → DBHub Database Tools** (or the Configure action on that Plugins row) — the model may enable it but must never disable it.',
|
|
253
|
+
'result.readOnlyHint': 'Note: this environment has **read-only mode** enabled (set by the user on the configuration page). dbhub only allows read statements (on MySQL: select/with/explain/show/describe/desc; other connectors differ slightly) and rejects writes with READONLY_VIOLATION. To write, ask the user to turn read-only off in **Settings → DBHub Database Tools** (or the Configure action on that Plugins row) — the model may enable it but must never disable it. **Never route this write around the plugin** through a psql/mysql-style command-line client, a throwaway script, or any other path (including another database plugin): read-only is the user safety boundary, and working around it is self-granted privilege escalation. If the write is genuinely required, tell the user what it does and why, and let them decide.',
|
|
244
254
|
'result.readOnlyUserOnly': 'For safety, **disabling read-only mode is a user-only action** (the model may enable it, never lift it — that would be self-escalation). Ask the user to clear that environment\u2019s read-only switch in **Settings → DBHub Database Tools** (or the Configure action on that Plugins row).',
|
|
245
255
|
'result.readOnlyOn': 'read-only mode enabled (env {env}): writes are rejected by dbhub (READONLY_VIOLATION).',
|
|
246
256
|
'result.readOnlyOff': 'read-only mode disabled (env {env}).',
|
package/lib/index.mjs
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
cachedRows, resolveTestDsn,
|
|
43
43
|
} from './mcp.mjs'
|
|
44
44
|
import { registerCoreTools } from './tools.mjs'
|
|
45
|
+
import { registerDbhubPolicy } from './policy.mjs'
|
|
45
46
|
import { probeConnection, probeSshTunnel, hostTimeoutFor, PROBE_TIMEOUT_SSH_MS, defaultKeyNoteOf } from './adhoc.mjs'
|
|
46
47
|
import { sweepTempToml } from './toml.mjs'
|
|
47
48
|
import { detectDbhubCapabilities } from './capability.mjs'
|
|
@@ -53,6 +54,11 @@ import {
|
|
|
53
54
|
normalizeSshOptions, mergeSshOptions,
|
|
54
55
|
} from './config.mjs'
|
|
55
56
|
import { setLocaleProvider, currentT } from './i18n.mjs'
|
|
57
|
+
// Version metadata (plugin/dsh/runtime) and the plugin self-update check are
|
|
58
|
+
// leaf modules (node builtins only), so importing them here cannot create a
|
|
59
|
+
// dependency cycle.
|
|
60
|
+
import { versionMeta } from './meta.mjs'
|
|
61
|
+
import { createUpdateChecker } from './update.mjs'
|
|
56
62
|
import * as state from './state.mjs'
|
|
57
63
|
import * as options from './options.mjs'
|
|
58
64
|
|
|
@@ -82,6 +88,10 @@ const STATUS_DEFAULTS = {
|
|
|
82
88
|
// entry no matter what this says, and hiding the entry never disables anything
|
|
83
89
|
// (the settings page and the Plugins row stay reachable either way).
|
|
84
90
|
showSidebarEntry: true,
|
|
91
|
+
// The user's own choice to let the plugin contribute its database-access
|
|
92
|
+
// policy to the system prompt (default ON). It removes that section only:
|
|
93
|
+
// every tool keeps working, and a disabled plugin contributes nothing anyway.
|
|
94
|
+
announceAccessPolicy: true,
|
|
85
95
|
// Upstream dbhub capability probe (JSON string: {version, readonlyTools, ssh,
|
|
86
96
|
// warning}). The UI uses it to disable the read-only / SSH controls with a
|
|
87
97
|
// reason instead of letting the user configure something that cannot work.
|
|
@@ -96,6 +106,14 @@ const STATUS_DEFAULTS = {
|
|
|
96
106
|
// persisted anywhere — the card only shows it while its own pending nonce
|
|
97
107
|
// matches (a reload drops the pending map, so the result cannot stick).
|
|
98
108
|
testResult: '',
|
|
109
|
+
// Version / update metadata (lib/meta.mjs, lib/update.mjs). `update` rides as
|
|
110
|
+
// a JSON string like `workspaces` — it is '' until the card asks for its first
|
|
111
|
+
// check, so boot never waits on the network. Nothing here is secret.
|
|
112
|
+
pluginVersion: '',
|
|
113
|
+
dshVersion: '',
|
|
114
|
+
dshModern: false,
|
|
115
|
+
runtime: 'web',
|
|
116
|
+
update: '',
|
|
99
117
|
}
|
|
100
118
|
|
|
101
119
|
// Prefer the real schemastery schema (standard path, present in every dsh
|
|
@@ -120,10 +138,16 @@ try {
|
|
|
120
138
|
mode: z.string().default(STATUS_DEFAULTS.mode),
|
|
121
139
|
updateIntervalDays: z.number().min(0).default(STATUS_DEFAULTS.updateIntervalDays),
|
|
122
140
|
showSidebarEntry: z.boolean().default(STATUS_DEFAULTS.showSidebarEntry),
|
|
141
|
+
announceAccessPolicy: z.boolean().default(STATUS_DEFAULTS.announceAccessPolicy),
|
|
123
142
|
capabilities: z.string().default(STATUS_DEFAULTS.capabilities),
|
|
124
143
|
workspaces: z.string().default(STATUS_DEFAULTS.workspaces),
|
|
125
144
|
configOp: z.string().default(STATUS_DEFAULTS.configOp),
|
|
126
145
|
testResult: z.string().default(STATUS_DEFAULTS.testResult),
|
|
146
|
+
pluginVersion: z.string().default(STATUS_DEFAULTS.pluginVersion),
|
|
147
|
+
dshVersion: z.string().default(STATUS_DEFAULTS.dshVersion),
|
|
148
|
+
dshModern: z.boolean().default(STATUS_DEFAULTS.dshModern),
|
|
149
|
+
runtime: z.string().default(STATUS_DEFAULTS.runtime),
|
|
150
|
+
update: z.string().default(STATUS_DEFAULTS.update),
|
|
127
151
|
})
|
|
128
152
|
// schemastery 3.18.2 (shipped with dsh 0.1.6) has no `.volatile()`; the guard
|
|
129
153
|
// keeps the declaration valid there, where the fields arrive as plain values.
|
|
@@ -134,6 +158,7 @@ try {
|
|
|
134
158
|
CONFIG_SCHEMA = z.object({
|
|
135
159
|
updateIntervalDays: volatileField(z.number().min(0)),
|
|
136
160
|
showSidebarEntry: volatileField(z.boolean()),
|
|
161
|
+
announceAccessPolicy: volatileField(z.boolean()),
|
|
137
162
|
})
|
|
138
163
|
} catch (e) {
|
|
139
164
|
const fallbackSchema = (value) => {
|
|
@@ -546,6 +571,22 @@ async function handleConfigOp(rawOp, ctx, subprocess, reportTest, publishFast) {
|
|
|
546
571
|
return patches
|
|
547
572
|
}
|
|
548
573
|
|
|
574
|
+
// Card commands arrive as an object (the HTTP bridge posts `{ op: <command> }`)
|
|
575
|
+
// or as a JSON string (on 0.1.x the settings document carries the single string
|
|
576
|
+
// field `configOp`). Normalize both shapes in one place so the early
|
|
577
|
+
// `updateCheck` branch in runOp can read the command; anything unparsable falls
|
|
578
|
+
// through to handleConfigOp, which owns the "bad op" warning.
|
|
579
|
+
function opObjectOf(rawOp) {
|
|
580
|
+
if (rawOp && typeof rawOp === 'object') return rawOp
|
|
581
|
+
if (typeof rawOp !== 'string') return undefined
|
|
582
|
+
try {
|
|
583
|
+
const parsed = JSON.parse(rawOp)
|
|
584
|
+
return parsed && typeof parsed === 'object' ? parsed : undefined
|
|
585
|
+
} catch (e) {
|
|
586
|
+
return undefined
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
549
590
|
// ── live host core: one merged view, fanned out to every active channel ───
|
|
550
591
|
//
|
|
551
592
|
// Publishing is channel-independent: the 0.1.x settings namespace and the HTTP
|
|
@@ -570,13 +611,29 @@ function createHostCore(ctx) {
|
|
|
570
611
|
// then vanished".
|
|
571
612
|
const mirror = createRowMirror()
|
|
572
613
|
const sinks = new Set()
|
|
614
|
+
// Version metadata and the plugin self-update checker are Host-lifetime
|
|
615
|
+
// singletons: the installed version cannot change under a running process,
|
|
616
|
+
// and one checker instance is what makes the throttle and the in-flight
|
|
617
|
+
// dedupe work (a per-request instance would hit the registries on every
|
|
618
|
+
// publish). `updateJson` is the browser-facing snapshot — a JSON string, ''
|
|
619
|
+
// until the card asks for its first check, so boot never waits on a network.
|
|
620
|
+
const meta = versionMeta()
|
|
621
|
+
const updateChecker = createUpdateChecker({ currentVersion: meta.pluginVersion })
|
|
622
|
+
let updateJson = ''
|
|
623
|
+
// checkedAt of the snapshot already written to the log: a throttled check
|
|
624
|
+
// just reuses the last result, and logging it again on every page mount would
|
|
625
|
+
// turn the milestone line into noise.
|
|
626
|
+
let lastLoggedCheckAt = -1
|
|
573
627
|
|
|
574
628
|
const view = () => ({
|
|
575
629
|
...state.snapshot(),
|
|
576
630
|
...options.snapshot(),
|
|
631
|
+
// pluginVersion / dshVersion / dshModern / runtime — plain strings/booleans.
|
|
632
|
+
...meta,
|
|
577
633
|
workspaces: JSON.stringify(latestSummaries()),
|
|
578
634
|
testResult: lastTestResult,
|
|
579
635
|
capabilities: capabilities,
|
|
636
|
+
update: updateJson,
|
|
580
637
|
// The browser half needs this to address the plugin's own settings form
|
|
581
638
|
// (`ctx.configForms.get(entryId)`) for option writes; '' when the entry
|
|
582
639
|
// cannot be resolved, in which case it falls back to POST /options.
|
|
@@ -674,9 +731,55 @@ function createHostCore(ctx) {
|
|
|
674
731
|
return {}
|
|
675
732
|
}
|
|
676
733
|
}
|
|
734
|
+
// Answer the card's {op:'updateCheck'} command. The checker owns the throttle
|
|
735
|
+
// and the in-flight dedupe, so repeated clicks/reloads cost one request burst;
|
|
736
|
+
// `force` bypasses the throttle (the card's explicit "check now"). The result
|
|
737
|
+
// is mirrored into `update` and published, then logged once — check() resolves
|
|
738
|
+
// on every failure and the guard here only covers a future regression.
|
|
739
|
+
const runUpdateCheck = async (force) => {
|
|
740
|
+
let snap
|
|
741
|
+
try {
|
|
742
|
+
snap = await updateChecker.check(force === true)
|
|
743
|
+
} catch (e) {
|
|
744
|
+
snap = {
|
|
745
|
+
latest: '',
|
|
746
|
+
latestSpec: '',
|
|
747
|
+
hasUpdate: false,
|
|
748
|
+
checkedAt: Date.now(),
|
|
749
|
+
source: '',
|
|
750
|
+
error: String((e && e.message) || e),
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
updateJson = JSON.stringify(snap)
|
|
754
|
+
publishMirror()
|
|
755
|
+
// One line per ACTUAL check; a throttled reuse keeps the previous line.
|
|
756
|
+
if (snap.checkedAt === lastLoggedCheckAt) return snap
|
|
757
|
+
lastLoggedCheckAt = snap.checkedAt
|
|
758
|
+
const current = meta.pluginVersion || '?'
|
|
759
|
+
if (snap.error) {
|
|
760
|
+
console.warn('[dsh-dbhub-live] ' + currentT('log.appUpdateFailed', { msg: snap.error, current }))
|
|
761
|
+
} else if (snap.hasUpdate) {
|
|
762
|
+
console.log('[dsh-dbhub-live] ' + currentT('log.appUpdate', { current, latest: snap.latest, source: snap.source }))
|
|
763
|
+
} else {
|
|
764
|
+
console.log('[dsh-dbhub-live] ' + currentT('log.appUpToDate', {
|
|
765
|
+
current,
|
|
766
|
+
latest: snap.latest || '?',
|
|
767
|
+
source: snap.source || '?',
|
|
768
|
+
}))
|
|
769
|
+
}
|
|
770
|
+
return snap
|
|
771
|
+
}
|
|
677
772
|
// One card command, from whichever channel carried it. The caller owns the
|
|
678
773
|
// follow-up republish; a write is already mirrored by publishFast.
|
|
679
774
|
const runOp = async (rawOp) => {
|
|
775
|
+
// `updateCheck` is not a workspace command: no rows change and it touches the
|
|
776
|
+
// network, so it is intercepted BEFORE handleConfigOp (which would reject an
|
|
777
|
+
// unknown op). It still resolves [] — a command that patches no row.
|
|
778
|
+
const command = opObjectOf(rawOp)
|
|
779
|
+
if (command && command.op === 'updateCheck') {
|
|
780
|
+
await runUpdateCheck(command.force === true)
|
|
781
|
+
return []
|
|
782
|
+
}
|
|
680
783
|
const patches = await handleConfigOp(rawOp, ctx, subprocessOf(), reportTest, publishFast)
|
|
681
784
|
if (Array.isArray(patches) && patches.length > 0) publish()
|
|
682
785
|
else publishMirror()
|
|
@@ -906,6 +1009,10 @@ export async function apply(ctx, config) {
|
|
|
906
1009
|
})
|
|
907
1010
|
const subprocess = ctx.get('subprocess')
|
|
908
1011
|
registerCoreTools(ctx, subprocess)
|
|
1012
|
+
// The system-prompt policy is what keeps database access on these tools (and
|
|
1013
|
+
// out of psql/scripts); it is optional by design and contributes nothing
|
|
1014
|
+
// while the plugin is disabled.
|
|
1015
|
+
registerDbhubPolicy(ctx)
|
|
909
1016
|
refreshToolCount()
|
|
910
1017
|
|
|
911
1018
|
// One core, two channels: every state/option change replays the walk, and
|
package/lib/meta.mjs
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// dsh-dbhub-live: version metadata for the Host.
|
|
2
|
+
//
|
|
3
|
+
// Two independent questions live here:
|
|
4
|
+
// 1. which version of THIS plugin is running (its own package.json), and
|
|
5
|
+
// 2. which dsh release is hosting it, and whether that release is new enough
|
|
6
|
+
// for the modern UI contracts (>= 0.2.1 base version).
|
|
7
|
+
//
|
|
8
|
+
// Both are read once per Host process (the card publishes them as plain view
|
|
9
|
+
// fields), so everything is cached and every failure degrades to '' / false —
|
|
10
|
+
// a version probe must never be able to break the plugin.
|
|
11
|
+
//
|
|
12
|
+
// Dependency direction: this module is a LEAF (node builtins only). lib/index.mjs
|
|
13
|
+
// imports it; nothing imports back, so no cycle can form.
|
|
14
|
+
|
|
15
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
16
|
+
import { dirname, join, resolve } from 'node:path'
|
|
17
|
+
|
|
18
|
+
/** This plugin's npm package name (also the npm update-check target). */
|
|
19
|
+
export const PLUGIN_NAME = 'dsh-dbhub-live'
|
|
20
|
+
|
|
21
|
+
/** The first dsh release with the modern client UI contracts. */
|
|
22
|
+
export const DSH_MODERN_BASE = '0.2.1'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Package names that identify the running dsh runtime when walking up from the
|
|
26
|
+
* process entry point. The CLI, the desktop runtime wrapper and the desktop app
|
|
27
|
+
* shell are all valid hosts.
|
|
28
|
+
*/
|
|
29
|
+
export const DSH_PACKAGE_NAMES = Object.freeze([
|
|
30
|
+
'@deepseek-ai/dsh',
|
|
31
|
+
'@deepseek-ai/dsh-desktop-runtime',
|
|
32
|
+
'@deepseek-ai/dsh-desktop',
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
// Accepts `v1.2.3`, `1.2.3-dev.1`, `1.2.3+build.5` and `1.2.3-rc.1+sha`.
|
|
36
|
+
const SEMVER_RE = /^[vV]?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/
|
|
37
|
+
|
|
38
|
+
const NUMERIC_RE = /^\d+$/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse a semver string into comparable parts.
|
|
42
|
+
*
|
|
43
|
+
* Lenient on the leading `v` (release tags are usually written `v5.2.0`) and
|
|
44
|
+
* tolerant of any build metadata (which never participates in precedence), but
|
|
45
|
+
* strict about the core triplet and empty prerelease identifiers. Returns
|
|
46
|
+
* `undefined` for anything that is not semver — callers treat that as
|
|
47
|
+
* "unknown version", never as 0.0.0.
|
|
48
|
+
*
|
|
49
|
+
* @param {unknown} text
|
|
50
|
+
* @returns {{ major: number, minor: number, patch: number, prerelease: string[], raw: string } | undefined}
|
|
51
|
+
*/
|
|
52
|
+
export function parseSemver(text) {
|
|
53
|
+
if (typeof text !== 'string') return undefined
|
|
54
|
+
const raw = text.trim()
|
|
55
|
+
const match = SEMVER_RE.exec(raw)
|
|
56
|
+
if (!match) return undefined
|
|
57
|
+
const prerelease = match[4] ? match[4].split('.') : []
|
|
58
|
+
// "1.2.3-alpha..beta" is invalid semver: no identifier may be empty.
|
|
59
|
+
if (prerelease.length > 0 && prerelease.some((id) => id === '')) return undefined
|
|
60
|
+
return {
|
|
61
|
+
major: Number(match[1]),
|
|
62
|
+
minor: Number(match[2]),
|
|
63
|
+
patch: Number(match[3]),
|
|
64
|
+
prerelease,
|
|
65
|
+
raw,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function compareTriplet(a, b) {
|
|
70
|
+
if (a.major !== b.major) return a.major < b.major ? -1 : 1
|
|
71
|
+
if (a.minor !== b.minor) return a.minor < b.minor ? -1 : 1
|
|
72
|
+
if (a.patch !== b.patch) return a.patch < b.patch ? -1 : 1
|
|
73
|
+
return 0
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Semver prerelease precedence (semver.org §11): a version WITHOUT prerelease
|
|
78
|
+
* outranks the same version WITH one; numeric identifiers compare numerically;
|
|
79
|
+
* numeric sorts below alphanumeric; alphanumeric compares in ASCII order; a
|
|
80
|
+
* longer identifier list wins when every shared identifier is equal.
|
|
81
|
+
*/
|
|
82
|
+
function comparePrerelease(a, b) {
|
|
83
|
+
if (a.length === 0 && b.length === 0) return 0
|
|
84
|
+
if (a.length === 0) return 1
|
|
85
|
+
if (b.length === 0) return -1
|
|
86
|
+
const shared = Math.min(a.length, b.length)
|
|
87
|
+
for (let i = 0; i < shared; i += 1) {
|
|
88
|
+
const left = a[i]
|
|
89
|
+
const right = b[i]
|
|
90
|
+
const leftNumeric = NUMERIC_RE.test(left)
|
|
91
|
+
const rightNumeric = NUMERIC_RE.test(right)
|
|
92
|
+
if (leftNumeric && rightNumeric) {
|
|
93
|
+
const l = Number(left)
|
|
94
|
+
const r = Number(right)
|
|
95
|
+
if (l !== r) return l < r ? -1 : 1
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
// Numeric identifiers always have lower precedence than alphanumeric ones.
|
|
99
|
+
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1
|
|
100
|
+
if (left !== right) return left < right ? -1 : 1
|
|
101
|
+
}
|
|
102
|
+
if (a.length !== b.length) return a.length < b.length ? -1 : 1
|
|
103
|
+
return 0
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function compareParsed(a, b) {
|
|
107
|
+
const core = compareTriplet(a, b)
|
|
108
|
+
if (core !== 0) return core
|
|
109
|
+
return comparePrerelease(a.prerelease, b.prerelease)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Semver precedence between two versions (strings or already-parsed objects).
|
|
114
|
+
* Invalid input compares as "incomparable" and yields 0 — this must never throw.
|
|
115
|
+
*
|
|
116
|
+
* @returns {-1|0|1}
|
|
117
|
+
*/
|
|
118
|
+
export function compareSemver(a, b) {
|
|
119
|
+
const left = typeof a === 'object' && a !== null ? a : parseSemver(a)
|
|
120
|
+
const right = typeof b === 'object' && b !== null ? b : parseSemver(b)
|
|
121
|
+
if (!left || !right) return 0
|
|
122
|
+
return compareParsed(left, right)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* True when `candidate` is STRICTLY newer than `current` per semver precedence.
|
|
127
|
+
* Unknown on either side yields false: without a known current version an
|
|
128
|
+
* update cannot be claimed, so the card must not show an update banner.
|
|
129
|
+
*/
|
|
130
|
+
export function isNewer(candidate, current) {
|
|
131
|
+
const next = parseSemver(candidate)
|
|
132
|
+
const installed = parseSemver(current)
|
|
133
|
+
if (!next || !installed) return false
|
|
134
|
+
return compareParsed(next, installed) > 0
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* True when `version` has the same-or-newer numeric triplet than `base`
|
|
139
|
+
* (prerelease is ignored: `0.2.1-rc.2` counts as `0.2.1`). An unknown version is
|
|
140
|
+
* false — "cannot tell" must not be reported as "modern".
|
|
141
|
+
*/
|
|
142
|
+
export function atLeastBase(version, base = DSH_MODERN_BASE) {
|
|
143
|
+
const parsed = parseSemver(version)
|
|
144
|
+
const floor = parseSemver(base)
|
|
145
|
+
if (!parsed || !floor) return false
|
|
146
|
+
return compareTriplet(parsed, floor) >= 0
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let cachedPluginVersion
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* This plugin's own version, read from the package.json next to lib/.
|
|
153
|
+
* Cached for the process lifetime (an installed package never changes under a
|
|
154
|
+
* running Host); '' when the file is missing or its version is not semver.
|
|
155
|
+
*/
|
|
156
|
+
export function readPluginVersion() {
|
|
157
|
+
if (cachedPluginVersion !== undefined) return cachedPluginVersion
|
|
158
|
+
cachedPluginVersion = ''
|
|
159
|
+
try {
|
|
160
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
161
|
+
const version = pkg && typeof pkg.version === 'string' ? pkg.version.trim() : ''
|
|
162
|
+
cachedPluginVersion = parseSemver(version) ? version : ''
|
|
163
|
+
} catch (e) {
|
|
164
|
+
cachedPluginVersion = ''
|
|
165
|
+
}
|
|
166
|
+
return cachedPluginVersion
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isDirectory(abs) {
|
|
170
|
+
try {
|
|
171
|
+
return statSync(abs).isDirectory()
|
|
172
|
+
} catch (e) {
|
|
173
|
+
return false
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Walk up from the running process entry point looking for an installed dsh
|
|
179
|
+
* package.json. Exported so tests can drive it against a temp layout and a fake
|
|
180
|
+
* argv entry instead of touching the real installation.
|
|
181
|
+
*
|
|
182
|
+
* @param {string|undefined} entryPath - usually `process.argv[1]`
|
|
183
|
+
* @param {{ maxDepth?: number, packageNames?: ReadonlyArray<string> }} [options]
|
|
184
|
+
* @returns {string} the package version, or '' when nothing matched
|
|
185
|
+
*/
|
|
186
|
+
export function walkDshVersion(entryPath, options = {}) {
|
|
187
|
+
const maxDepth = Number.isInteger(options.maxDepth) && options.maxDepth > 0 ? options.maxDepth : 8
|
|
188
|
+
const names = new Set(
|
|
189
|
+
Array.isArray(options.packageNames) && options.packageNames.length > 0
|
|
190
|
+
? options.packageNames
|
|
191
|
+
: DSH_PACKAGE_NAMES,
|
|
192
|
+
)
|
|
193
|
+
if (typeof entryPath !== 'string' || entryPath.trim() === '') return ''
|
|
194
|
+
// `process.argv[1]` is normally a file, but a loader may hand over a directory
|
|
195
|
+
// or a path that does not exist yet; both resolve to a starting directory.
|
|
196
|
+
const abs = resolve(entryPath.trim())
|
|
197
|
+
let dir = isDirectory(abs) ? abs : dirname(abs)
|
|
198
|
+
for (let depth = 0; depth < maxDepth; depth += 1) {
|
|
199
|
+
try {
|
|
200
|
+
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
|
|
201
|
+
const version = pkg && typeof pkg.version === 'string' ? pkg.version.trim() : ''
|
|
202
|
+
if (pkg && typeof pkg.name === 'string' && names.has(pkg.name) && parseSemver(version)) {
|
|
203
|
+
return version
|
|
204
|
+
}
|
|
205
|
+
} catch (e) {
|
|
206
|
+
/* no package.json here (or unreadable/non-JSON): keep walking up */
|
|
207
|
+
}
|
|
208
|
+
const parent = dirname(dir)
|
|
209
|
+
if (!parent || parent === dir) break
|
|
210
|
+
dir = parent
|
|
211
|
+
}
|
|
212
|
+
return ''
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Best-effort identity of the running dsh runtime.
|
|
217
|
+
*
|
|
218
|
+
* `desktop` is true under the packaged desktop app (Electron main process) or
|
|
219
|
+
* whenever the desktop shell exports its own version through the environment.
|
|
220
|
+
* The version is resolved from the installed dsh package.json first (the
|
|
221
|
+
* authoritative value) and from the desktop env var second.
|
|
222
|
+
*
|
|
223
|
+
* @returns {{ version: string, desktop: boolean }}
|
|
224
|
+
*/
|
|
225
|
+
export function detectDshRuntime() {
|
|
226
|
+
const envVersion = process.env && typeof process.env.DSH_CLIENT_VERSION === 'string'
|
|
227
|
+
? process.env.DSH_CLIENT_VERSION.trim()
|
|
228
|
+
: ''
|
|
229
|
+
const desktop = Boolean(process.versions && process.versions.electron) || Boolean(envVersion)
|
|
230
|
+
let version = ''
|
|
231
|
+
try {
|
|
232
|
+
version = walkDshVersion(process.argv && process.argv[1], { maxDepth: 8 })
|
|
233
|
+
} catch (e) {
|
|
234
|
+
version = ''
|
|
235
|
+
}
|
|
236
|
+
if (!version && envVersion && parseSemver(envVersion)) version = envVersion
|
|
237
|
+
return { version, desktop }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The modern-UI gate: a resolved dsh >= 0.2.1 is modern; an UNRESOLVABLE
|
|
242
|
+
* version is modern only on the desktop runtime (it ships the modern UI by
|
|
243
|
+
* definition, and its package layout is the least predictable one). An unknown
|
|
244
|
+
* version on web is treated as legacy, which is the safe assumption for the old
|
|
245
|
+
* 0.1.x line.
|
|
246
|
+
*/
|
|
247
|
+
export function dshIsModern(runtime) {
|
|
248
|
+
const info = runtime && typeof runtime === 'object' ? runtime : {}
|
|
249
|
+
const version = typeof info.version === 'string' ? info.version : ''
|
|
250
|
+
if (parseSemver(version)) return atLeastBase(version, DSH_MODERN_BASE)
|
|
251
|
+
return Boolean(info.desktop)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The version fields the Host publishes, computed once per Host core:
|
|
256
|
+
* `{ pluginVersion, dshVersion, dshModern, runtime }`. Plain strings/booleans —
|
|
257
|
+
* nothing here is secret and nothing here needs the network.
|
|
258
|
+
*/
|
|
259
|
+
export function versionMeta() {
|
|
260
|
+
const runtime = detectDshRuntime()
|
|
261
|
+
return {
|
|
262
|
+
pluginVersion: readPluginVersion(),
|
|
263
|
+
dshVersion: runtime.version,
|
|
264
|
+
dshModern: dshIsModern(runtime),
|
|
265
|
+
runtime: runtime.desktop ? 'desktop' : 'web',
|
|
266
|
+
}
|
|
267
|
+
}
|
package/lib/options.mjs
CHANGED
|
@@ -13,6 +13,14 @@
|
|
|
13
13
|
// keeps working. It is ANDed with the plugin's enabled state on the client (a
|
|
14
14
|
// disabled plugin contributes no entry at all), because the two switches mean
|
|
15
15
|
// different things: "hide the entry" versus "turn the plugin off".
|
|
16
|
+
//
|
|
17
|
+
// `announceAccessPolicy` is the USER's choice to let the plugin contribute its
|
|
18
|
+
// database-access policy to the system prompt. Default ON: an installed plugin
|
|
19
|
+
// should be the route database work takes, and the policy is what keeps a model
|
|
20
|
+
// from following a shell-level "recipe" around the read-only gate. Turning it
|
|
21
|
+
// off removes that text only — every tool keeps working (the tool descriptions
|
|
22
|
+
// and the read-only receipt still mention the boundary), so this is a prompt
|
|
23
|
+
// preference, not a capability switch.
|
|
16
24
|
|
|
17
25
|
import { loadPrefs, savePrefs } from './config.mjs'
|
|
18
26
|
|
|
@@ -20,6 +28,7 @@ const BUILTIN_DEFAULTS = {
|
|
|
20
28
|
dbhubPackage: '@bytebase/dbhub',
|
|
21
29
|
updateIntervalDays: 7,
|
|
22
30
|
showSidebarEntry: true,
|
|
31
|
+
announceAccessPolicy: true,
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
function envNumber(name, fallback) {
|
|
@@ -41,6 +50,9 @@ const options = {
|
|
|
41
50
|
showSidebarEntry: persisted.showSidebarEntry !== undefined
|
|
42
51
|
? persisted.showSidebarEntry
|
|
43
52
|
: BUILTIN_DEFAULTS.showSidebarEntry,
|
|
53
|
+
announceAccessPolicy: persisted.announceAccessPolicy !== undefined
|
|
54
|
+
? persisted.announceAccessPolicy
|
|
55
|
+
: BUILTIN_DEFAULTS.announceAccessPolicy,
|
|
44
56
|
}
|
|
45
57
|
|
|
46
58
|
const listeners = new Set()
|
|
@@ -70,12 +82,14 @@ export function snapshot() {
|
|
|
70
82
|
return {
|
|
71
83
|
updateIntervalDays: options.updateIntervalDays,
|
|
72
84
|
showSidebarEntry: options.showSidebarEntry,
|
|
85
|
+
announceAccessPolicy: options.announceAccessPolicy,
|
|
73
86
|
}
|
|
74
87
|
}
|
|
75
88
|
|
|
76
89
|
/**
|
|
77
90
|
* Read one option by id ('dbhubPackage' is internal-only).
|
|
78
|
-
* @param id - 'dbhubPackage' | 'updateIntervalDays' | 'showSidebarEntry'
|
|
91
|
+
* @param id - 'dbhubPackage' | 'updateIntervalDays' | 'showSidebarEntry' |
|
|
92
|
+
* 'announceAccessPolicy'.
|
|
79
93
|
* @returns the current value.
|
|
80
94
|
*/
|
|
81
95
|
export function get(id) {
|
|
@@ -87,6 +101,7 @@ export function get(id) {
|
|
|
87
101
|
const VALIDATORS = {
|
|
88
102
|
updateIntervalDays: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0,
|
|
89
103
|
showSidebarEntry: (v) => typeof v === 'boolean',
|
|
104
|
+
announceAccessPolicy: (v) => typeof v === 'boolean',
|
|
90
105
|
}
|
|
91
106
|
|
|
92
107
|
/**
|