dsh-db-tool 0.1.5 → 0.1.6
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/client/client.js +46 -7
- package/dist/adapters/sql-shared/pg-like.js +11 -3
- package/dist/http/index.js +3 -2
- package/dist/index.js +2 -2
- package/dist/manager.js +6 -6
- package/dist/store/connections.js +14 -2
- package/package.json +1 -1
- package/skills/db-admin/SKILL.md +2 -0
package/client/client.js
CHANGED
|
@@ -68,6 +68,7 @@ window.__ModuleLoader__.load({
|
|
|
68
68
|
noDatabases: "无可用数据库",
|
|
69
69
|
noSchemas: "无模式",
|
|
70
70
|
noTables: "无表",
|
|
71
|
+
defaultDb: "默认库",
|
|
71
72
|
loadFailed: "加载失败",
|
|
72
73
|
structure: "结构",
|
|
73
74
|
preview: "数据预览",
|
|
@@ -153,6 +154,7 @@ window.__ModuleLoader__.load({
|
|
|
153
154
|
noDatabases: "No databases available",
|
|
154
155
|
noSchemas: "No schemas",
|
|
155
156
|
noTables: "No tables",
|
|
157
|
+
defaultDb: "Default DB",
|
|
156
158
|
loadFailed: "Failed to load",
|
|
157
159
|
structure: "Structure",
|
|
158
160
|
preview: "Preview",
|
|
@@ -445,6 +447,20 @@ window.__ModuleLoader__.load({
|
|
|
445
447
|
/** 新建表单初始态:分字段模式 + 当前 kind 的官方默认值 */
|
|
446
448
|
const freshForm = () => withKindDefaults({ ...EMPTY_FORM, mode: "fields" });
|
|
447
449
|
|
|
450
|
+
/** 编辑回填:从 meta 还原已存配置(密码不出库,留空=保留原密码)。
|
|
451
|
+
* url 方式 → 回填脱敏 url;分字段方式 → 回填 host/port/user/database。 */
|
|
452
|
+
function buildEditForm(c) {
|
|
453
|
+
const mode = c.mode || (c.safeUrl ? "url" : "fields");
|
|
454
|
+
return {
|
|
455
|
+
...EMPTY_FORM,
|
|
456
|
+
id: c.id, kind: c.kind, name: c.name || "", mode,
|
|
457
|
+
url: c.safeUrl || "",
|
|
458
|
+
host: c.host || "", port: c.port != null ? String(c.port) : "",
|
|
459
|
+
user: c.user || "", database: c.database || "",
|
|
460
|
+
ssl: !!c.ssl,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
448
464
|
function ConnForm(props) {
|
|
449
465
|
// 新建(无 initial)默认分字段模式并预填官方默认值;编辑保持用户数据原样
|
|
450
466
|
const [form, setForm] = React.useState(() => props.initial || freshForm());
|
|
@@ -459,18 +475,29 @@ window.__ModuleLoader__.load({
|
|
|
459
475
|
setDirty(new Set());
|
|
460
476
|
setDraftTest(null);
|
|
461
477
|
}
|
|
462
|
-
/** 从 URL
|
|
478
|
+
/** 从 URL 模式切回分字段:保留表单里已有值(编辑回填场景),只对空字段补官方默认值 */
|
|
463
479
|
function reenterFields() {
|
|
464
|
-
setForm((prev) =>
|
|
480
|
+
setForm((prev) => {
|
|
481
|
+
const keep = new Set(dirty);
|
|
482
|
+
for (const k of ["host", "port", "user", "database"]) if (prev[k]) keep.add(k);
|
|
483
|
+
return withKindDefaults(Object.assign({}, prev, { mode: "fields" }), keep);
|
|
484
|
+
});
|
|
465
485
|
setDraftTest(null);
|
|
466
486
|
}
|
|
467
487
|
function draftBody() {
|
|
468
488
|
const body = { kind: form.kind, ssl: !!form.ssl };
|
|
469
|
-
|
|
470
|
-
|
|
489
|
+
// 编辑保存语义:url 模式未改动(仍等于回填的脱敏 url)→ 不发 url,保留 secrets 原值
|
|
490
|
+
const origUrl = props.initial && props.initial.mode === "url" ? props.initial.url : undefined;
|
|
491
|
+
if (form.mode === "url") {
|
|
492
|
+
if (form.url && form.url !== origUrl) body.url = form.url;
|
|
493
|
+
// 新建 url 连接必须发
|
|
494
|
+
if (!props.initial && form.url) body.url = form.url;
|
|
495
|
+
} else {
|
|
471
496
|
body.fields = { host: form.host, user: form.user, database: form.database || undefined };
|
|
472
497
|
if (form.port) body.fields.port = Number(form.port);
|
|
473
498
|
if (form.password) body.fields.password = form.password;
|
|
499
|
+
// 从 url 方式切到分字段保存 → 服务端清除已存 url(否则连接仍走旧 url)
|
|
500
|
+
if (props.initial && props.initial.mode === "url") body.clearUrl = true;
|
|
474
501
|
}
|
|
475
502
|
return body;
|
|
476
503
|
}
|
|
@@ -583,7 +610,7 @@ window.__ModuleLoader__.load({
|
|
|
583
610
|
|
|
584
611
|
if (editing) {
|
|
585
612
|
return React.createElement(ConnForm, {
|
|
586
|
-
initial: editing === "new" ? null :
|
|
613
|
+
initial: editing === "new" ? null : buildEditForm(editing),
|
|
587
614
|
busy: busy === "save",
|
|
588
615
|
onSubmit: saveConn,
|
|
589
616
|
onCancel: () => setEditing(null),
|
|
@@ -909,6 +936,14 @@ window.__ModuleLoader__.load({
|
|
|
909
936
|
const [params, setParams] = React.useState("");
|
|
910
937
|
const [result, setResult] = React.useState(null); // {kind:'query',...}|{kind:'exec',...}
|
|
911
938
|
const [busy, setBusy] = React.useState(false);
|
|
939
|
+
// Navicat 式跨库操控:当前库下拉(""=连接默认库),查询/执行路由到所选库
|
|
940
|
+
const [dbList, setDbList] = React.useState([]);
|
|
941
|
+
const [db, setDb] = React.useState("");
|
|
942
|
+
React.useEffect(() => {
|
|
943
|
+
setDbList([]); setDb("");
|
|
944
|
+
if (!connId || !projectPath) return;
|
|
945
|
+
api("databases" + qs({ project: projectPath, connId })).then((l) => setDbList(l || []), () => setDbList([]));
|
|
946
|
+
}, [connId, projectPath]);
|
|
912
947
|
|
|
913
948
|
async function run() {
|
|
914
949
|
setBusy(true);
|
|
@@ -924,7 +959,7 @@ window.__ModuleLoader__.load({
|
|
|
924
959
|
let parsedParams;
|
|
925
960
|
if (params.trim()) { try { parsedParams = JSON.parse(params); } catch (e) { throw new Error(t("paramsJson") + ": " + e.message); } }
|
|
926
961
|
const data = await runGuarded(
|
|
927
|
-
(challengeId) => api("execute", { method: "POST", body: { projectPath, connId, statement: sql, params: parsedParams, challengeId } }),
|
|
962
|
+
(challengeId) => api("execute", { method: "POST", body: { projectPath, connId, statement: sql, params: parsedParams, database: db || undefined, challengeId } }),
|
|
928
963
|
askConfirm,
|
|
929
964
|
);
|
|
930
965
|
setResult({ kind: "exec", message: (data && data.message) || "", affectedRows: data && data.affectedRows });
|
|
@@ -932,7 +967,7 @@ window.__ModuleLoader__.load({
|
|
|
932
967
|
let parsedParams;
|
|
933
968
|
if (params.trim()) { try { parsedParams = JSON.parse(params); } catch (e) { throw new Error(t("paramsJson") + ": " + e.message); } }
|
|
934
969
|
const data = await runGuarded(
|
|
935
|
-
(challengeId) => api("query", { method: "POST", body: { projectPath, connId, sql, params: parsedParams, challengeId } }),
|
|
970
|
+
(challengeId) => api("query", { method: "POST", body: { projectPath, connId, sql, params: parsedParams, database: db || undefined, challengeId } }),
|
|
936
971
|
askConfirm,
|
|
937
972
|
);
|
|
938
973
|
setResult({ kind: "query", data });
|
|
@@ -971,6 +1006,10 @@ window.__ModuleLoader__.load({
|
|
|
971
1006
|
React.createElement("select", { value: connId, onChange: (e) => setConnId(e.target.value) },
|
|
972
1007
|
React.createElement("option", { value: "" }, t("viewManage") + "…"),
|
|
973
1008
|
conns.map((c) => React.createElement("option", { key: c.id, value: c.id }, (c.name || c.id) + " (" + c.kind + ")"))),
|
|
1009
|
+
// 当前库(Navicat 式跨库:选中非默认库后 SQL 在该库执行,pg/gaussdb 按库路由)
|
|
1010
|
+
dbList.length > 1 ? React.createElement("select", { value: db, onChange: (e) => setDb(e.target.value) },
|
|
1011
|
+
React.createElement("option", { value: "" }, t("defaultDb")),
|
|
1012
|
+
dbList.map((d) => React.createElement("option", { key: d, value: d }, d))) : null,
|
|
974
1013
|
),
|
|
975
1014
|
React.createElement(
|
|
976
1015
|
"div",
|
|
@@ -142,9 +142,17 @@ export async function createPgLikeAdapter(kind, Driver, conn, opts) {
|
|
|
142
142
|
const v = res.rows[0]?.v;
|
|
143
143
|
return { ok: true, serverInfo: v == null ? kind : String(v) };
|
|
144
144
|
}),
|
|
145
|
-
query: (sql, params) => humanize(`${kind} 查询失败`, async () =>
|
|
146
|
-
|
|
147
|
-
const res =
|
|
145
|
+
query: (sql, params, database) => humanize(`${kind} 查询失败`, async () => {
|
|
146
|
+
// 跨库操控(Navicat 式):指定目标库 → 路由到该库的池(主池事务不跨库,忽略事务路由)
|
|
147
|
+
const res = database
|
|
148
|
+
? await poolFor(assertIdent(database, '数据库')).query(sql, params ?? [])
|
|
149
|
+
: await run(sql, params ?? []);
|
|
150
|
+
return toQueryResult(res);
|
|
151
|
+
}),
|
|
152
|
+
execute: (statement, params, database) => humanize(`${kind} 执行失败`, async () => {
|
|
153
|
+
const res = database
|
|
154
|
+
? await poolFor(assertIdent(database, '数据库')).query(statement, params ?? [])
|
|
155
|
+
: await run(statement, params ?? []);
|
|
148
156
|
const n = res.rowCount;
|
|
149
157
|
return {
|
|
150
158
|
affectedRows: typeof n === 'number' ? n : undefined,
|
package/dist/http/index.js
CHANGED
|
@@ -177,6 +177,7 @@ async function route(req, res, service, path, q, opts) {
|
|
|
177
177
|
...(b['name'] !== undefined ? { name: str(b['name']) } : {}),
|
|
178
178
|
...(b['url'] !== undefined ? { url: str(b['url']) } : {}),
|
|
179
179
|
...(b['fields'] !== undefined ? { fields: b['fields'] } : {}),
|
|
180
|
+
...(b['clearUrl'] === true ? { clearUrl: true } : {}),
|
|
180
181
|
...(ssl !== undefined ? { ssl } : {}),
|
|
181
182
|
}));
|
|
182
183
|
}
|
|
@@ -222,11 +223,11 @@ async function route(req, res, service, path, q, opts) {
|
|
|
222
223
|
}
|
|
223
224
|
if (path === '/api/query' && req.method === 'POST') {
|
|
224
225
|
const b = await readBody(req);
|
|
225
|
-
return await sendMaybeConfirm(res, service.query(projectOfBody(b), str(b['connId']), str(b['sql']), b['params'], optStr(b['challengeId'])));
|
|
226
|
+
return await sendMaybeConfirm(res, service.query(projectOfBody(b), str(b['connId']), str(b['sql']), b['params'], optStr(b['challengeId']), optStr(b['database'])));
|
|
226
227
|
}
|
|
227
228
|
if (path === '/api/execute' && req.method === 'POST') {
|
|
228
229
|
const b = await readBody(req);
|
|
229
|
-
return await sendMaybeConfirm(res, service.execute(projectOfBody(b), str(b['connId']), str(b['statement']), b['params'], optStr(b['challengeId'])));
|
|
230
|
+
return await sendMaybeConfirm(res, service.execute(projectOfBody(b), str(b['connId']), str(b['statement']), b['params'], optStr(b['challengeId']), optStr(b['database'])));
|
|
230
231
|
}
|
|
231
232
|
if (path === '/api/script' && req.method === 'POST') {
|
|
232
233
|
const b = await readBody(req);
|
package/dist/index.js
CHANGED
|
@@ -25,7 +25,7 @@ const TOOL_DESCRIPTION = `数据库管理工具(dsh-db-tool):在已配置
|
|
|
25
25
|
action 说明:
|
|
26
26
|
- list_connections:列出连接(脱敏,含各连接的 kind 与授权信息需另经 HTTP 面板查看)。
|
|
27
27
|
- query:只读 SQL/命令(conn_id, sql, params?)。
|
|
28
|
-
- execute:写操作/DDL(conn_id, statement, params
|
|
28
|
+
- execute:写操作/DDL(conn_id, statement, params?, database? 跨库目标)。
|
|
29
29
|
- schema:结构浏览(conn_id;给 table 查列、给 database 查表、都不给列库)。
|
|
30
30
|
- preview:预览行,limit≤50(conn_id, table, database?, limit?)。
|
|
31
31
|
- run_script:子进程沙箱脚本(conn_id, code),60s 超时强杀,permission model 禁文件
|
|
@@ -84,7 +84,7 @@ export function apply(ctx) {
|
|
|
84
84
|
sql: { type: 'string', description: 'query 的只读 SQL/命令' },
|
|
85
85
|
statement: { type: 'string', description: 'execute 的语句' },
|
|
86
86
|
params: { type: 'array', items: {}, description: '占位符参数' },
|
|
87
|
-
database: { type: 'string', description: '库/schema
|
|
87
|
+
database: { type: 'string', description: '库/schema(可选)。query/execute:指定目标库即跨库执行(Navicat 式,pg/gaussdb 支持按库路由);schema/preview:浏览目标(pg/gaussdb 传 "库名.schema")' },
|
|
88
88
|
table: { type: 'string', description: '表/集合/键(schema/preview 用)' },
|
|
89
89
|
limit: { type: 'number', description: 'preview 行数上限(≤50)' },
|
|
90
90
|
offset: { type: 'number', description: 'preview 行偏移(翻页用,默认 0)' },
|
package/dist/manager.js
CHANGED
|
@@ -190,7 +190,7 @@ export class DbToolService {
|
|
|
190
190
|
}
|
|
191
191
|
}
|
|
192
192
|
/* -- SQL 控制台(guard + challenge) -- */
|
|
193
|
-
async query(projectPath, connId, sql, params, challengeId) {
|
|
193
|
+
async query(projectPath, connId, sql, params, challengeId, database) {
|
|
194
194
|
const statement = assertNonEmpty(sql, 'sql');
|
|
195
195
|
return this.runGuarded({
|
|
196
196
|
projectPath,
|
|
@@ -198,11 +198,11 @@ export class DbToolService {
|
|
|
198
198
|
op: 'query',
|
|
199
199
|
statement,
|
|
200
200
|
challengeId,
|
|
201
|
-
run: (adapter) => adapter.query(statement, params),
|
|
201
|
+
run: (adapter) => adapter.query(statement, params, database),
|
|
202
202
|
rowsAffected: (r) => r.rowCount,
|
|
203
203
|
});
|
|
204
204
|
}
|
|
205
|
-
async execute(projectPath, connId, statement, params, challengeId) {
|
|
205
|
+
async execute(projectPath, connId, statement, params, challengeId, database) {
|
|
206
206
|
const stmt = assertNonEmpty(statement, 'statement');
|
|
207
207
|
return this.runGuarded({
|
|
208
208
|
projectPath,
|
|
@@ -210,7 +210,7 @@ export class DbToolService {
|
|
|
210
210
|
op: 'execute',
|
|
211
211
|
statement: stmt,
|
|
212
212
|
challengeId,
|
|
213
|
-
run: (adapter) => adapter.execute(stmt, params),
|
|
213
|
+
run: (adapter) => adapter.execute(stmt, params, database),
|
|
214
214
|
rowsAffected: (r) => r.affectedRows,
|
|
215
215
|
});
|
|
216
216
|
}
|
|
@@ -429,11 +429,11 @@ export async function handleToolAction(service, args, projectPath) {
|
|
|
429
429
|
case 'list_connections':
|
|
430
430
|
return ok(j(service.listConnections()));
|
|
431
431
|
case 'query': {
|
|
432
|
-
const r = await service.query(projectPath, requireConn(connId), assertArg(args.sql, 'sql'), args.params, challengeId);
|
|
432
|
+
const r = await service.query(projectPath, requireConn(connId), assertArg(args.sql, 'sql'), args.params, challengeId, args.database);
|
|
433
433
|
return ok(needConfirmText(r) ?? j(r));
|
|
434
434
|
}
|
|
435
435
|
case 'execute': {
|
|
436
|
-
const r = await service.execute(projectPath, requireConn(connId), assertArg(args.statement ?? args.sql, 'statement'), args.params, challengeId);
|
|
436
|
+
const r = await service.execute(projectPath, requireConn(connId), assertArg(args.statement ?? args.sql, 'statement'), args.params, challengeId, args.database);
|
|
437
437
|
return ok(needConfirmText(r) ?? j(r));
|
|
438
438
|
}
|
|
439
439
|
case 'schema': {
|
|
@@ -40,14 +40,21 @@ function toMeta(rec) {
|
|
|
40
40
|
const meta = { id: rec.id, kind: rec.kind };
|
|
41
41
|
if (rec.name !== undefined)
|
|
42
42
|
meta.name = rec.name;
|
|
43
|
-
if (rec.urlSafe !== undefined)
|
|
43
|
+
if (rec.urlSafe !== undefined) {
|
|
44
44
|
meta.safeUrl = rec.urlSafe;
|
|
45
|
+
meta.mode = 'url';
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
meta.mode = 'fields';
|
|
49
|
+
}
|
|
45
50
|
if (rec.fields) {
|
|
46
|
-
const { host, port, database } = rec.fields;
|
|
51
|
+
const { host, port, user, database } = rec.fields;
|
|
47
52
|
if (typeof host === 'string')
|
|
48
53
|
meta.host = host;
|
|
49
54
|
if (typeof port === 'number')
|
|
50
55
|
meta.port = port;
|
|
56
|
+
if (typeof user === 'string')
|
|
57
|
+
meta.user = user;
|
|
51
58
|
if (typeof database === 'string')
|
|
52
59
|
meta.database = database;
|
|
53
60
|
}
|
|
@@ -125,6 +132,11 @@ export class ConnectionStore {
|
|
|
125
132
|
rec.urlSafe = redactUrl(patch.url);
|
|
126
133
|
this.secrets.set(id, { url: patch.url });
|
|
127
134
|
}
|
|
135
|
+
if (patch.clearUrl && rec.urlSafe !== undefined) {
|
|
136
|
+
delete rec.urlSafe;
|
|
137
|
+
// set 为合并写;显式置 undefined 经 JSON 序列化后等效删除该键
|
|
138
|
+
this.secrets.set(id, { url: undefined });
|
|
139
|
+
}
|
|
128
140
|
this.save(data);
|
|
129
141
|
return toMeta(rec);
|
|
130
142
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-db-tool",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "DSH community plugin: chat-operated multi-database admin tool with sidebar management, project-scoped grants, ro/rw modes and dangerous-operation confirmation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/skills/db-admin/SKILL.md
CHANGED
|
@@ -33,6 +33,7 @@ DatabaseManager({ action: "query", conn_id: "c1", sql: "SELECT id, name FROM use
|
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
- `sql` 必填;`params` 为参数数组(占位符见方言速查);可选 `challenge_id`(危险读如 Redis `KEYS *` 触发确认后重试用)。
|
|
36
|
+
- 可选 `database`:跨库执行目标(Navicat 式)。pg/gaussdb 传库名(如 `gycwd`)即在该库上执行(同一凭据开独立连接,ro 授权下同样强制只读);其余库种忽略此参数(MySQL 可直接用 `库名.表` 语法)。
|
|
36
37
|
- 返回 `{columns, rows, rowCount, truncated?}`,所有单元格已规范化为 string/number/null。
|
|
37
38
|
- ro 与 rw 授权下都可用,但只读接口收到非查询语句会被拒。
|
|
38
39
|
|
|
@@ -43,6 +44,7 @@ DatabaseManager({ action: "execute", conn_id: "c1", statement: "UPDATE users SET
|
|
|
43
44
|
```
|
|
44
45
|
|
|
45
46
|
- `statement`(或 `sql`)必填,单条语句;DDL/DML/维护命令都会触发危险确认。
|
|
47
|
+
- 可选 `database`:跨库执行目标,语义同 query(pg/gaussdb 支持按库路由,目标库权限由数据库侧用户权限控制)。
|
|
46
48
|
- 返回 `{affectedRows?, message}`,`message` 为中文结果描述。
|
|
47
49
|
- ro 授权下直接返回 `READ_ONLY` 错误。
|
|
48
50
|
|