@nsyan/db 1.0.0 → 1.2.0
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/README.md +68 -93
- package/docs/USAGE.md +138 -0
- package/index.ts +216 -31
- package/package.json +11 -2
- package/src/config.ts +72 -8
- package/src/core/audit.ts +67 -0
- package/src/core/policy.ts +13 -0
- package/src/core/scan/candidates.ts +13 -5
- package/src/core/scan/parsers.ts +1 -1
- package/src/core/scan/spring.ts +29 -3
- package/src/core/scan/walker.ts +22 -6
- package/src/core/types.ts +14 -3
- package/src/dialects/dm.ts +53 -24
- package/src/dialects/document-dialect.ts +273 -0
- package/src/dialects/elasticsearch.ts +47 -22
- package/src/dialects/graph-dialect.ts +234 -0
- package/src/dialects/index.ts +2 -0
- package/src/dialects/kv-dialect.ts +13 -0
- package/src/dialects/mongodb.ts +341 -0
- package/src/dialects/mysql.ts +12 -1
- package/src/dialects/neo4j.ts +321 -0
- package/src/dialects/search-dialect.ts +20 -5
package/index.ts
CHANGED
|
@@ -2,20 +2,22 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { registry } from "./src/dialects/index.js";
|
|
5
|
-
import { decide } from "./src/core/policy.js";
|
|
5
|
+
import { decide, effectiveReadonly, writeRequiresReason } from "./src/core/policy.js";
|
|
6
|
+
import { appendAuditLog } from "./src/core/audit.js";
|
|
6
7
|
import { scanProject } from "./src/core/scan/candidates.js";
|
|
7
8
|
import type { Candidate, ConnConfig, DbTypeId } from "./src/core/types.js";
|
|
8
9
|
import {
|
|
9
10
|
loadConfigs, saveConfigs, loadPluginConfig, savePluginConfig, getConfigSummary,
|
|
10
11
|
findConfig, toRuntimeConfig, shortTypeLabel, fullTypeLabel,
|
|
11
12
|
parseConnectionString, getDefaultConfig, setDefaultConfig, writeQueryExport,
|
|
13
|
+
recordLastUsed, recordTestResult, connSummaryLine, envTagLabel, formatRelativeTime,
|
|
12
14
|
} from "./src/config.js";
|
|
13
15
|
import type { PluginConfig } from "./src/config.js";
|
|
14
16
|
|
|
15
17
|
// ── URL 解析:遍历 registry 各方言 parseUrl,首个非 null 胜出 ────
|
|
16
18
|
// 关系型 JDBC + 原生 URI 双形态由各方言 parseUrl 兼收(Spec §7)
|
|
17
19
|
|
|
18
|
-
function parseDbUrl(url: string): { dialectId: DbTypeId; host: string; port: number; username?: string; password?: string; database?: string; dbIndex?: number } | null {
|
|
20
|
+
function parseDbUrl(url: string): { dialectId: DbTypeId; host: string; port: number; username?: string; password?: string; database?: string; dbIndex?: number; options?: Record<string, string> } | null {
|
|
19
21
|
for (const d of registry.values()) {
|
|
20
22
|
const p = d.parseUrl(url);
|
|
21
23
|
if (p) return { dialectId: d.id, ...p };
|
|
@@ -37,9 +39,7 @@ function buildDisplayList(configs: ConnConfig[]): { list: string[]; map: Map<str
|
|
|
37
39
|
const map = new Map<string, ConnConfig>();
|
|
38
40
|
const list: string[] = [];
|
|
39
41
|
for (const c of configs) {
|
|
40
|
-
const
|
|
41
|
-
const desc = c.description ? ` - ${c.description}` : "";
|
|
42
|
-
const display = `${c.name} [${typeLabel}]${desc}`;
|
|
42
|
+
const display = connSummaryLine(c);
|
|
43
43
|
list.push(display);
|
|
44
44
|
map.set(display, c);
|
|
45
45
|
}
|
|
@@ -56,6 +56,10 @@ function familyHint(c: ConnConfig): string {
|
|
|
56
56
|
case "mysql":
|
|
57
57
|
case "oracle":
|
|
58
58
|
return "关系型,sql 参数填 SQL";
|
|
59
|
+
case "mongodb":
|
|
60
|
+
return "MongoDB 文档库,sql 参数填 JSON 命令信封(如 {\"find\":\"users\",\"filter\":{}};读命令 find/count/distinct/aggregate)";
|
|
61
|
+
case "neo4j":
|
|
62
|
+
return "Neo4j 图数据库,sql 参数填 Cypher(如 MATCH (n:Person) RETURN n LIMIT 10;list_tables 列出 label 与关系类型,describe_table 目标填 label 名或 rel:类型)";
|
|
59
63
|
default:
|
|
60
64
|
return `${fullTypeLabel(c.type)},sql 参数填查询或命令`;
|
|
61
65
|
}
|
|
@@ -93,13 +97,15 @@ function buildDbListHint(configs: ConnConfig[], cfg: PluginConfig): string {
|
|
|
93
97
|
}
|
|
94
98
|
const lines = configs.map((c) => {
|
|
95
99
|
const desc = c.description ? ` - ${c.description}` : "";
|
|
96
|
-
|
|
100
|
+
const tag = envTagLabel(c);
|
|
101
|
+
return `- ${c.name}[${shortTypeLabel(c.type)}]${tag ? " " + tag : ""} - ${fullTypeLabel(c.type)},${familyHint(c)}${desc}`;
|
|
97
102
|
});
|
|
98
103
|
return [
|
|
99
104
|
"[数据库工具执行策略]",
|
|
100
105
|
policy,
|
|
101
106
|
confirmation,
|
|
102
107
|
safety,
|
|
108
|
+
"标注 [prod·强制只读] 的连接无论全局只读设置如何均只允许查询。",
|
|
103
109
|
"[可用数据库]",
|
|
104
110
|
...lines,
|
|
105
111
|
"query_database / list_tables / describe_table 的 database 参数必须使用上述名称(不含中括号内容,名称区分大小写)。",
|
|
@@ -209,6 +215,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
209
215
|
const idx = all.findIndex((x) => x.name === name);
|
|
210
216
|
if (idx >= 0) all[idx] = conn; else all.push(conn);
|
|
211
217
|
saveConfigs(all);
|
|
218
|
+
recordTestResult(name, result);
|
|
212
219
|
ctx.ui.notify(`配置已保存: ${name}`, "success");
|
|
213
220
|
}
|
|
214
221
|
};
|
|
@@ -218,32 +225,60 @@ export default function (pi: ExtensionAPI) {
|
|
|
218
225
|
const name = (await ctx.ui.input("连接名称", ""))?.trim();
|
|
219
226
|
if (!name) { ctx.ui.notify("连接名称不能为空", "error"); return; }
|
|
220
227
|
|
|
221
|
-
// 首问:一键连接串 / 逐步填写
|
|
228
|
+
// 首问:一键连接串 / 逐步填写 / 从现有复制(v1.1 UX 共识 Q2)
|
|
222
229
|
const mode = await ctx.ui.select("添加方式", [
|
|
223
230
|
"⚡ 粘贴连接串(一键)",
|
|
224
231
|
"📝 逐步填写",
|
|
232
|
+
"📋 从现有复制",
|
|
225
233
|
]);
|
|
226
234
|
if (!mode) return;
|
|
227
235
|
|
|
236
|
+
if (mode === "📋 从现有复制") {
|
|
237
|
+
const source = await selectDbConfig(ctx, loadConfigs(), "选择要复制的连接");
|
|
238
|
+
if (!source) return;
|
|
239
|
+
let newName = (await ctx.ui.input("新连接名称", `${source.name}-copy`))?.trim();
|
|
240
|
+
while (newName && loadConfigs().some((c) => c.name === newName)) {
|
|
241
|
+
newName = (await ctx.ui.input(`名称已存在: ${newName},请换一个`, `${newName}-2`))?.trim();
|
|
242
|
+
}
|
|
243
|
+
if (!newName) { ctx.ui.notify("已取消复制", "info"); return; }
|
|
244
|
+
const copy: ConnConfig = {
|
|
245
|
+
...source,
|
|
246
|
+
id: randomUUID(),
|
|
247
|
+
name: newName,
|
|
248
|
+
isDefault: false,
|
|
249
|
+
lastUsedAt: undefined,
|
|
250
|
+
lastTest: undefined,
|
|
251
|
+
createdAt: new Date().toISOString(),
|
|
252
|
+
};
|
|
253
|
+
const all = loadConfigs();
|
|
254
|
+
all.push(copy);
|
|
255
|
+
saveConfigs(all);
|
|
256
|
+
ctx.ui.notify(`已复制为 ${newName}(类型/账号/环境标签等设置一并带上)`, "success");
|
|
257
|
+
if (await ctx.ui.confirm("从现有复制", "立即编辑副本(主机/端口/库/标签等)?")) {
|
|
258
|
+
await editDbConfig(ctx, copy);
|
|
259
|
+
}
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
228
263
|
let parsed: ReturnType<typeof parseDbUrl>;
|
|
229
264
|
let username = "";
|
|
230
265
|
let password = "";
|
|
231
266
|
let dbIndex: number | undefined;
|
|
232
267
|
|
|
233
268
|
if (mode === "⚡ 粘贴连接串(一键)") {
|
|
234
|
-
const url = (await ctx.ui.input("连接串", "postgresql://user:pass@host:5432/db 或 jdbc:mysql://... 或 redis://:pass@host:6379/0"))?.trim();
|
|
269
|
+
const url = (await ctx.ui.input("连接串", "postgresql://user:pass@host:5432/db 或 jdbc:mysql://... 或 redis://:pass@host:6379/0 或 mongodb://user:pass@host:27017/db 或 neo4j://user:pass@host:7687"))?.trim();
|
|
235
270
|
if (!url) { ctx.ui.notify("连接串不能为空", "error"); return; }
|
|
236
271
|
const pcs = parseConnectionString(url);
|
|
237
272
|
if (!pcs) {
|
|
238
|
-
ctx.ui.notify("连接串无法识别。支持: postgresql/mysql/oracle/dm/hive JDBC、redis(s)://、http(s)://host:9200", "error");
|
|
273
|
+
ctx.ui.notify("连接串无法识别。支持: postgresql/mysql/oracle/dm/hive JDBC、redis(s)://、mongodb(srv)://、neo4j/bolt(s)://、http(s)://host:9200", "error");
|
|
239
274
|
return;
|
|
240
275
|
}
|
|
241
|
-
parsed = { dialectId: pcs.dialectId, host: pcs.host, port: pcs.port, database: pcs.database };
|
|
276
|
+
parsed = { dialectId: pcs.dialectId, host: pcs.host, port: pcs.port, database: pcs.database, options: pcs.options };
|
|
242
277
|
username = pcs.username ?? "";
|
|
243
278
|
password = pcs.password ?? "";
|
|
244
279
|
dbIndex = pcs.dbIndex;
|
|
245
280
|
} else {
|
|
246
|
-
const url = (await ctx.ui.input("连接 URL", "jdbc:postgresql://host:port/database"))?.trim();
|
|
281
|
+
const url = (await ctx.ui.input("连接 URL", "jdbc:postgresql://host:port/database 或 mongodb://host:27017/db"))?.trim();
|
|
247
282
|
if (!url) { ctx.ui.notify("连接 URL 不能为空", "error"); return; }
|
|
248
283
|
const pcs = parseConnectionString(url);
|
|
249
284
|
if (!pcs) {
|
|
@@ -251,17 +286,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
251
286
|
" PostgreSQL/MySQL/DM/Hive: jdbc:<dialect>://host:port/db\n" +
|
|
252
287
|
" Oracle: jdbc:oracle:thin:@//host:port/service 或 @host:port:SID\n" +
|
|
253
288
|
" Redis: redis://[:password@]host:port[/db]\n" +
|
|
289
|
+
" MongoDB: mongodb://user:pass@host:27017/db 或 mongodb+srv://...\n" +
|
|
290
|
+
" Neo4j: neo4j://user:pass@host:7687/db 或 bolt://host:7687(路径段为图数据库名)\n" +
|
|
254
291
|
" ES: http://host:9200", "error");
|
|
255
292
|
return;
|
|
256
293
|
}
|
|
257
|
-
parsed = { dialectId: pcs.dialectId, host: pcs.host, port: pcs.port, database: pcs.database };
|
|
294
|
+
parsed = { dialectId: pcs.dialectId, host: pcs.host, port: pcs.port, database: pcs.database, options: pcs.options };
|
|
258
295
|
dbIndex = pcs.dbIndex;
|
|
259
296
|
|
|
260
|
-
// 家族分支字段:Redis 无账号要求、需库号;ES
|
|
297
|
+
// 家族分支字段:Redis 无账号要求、需库号;MongoDB 账号可空;ES/其余 账号+密码
|
|
261
298
|
if (parsed.dialectId === "redis") {
|
|
262
299
|
password = (await ctx.ui.input("密码(可空)", ""))?.trim() ?? "";
|
|
263
300
|
const idxInput = (await ctx.ui.input("库号 dbIndex(0-15,缺省 0)", String(dbIndex ?? 0)))?.trim();
|
|
264
301
|
dbIndex = idxInput !== undefined && idxInput !== "" ? parseInt(idxInput, 10) : dbIndex;
|
|
302
|
+
} else if (parsed.dialectId === "mongodb") {
|
|
303
|
+
username = (await ctx.ui.input("账号(可空,本地无认证留空)", ""))?.trim() ?? "";
|
|
304
|
+
password = (await ctx.ui.input("密码(可空)", ""))?.trim() ?? "";
|
|
305
|
+
} else if (parsed.dialectId === "neo4j") {
|
|
306
|
+
username = (await ctx.ui.input("账号", "neo4j"))?.trim() || "neo4j";
|
|
307
|
+
password = (await ctx.ui.input("密码", ""))?.trim() ?? "";
|
|
308
|
+
const dbInput = (await ctx.ui.input("图数据库名(缺省 neo4j)", parsed.database ?? "neo4j"))?.trim();
|
|
309
|
+
parsed.database = dbInput || parsed.database || "neo4j";
|
|
265
310
|
} else {
|
|
266
311
|
username = (await ctx.ui.input("账号", "root"))?.trim() || "root";
|
|
267
312
|
password = (await ctx.ui.input("密码", ""))?.trim() ?? "";
|
|
@@ -286,6 +331,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
286
331
|
}
|
|
287
332
|
ctx.ui.notify(`连接成功 (${result.version}, ${result.latency})`, "success");
|
|
288
333
|
|
|
334
|
+
// 环境标签(v1.1 UX 共识 Q3):测试通过后再问,失败不浪费输入;prod 主动建议强制只读
|
|
335
|
+
let envTag: ConnConfig["envTag"];
|
|
336
|
+
let forceReadonly: boolean | undefined;
|
|
337
|
+
const envChoice = await ctx.ui.select("环境标签(可跳过)", ["跳过", "dev", "test", "prod"]);
|
|
338
|
+
if (envChoice && envChoice !== "跳过") {
|
|
339
|
+
envTag = envChoice as ConnConfig["envTag"];
|
|
340
|
+
if (envTag === "prod") {
|
|
341
|
+
forceReadonly = await ctx.ui.confirm(
|
|
342
|
+
"生产库安全建议",
|
|
343
|
+
"将此连接设为强制只读?\n开启后该连接无视全局只读开关,永远只接受查询(生产库建议开启)。",
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
289
348
|
const configs = loadConfigs();
|
|
290
349
|
if (configs.some((c) => c.name === name)) {
|
|
291
350
|
ctx.ui.notify(`已存在同名配置: ${name}`, "error");
|
|
@@ -303,13 +362,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
303
362
|
password,
|
|
304
363
|
database: parsed.database,
|
|
305
364
|
dbIndex,
|
|
365
|
+
options: parsed.options,
|
|
366
|
+
envTag,
|
|
367
|
+
forceReadonly,
|
|
306
368
|
isDefault: configs.length === 0, // 首个连接自动设为默认(与提示文案一致)
|
|
307
369
|
createdAt: new Date().toISOString(),
|
|
308
370
|
};
|
|
309
371
|
|
|
310
372
|
configs.push(config);
|
|
311
373
|
saveConfigs(configs);
|
|
312
|
-
|
|
374
|
+
recordTestResult(name, result);
|
|
375
|
+
ctx.ui.notify(`配置已保存: ${name}${envTag ? ` [${envTag}${forceReadonly ? "·强制只读" : ""}]` : ""}${configs.length === 1 ? "(首个连接已设为默认)" : ""}`, "success");
|
|
313
376
|
};
|
|
314
377
|
|
|
315
378
|
// ── 编辑数据库连接 ──────────────────────────────
|
|
@@ -320,6 +383,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
320
383
|
`URL: ${currentUrl}`,
|
|
321
384
|
`账号: ${original.username}`,
|
|
322
385
|
`密码: ${original.password || ""}`,
|
|
386
|
+
`环境标签: ${original.envTag ?? ""}`,
|
|
387
|
+
`强制只读: ${original.forceReadonly ? "是" : "否"}`,
|
|
323
388
|
`说明: ${original.description || ""}`,
|
|
324
389
|
].join("\n");
|
|
325
390
|
|
|
@@ -353,6 +418,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
353
418
|
const descRaw = getValue("说明");
|
|
354
419
|
const description = descRaw === "" ? undefined : (descRaw || original.description);
|
|
355
420
|
|
|
421
|
+
// 环境标签/强制只读(行缺省=保持原值;标签仅接受 dev/test/prod,其余/清空=去除标签)
|
|
422
|
+
const envRaw = getValue("环境标签")?.trim().toLowerCase();
|
|
423
|
+
const envTag = envRaw === undefined || envRaw === ""
|
|
424
|
+
? (envRaw === "" ? undefined : original.envTag)
|
|
425
|
+
: (["dev", "test", "prod"].includes(envRaw) ? envRaw as ConnConfig["envTag"] : original.envTag);
|
|
426
|
+
const roRaw = getValue("强制只读")?.trim();
|
|
427
|
+
const forceReadonly = roRaw === undefined ? original.forceReadonly : roRaw === "是";
|
|
428
|
+
|
|
356
429
|
const updated: ConnConfig = {
|
|
357
430
|
...original,
|
|
358
431
|
name,
|
|
@@ -362,6 +435,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
362
435
|
username,
|
|
363
436
|
password,
|
|
364
437
|
database: parsed.database,
|
|
438
|
+
options: parsed.options ?? original.options, // displayUrl 不带查询参数,保留原 options(authSource 等)
|
|
439
|
+
envTag,
|
|
440
|
+
forceReadonly,
|
|
365
441
|
description,
|
|
366
442
|
};
|
|
367
443
|
|
|
@@ -379,6 +455,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
379
455
|
all[idx] = updated;
|
|
380
456
|
saveConfigs(all);
|
|
381
457
|
ctx.ui.notify(`已更新: ${updated.name}`, "success");
|
|
458
|
+
|
|
459
|
+
// 编辑后自动回测(v1.1 UX 共识 Q1):结果回写 lastTest,不阻塞保存
|
|
460
|
+
const testDialect = registry.get(updated.type)!;
|
|
461
|
+
ctx.ui.notify(`正在测试 ${testDialect.label} 连接...`, "info");
|
|
462
|
+
const test = await testDialect.testConnection(toRuntimeConfig(updated, testDialect.defaultPort));
|
|
463
|
+
recordTestResult(updated.name, test);
|
|
464
|
+
ctx.ui.notify(
|
|
465
|
+
test.success
|
|
466
|
+
? `✓ 连接正常 (${test.version ?? "?"}, ${test.latency ?? "?"})`
|
|
467
|
+
: `⚠ 已保存,但连接测试失败: ${test.error}`,
|
|
468
|
+
test.success ? "success" : "error",
|
|
469
|
+
);
|
|
382
470
|
};
|
|
383
471
|
|
|
384
472
|
// ── 选择数据库公共操作 ────────────────────────────
|
|
@@ -398,10 +486,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
398
486
|
ctx.ui.notify("尚无数据库连接", "info");
|
|
399
487
|
return;
|
|
400
488
|
}
|
|
401
|
-
const lines = configs.map((c) => {
|
|
402
|
-
const desc = c.description ? ` - ${c.description}` : "";
|
|
403
|
-
return ` ${c.name} [${shortTypeLabel(c.type)}]${desc}`;
|
|
404
|
-
});
|
|
489
|
+
const lines = configs.map((c) => ` ${connSummaryLine(c)}`);
|
|
405
490
|
ctx.ui.notify(`数据库连接 (${configs.length}):\n${lines.join("\n")}`, "info");
|
|
406
491
|
};
|
|
407
492
|
|
|
@@ -423,6 +508,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
423
508
|
"📝 执行查询",
|
|
424
509
|
"📋 列出表",
|
|
425
510
|
"🔍 查看详情",
|
|
511
|
+
"🧪 测试连接",
|
|
426
512
|
config.isDefault ? "⭐ 取消默认" : "⭐ 设为默认",
|
|
427
513
|
"✏️ 编辑",
|
|
428
514
|
"🗑️ 删除",
|
|
@@ -441,8 +527,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
441
527
|
if (!sql) return;
|
|
442
528
|
const cfg = loadPluginConfig();
|
|
443
529
|
const dialect = registry.get(config.type)!;
|
|
444
|
-
const
|
|
445
|
-
const
|
|
530
|
+
const effRo = effectiveReadonly(cfg.ai_readonly, config); // 连接级强制只读生效(v1.2 Q3)
|
|
531
|
+
const verdict = dialect.isAllowed(sql, effRo);
|
|
532
|
+
const action = decide(verdict, effRo, cfg.confirm_before_exec);
|
|
446
533
|
if (action === "deny") {
|
|
447
534
|
ctx.ui.notify(`不允许执行: ${verdict.reason}`, "error");
|
|
448
535
|
continue;
|
|
@@ -456,9 +543,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
456
543
|
}
|
|
457
544
|
ctx.ui.notify("正在执行查询...", "info");
|
|
458
545
|
const result = await dialect.executeOn(toRuntimeConfig(config, dialect.defaultPort), sql, {
|
|
459
|
-
readonly:
|
|
546
|
+
readonly: effRo, maxRows: cfg.max_rows, timeoutSec: cfg.query_timeout,
|
|
460
547
|
});
|
|
461
548
|
if (result.success) {
|
|
549
|
+
recordLastUsed(config.name);
|
|
462
550
|
const lines = [`查询完成 (${result.duration})`, `返回 ${result.rowCount} 行`];
|
|
463
551
|
if (result.columns && result.columns.length > 0) {
|
|
464
552
|
lines.push("列: " + result.columns.join(", "));
|
|
@@ -483,6 +571,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
483
571
|
} else {
|
|
484
572
|
ctx.ui.notify(`查询失败: ${result.error}`, "error");
|
|
485
573
|
}
|
|
574
|
+
} else if (choice === "🧪 测试连接") {
|
|
575
|
+
const dialect = registry.get(config.type)!;
|
|
576
|
+
ctx.ui.notify(`正在测试 ${dialect.label} 连接...`, "info");
|
|
577
|
+
const result = await dialect.testConnection(toRuntimeConfig(config, dialect.defaultPort));
|
|
578
|
+
recordTestResult(config.name, result);
|
|
579
|
+
ctx.ui.notify(
|
|
580
|
+
result.success
|
|
581
|
+
? `✓ 连接正常 (${result.version ?? "?"}, ${result.latency ?? "?"})`
|
|
582
|
+
: `✗ 连接失败: ${result.error}`,
|
|
583
|
+
result.success ? "success" : "error",
|
|
584
|
+
);
|
|
585
|
+
continue;
|
|
486
586
|
} else if (choice === "📋 列出表") {
|
|
487
587
|
const dialect = registry.get(config.type)!;
|
|
488
588
|
const result = await dialect.listTables(toRuntimeConfig(config, dialect.defaultPort));
|
|
@@ -555,6 +655,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
555
655
|
label: "查询超时(s)",
|
|
556
656
|
current: String(cfg.query_timeout),
|
|
557
657
|
},
|
|
658
|
+
{
|
|
659
|
+
key: "audit_enabled" as const,
|
|
660
|
+
label: "审计日志",
|
|
661
|
+
current: cfg.audit_enabled ? "是" : "否",
|
|
662
|
+
options: ["是", "否"],
|
|
663
|
+
},
|
|
558
664
|
];
|
|
559
665
|
|
|
560
666
|
// 选择要修改的字段
|
|
@@ -596,7 +702,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
596
702
|
const updatedFields = fields.map((f) => {
|
|
597
703
|
const val = (newCfg as any)[f.key];
|
|
598
704
|
const display =
|
|
599
|
-
f.key === "ai_readonly" ? (val ? "是" : "否") :
|
|
705
|
+
f.key === "ai_readonly" || f.key === "audit_enabled" ? (val ? "是" : "否") :
|
|
600
706
|
f.key === "confirm_before_exec" ?
|
|
601
707
|
(val === "never" ? "不确认" : val === "write" ? "写操作确认" : "每次都确认") :
|
|
602
708
|
String(val);
|
|
@@ -644,13 +750,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
644
750
|
pi.registerTool({
|
|
645
751
|
name: "query_database",
|
|
646
752
|
label: "数据库查询",
|
|
647
|
-
description: "
|
|
648
|
-
promptSnippet: "执行 SQL
|
|
753
|
+
description: "执行语句,支持关系型(PostgreSQL/MySQL/Oracle/达梦)/ Redis / Elasticsearch / MongoDB / Neo4j / Hive / Spark 十种数据库,返回执行结果。支持读和写,写操作受确认策略约束且必须附 reason 执行理由(动机+影响范围),用户确认框将展示该理由;是否允许写以及是否需确认,以系统提示中的当前数据库工具执行策略为准。DROP TABLE 始终禁止。MongoDB 的 sql 参数填 JSON 命令信封(db.runCommand 形态,如 {\"find\":\"users\",\"filter\":{}});Neo4j 填 Cypher(如 MATCH (n:Person) RETURN n LIMIT 10)。",
|
|
754
|
+
promptSnippet: "执行 SQL 语句。先根据系统提示中的当前数据库工具执行策略判断是否允许写操作;写操作必须在 reason 参数说明动机与影响范围(如\"将status=2的历史订单归档,预计影响1.2万行\"),否则会被拒绝。database 参数取系统提示「可用数据库」列表中的名称(缺省走默认连接)。使用 list_tables 查看表结构后再编写 SQL。",
|
|
649
755
|
parameters: Type.Object({
|
|
650
756
|
database: Type.Optional(Type.String({ description: "数据库连接名称(取系统提示「可用数据库」列表中的名称;缺省走默认连接)" })),
|
|
651
|
-
sql: Type.String({ description: "SQL
|
|
757
|
+
sql: Type.String({ description: "语句;关系型填 SQL,MongoDB 填 JSON 命令信封,Redis 填命令,ES 填 DSL,Neo4j 填 Cypher" }),,
|
|
758
|
+
reason: Type.Optional(Type.String({ description: "执行理由,写操作必填:动机+影响范围(如\"将status=2的历史订单归档,预计影响1.2万行\")。读操作无需填写" })),
|
|
652
759
|
}),
|
|
653
|
-
async execute(_toolCallId: string, params: { database?: string; sql: string }, _signal: any, _onUpdate?: any, ctx?: any) {
|
|
760
|
+
async execute(_toolCallId: string, params: { database?: string; sql: string; reason?: string }, _signal: any, _onUpdate?: any, ctx?: any) {
|
|
654
761
|
const cfg = loadPluginConfig();
|
|
655
762
|
|
|
656
763
|
const target = resolveTargetDb(params.database);
|
|
@@ -661,6 +768,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
661
768
|
}
|
|
662
769
|
const config = target.config;
|
|
663
770
|
|
|
771
|
+
// 生效只读 = 全局只读 ∨ 连接级强制只读(v1.1 UX 共识 Q3)
|
|
772
|
+
const effectiveReadOnly = effectiveReadonly(cfg.ai_readonly, config);
|
|
773
|
+
|
|
664
774
|
// verdict 流程(策略层统一裁决,含 DROP 硬限制与只读检查):
|
|
665
775
|
// verdict = dialect.isAllowed(sql, readonly) → decide → deny/confirm/run
|
|
666
776
|
const dialect = registry.get(config.type);
|
|
@@ -669,11 +779,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
669
779
|
content: [{ type: "text" as const, text: `数据库类型 "${config.type}" 暂不支持。` }],
|
|
670
780
|
};
|
|
671
781
|
}
|
|
672
|
-
const verdict = dialect.isAllowed(params.sql,
|
|
673
|
-
const action = decide(verdict,
|
|
782
|
+
const verdict = dialect.isAllowed(params.sql, effectiveReadOnly);
|
|
783
|
+
const action = decide(verdict, effectiveReadOnly, cfg.confirm_before_exec);
|
|
674
784
|
if (action === "deny") {
|
|
785
|
+
const note = !cfg.ai_readonly && config.forceReadonly === true
|
|
786
|
+
? `(连接 ${config.name} 已设置强制只读)`
|
|
787
|
+
: "";
|
|
788
|
+
return {
|
|
789
|
+
content: [{ type: "text" as const, text: (verdict.reason ?? "该操作不被允许。如需修改,请执行 /db config 更改配置。") + note }],
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
// 写操作强制附执行理由(v1.1 UX 共识 Q1/Q3:与确认策略解耦;缺 reason 拒绝并引导 AI 补充)
|
|
793
|
+
if (writeRequiresReason(verdict, params.reason)) {
|
|
675
794
|
return {
|
|
676
|
-
content: [{ type: "text" as const, text:
|
|
795
|
+
content: [{ type: "text" as const, text: "写操作必须附执行理由:请在 reason 参数中说明动机与影响范围(如\"将status=2的历史订单归档,预计影响1.2万行\"),补充后重试。" }],
|
|
677
796
|
};
|
|
678
797
|
}
|
|
679
798
|
if (action === "confirm") {
|
|
@@ -684,7 +803,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
684
803
|
}
|
|
685
804
|
const ok = await ctx.ui.confirm(
|
|
686
805
|
"SQL 执行确认",
|
|
687
|
-
|
|
806
|
+
`理由: ${params.reason}\n\n${verdict.summary ?? ""}\n\n数据库: ${config.name}\n\nSQL:\n${params.sql}`,
|
|
688
807
|
);
|
|
689
808
|
if (!ok) {
|
|
690
809
|
return {
|
|
@@ -697,7 +816,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
697
816
|
const result = await dialect.executeOn(
|
|
698
817
|
toRuntimeConfig(config, dialect.defaultPort),
|
|
699
818
|
params.sql,
|
|
700
|
-
{ readonly:
|
|
819
|
+
{ readonly: effectiveReadOnly, maxRows: cfg.max_rows, timeoutSec: cfg.query_timeout },
|
|
701
820
|
);
|
|
702
821
|
|
|
703
822
|
if (!result.success) {
|
|
@@ -706,6 +825,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
706
825
|
};
|
|
707
826
|
}
|
|
708
827
|
|
|
828
|
+
recordLastUsed(config.name);
|
|
829
|
+
// 写操作审计(v1.1 UX 共识 Q4 + 可配置修订:默认关闭,cfg.audit_enabled 开启才落盘;失败不阻断主流程)
|
|
830
|
+
if (verdict.isWrite && cfg.audit_enabled) {
|
|
831
|
+
appendAuditLog({
|
|
832
|
+
time: new Date().toISOString(),
|
|
833
|
+
project: process.cwd(),
|
|
834
|
+
connection: config.name,
|
|
835
|
+
type: config.type,
|
|
836
|
+
summary: verdict.summary ?? "",
|
|
837
|
+
reason: (params.reason ?? "").trim(),
|
|
838
|
+
sql: params.sql,
|
|
839
|
+
readonly: effectiveReadOnly,
|
|
840
|
+
});
|
|
841
|
+
}
|
|
709
842
|
let text = `查询完成 (${result.duration}),返回 ${result.rowCount} 行\n`;
|
|
710
843
|
if (result.columns && result.columns.length > 0) {
|
|
711
844
|
text += `列: ${result.columns.join(", ")}\n\n`;
|
|
@@ -730,6 +863,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
730
863
|
} else {
|
|
731
864
|
text += "无数据返回。";
|
|
732
865
|
}
|
|
866
|
+
// 写操作理由随结果回显(v1.1 UX 共识 Q2:说了什么→做了什么闭环)
|
|
867
|
+
if (verdict.isWrite) {
|
|
868
|
+
text += `\n执行理由: ${params.reason}`;
|
|
869
|
+
}
|
|
733
870
|
|
|
734
871
|
return { content: [{ type: "text" as const, text }] };
|
|
735
872
|
},
|
|
@@ -767,6 +904,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
767
904
|
content: [{ type: "text" as const, text: `获取表列表失败: ${result.error}` }],
|
|
768
905
|
};
|
|
769
906
|
}
|
|
907
|
+
recordLastUsed(config.name);
|
|
770
908
|
|
|
771
909
|
const lines = result.tables.map((t) => {
|
|
772
910
|
const schema = t.schema ? `${t.schema}.` : "";
|
|
@@ -812,6 +950,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
812
950
|
content: [{ type: "text" as const, text: `获取表结构失败: ${result.error}` }],
|
|
813
951
|
};
|
|
814
952
|
}
|
|
953
|
+
recordLastUsed(config.name);
|
|
815
954
|
|
|
816
955
|
const lines = [`表: ${params.table}`, `共 ${result.count} 列\n`];
|
|
817
956
|
// 表头
|
|
@@ -861,6 +1000,35 @@ export default function (pi: ExtensionAPI) {
|
|
|
861
1000
|
},
|
|
862
1001
|
});
|
|
863
1002
|
|
|
1003
|
+
// 工具 5: db_connections(v1.1 UX 共识 Q5:AI 自查连接清单与环境标签,识别生产库)
|
|
1004
|
+
pi.registerTool({
|
|
1005
|
+
name: "db_connections",
|
|
1006
|
+
label: "列出数据库连接",
|
|
1007
|
+
description: "列出所有已配置的数据库连接(名称/类型/环境标签/默认标记/最近测试结果/连接地址,不含密码)。用于确认可用连接、识别生产库。增删改连接需用户在终端执行 /db。",
|
|
1008
|
+
promptSnippet: "当用户提到某个环境(如“生产库”)或不确定该用哪个连接时,先调用本工具确认连接清单再查询;database 参数应使用返回列表中的名称。",
|
|
1009
|
+
parameters: Type.Object({}),
|
|
1010
|
+
async execute() {
|
|
1011
|
+
const configs = loadConfigs();
|
|
1012
|
+
if (configs.length === 0) {
|
|
1013
|
+
return {
|
|
1014
|
+
content: [{ type: "text" as const, text: "尚无数据库连接。请引导用户在终端执行 /db add 或 /db scan 建连。" }],
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
const lines = configs.map((c) => {
|
|
1018
|
+
const marks = [c.isDefault ? "⭐默认" : "", envTagLabel(c)].filter(Boolean).join(" ");
|
|
1019
|
+
const test = c.lastTest
|
|
1020
|
+
? `测试 ${formatRelativeTime(c.lastTest.at)} ${c.lastTest.ok ? "✓" : "✗"}${c.lastTest.latency ? " " + c.lastTest.latency : ""}${c.lastTest.version ? " · " + c.lastTest.version : ""}`
|
|
1021
|
+
: "未测试";
|
|
1022
|
+
const used = c.lastUsedAt ? `上次使用 ${formatRelativeTime(c.lastUsedAt)}` : "";
|
|
1023
|
+
const url = registry.get(c.type)?.displayUrl(c) ?? "";
|
|
1024
|
+
return `- ${c.name} [${shortTypeLabel(c.type)}]${marks ? " " + marks : ""} - ${fullTypeLabel(c.type)}${c.database ? " · 库: " + c.database : ""} · ${url} · ${test}${used ? " · " + used : ""}${c.description ? " · " + c.description : ""}`;
|
|
1025
|
+
});
|
|
1026
|
+
return {
|
|
1027
|
+
content: [{ type: "text" as const, text: `共 ${configs.length} 个连接:\n${lines.join("\n")}\n\n标注 [prod·强制只读] 的连接永远只读(即使全局允许写操作)。` }],
|
|
1028
|
+
};
|
|
1029
|
+
},
|
|
1030
|
+
});
|
|
1031
|
+
|
|
864
1032
|
// ── 注册 /db 命令(给用户管理连接) ──────────────
|
|
865
1033
|
pi.registerCommand("db", {
|
|
866
1034
|
description: "AI 接入数据库",
|
|
@@ -872,6 +1040,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
872
1040
|
// 导航菜单:查看 / 编辑 / 新增 / 删除 / 设置
|
|
873
1041
|
const navActions = [
|
|
874
1042
|
"📋 打开连接",
|
|
1043
|
+
"⚡ 切换默认",
|
|
875
1044
|
"✏️ 编辑连接",
|
|
876
1045
|
"➕ 新增连接",
|
|
877
1046
|
"🔎 扫描建连",
|
|
@@ -886,6 +1055,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
886
1055
|
} else if (navChoice === "📋 打开连接") {
|
|
887
1056
|
const config = await selectDbConfig(ctx, configs, "选择连接");
|
|
888
1057
|
if (config) await showDbActions(ctx, config);
|
|
1058
|
+
} else if (navChoice === "⚡ 切换默认") {
|
|
1059
|
+
// v1.1 UX 共识 Q1:两层直达,替代“打开→动作→设为默认”三层路径
|
|
1060
|
+
const configsNow = loadConfigs();
|
|
1061
|
+
if (configsNow.length === 0) {
|
|
1062
|
+
ctx.ui.notify("尚无数据库连接", "info");
|
|
1063
|
+
} else {
|
|
1064
|
+
const target = await selectDbConfig(ctx, configsNow, "设为默认连接(⭐ 为当前默认)");
|
|
1065
|
+
if (target) {
|
|
1066
|
+
if (target.isDefault) {
|
|
1067
|
+
ctx.ui.notify(`已是默认连接: ${target.name}`, "info");
|
|
1068
|
+
} else {
|
|
1069
|
+
saveConfigs(setDefaultConfig(configsNow, target.id));
|
|
1070
|
+
ctx.ui.notify(`默认连接已切换: ${target.name}`, "success");
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
889
1074
|
} else if (navChoice === "✏️ 编辑连接") {
|
|
890
1075
|
const config = await selectDbConfig(ctx, configs, "选择要编辑的连接");
|
|
891
1076
|
if (config) await editDbConfig(ctx, config);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nsyan/db",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "AI 接入数据库扩展 ——
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "AI 接入数据库扩展 —— 六大家族方言架构,支持 PostgreSQL/MySQL/Oracle/达梦/Redis/Elasticsearch/MongoDB/Neo4j/Hive/Spark 十种数据库,提供查询/表结构/扫描建连/连接清单工具给 LLM",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-extension",
|
|
7
7
|
"pi-package",
|
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
"dm",
|
|
14
14
|
"redis",
|
|
15
15
|
"elasticsearch",
|
|
16
|
+
"mongodb",
|
|
17
|
+
"mongo",
|
|
18
|
+
"neo4j",
|
|
19
|
+
"graph",
|
|
20
|
+
"cypher",
|
|
16
21
|
"hive",
|
|
17
22
|
"spark"
|
|
18
23
|
],
|
|
@@ -30,6 +35,7 @@
|
|
|
30
35
|
"files": [
|
|
31
36
|
"index.ts",
|
|
32
37
|
"src/",
|
|
38
|
+
"docs/",
|
|
33
39
|
"README.md"
|
|
34
40
|
],
|
|
35
41
|
"exports": {
|
|
@@ -43,8 +49,11 @@
|
|
|
43
49
|
"es7": "npm:@elastic/elasticsearch@7",
|
|
44
50
|
"hive-driver": "^1.0.1",
|
|
45
51
|
"ioredis": "^6.0.0",
|
|
52
|
+
"mongodb": "^6.21.0",
|
|
46
53
|
"mysql2": "^3.23.1",
|
|
54
|
+
"neo4j-driver": "^5.28.3",
|
|
47
55
|
"oracledb": "^7.0.1",
|
|
56
|
+
"neo4j-driver": "^5.28.3",
|
|
48
57
|
"pg": "^8.22.0"
|
|
49
58
|
},
|
|
50
59
|
"devDependencies": {
|