dsh-db-tool 0.1.5 → 0.1.7
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 +51 -8
- package/dist/adapters/sql-shared/pg-like.js +11 -3
- package/dist/http/index.js +5 -2
- package/dist/index.js +2 -2
- package/dist/manager.js +24 -11
- package/dist/store/connections.js +41 -9
- package/package.json +1 -1
- package/skills/db-admin/SKILL.md +2 -0
package/client/client.js
CHANGED
|
@@ -38,6 +38,7 @@ window.__ModuleLoader__.load({
|
|
|
38
38
|
fieldsPort: "端口",
|
|
39
39
|
fieldsUser: "用户名",
|
|
40
40
|
fieldsPassword: "密码",
|
|
41
|
+
passwordSaved: "已保存(留空保持不变)",
|
|
41
42
|
fieldsDatabase: "数据库名",
|
|
42
43
|
ssl: "启用 SSL",
|
|
43
44
|
test: "测试",
|
|
@@ -68,6 +69,7 @@ window.__ModuleLoader__.load({
|
|
|
68
69
|
noDatabases: "无可用数据库",
|
|
69
70
|
noSchemas: "无模式",
|
|
70
71
|
noTables: "无表",
|
|
72
|
+
defaultDb: "默认库",
|
|
71
73
|
loadFailed: "加载失败",
|
|
72
74
|
structure: "结构",
|
|
73
75
|
preview: "数据预览",
|
|
@@ -125,6 +127,7 @@ window.__ModuleLoader__.load({
|
|
|
125
127
|
fieldsPort: "Port",
|
|
126
128
|
fieldsUser: "User",
|
|
127
129
|
fieldsPassword: "Password",
|
|
130
|
+
passwordSaved: "Saved (leave blank to keep)",
|
|
128
131
|
fieldsDatabase: "Database",
|
|
129
132
|
ssl: "Enable SSL",
|
|
130
133
|
test: "Test",
|
|
@@ -153,6 +156,7 @@ window.__ModuleLoader__.load({
|
|
|
153
156
|
noDatabases: "No databases available",
|
|
154
157
|
noSchemas: "No schemas",
|
|
155
158
|
noTables: "No tables",
|
|
159
|
+
defaultDb: "Default DB",
|
|
156
160
|
loadFailed: "Failed to load",
|
|
157
161
|
structure: "Structure",
|
|
158
162
|
preview: "Preview",
|
|
@@ -445,6 +449,20 @@ window.__ModuleLoader__.load({
|
|
|
445
449
|
/** 新建表单初始态:分字段模式 + 当前 kind 的官方默认值 */
|
|
446
450
|
const freshForm = () => withKindDefaults({ ...EMPTY_FORM, mode: "fields" });
|
|
447
451
|
|
|
452
|
+
/** 编辑回填:从 meta 还原已存配置(密码不出库,留空=保留原密码)。
|
|
453
|
+
* url 方式 → 回填脱敏 url;分字段方式 → 回填 host/port/user/database。 */
|
|
454
|
+
function buildEditForm(c) {
|
|
455
|
+
const mode = c.mode || (c.safeUrl ? "url" : "fields");
|
|
456
|
+
return {
|
|
457
|
+
...EMPTY_FORM,
|
|
458
|
+
id: c.id, kind: c.kind, name: c.name || "", mode,
|
|
459
|
+
url: c.safeUrl || "",
|
|
460
|
+
host: c.host || "", port: c.port != null ? String(c.port) : "",
|
|
461
|
+
user: c.user || "", database: c.database || "",
|
|
462
|
+
ssl: !!c.ssl,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
448
466
|
function ConnForm(props) {
|
|
449
467
|
// 新建(无 initial)默认分字段模式并预填官方默认值;编辑保持用户数据原样
|
|
450
468
|
const [form, setForm] = React.useState(() => props.initial || freshForm());
|
|
@@ -459,18 +477,31 @@ window.__ModuleLoader__.load({
|
|
|
459
477
|
setDirty(new Set());
|
|
460
478
|
setDraftTest(null);
|
|
461
479
|
}
|
|
462
|
-
/** 从 URL
|
|
480
|
+
/** 从 URL 模式切回分字段:保留表单里已有值(编辑回填场景),只对空字段补官方默认值 */
|
|
463
481
|
function reenterFields() {
|
|
464
|
-
setForm((prev) =>
|
|
482
|
+
setForm((prev) => {
|
|
483
|
+
const keep = new Set(dirty);
|
|
484
|
+
for (const k of ["host", "port", "user", "database"]) if (prev[k]) keep.add(k);
|
|
485
|
+
return withKindDefaults(Object.assign({}, prev, { mode: "fields" }), keep);
|
|
486
|
+
});
|
|
465
487
|
setDraftTest(null);
|
|
466
488
|
}
|
|
467
489
|
function draftBody() {
|
|
468
490
|
const body = { kind: form.kind, ssl: !!form.ssl };
|
|
469
|
-
|
|
470
|
-
|
|
491
|
+
// 编辑已有连接:透传 connId,服务端测试草稿时拼回已存机密(密码留空/url 未改动语义)
|
|
492
|
+
if (props.initial) body.connId = form.id;
|
|
493
|
+
// 编辑保存语义:url 模式未改动(仍等于回填的脱敏 url)→ 不发 url,保留 secrets 原值
|
|
494
|
+
const origUrl = props.initial && props.initial.mode === "url" ? props.initial.url : undefined;
|
|
495
|
+
if (form.mode === "url") {
|
|
496
|
+
if (form.url && form.url !== origUrl) body.url = form.url;
|
|
497
|
+
// 新建 url 连接必须发
|
|
498
|
+
if (!props.initial && form.url) body.url = form.url;
|
|
499
|
+
} else {
|
|
471
500
|
body.fields = { host: form.host, user: form.user, database: form.database || undefined };
|
|
472
501
|
if (form.port) body.fields.port = Number(form.port);
|
|
473
502
|
if (form.password) body.fields.password = form.password;
|
|
503
|
+
// 从 url 方式切到分字段保存 → 服务端清除已存 url(否则连接仍走旧 url)
|
|
504
|
+
if (props.initial && props.initial.mode === "url") body.clearUrl = true;
|
|
474
505
|
}
|
|
475
506
|
return body;
|
|
476
507
|
}
|
|
@@ -520,7 +551,7 @@ window.__ModuleLoader__.load({
|
|
|
520
551
|
React.createElement("input", { placeholder: t("fieldsDatabase"), value: form.database, onChange: (e) => patch({ database: e.target.value }) })),
|
|
521
552
|
React.createElement("div", { className: "dbt-row" },
|
|
522
553
|
React.createElement("input", { placeholder: t("fieldsUser"), value: form.user, onChange: (e) => patch({ user: e.target.value }) }),
|
|
523
|
-
React.createElement("input", { type: "password", placeholder: t("fieldsPassword"), value: form.password, onChange: (e) => patch({ password: e.target.value }) })),
|
|
554
|
+
React.createElement("input", { type: "password", placeholder: props.initial && props.initial.hasPassword ? t("passwordSaved") : t("fieldsPassword"), value: form.password, onChange: (e) => patch({ password: e.target.value }) })),
|
|
524
555
|
),
|
|
525
556
|
// 分组 3:操作
|
|
526
557
|
React.createElement(
|
|
@@ -583,7 +614,7 @@ window.__ModuleLoader__.load({
|
|
|
583
614
|
|
|
584
615
|
if (editing) {
|
|
585
616
|
return React.createElement(ConnForm, {
|
|
586
|
-
initial: editing === "new" ? null :
|
|
617
|
+
initial: editing === "new" ? null : buildEditForm(editing),
|
|
587
618
|
busy: busy === "save",
|
|
588
619
|
onSubmit: saveConn,
|
|
589
620
|
onCancel: () => setEditing(null),
|
|
@@ -909,6 +940,14 @@ window.__ModuleLoader__.load({
|
|
|
909
940
|
const [params, setParams] = React.useState("");
|
|
910
941
|
const [result, setResult] = React.useState(null); // {kind:'query',...}|{kind:'exec',...}
|
|
911
942
|
const [busy, setBusy] = React.useState(false);
|
|
943
|
+
// Navicat 式跨库操控:当前库下拉(""=连接默认库),查询/执行路由到所选库
|
|
944
|
+
const [dbList, setDbList] = React.useState([]);
|
|
945
|
+
const [db, setDb] = React.useState("");
|
|
946
|
+
React.useEffect(() => {
|
|
947
|
+
setDbList([]); setDb("");
|
|
948
|
+
if (!connId || !projectPath) return;
|
|
949
|
+
api("databases" + qs({ project: projectPath, connId })).then((l) => setDbList(l || []), () => setDbList([]));
|
|
950
|
+
}, [connId, projectPath]);
|
|
912
951
|
|
|
913
952
|
async function run() {
|
|
914
953
|
setBusy(true);
|
|
@@ -924,7 +963,7 @@ window.__ModuleLoader__.load({
|
|
|
924
963
|
let parsedParams;
|
|
925
964
|
if (params.trim()) { try { parsedParams = JSON.parse(params); } catch (e) { throw new Error(t("paramsJson") + ": " + e.message); } }
|
|
926
965
|
const data = await runGuarded(
|
|
927
|
-
(challengeId) => api("execute", { method: "POST", body: { projectPath, connId, statement: sql, params: parsedParams, challengeId } }),
|
|
966
|
+
(challengeId) => api("execute", { method: "POST", body: { projectPath, connId, statement: sql, params: parsedParams, database: db || undefined, challengeId } }),
|
|
928
967
|
askConfirm,
|
|
929
968
|
);
|
|
930
969
|
setResult({ kind: "exec", message: (data && data.message) || "", affectedRows: data && data.affectedRows });
|
|
@@ -932,7 +971,7 @@ window.__ModuleLoader__.load({
|
|
|
932
971
|
let parsedParams;
|
|
933
972
|
if (params.trim()) { try { parsedParams = JSON.parse(params); } catch (e) { throw new Error(t("paramsJson") + ": " + e.message); } }
|
|
934
973
|
const data = await runGuarded(
|
|
935
|
-
(challengeId) => api("query", { method: "POST", body: { projectPath, connId, sql, params: parsedParams, challengeId } }),
|
|
974
|
+
(challengeId) => api("query", { method: "POST", body: { projectPath, connId, sql, params: parsedParams, database: db || undefined, challengeId } }),
|
|
936
975
|
askConfirm,
|
|
937
976
|
);
|
|
938
977
|
setResult({ kind: "query", data });
|
|
@@ -971,6 +1010,10 @@ window.__ModuleLoader__.load({
|
|
|
971
1010
|
React.createElement("select", { value: connId, onChange: (e) => setConnId(e.target.value) },
|
|
972
1011
|
React.createElement("option", { value: "" }, t("viewManage") + "…"),
|
|
973
1012
|
conns.map((c) => React.createElement("option", { key: c.id, value: c.id }, (c.name || c.id) + " (" + c.kind + ")"))),
|
|
1013
|
+
// 当前库(Navicat 式跨库:选中非默认库后 SQL 在该库执行,pg/gaussdb 按库路由)
|
|
1014
|
+
dbList.length > 1 ? React.createElement("select", { value: db, onChange: (e) => setDb(e.target.value) },
|
|
1015
|
+
React.createElement("option", { value: "" }, t("defaultDb")),
|
|
1016
|
+
dbList.map((d) => React.createElement("option", { key: d, value: d }, d))) : null,
|
|
974
1017
|
),
|
|
975
1018
|
React.createElement(
|
|
976
1019
|
"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
|
@@ -160,6 +160,8 @@ async function route(req, res, service, path, q, opts) {
|
|
|
160
160
|
...(b['url'] !== undefined ? { url: str(b['url']) } : {}),
|
|
161
161
|
...(b['fields'] !== undefined ? { fields: b['fields'] } : {}),
|
|
162
162
|
...(ssl !== undefined ? { ssl } : {}),
|
|
163
|
+
// 编辑已有连接时透传:服务端拼回已存机密(留空密码/url 未改动的测试语义)
|
|
164
|
+
...(b['connId'] !== undefined ? { connId: optStr(b['connId']) } : {}),
|
|
163
165
|
}));
|
|
164
166
|
}
|
|
165
167
|
const connMatch = /^\/api\/connections\/([^/]+)(\/test)?$/.exec(path);
|
|
@@ -177,6 +179,7 @@ async function route(req, res, service, path, q, opts) {
|
|
|
177
179
|
...(b['name'] !== undefined ? { name: str(b['name']) } : {}),
|
|
178
180
|
...(b['url'] !== undefined ? { url: str(b['url']) } : {}),
|
|
179
181
|
...(b['fields'] !== undefined ? { fields: b['fields'] } : {}),
|
|
182
|
+
...(b['clearUrl'] === true ? { clearUrl: true } : {}),
|
|
180
183
|
...(ssl !== undefined ? { ssl } : {}),
|
|
181
184
|
}));
|
|
182
185
|
}
|
|
@@ -222,11 +225,11 @@ async function route(req, res, service, path, q, opts) {
|
|
|
222
225
|
}
|
|
223
226
|
if (path === '/api/query' && req.method === 'POST') {
|
|
224
227
|
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'])));
|
|
228
|
+
return await sendMaybeConfirm(res, service.query(projectOfBody(b), str(b['connId']), str(b['sql']), b['params'], optStr(b['challengeId']), optStr(b['database'])));
|
|
226
229
|
}
|
|
227
230
|
if (path === '/api/execute' && req.method === 'POST') {
|
|
228
231
|
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'])));
|
|
232
|
+
return await sendMaybeConfirm(res, service.execute(projectOfBody(b), str(b['connId']), str(b['statement']), b['params'], optStr(b['challengeId']), optStr(b['database'])));
|
|
230
233
|
}
|
|
231
234
|
if (path === '/api/script' && req.method === 'POST') {
|
|
232
235
|
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
|
@@ -75,15 +75,28 @@ export class DbToolService {
|
|
|
75
75
|
/**
|
|
76
76
|
* 测试未保存的连接草稿(侧边栏「保存前测试」)。不落库、不写审计,
|
|
77
77
|
* 一次性适配器用完即关。url/fields 校验交给适配器层(与保存后测试同口径)。
|
|
78
|
+
* 编辑已有连接时传 connId:密码留空/url 未改动时自动拼回已存机密
|
|
79
|
+
* (仅注入本次测试,绝不回传客户端)。
|
|
78
80
|
*/
|
|
79
81
|
async testDraft(input) {
|
|
80
82
|
const rc = {
|
|
81
|
-
meta: { id: '(draft)', kind: input.kind, name: '(draft)' },
|
|
83
|
+
meta: { id: input.connId ?? '(draft)', kind: input.kind, name: '(draft)' },
|
|
82
84
|
};
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (input.
|
|
86
|
-
|
|
85
|
+
let url = input.url;
|
|
86
|
+
let fields = input.fields !== undefined ? { ...input.fields } : undefined;
|
|
87
|
+
if (input.connId) {
|
|
88
|
+
// 已存连接:编辑时客户端不发旧机密(留空语义),这里从 secrets 拼回
|
|
89
|
+
const sec = this.store.secrets.get(input.connId);
|
|
90
|
+
if (url === undefined && sec?.url !== undefined)
|
|
91
|
+
url = sec.url;
|
|
92
|
+
if (fields !== undefined && sec?.password !== undefined && fields.password === undefined) {
|
|
93
|
+
fields.password = sec.password;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (url !== undefined)
|
|
97
|
+
rc.url = url;
|
|
98
|
+
if (fields !== undefined)
|
|
99
|
+
rc.fields = fields;
|
|
87
100
|
if (input.ssl !== undefined)
|
|
88
101
|
rc.ssl = input.ssl;
|
|
89
102
|
try {
|
|
@@ -190,7 +203,7 @@ export class DbToolService {
|
|
|
190
203
|
}
|
|
191
204
|
}
|
|
192
205
|
/* -- SQL 控制台(guard + challenge) -- */
|
|
193
|
-
async query(projectPath, connId, sql, params, challengeId) {
|
|
206
|
+
async query(projectPath, connId, sql, params, challengeId, database) {
|
|
194
207
|
const statement = assertNonEmpty(sql, 'sql');
|
|
195
208
|
return this.runGuarded({
|
|
196
209
|
projectPath,
|
|
@@ -198,11 +211,11 @@ export class DbToolService {
|
|
|
198
211
|
op: 'query',
|
|
199
212
|
statement,
|
|
200
213
|
challengeId,
|
|
201
|
-
run: (adapter) => adapter.query(statement, params),
|
|
214
|
+
run: (adapter) => adapter.query(statement, params, database),
|
|
202
215
|
rowsAffected: (r) => r.rowCount,
|
|
203
216
|
});
|
|
204
217
|
}
|
|
205
|
-
async execute(projectPath, connId, statement, params, challengeId) {
|
|
218
|
+
async execute(projectPath, connId, statement, params, challengeId, database) {
|
|
206
219
|
const stmt = assertNonEmpty(statement, 'statement');
|
|
207
220
|
return this.runGuarded({
|
|
208
221
|
projectPath,
|
|
@@ -210,7 +223,7 @@ export class DbToolService {
|
|
|
210
223
|
op: 'execute',
|
|
211
224
|
statement: stmt,
|
|
212
225
|
challengeId,
|
|
213
|
-
run: (adapter) => adapter.execute(stmt, params),
|
|
226
|
+
run: (adapter) => adapter.execute(stmt, params, database),
|
|
214
227
|
rowsAffected: (r) => r.affectedRows,
|
|
215
228
|
});
|
|
216
229
|
}
|
|
@@ -429,11 +442,11 @@ export async function handleToolAction(service, args, projectPath) {
|
|
|
429
442
|
case 'list_connections':
|
|
430
443
|
return ok(j(service.listConnections()));
|
|
431
444
|
case 'query': {
|
|
432
|
-
const r = await service.query(projectPath, requireConn(connId), assertArg(args.sql, 'sql'), args.params, challengeId);
|
|
445
|
+
const r = await service.query(projectPath, requireConn(connId), assertArg(args.sql, 'sql'), args.params, challengeId, args.database);
|
|
433
446
|
return ok(needConfirmText(r) ?? j(r));
|
|
434
447
|
}
|
|
435
448
|
case 'execute': {
|
|
436
|
-
const r = await service.execute(projectPath, requireConn(connId), assertArg(args.statement ?? args.sql, 'statement'), args.params, challengeId);
|
|
449
|
+
const r = await service.execute(projectPath, requireConn(connId), assertArg(args.statement ?? args.sql, 'statement'), args.params, challengeId, args.database);
|
|
437
450
|
return ok(needConfirmText(r) ?? j(r));
|
|
438
451
|
}
|
|
439
452
|
case 'schema': {
|
|
@@ -35,19 +35,29 @@ function splitPassword(fields) {
|
|
|
35
35
|
const { [PASSWORD_KEY]: password, ...clean } = fields;
|
|
36
36
|
return { clean, password: password };
|
|
37
37
|
}
|
|
38
|
-
/** ConnRecord → 用户可见的 ConnectionMeta(契约见 lib/adapters/types.ts
|
|
39
|
-
|
|
38
|
+
/** ConnRecord → 用户可见的 ConnectionMeta(契约见 lib/adapters/types.ts)。
|
|
39
|
+
* hasPassword 只回布尔指示,明文绝不出库。 */
|
|
40
|
+
function toMeta(rec, hasPassword = false) {
|
|
40
41
|
const meta = { id: rec.id, kind: rec.kind };
|
|
41
42
|
if (rec.name !== undefined)
|
|
42
43
|
meta.name = rec.name;
|
|
43
|
-
if (rec.urlSafe !== undefined)
|
|
44
|
+
if (rec.urlSafe !== undefined) {
|
|
44
45
|
meta.safeUrl = rec.urlSafe;
|
|
46
|
+
meta.mode = 'url';
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
meta.mode = 'fields';
|
|
50
|
+
}
|
|
51
|
+
if (hasPassword)
|
|
52
|
+
meta.hasPassword = true;
|
|
45
53
|
if (rec.fields) {
|
|
46
|
-
const { host, port, database } = rec.fields;
|
|
54
|
+
const { host, port, user, database } = rec.fields;
|
|
47
55
|
if (typeof host === 'string')
|
|
48
56
|
meta.host = host;
|
|
49
57
|
if (typeof port === 'number')
|
|
50
58
|
meta.port = port;
|
|
59
|
+
if (typeof user === 'string')
|
|
60
|
+
meta.user = user;
|
|
51
61
|
if (typeof database === 'string')
|
|
52
62
|
meta.database = database;
|
|
53
63
|
}
|
|
@@ -71,12 +81,16 @@ export class ConnectionStore {
|
|
|
71
81
|
findRec(data, id) {
|
|
72
82
|
return data.connections.find((c) => c.id === id);
|
|
73
83
|
}
|
|
84
|
+
/** 该连接是否已存密码(布尔指示,不读明文) */
|
|
85
|
+
hasPassword(id) {
|
|
86
|
+
return this.secrets.get(id)?.password !== undefined;
|
|
87
|
+
}
|
|
74
88
|
list() {
|
|
75
|
-
return this.load().connections.map(toMeta);
|
|
89
|
+
return this.load().connections.map((r) => toMeta(r, this.hasPassword(r.id)));
|
|
76
90
|
}
|
|
77
91
|
get(id) {
|
|
78
92
|
const rec = this.findRec(this.load(), id);
|
|
79
|
-
return rec ? toMeta(rec) : undefined;
|
|
93
|
+
return rec ? toMeta(rec, this.hasPassword(id)) : undefined;
|
|
80
94
|
}
|
|
81
95
|
/** 新建连接;id 已存在时抛错 */
|
|
82
96
|
create(input) {
|
|
@@ -103,7 +117,7 @@ export class ConnectionStore {
|
|
|
103
117
|
this.secrets.set(input.id, secretPatch);
|
|
104
118
|
data.connections.push(rec);
|
|
105
119
|
this.save(data);
|
|
106
|
-
return toMeta(rec);
|
|
120
|
+
return toMeta(rec, this.hasPassword(input.id));
|
|
107
121
|
}
|
|
108
122
|
/** 部分更新;不存在返回 undefined。url/fields 变更时同步拆分 secrets */
|
|
109
123
|
update(id, patch) {
|
|
@@ -125,8 +139,26 @@ export class ConnectionStore {
|
|
|
125
139
|
rec.urlSafe = redactUrl(patch.url);
|
|
126
140
|
this.secrets.set(id, { url: patch.url });
|
|
127
141
|
}
|
|
142
|
+
if (patch.clearUrl && rec.urlSafe !== undefined) {
|
|
143
|
+
delete rec.urlSafe;
|
|
144
|
+
// 密码迁移:原 url 内嵌密码 → fields 密码(切方式后保持可连,编辑留空即保留)
|
|
145
|
+
const secUrl = this.secrets.get(id)?.url;
|
|
146
|
+
if (secUrl !== undefined) {
|
|
147
|
+
try {
|
|
148
|
+
const u = new URL(secUrl);
|
|
149
|
+
if (u.password && this.secrets.get(id)?.password === undefined) {
|
|
150
|
+
this.secrets.set(id, { password: decodeURIComponent(u.password) });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
/* 非 URL 形态,忽略迁移 */
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// set 为合并写;显式置 undefined 经 JSON 序列化后等效删除该键
|
|
158
|
+
this.secrets.set(id, { url: undefined });
|
|
159
|
+
}
|
|
128
160
|
this.save(data);
|
|
129
|
-
return toMeta(rec);
|
|
161
|
+
return toMeta(rec, this.hasPassword(id));
|
|
130
162
|
}
|
|
131
163
|
/** 删除连接,级联删除 secrets 与该连接的所有项目授权。
|
|
132
164
|
* 顺序按"失败开放"原则:先删权限(grants),再删机密(secrets),最后改
|
|
@@ -150,7 +182,7 @@ export class ConnectionStore {
|
|
|
150
182
|
const rec = this.findRec(this.load(), id);
|
|
151
183
|
if (!rec)
|
|
152
184
|
throw new Error(`连接不存在: ${id}`);
|
|
153
|
-
const meta = toMeta(rec);
|
|
185
|
+
const meta = toMeta(rec, this.secrets.get(id)?.password !== undefined);
|
|
154
186
|
const rc = { meta };
|
|
155
187
|
const sec = this.secrets.get(id);
|
|
156
188
|
if (sec?.url !== undefined)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-db-tool",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
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
|
|