@nsyan/db 1.3.0 → 1.3.2
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 +2 -1
- package/docs/USAGE.md +9 -3
- package/index.ts +92 -47
- package/package.json +7 -4
- package/src/config.ts +18 -3
- package/src/core/audit.ts +1 -1
- package/src/core/scan/tree.ts +8 -4
- package/src/core/scan/trust.ts +86 -0
- package/src/core/scan/validate.ts +15 -4
- package/src/core/scan/walker.ts +110 -31
- package/src/core/types.ts +3 -0
- package/src/dialects/dm.ts +5 -2
- package/src/dialects/elasticsearch.ts +50 -8
- package/src/dialects/hive.ts +3 -1
- package/src/dialects/mysql.ts +4 -2
- package/src/dialects/neo4j.ts +2 -2
- package/src/dialects/oracle.ts +4 -2
- package/src/dialects/redis.ts +8 -3
- package/src/dialects/relational-dialect.ts +4 -1
- package/src/dialects/spark.ts +2 -1
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
- **10 种数据库 · 6 家族**:PostgreSQL · MySQL · Oracle · 达梦 · Redis · Elasticsearch · MongoDB · Neo4j · Hive · Spark
|
|
17
17
|
- **AI 工具**:查询 / 表结构 / 连接清单 / 扫描建连,读多写少场景的 token 友好输出
|
|
18
|
-
- **扫描建连**:AI 驱动——工具返回项目文件树,会话 AI 自行定位并阅读配置文件(任意语言/格式),提取候选后弹框确认建连;防幻觉校验(url
|
|
18
|
+
- **扫描建连**:AI 驱动——工具返回项目文件树,会话 AI 自行定位并阅读配置文件(任意语言/格式),提取候选后弹框确认建连;防幻觉校验(url 必须能被方言解析)+ 来源可信度评级(远端证据 / 环境标识 / 跨环境冲突)提示“能连通 ≠ 属于当前环境”
|
|
19
19
|
- **安全模型**:只读模式、写确认 + 执行理由、连接级强制只读、家族白名单、管理命令恒拒
|
|
20
20
|
- **审计**(可选):写操作按天落盘,含理由与完整 SQL
|
|
21
21
|
- **零构建**:TypeScript 由 pi/tsx 直接加载,core/dialects 零 pi 运行时依赖
|
|
@@ -74,6 +74,7 @@ pi install ./packages/db # 本仓库开发者
|
|
|
74
74
|
| 家族白名单 | Redis/ES/Mongo 只读白名单;`DROP`、管理 DDL、服务端 JS、`CALL dbms.*` 恒拒 |
|
|
75
75
|
| 审计日志 | 默认关;`/db config` 开启后写操作按天落盘(完整 SQL,0600) |
|
|
76
76
|
| 凭据 | 配置文件 `0600`;扫描场景密码经确认框补录(v1.3 起随 AI 阅读进会话,用户知情接受) |
|
|
77
|
+
| 扫描越界 | `path` 相对会话工作目录(`ctx.cwd`)解析,按**真实路径(realpath)**判定:`..` 上跳、绝对路径、仓库内指向子树外的软链(如 `dbconf -> /etc`)一律拒绝;`ctx.cwd` 缺失时 fail-closed,绝不回落到宿主进程目录 |
|
|
77
78
|
|
|
78
79
|
## ⚙️ 设置
|
|
79
80
|
|
package/docs/USAGE.md
CHANGED
|
@@ -60,13 +60,19 @@
|
|
|
60
60
|
|
|
61
61
|
**流程**:
|
|
62
62
|
|
|
63
|
-
1. AI 调用 `scan_project_configs` 获取项目文件树(path
|
|
64
|
-
2. AI
|
|
63
|
+
1. AI 调用 `scan_project_configs` 获取项目文件树(path 相对**会话工作目录**(`ctx.cwd`)解析,越界拒绝;判定按**真实路径**(realpath):仓库内指向子树外的软链(如 `dbconf -> /etc`)同样拒绝,扫描不会成为越界的第二个入口)
|
|
64
|
+
2. AI 先定位**当前生效的环境**(Spring:application.yml/bootstrap.yml + pom.xml 的 profile,Maven `@xxx@` 占位符真值在 `<profile><properties>` 里),再挑出可能含连接配置的文件阅读提取;配置指向配置中心(nacos/apollo)时以**远端为准**(仓库里签入的副本常已过期)
|
|
65
65
|
3. AI 调用 `db_scan_save` 提交候选——工具逐个弹确认框:确认 → 补录缺字段 → **测试连接通过才写盘**;同名连接三选一(覆盖/改名/跳过)
|
|
66
66
|
4. 被拒绝的候选(url 与类型矛盾等)AI 会按返回原因修正后重提
|
|
67
67
|
|
|
68
|
+
> ⚠️ **“能连通”≠“属于当前环境”**:同一仓库常并存 dev/test/devops/prod/dm 多套配置,连错的库(尤其是废弃库名、其他环境的地址)一样能测通。请核对确认框里的**来源**字段是否对应生效 profile。
|
|
69
|
+
|
|
68
70
|
**防幻觉校验**(确定性规则,AI 不可绕过):dialectId 必须在支持列表内;带 `url` 的候选必须能被对应方言 `parseUrl` 解析(解析成功后以 URL 为准);host 必填、port 范围 1~65535。
|
|
69
71
|
|
|
72
|
+
**来源可信度**(确定性规则,展示用、不拒绝):插件按候选的 `source` 评级并在确认框/结果里展示理由——🟢 source 含**远端检索证据**(远端 URL / `dataId=` / 显式「远端」)/ 🟡 本地文件带环境标识,或**仅自述配置中心产品名**(仓库里签入的副本也会命中产品名,而签入副本会漂移,所以不升级为可信)/ 🔴 无环境标识、或本批次跨多个环境。`testConnection` 只证明库存在、凭据有效,**不证明它属于当前环境**,这一评级就是你在写盘前拦住「连错环境」的那一眼。
|
|
73
|
+
|
|
74
|
+
> 边界说明:评级输入是**模型自述的 source 文本**,不是独立取证(写什么就信什么);它提高的是「误连环境」的被发现概率,不是安全保证。
|
|
75
|
+
|
|
70
76
|
**结果状态**:✅ 可直接建 / ✏️ 待补字段(确认框中补录)/ ⏭️ 同名已存在。
|
|
71
77
|
|
|
72
78
|
### 配置中心场景(Nacos / Apollo / Spring Cloud Config)
|
|
@@ -83,7 +89,7 @@
|
|
|
83
89
|
```
|
|
84
90
|
3. 从返回的 YAML 原文提取连接候选 → `db_scan_save` 提交(校验/确认/测试连接照常)
|
|
85
91
|
|
|
86
|
-
本地 profile 文件(如 `application-dev.yml
|
|
92
|
+
本地 profile 文件(如 `application-dev.yml`)与配置中心共有的项目:**以远端为准**(仓库里签入的副本常已过期),两边若都提取出候选,重复连接靠同名查重(⏭️ exists 状态)与确认框兜底,但冲突值不取本地。
|
|
87
93
|
|
|
88
94
|
**隐私行为变化**(v1.3.0,用户知情接受):配置文件原文(含密码)会随 AI 阅读进入会话上下文;v1.2 及以前"密码只在终端补录、不进模型上下文"的承诺不再适用于扫描场景(查询/连接管理不受影响)。
|
|
89
95
|
|
package/index.ts
CHANGED
|
@@ -5,7 +5,9 @@ import { registry } from "./src/dialects/index.js";
|
|
|
5
5
|
import { decide, effectiveReadonly, writeRequiresReason } from "./src/core/policy.js";
|
|
6
6
|
import { appendAuditLog } from "./src/core/audit.js";
|
|
7
7
|
import { collectTree } from "./src/core/scan/tree.js";
|
|
8
|
+
import { MAX_DEPTH } from "./src/core/scan/walker.js";
|
|
8
9
|
import { validateCandidates } from "./src/core/scan/validate.js";
|
|
10
|
+
import { TRUST_GLYPH, TRUST_LABEL } from "./src/core/scan/trust.js";
|
|
9
11
|
import type { Candidate, ConnConfig, DbTypeId } from "./src/core/types.js";
|
|
10
12
|
import {
|
|
11
13
|
loadConfigs, saveConfigs, loadPluginConfig, savePluginConfig, getConfigSummary,
|
|
@@ -50,13 +52,21 @@ function buildDisplayList(configs: ConnConfig[]): { list: string[]; map: Map<str
|
|
|
50
52
|
// ── 构建「可用数据库」提示(注入系统提示,让 AI 知道有哪些连接) ────
|
|
51
53
|
// 效率项(Spec §6):每连接只注一行 `名称[家族] + 一行语义`,细节压进系统提示
|
|
52
54
|
|
|
53
|
-
//
|
|
55
|
+
// 各家族一行语义:让 AI 一眼知道该类型的 sql 参数填什么(覆盖全部十种类型)
|
|
54
56
|
function familyHint(c: ConnConfig): string {
|
|
55
57
|
switch (c.type) {
|
|
56
58
|
case "postgresql":
|
|
57
59
|
case "mysql":
|
|
58
60
|
case "oracle":
|
|
61
|
+
case "dm":
|
|
59
62
|
return "关系型,sql 参数填 SQL";
|
|
63
|
+
case "redis":
|
|
64
|
+
return "Redis 键值库,sql 参数填单条 Redis 命令(如 GET key / SCAN 0 MATCH user:* COUNT 100;KEYS 已禁用,扫描请用 SCAN)";
|
|
65
|
+
case "elasticsearch":
|
|
66
|
+
return "Elasticsearch 搜索库,sql 参数填 DSL JSON(如 {\"query\":{\"match_all\":{}}};仅支持读端点 _search/_count/_mget)";
|
|
67
|
+
case "hive":
|
|
68
|
+
case "spark":
|
|
69
|
+
return "大数据 SQL 引擎,sql 参数填 SQL(如 SELECT * FROM 库.表 LIMIT 10)";
|
|
60
70
|
case "mongodb":
|
|
61
71
|
return "MongoDB 文档库,sql 参数填 JSON 命令信封(如 {\"find\":\"users\",\"filter\":{}};读命令 find/count/distinct/aggregate)";
|
|
62
72
|
case "neo4j":
|
|
@@ -70,7 +80,7 @@ function familyHint(c: ConnConfig): string {
|
|
|
70
80
|
const SCAN_TRIGGER_HINT = [
|
|
71
81
|
"[项目扫描建连]",
|
|
72
82
|
"当用户说“连一下这个项目的数据库”“帮我把这项目的库配上”“/db scan”等时,走两步 AI 扫描流程:",
|
|
73
|
-
"第 1 步:调用 scan_project_configs 获取项目文件树(path
|
|
83
|
+
"第 1 步:调用 scan_project_configs 获取项目文件树(path 相对会话工作目录 ctx.cwd 解析,按真实路径判定越界,越界拒绝);",
|
|
74
84
|
"第 2 步:从树中自行判断哪些文件可能包含数据库连接配置(任意语言:Spring/Py/Go/TS/Rust 的 yml/toml/env/ini/json 等都算),用自带读文件工具阅读它们,",
|
|
75
85
|
" 从中提取连接候选(类型/host/端口/库名/账号/密码),用户说“只要 pg”这类话时只提取对应类型;",
|
|
76
86
|
"第 3 步:调用 db_scan_save 提交候选——工具会弹确认框,用户逐个确认并补录密码后才写盘,绝不静默建连。",
|
|
@@ -126,7 +136,16 @@ const SCAN_GLYPH: Record<Candidate["status"], string> = {
|
|
|
126
136
|
function candidateLine(c: Candidate): string {
|
|
127
137
|
const p = c.partial;
|
|
128
138
|
const miss = c.missing.length ? `,缺: ${c.missing.join("/")}` : "";
|
|
129
|
-
|
|
139
|
+
const trust = `${TRUST_GLYPH[c.trust.level]}${TRUST_LABEL[c.trust.level]}(环境: ${c.trust.env ?? "未知"})`;
|
|
140
|
+
return `${SCAN_GLYPH[c.status]} [${c.status}] ${p.name} (${c.dialectId}) ${p.host ?? ""}:${p.port ?? ""}${p.database ? "/" + p.database : ""}${miss} ${trust} <- ${c.source}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 会话工作目录(信任锚)——扫描与审计都用它,不用 `process.cwd()`。
|
|
144
|
+
* `process.cwd()` 是宿主进程的 OS cwd,pi-web 下是 pi-web 包目录、TUI 下是启动目录,
|
|
145
|
+
* 都与会话 cwd 不同源(见 walker.resolveRoot 注释)。缺失时返回 null,由调用方 fail-closed。 */
|
|
146
|
+
function sessionCwdOf(ctx: any): string | null {
|
|
147
|
+
const cwd = ctx?.cwd;
|
|
148
|
+
return typeof cwd === "string" && cwd ? cwd : null;
|
|
130
149
|
}
|
|
131
150
|
|
|
132
151
|
// ── 导出扩展 ──────────────────────────────────────
|
|
@@ -140,9 +159,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
140
159
|
const confirmAndSaveCandidate = async (ctx: any, cand: Candidate): Promise<"saved" | "skipped" | "failed"> => {
|
|
141
160
|
const p = cand.partial;
|
|
142
161
|
let name = p.name!;
|
|
162
|
+
// 来源可信度必须出现在确认框里:testConnection 通过之后,唯一能拦住「连错环境」的就是用户这一眼
|
|
163
|
+
const trustBlock = `\n${TRUST_GLYPH[cand.trust.level]} ${TRUST_LABEL[cand.trust.level]}(环境: ${cand.trust.env ?? "未知"})\n`
|
|
164
|
+
+ cand.trust.reasons.map((r) => ` · ${r}`).join("\n");
|
|
143
165
|
const ok = await ctx.ui.confirm(
|
|
144
166
|
"AI 扫描建连",
|
|
145
|
-
`添加 ${name} (${cand.dialectId})?\n${p.host ?? ""}:${p.port ?? ""}${p.database ? "/" + p.database : ""}\n来源: ${cand.source}`,
|
|
167
|
+
`添加 ${name} (${cand.dialectId})?\n${p.host ?? ""}:${p.port ?? ""}${p.database ? "/" + p.database : ""}\n来源: ${cand.source}${trustBlock}`,
|
|
146
168
|
);
|
|
147
169
|
if (!ok) return "skipped";
|
|
148
170
|
|
|
@@ -530,15 +552,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
530
552
|
if (result.rows.length > 10) {
|
|
531
553
|
lines.push(`... 还有 ${result.rows.length - 10} 行`);
|
|
532
554
|
}
|
|
533
|
-
if (result.rows.length > 50) {
|
|
555
|
+
if (result.rows.length > 50 || result.truncated) {
|
|
534
556
|
try {
|
|
535
557
|
const exp = writeQueryExport(result.columns ?? [], result.rows, `db-menu-${config.name.replace(/[^\w.-]/g, "_")}`);
|
|
536
|
-
lines.push(
|
|
558
|
+
lines.push(`已导出取回的结果 (${result.rows.length} 行): ${exp.csvPath} | ${exp.jsonPath}`);
|
|
537
559
|
} catch { /* 导出失败不影响主结果 */ }
|
|
538
560
|
}
|
|
539
561
|
}
|
|
540
562
|
if (result.truncated) {
|
|
541
|
-
lines.push("(结果已达 maxRows
|
|
563
|
+
lines.push("(结果已达 maxRows 上限截断,服务端仍有更多数据未取回)");
|
|
542
564
|
}
|
|
543
565
|
ctx.ui.notify(lines.join("\n"), "info");
|
|
544
566
|
} else {
|
|
@@ -655,7 +677,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
655
677
|
const val = await ctx.ui.select(`选择 ${field.label}`, field.options);
|
|
656
678
|
if (!val) continue;
|
|
657
679
|
if (field.valueMap) {
|
|
658
|
-
(newCfg as any)[field.key] = field.valueMap[val];
|
|
680
|
+
(newCfg as any)[field.key] = (field.valueMap as Record<string, unknown>)[val];
|
|
659
681
|
} else {
|
|
660
682
|
(newCfg as any)[field.key] = val === "是";
|
|
661
683
|
}
|
|
@@ -699,7 +721,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
699
721
|
};
|
|
700
722
|
});
|
|
701
723
|
|
|
702
|
-
// ── 注册
|
|
724
|
+
// ── 注册 6 个工具(给 LLM 调用) ──────────────────
|
|
725
|
+
// 注:pi 的 AgentToolResult.details 为必填字段(registerTool 的 TDetails 默认 unknown)。
|
|
726
|
+
// 本插件工具没有结构化详情,故在返回对象里统一显式写 details: undefined——与"完全不提供该字段"
|
|
727
|
+
// 在运行时等价:宿主读取走可选链(result.details?.x)、合并判定用 `!== undefined`,
|
|
728
|
+
// 且 JSON 序列化会跳过 undefined 值。
|
|
703
729
|
|
|
704
730
|
// 解析工具 database 参数:缺省走默认连接(Spec §11.1);无默认则报错指引
|
|
705
731
|
function resolveTargetDb(name: string | undefined): { config?: ConnConfig; error?: string } {
|
|
@@ -735,7 +761,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
735
761
|
|
|
736
762
|
const target = resolveTargetDb(params.database);
|
|
737
763
|
if (target.error || !target.config) {
|
|
738
|
-
return {
|
|
764
|
+
return { details: undefined,
|
|
739
765
|
content: [{ type: "text" as const, text: target.error ?? "未找到数据库配置" }],
|
|
740
766
|
};
|
|
741
767
|
}
|
|
@@ -748,7 +774,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
748
774
|
// verdict = dialect.isAllowed(sql, readonly) → decide → deny/confirm/run
|
|
749
775
|
const dialect = registry.get(config.type);
|
|
750
776
|
if (!dialect) {
|
|
751
|
-
return {
|
|
777
|
+
return { details: undefined,
|
|
752
778
|
content: [{ type: "text" as const, text: `数据库类型 "${config.type}" 暂不支持。` }],
|
|
753
779
|
};
|
|
754
780
|
}
|
|
@@ -758,19 +784,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
758
784
|
const note = !cfg.ai_readonly && config.forceReadonly === true
|
|
759
785
|
? `(连接 ${config.name} 已设置强制只读)`
|
|
760
786
|
: "";
|
|
761
|
-
return {
|
|
787
|
+
return { details: undefined,
|
|
762
788
|
content: [{ type: "text" as const, text: (verdict.reason ?? "该操作不被允许。如需修改,请执行 /db config 更改配置。") + note }],
|
|
763
789
|
};
|
|
764
790
|
}
|
|
765
791
|
// 写操作强制附执行理由(v1.1 UX 共识 Q1/Q3:与确认策略解耦;缺 reason 拒绝并引导 AI 补充)
|
|
766
792
|
if (writeRequiresReason(verdict, params.reason)) {
|
|
767
|
-
return {
|
|
793
|
+
return { details: undefined,
|
|
768
794
|
content: [{ type: "text" as const, text: "写操作必须附执行理由:请在 reason 参数中说明动机与影响范围(如\"将status=2的历史订单归档,预计影响1.2万行\"),补充后重试。" }],
|
|
769
795
|
};
|
|
770
796
|
}
|
|
771
797
|
if (action === "confirm") {
|
|
772
798
|
if (!ctx?.hasUI) {
|
|
773
|
-
return {
|
|
799
|
+
return { details: undefined,
|
|
774
800
|
content: [{ type: "text" as const, text: "当前环境无法弹出确认对话框,已取消 SQL 执行。请在有界面的环境中操作。" }],
|
|
775
801
|
};
|
|
776
802
|
}
|
|
@@ -779,7 +805,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
779
805
|
`理由: ${params.reason}\n\n${verdict.summary ?? ""}\n\n数据库: ${config.name}\n\nSQL:\n${params.sql}`,
|
|
780
806
|
);
|
|
781
807
|
if (!ok) {
|
|
782
|
-
return {
|
|
808
|
+
return { details: undefined,
|
|
783
809
|
content: [{ type: "text" as const, text: "用户取消了 SQL 执行。" }],
|
|
784
810
|
};
|
|
785
811
|
}
|
|
@@ -793,7 +819,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
793
819
|
);
|
|
794
820
|
|
|
795
821
|
if (!result.success) {
|
|
796
|
-
return {
|
|
822
|
+
return { details: undefined,
|
|
797
823
|
content: [{ type: "text" as const, text: `查询失败: ${result.error}` }],
|
|
798
824
|
};
|
|
799
825
|
}
|
|
@@ -803,7 +829,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
803
829
|
if (verdict.isWrite && cfg.audit_enabled) {
|
|
804
830
|
appendAuditLog({
|
|
805
831
|
time: new Date().toISOString(),
|
|
806
|
-
project:
|
|
832
|
+
project: sessionCwdOf(ctx) ?? "(会话目录未知)",
|
|
807
833
|
connection: config.name,
|
|
808
834
|
type: config.type,
|
|
809
835
|
summary: verdict.summary ?? "",
|
|
@@ -823,15 +849,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
823
849
|
const rows = result.rows.slice(0, 50).map((r) => r.join(" | "));
|
|
824
850
|
text += [header, separator, ...rows].join("\n");
|
|
825
851
|
if (result.rows.length > 50) {
|
|
826
|
-
|
|
827
|
-
|
|
852
|
+
text += `\n... 还有 ${result.rows.length - 50} 行(本次共取回 ${result.rows.length} 行)`;
|
|
853
|
+
}
|
|
854
|
+
// 取回行数超展示上限,或被 maxRows 截断时落盘 /tmp(CSV+JSON),只回路径不贴全量
|
|
855
|
+
if (result.rows.length > 50 || result.truncated) {
|
|
828
856
|
try {
|
|
829
857
|
const exp = writeQueryExport(result.columns ?? [], result.rows, `db-query-${config.name.replace(/[^\w.-]/g, "_")}`);
|
|
830
|
-
text += `\n
|
|
858
|
+
text += `\n已导出取回的结果 (${result.rows.length} 行): ${exp.csvPath} | ${exp.jsonPath}`;
|
|
831
859
|
} catch { /* 导出失败不影响主结果 */ }
|
|
832
860
|
}
|
|
833
861
|
if (result.truncated) {
|
|
834
|
-
text += `\n(结果已达 maxRows
|
|
862
|
+
text += `\n(结果已达 maxRows 上限截断,服务端仍有更多数据未取回)`;
|
|
835
863
|
}
|
|
836
864
|
} else {
|
|
837
865
|
text += "无数据返回。";
|
|
@@ -841,7 +869,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
841
869
|
text += `\n执行理由: ${params.reason}`;
|
|
842
870
|
}
|
|
843
871
|
|
|
844
|
-
return { content: [{ type: "text" as const, text }] };
|
|
872
|
+
return { details: undefined, content: [{ type: "text" as const, text }] };
|
|
845
873
|
},
|
|
846
874
|
});
|
|
847
875
|
|
|
@@ -858,7 +886,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
858
886
|
async execute(_toolCallId: string, params: { database?: string; pattern?: string }, _signal: any) {
|
|
859
887
|
const target = resolveTargetDb(params.database);
|
|
860
888
|
if (target.error || !target.config) {
|
|
861
|
-
return {
|
|
889
|
+
return { details: undefined,
|
|
862
890
|
content: [{ type: "text" as const, text: target.error ?? "未找到数据库配置" }],
|
|
863
891
|
};
|
|
864
892
|
}
|
|
@@ -866,14 +894,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
866
894
|
|
|
867
895
|
const dialect = registry.get(config.type);
|
|
868
896
|
if (!dialect) {
|
|
869
|
-
return {
|
|
897
|
+
return { details: undefined,
|
|
870
898
|
content: [{ type: "text" as const, text: `数据库类型 "${config.type}" 暂不支持。` }],
|
|
871
899
|
};
|
|
872
900
|
}
|
|
873
901
|
const result = await dialect.listTables(toRuntimeConfig(config, dialect.defaultPort), params.pattern);
|
|
874
902
|
|
|
875
903
|
if (!result.success || !result.tables) {
|
|
876
|
-
return {
|
|
904
|
+
return { details: undefined,
|
|
877
905
|
content: [{ type: "text" as const, text: `获取表列表失败: ${result.error}` }],
|
|
878
906
|
};
|
|
879
907
|
}
|
|
@@ -885,7 +913,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
885
913
|
return `${schema}${t.name} (${t.type})${desc}`;
|
|
886
914
|
});
|
|
887
915
|
|
|
888
|
-
return {
|
|
916
|
+
return { details: undefined,
|
|
889
917
|
content: [{ type: "text" as const, text: `数据库 "${config.name}"${params.pattern ? `(pattern: ${params.pattern})` : ""} 共 ${result.count} 张表:\n${lines.join("\n")}` }],
|
|
890
918
|
};
|
|
891
919
|
},
|
|
@@ -904,7 +932,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
904
932
|
async execute(_toolCallId: string, params: { database?: string; table: string }, _signal: any) {
|
|
905
933
|
const target = resolveTargetDb(params.database);
|
|
906
934
|
if (target.error || !target.config) {
|
|
907
|
-
return {
|
|
935
|
+
return { details: undefined,
|
|
908
936
|
content: [{ type: "text" as const, text: target.error ?? "未找到数据库配置" }],
|
|
909
937
|
};
|
|
910
938
|
}
|
|
@@ -912,14 +940,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
912
940
|
|
|
913
941
|
const dialect = registry.get(config.type);
|
|
914
942
|
if (!dialect) {
|
|
915
|
-
return {
|
|
943
|
+
return { details: undefined,
|
|
916
944
|
content: [{ type: "text" as const, text: `数据库类型 "${config.type}" 暂不支持。` }],
|
|
917
945
|
};
|
|
918
946
|
}
|
|
919
947
|
const result = await dialect.describeTable(toRuntimeConfig(config, dialect.defaultPort), params.table);
|
|
920
948
|
|
|
921
949
|
if (!result.success || !result.columns) {
|
|
922
|
-
return {
|
|
950
|
+
return { details: undefined,
|
|
923
951
|
content: [{ type: "text" as const, text: `获取表结构失败: ${result.error}` }],
|
|
924
952
|
};
|
|
925
953
|
}
|
|
@@ -935,7 +963,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
935
963
|
);
|
|
936
964
|
}
|
|
937
965
|
|
|
938
|
-
return {
|
|
966
|
+
return { details: undefined,
|
|
939
967
|
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
940
968
|
};
|
|
941
969
|
},
|
|
@@ -945,30 +973,44 @@ export default function (pi: ExtensionAPI) {
|
|
|
945
973
|
pi.registerTool({
|
|
946
974
|
name: "scan_project_configs",
|
|
947
975
|
label: "扫描项目数据库配置",
|
|
948
|
-
description: "获取项目文件树,供 AI 自行定位并阅读数据库连接配置文件(任意语言:Spring/Py/Go/TS/Rust 的 yml/toml/env/ini/json 等均覆盖)。本身不做提取;提取结果用 db_scan_save 提交。",
|
|
949
|
-
promptSnippet: "当用户说「连一下这个项目的数据库」「帮我把这项目的库配上」「/db scan 配置pg」等时调用第 1 步。返回项目文件树后:自己判断哪些文件可能含数据库连接配置(用户指了类型就只找该类型),用自带读文件工具阅读,提取连接候选(类型/host/端口/库名/账号/密码),然后调用 db_scan_save 提交。path
|
|
976
|
+
description: "获取项目文件树,供 AI 自行定位并阅读数据库连接配置文件(任意语言:Spring/Py/Go/TS/Rust 的 yml/toml/env/ini/json 等均覆盖)。本身不做提取;提取结果用 db_scan_save 提交。path 相对会话工作目录(ctx.cwd)解析,按真实路径(realpath)强制限定在该子树内。",
|
|
977
|
+
promptSnippet: "当用户说「连一下这个项目的数据库」「帮我把这项目的库配上」「/db scan 配置pg」等时调用第 1 步。返回项目文件树后:自己判断哪些文件可能含数据库连接配置(用户指了类型就只找该类型),用自带读文件工具阅读,提取连接候选(类型/host/端口/库名/账号/密码),然后调用 db_scan_save 提交。path 相对会话工作目录解析,必须在会话工作目录子树内。",
|
|
978
|
+
promptGuidelines: [
|
|
979
|
+
"扫描建连(scan_project_configs → db_scan_save)先定位「当前生效的环境」再取配置:Spring 项目读 application.yml/bootstrap.yml 与 pom.xml 的 profiles(Maven 的 @xxx@ 占位符真值在 pom.xml 的 <profile><properties> 里);同仓库常并存 dev/test/devops/prod/dm 多套配置,读错 profile 会拿到废弃库。",
|
|
980
|
+
"配置指向配置中心(nacos/apollo/spring-cloud-config)时以远端为准:仓库里签入的 *_配置副本.yaml 常已过期,远端与本地冲突时用远端值;每个候选的 source 写清「生效 profile + 来源文件/dataId」。",
|
|
981
|
+
"能连通只证明库存在且凭据有效,不等于它属于当前环境:来源可疑(非生效 profile、环境无法确定)的候选不要提交,先向用户确认。插件按确定性规则给每个候选评级并随工具返回(🟢 source 含远端证据 URL/dataId、🟡 本地文件带环境标识或仅自述配置中心、🔴 无环境标识或本批跨环境冲突),汇报结果时必须如实转述评级与理由,不得把 🟡/🔴 说成已核实;从远端拉的候选请在 source 里写明 dataId 或 URL。",
|
|
982
|
+
],
|
|
950
983
|
parameters: Type.Object({
|
|
951
|
-
path: Type.Optional(Type.String({ description: "
|
|
984
|
+
path: Type.Optional(Type.String({ description: "扫描根目录,缺省为会话工作目录;强制限定在会话工作目录子树内(按真实路径判定,指向子树外的软链同样被拒,越界拒绝)" })),
|
|
952
985
|
}),
|
|
953
|
-
async execute(_toolCallId: string, params: { path?: string }, _signal: any) {
|
|
986
|
+
async execute(_toolCallId: string, params: { path?: string }, _signal: any, _onUpdate?: any, ctx?: any) {
|
|
987
|
+
// 锚点取会话 cwd(ctx.cwd)。缺失时 **fail-closed**:用 process.cwd() 兜底等于把红线
|
|
988
|
+
// 钉在宿主目录上(pi-web 下就是 pi-web 包目录),正是本版修掉的错锚点
|
|
989
|
+
const anchor = sessionCwdOf(ctx);
|
|
990
|
+
if (!anchor) {
|
|
991
|
+
return { details: undefined,
|
|
992
|
+
content: [{ type: "text" as const, text: "扫描失败: 无法获取会话工作目录(ctx.cwd 缺失)——拒绝在未知锚点下扫描。" }],
|
|
993
|
+
};
|
|
994
|
+
}
|
|
954
995
|
try {
|
|
955
|
-
const tree = collectTree(params.path ?? ".");
|
|
996
|
+
const tree = collectTree(params.path ?? ".", anchor);
|
|
956
997
|
const text = [
|
|
957
998
|
`项目根目录: ${tree.root}(文件总数 ${tree.total}${tree.truncated ? ",已截断展示前 " + tree.lines.length + " 条" : ""})`,
|
|
999
|
+
...(tree.depthCapped ? [`⚠️ 文件树可能不完整:有目录未展开(超过 ${MAX_DEPTH} 层或目录数上限)——可把更深的子目录作为 path 再扫一次`] : []),
|
|
958
1000
|
"",
|
|
959
1001
|
"文件树(相对路径,浅层优先):",
|
|
960
1002
|
...tree.lines,
|
|
961
1003
|
"",
|
|
962
1004
|
"下一步(必须执行):",
|
|
963
|
-
"1.
|
|
964
|
-
"2.
|
|
965
|
-
"3.
|
|
1005
|
+
"1. 先定位「当前生效的环境」,再看文件:Spring 项目看 application.yml/bootstrap.yml 与 pom.xml 的 profiles(Maven 的 @xxx@ 占位符真值在 pom.xml 的 <profile><properties> 里),确定生效 profile(如 devops)与生效配置文件(如 application-devops.yml)——同仓库常并存 dev/test/devops/prod/dm 多套配置,读错 profile 会拿到废弃库;再判断哪些文件可能包含数据库连接配置——任意语言/格式都算:Spring application*.yml、Python settings/config.py、Go config.yaml、Node .env、Rust .env+config.toml、docker-compose、K8s manifest 等;",
|
|
1006
|
+
"2. 用你的读文件工具阅读生效 profile 的文件;若 bootstrap.yml/application.yml 指向配置中心(nacos/apollo/spring-cloud-config),必须以远端配置为准(仓库里签入的副本常已过期)——例如 Nacos:先用 POST {server}/nacos/v1/auth/login(form: username/password)拿 accessToken,再 GET {server}/nacos/v1/cs/configs?dataId=<服务名>.yaml&group=<group>&tenant=<namespace-id>&accessToken=<token>;远端与本地冲突时以远端为准;",
|
|
1007
|
+
"3. 从生效配置中提取连接候选:dialectId(postgresql/mysql/oracle/dm/redis/elasticsearch/mongodb/neo4j/hive/spark)、host、port、database、username、password、url(如有完整连接串)、source(写清「生效 profile + 来源文件/dataId」,如 pom.xml#devops → application-devops.yml);用户指定了类型(如「配置pg」)就只提取该类型;来源可疑(非生效 profile、环境无法确定)的候选不要提交,先向用户确认;",
|
|
966
1008
|
"4. 调用 db_scan_save 提交候选数组。带 url 的候选必须能被对应方言解析,编造的 url 会被拒绝;",
|
|
967
1009
|
"5. 工具会弹确认框让用户逐个确认并补录密码,把工具返回的保存结果汇报给用户。",
|
|
968
1010
|
].join("\n");
|
|
969
|
-
return { content: [{ type: "text" as const, text }] };
|
|
1011
|
+
return { details: undefined, content: [{ type: "text" as const, text }] };
|
|
970
1012
|
} catch (err) {
|
|
971
|
-
return {
|
|
1013
|
+
return { details: undefined,
|
|
972
1014
|
content: [{ type: "text" as const, text: `扫描失败: ${err instanceof Error ? err.message : String(err)}` }],
|
|
973
1015
|
};
|
|
974
1016
|
}
|
|
@@ -991,12 +1033,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
991
1033
|
password: Type.Optional(Type.String({ description: "密码(缺失时确认框会引导用户补录)" })),
|
|
992
1034
|
url: Type.Optional(Type.String({ description: "完整连接串(可选,提供时必须能被方言解析,否则整条拒绝)" })),
|
|
993
1035
|
name: Type.Optional(Type.String({ description: "连接名称(缺省自动生成)" })),
|
|
994
|
-
source: Type.Optional(Type.String({ description: "
|
|
1036
|
+
source: Type.Optional(Type.String({ description: "来源描述。本地文件写「生效 profile + 来源文件」,如 pom.xml#devops → application-devops.yml;从配置中心拉的必须写远端证据(远端 URL 或 dataId=),否则只能评为 🟡。插件按此评级并在确认框展示,缺失/无环境标识评为 🔴" })),
|
|
995
1037
|
}), { description: "连接候选数组" }),
|
|
996
1038
|
}),
|
|
997
1039
|
async execute(_toolCallId: string, params: { candidates: unknown[] }, _signal: any, _onUpdate?: any, ctx?: any) {
|
|
998
1040
|
if (!ctx?.ui) {
|
|
999
|
-
return { content: [{ type: "text" as const, text: "db_scan_save 需要交互式会话(ctx.ui 不可用)。请引导用户在终端执行 /db add 手动添加。" }] };
|
|
1041
|
+
return { details: undefined, content: [{ type: "text" as const, text: "db_scan_save 需要交互式会话(ctx.ui 不可用)。请引导用户在终端执行 /db add 手动添加。" }] };
|
|
1000
1042
|
}
|
|
1001
1043
|
const { candidates, rejected } = validateCandidates(params.candidates, new Set(loadConfigs().map((c) => c.name)));
|
|
1002
1044
|
const lines: string[] = [];
|
|
@@ -1006,20 +1048,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
1006
1048
|
if (candidates.length === 0) {
|
|
1007
1049
|
lines.push(rejected.length > 0 ? "没有可保存的候选。" : "未收到有效候选(candidates 需为数组,每项含 dialectId+host)。",
|
|
1008
1050
|
"提示:从配置文件原文提取后重试;确认方言取值在支持列表内。");
|
|
1009
|
-
return { content: [{ type: "text" as const, text: lines.join("\n") }] };
|
|
1051
|
+
return { details: undefined, content: [{ type: "text" as const, text: lines.join("\n") }] };
|
|
1010
1052
|
}
|
|
1011
1053
|
lines.push(`收到 ${candidates.length} 个连接候选,逐个确认:`, ...candidates.map(candidateLine), "");
|
|
1012
1054
|
const results: string[] = [];
|
|
1013
1055
|
for (const cand of candidates) {
|
|
1014
1056
|
try {
|
|
1015
1057
|
const r = await confirmAndSaveCandidate(ctx, cand);
|
|
1016
|
-
|
|
1058
|
+
const trustLine = `${TRUST_GLYPH[cand.trust.level]}${TRUST_LABEL[cand.trust.level]}(环境: ${cand.trust.env ?? "未知"})`;
|
|
1059
|
+
// 理由一并回传:promptGuidelines 要求模型如实转述评级与理由,模型看不到就无从转述
|
|
1060
|
+
const why = cand.trust.reasons.length ? `\n ${cand.trust.reasons.join(";")}` : "";
|
|
1061
|
+
results.push(`${SCAN_GLYPH[cand.status]} ${cand.partial.name} (${cand.dialectId}): ${r === "saved" ? "已保存" : r === "skipped" ? "用户跳过" : "连接失败未保存"} ${trustLine}${why}`);
|
|
1017
1062
|
} catch (err) {
|
|
1018
1063
|
results.push(`❌ ${cand.partial.name} (${cand.dialectId}): 异常 ${err instanceof Error ? err.message : String(err)}`);
|
|
1019
1064
|
}
|
|
1020
1065
|
}
|
|
1021
1066
|
lines.push("", "保存结果:", ...results.map((r) => " - " + r));
|
|
1022
|
-
return { content: [{ type: "text" as const, text: lines.join("\n") }] };
|
|
1067
|
+
return { details: undefined, content: [{ type: "text" as const, text: lines.join("\n") }] };
|
|
1023
1068
|
},
|
|
1024
1069
|
});
|
|
1025
1070
|
|
|
@@ -1033,7 +1078,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1033
1078
|
async execute() {
|
|
1034
1079
|
const configs = loadConfigs();
|
|
1035
1080
|
if (configs.length === 0) {
|
|
1036
|
-
return {
|
|
1081
|
+
return { details: undefined,
|
|
1037
1082
|
content: [{ type: "text" as const, text: "尚无数据库连接。请引导用户在终端执行 /db add 或 /db scan 建连。" }],
|
|
1038
1083
|
};
|
|
1039
1084
|
}
|
|
@@ -1046,7 +1091,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1046
1091
|
const url = registry.get(c.type)?.displayUrl(c) ?? "";
|
|
1047
1092
|
return `- ${c.name} [${shortTypeLabel(c.type)}]${marks ? " " + marks : ""} - ${fullTypeLabel(c.type)}${c.database ? " · 库: " + c.database : ""} · ${url} · ${test}${used ? " · " + used : ""}${c.description ? " · " + c.description : ""}`;
|
|
1048
1093
|
});
|
|
1049
|
-
return {
|
|
1094
|
+
return { details: undefined,
|
|
1050
1095
|
content: [{ type: "text" as const, text: `共 ${configs.length} 个连接:\n${lines.join("\n")}\n\n标注 [prod·强制只读] 的连接永远只读(即使全局允许写操作)。` }],
|
|
1051
1096
|
};
|
|
1052
1097
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nsyan/db",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
4
4
|
"description": "AI 数据库接入扩展 for pi —— 达梦(DM)/PostgreSQL/MySQL/Oracle/Redis/Elasticsearch/MongoDB/Neo4j/Hive/Spark 十种数据库,六大家族方言,提供查询/表结构/扫描建连/连接清单 LLM 工具 | Database access extension for the pi coding agent: query, schema browsing, connection scanning and audit tools for PostgreSQL, MySQL, Oracle, DM (Dameng 达梦), Redis, Elasticsearch, MongoDB, Neo4j, Hive and Spark",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-extension",
|
|
@@ -60,14 +60,17 @@
|
|
|
60
60
|
"mysql2": "^3.23.1",
|
|
61
61
|
"neo4j-driver": "^5.28.3",
|
|
62
62
|
"oracledb": "^7.0.1",
|
|
63
|
-
"neo4j-driver": "^5.28.3",
|
|
64
63
|
"pg": "^8.22.0"
|
|
65
64
|
},
|
|
66
65
|
"devDependencies": {
|
|
66
|
+
"@earendil-works/pi-coding-agent": "^0.85.1",
|
|
67
|
+
"@types/node": "^22.20.2",
|
|
67
68
|
"@types/pg": "^8.20.0",
|
|
68
|
-
"tsx": "^4.23.13"
|
|
69
|
+
"tsx": "^4.23.13",
|
|
70
|
+
"typescript": "^7.0.2"
|
|
69
71
|
},
|
|
70
72
|
"scripts": {
|
|
71
|
-
"test": "tsx --test \"test/**/*.test.ts\""
|
|
73
|
+
"test": "tsx --test \"test/**/*.test.ts\"",
|
|
74
|
+
"typecheck": "tsc -p tsconfig.typecheck.json"
|
|
72
75
|
}
|
|
73
76
|
}
|
package/src/config.ts
CHANGED
|
@@ -274,11 +274,26 @@ export function toRuntimeConfig(c: ConnConfig, defaultPort: number): ConnConfig
|
|
|
274
274
|
};
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
-
// 供 UI
|
|
277
|
+
// 供 UI 层展示类型标签(原 index.ts 内 5 处重复映射的收敛点之一)
|
|
278
|
+
// 单一数据源:短标签与全称同表维护。键类型用 Record<DbTypeId, ...> 而非 Record<string, ...>,
|
|
279
|
+
// 新增方言而漏补标签时会在类型检查阶段报错,不再静默 fallback 成原始 id。
|
|
280
|
+
const TYPE_LABELS: Record<DbTypeId, { short: string; full: string }> = {
|
|
281
|
+
postgresql: { short: "PG", full: "PostgreSQL" },
|
|
282
|
+
mysql: { short: "MySQL", full: "MySQL" },
|
|
283
|
+
oracle: { short: "Oracle", full: "Oracle" },
|
|
284
|
+
dm: { short: "DM", full: "达梦" },
|
|
285
|
+
redis: { short: "Redis", full: "Redis" },
|
|
286
|
+
elasticsearch: { short: "ES", full: "Elasticsearch" },
|
|
287
|
+
mongodb: { short: "MongoDB", full: "MongoDB" },
|
|
288
|
+
neo4j: { short: "Neo4j", full: "Neo4j" },
|
|
289
|
+
hive: { short: "Hive", full: "Hive" },
|
|
290
|
+
spark: { short: "Spark", full: "Spark" },
|
|
291
|
+
};
|
|
292
|
+
|
|
278
293
|
export function shortTypeLabel(type: DbTypeId): string {
|
|
279
|
-
return
|
|
294
|
+
return TYPE_LABELS[type]?.short ?? type;
|
|
280
295
|
}
|
|
281
296
|
|
|
282
297
|
export function fullTypeLabel(type: DbTypeId): string {
|
|
283
|
-
return
|
|
298
|
+
return TYPE_LABELS[type]?.full ?? type;
|
|
284
299
|
}
|
package/src/core/audit.ts
CHANGED
|
@@ -12,7 +12,7 @@ export const AUDIT_DIR = join(homedir(), ".pi", "agent", "db-audit");
|
|
|
12
12
|
export interface AuditEntry {
|
|
13
13
|
/** ISO 时间 */
|
|
14
14
|
time: string;
|
|
15
|
-
/**
|
|
15
|
+
/** 执行时的项目路径(会话 cwd = ctx.cwd),跨项目区分用 */
|
|
16
16
|
project: string;
|
|
17
17
|
/** 连接名 */
|
|
18
18
|
connection: string;
|
package/src/core/scan/tree.ts
CHANGED
|
@@ -10,15 +10,18 @@ export interface TreeResult {
|
|
|
10
10
|
root: string; // 绝对路径
|
|
11
11
|
total: number; // 收集到的文件总数
|
|
12
12
|
truncated: boolean; // 是否超出 MAX_TREE_LINES 被截断
|
|
13
|
+
depthCapped: boolean; // 是否有目录未展开(超 MAX_DEPTH 或目录预算;如实上报,避免静默漏扫)
|
|
13
14
|
lines: string[]; // 相对路径清单(已排序:浅层优先)
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
const MAX_TREE_LINES = 3000;
|
|
17
18
|
|
|
18
|
-
/** 收集项目文件树(相对路径排序清单);越界由 resolveRoot
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
/** 收集项目文件树(相对路径排序清单);越界由 resolveRoot 拒绝。
|
|
20
|
+
* anchor 传会话工作目录(`ctx.cwd`),勿传 `process.cwd()`,原因见 walker.resolveRoot。
|
|
21
|
+
* 输出确定:walk 对目录项排序,本函数再按「浅层优先 + 字典序」排序,同一项目每次结果一致 */
|
|
22
|
+
export function collectTree(rootInput: string, anchor: string): TreeResult {
|
|
23
|
+
const root = resolveRoot(rootInput, anchor);
|
|
24
|
+
const { files, depthCapped } = walk(root);
|
|
22
25
|
const rels = files
|
|
23
26
|
.map((f) => f.slice(root.length + 1).split("\\").join("/"))
|
|
24
27
|
.sort((a, b) => {
|
|
@@ -29,6 +32,7 @@ export function collectTree(rootInput: string): TreeResult {
|
|
|
29
32
|
root,
|
|
30
33
|
total: rels.length,
|
|
31
34
|
truncated: rels.length > MAX_TREE_LINES,
|
|
35
|
+
depthCapped,
|
|
32
36
|
lines: rels.slice(0, MAX_TREE_LINES),
|
|
33
37
|
};
|
|
34
38
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// scan/trust.ts —— 候选「来源可信度」评估(确定性规则,纯函数,可单测)
|
|
2
|
+
// 动机:db_scan_save 唯一的客观闸门是 testConnection,而「能连通」只证明库存在、凭据有效,
|
|
3
|
+
// 不证明它属于当前环境。同仓库常并存 dev/test/devops/prod/dm 多套配置,签入的配置中心
|
|
4
|
+
// 副本又会随时间漂移,模型很容易提取到废弃库名或别的环境地址——而且照样能测通。
|
|
5
|
+
// 本模块不判断配置对不对(无确定解),只把「来源能证明什么」分级、在确认框里显式展示,
|
|
6
|
+
// 把环境判断交回用户,而不是让它静默通过。
|
|
7
|
+
//
|
|
8
|
+
// 边界(必须明说):评级输入是**模型自述的 `source` 文本**,不是独立取证——写什么就信什么。
|
|
9
|
+
// 它抬高的是「误连环境」的被发现概率(用户在确认框多看一眼),不构成安全保证;
|
|
10
|
+
// 真正的闸门只有 testConnection 与用户确认。
|
|
11
|
+
// 因此 🟢 要求 source 里有**远端检索证据**(URL / dataId / 显式「远端」);仅出现产品名
|
|
12
|
+
// (nacos/apollo/…)不升级——仓库里签入的同名副本会命中,而那恰恰是会漂移的东西。
|
|
13
|
+
|
|
14
|
+
export type TrustLevel = "high" | "medium" | "low";
|
|
15
|
+
|
|
16
|
+
export interface SourceTrust {
|
|
17
|
+
level: TrustLevel;
|
|
18
|
+
/** 从 source 识别出的环境标识(local/dev/test/devops/prod…),识别不出为 null */
|
|
19
|
+
env: string | null;
|
|
20
|
+
/** 展示给用户的中文理由:只陈述来源能证明什么,不含结论 */
|
|
21
|
+
reasons: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 远端检索证据:只有真的去远端拉过才会写进 source 的标记(URL / Nacos OpenAPI / dataId= / 显式「远端」) */
|
|
25
|
+
const REMOTE_EVIDENCE = /https?:\/\/|dataid\s*=|cs\/configs|(?:远端|远程|remote)/i;
|
|
26
|
+
|
|
27
|
+
/** 配置中心产品名:仓库里签入的副本文件名同样命中,不能单独证明「取自远端」 */
|
|
28
|
+
const CONFIG_CENTER_NAME = /\bnacos\b|\bapollo\b|\bconsul\b|config[-_ ]?center|config[-_ ]?server|spring-cloud-config/i;
|
|
29
|
+
|
|
30
|
+
/** 环境标识白名单:按分隔符整段比对,避免 "dev" 命中 "devops" */
|
|
31
|
+
const ENV_TOKENS = new Set([
|
|
32
|
+
"local", "devops", "dev", "sit", "test", "uat", "stage", "staging", "pre", "gray", "prod", "release", "dm",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const PROD_TOKENS = new Set(["prod", "release"]);
|
|
36
|
+
|
|
37
|
+
/** 从 source 文本取环境段(按 . _ - / \ # 空格 = 切分后整段匹配;切 `=` 使 `group=devops` 也能识别) */
|
|
38
|
+
export function envOf(source: string): string | null {
|
|
39
|
+
for (const tok of source.toLowerCase().split(/[._\-/\\#\s=]+/)) {
|
|
40
|
+
if (ENV_TOKENS.has(tok)) return tok;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 批量评估来源可信度(跨候选的环境冲突必须看整批,故入口是数组)。
|
|
47
|
+
* @param sources 与候选一一对应的 source 文本(空串表示未提供)
|
|
48
|
+
*/
|
|
49
|
+
export function assessSourceTrust(sources: readonly string[]): SourceTrust[] {
|
|
50
|
+
const envs = sources.map(envOf);
|
|
51
|
+
const distinct = [...new Set(envs.filter((e): e is string => e !== null))];
|
|
52
|
+
|
|
53
|
+
return sources.map((source, i) => {
|
|
54
|
+
const reasons: string[] = [];
|
|
55
|
+
const env = envs[i];
|
|
56
|
+
let level: TrustLevel;
|
|
57
|
+
|
|
58
|
+
if (REMOTE_EVIDENCE.test(source)) {
|
|
59
|
+
level = "high";
|
|
60
|
+
reasons.push("来源含远端检索证据(URL / dataId / 显式「远端」):远端配置是运行时真值");
|
|
61
|
+
} else if (CONFIG_CENTER_NAME.test(source)) {
|
|
62
|
+
level = "medium";
|
|
63
|
+
reasons.push("来源自述为配置中心但未写远端证据(URL / dataId):请确认它取自远端接口,而不是仓库里签入的副本——签入的副本常已过期");
|
|
64
|
+
} else if (env === null) {
|
|
65
|
+
level = "low";
|
|
66
|
+
reasons.push("来源无环境标识:同仓库常并存多套环境配置,且签入的配置中心副本可能已过期,无法判断它是否属于当前环境");
|
|
67
|
+
} else {
|
|
68
|
+
level = "medium";
|
|
69
|
+
reasons.push(`来源为本地文件、环境标识「${env}」:请确认它就是当前生效 profile(Maven 的 @xxx@ 占位符真值在 pom.xml 的 <profile><properties> 里)`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 一批里出现多个环境,说明至少有一部分候选不属于当前环境,必须让用户定夺
|
|
73
|
+
if (distinct.length > 1 && env !== null) {
|
|
74
|
+
level = "low";
|
|
75
|
+
reasons.unshift(`本批次候选来自多个环境(${distinct.join("、")}):请确认当前生效的是哪一个,只取生效环境的库`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (env !== null && PROD_TOKENS.has(env)) {
|
|
79
|
+
reasons.push("疑似生产环境:保存后建议用 /db 编辑该连接、把环境标签设为 prod(会启用强制只读)");
|
|
80
|
+
}
|
|
81
|
+
return { level, env, reasons };
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const TRUST_GLYPH: Record<TrustLevel, string> = { high: "🟢", medium: "🟡", low: "🔴" };
|
|
86
|
+
export const TRUST_LABEL: Record<TrustLevel, string> = { high: "来源可信", medium: "来源待核", low: "来源存疑" };
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
import { basename } from "node:path";
|
|
8
8
|
import { registry } from "../../dialects/index.js";
|
|
9
|
-
import
|
|
9
|
+
import { assessSourceTrust } from "./trust.js";
|
|
10
|
+
import type { Candidate, CandidateInput, CandidateStatus, ConnConfig, DbTypeId } from "../types.js";
|
|
10
11
|
|
|
11
12
|
/** 各类型建连必需字段(空串同样视为缺失)——家族语义,非正则,保留 */
|
|
12
13
|
const REQUIRED: Record<DbTypeId, string[]> = {
|
|
@@ -37,6 +38,13 @@ export function validateCandidates(raw: unknown, existingNames: Set<string>): Va
|
|
|
37
38
|
const candidates: Candidate[] = [];
|
|
38
39
|
const rejected: string[] = [];
|
|
39
40
|
const seenNames = new Set(existingNames);
|
|
41
|
+
// 来源可信度:跨候选的环境冲突要看整批,故先批量评估,再按原始下标挂到候选上
|
|
42
|
+
const trusts = assessSourceTrust(
|
|
43
|
+
list.map((item) =>
|
|
44
|
+
item && typeof item === "object" && nonEmpty((item as Record<string, unknown>).source)
|
|
45
|
+
? String((item as Record<string, unknown>).source)
|
|
46
|
+
: ""),
|
|
47
|
+
);
|
|
40
48
|
|
|
41
49
|
for (const [i, item] of list.entries()) {
|
|
42
50
|
if (item === null || typeof item !== "object") {
|
|
@@ -54,7 +62,9 @@ export function validateCandidates(raw: unknown, existingNames: Set<string>): Va
|
|
|
54
62
|
|
|
55
63
|
// ② 带 url 时:方言 parseUrl 必须能解析(防幻觉——claim 与 url 矛盾整条拒)
|
|
56
64
|
// 解析成功时以解析结果为准(host/port/database/username/password 由 URL 补全)
|
|
57
|
-
|
|
65
|
+
// 归一化后 port/dbIndex 恒为 number(下行 parseInt 收敛)。此处用 Partial<ConnConfig> 而非
|
|
66
|
+
// Partial<CandidateInput>——后者的 port 是 number|string,会让下方端口范围校验退化为字符串比较。
|
|
67
|
+
let bag: Partial<ConnConfig> = {
|
|
58
68
|
host: typeof c.host === "string" ? c.host.trim() : undefined,
|
|
59
69
|
port: typeof c.port === "number" ? c.port : parseInt(String(c.port ?? ""), 10) || undefined,
|
|
60
70
|
username: nonEmpty(c.username) ? String(c.username) : undefined,
|
|
@@ -90,7 +100,7 @@ export function validateCandidates(raw: unknown, existingNames: Set<string>): Va
|
|
|
90
100
|
}
|
|
91
101
|
|
|
92
102
|
// ④ 缺字段判定
|
|
93
|
-
const missing = (REQUIRED[dialect.id] ?? []).filter((f) => !nonEmpty(bag[f as keyof
|
|
103
|
+
const missing = (REQUIRED[dialect.id] ?? []).filter((f) => !nonEmpty(bag[f as keyof ConnConfig]));
|
|
94
104
|
|
|
95
105
|
// ⑤ 命名:显式 name > 默认;同名(含与本批前序候选撞名)→ exists 状态
|
|
96
106
|
let name = nonEmpty(c.name) ? String(c.name).trim()
|
|
@@ -104,7 +114,8 @@ export function validateCandidates(raw: unknown, existingNames: Set<string>): Va
|
|
|
104
114
|
dialectId: dialect.id,
|
|
105
115
|
partial: { name, ...bag } as Partial<import("../types.js").ConnConfig>,
|
|
106
116
|
missing,
|
|
107
|
-
source: nonEmpty(c.source) ? String(c.source) : "AI
|
|
117
|
+
source: nonEmpty(c.source) ? String(c.source) : "AI 提取(未提供来源)",
|
|
118
|
+
trust: trusts[i],
|
|
108
119
|
warnings: Array.isArray(c.warnings) ? c.warnings.map(String).slice(0, 3) : undefined,
|
|
109
120
|
});
|
|
110
121
|
}
|
package/src/core/scan/walker.ts
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
// scan/walker.ts —— 目录漫步:默认忽略常见无关目录;resolveRoot 越界拒绝(Spec §8.5 红线)
|
|
2
|
-
import { readdirSync, statSync, type Dirent } from "node:fs";
|
|
3
|
-
import { extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
2
|
+
import { readdirSync, realpathSync, statSync, type Dirent } from "node:fs";
|
|
3
|
+
import { basename, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
|
|
5
|
+
// 忽略的目录:只列**确定无关**的(依赖/构建产物/版本控制元数据/工具缓存)。
|
|
6
|
+
// 注意 docs 不在此列:本模块不对「目录名是否与配置有关」做猜测(见 tree.ts 设计注释),
|
|
7
|
+
// 配置样例/真实配置放 docs 下的项目是存在的,漏扫比多扫代价大。
|
|
5
8
|
const IGNORED_DIRS = new Set([
|
|
6
|
-
"node_modules", ".git", "target", "dist", "venv", "
|
|
7
|
-
"build", "coverage", "__pycache__", ".idea", ".vscode",
|
|
9
|
+
"node_modules", ".git", ".svn", ".hg", "target", "dist", "venv", ".venv", "logs",
|
|
10
|
+
"build", "coverage", "__pycache__", ".idea", ".vscode", ".next", ".nuxt", ".cache",
|
|
11
|
+
".gradle", ".tox", ".pytest_cache", ".mypy_cache", ".turbo", ".parcel-cache", ".sass-cache",
|
|
8
12
|
]);
|
|
9
13
|
|
|
10
14
|
const MAX_FILES = 2000;
|
|
11
|
-
|
|
15
|
+
/** 目录访问预算:软链别名会重复走到同一个真实目录(见 walk 的断环说明),
|
|
16
|
+
* 需要一个上界防止病态目录图把扫描拖死;超限经 depthCapped 上报,不静默。 */
|
|
17
|
+
const MAX_DIRS = 20000;
|
|
18
|
+
/** 深度上限:Java 多模块可达 `m/src/main/java/com/x/.../service/mapper/impl`(实测 14 层)、
|
|
19
|
+
* monorepo 嵌套更深;超限会通过 depthCapped 如实上报。导出供工具提示引用,避免硬编码层数漂移。 */
|
|
20
|
+
export const MAX_DEPTH = 16;
|
|
12
21
|
|
|
13
22
|
/** 扫描目标配置文件:不受普通文件配额挤占(大型 Java 工程源码文件可轻易冲爆 MAX_FILES) */
|
|
14
23
|
const CONFIG_EXTS = new Set([".yml", ".yaml", ".properties"]);
|
|
@@ -17,47 +26,117 @@ function isConfigFile(name: string): boolean {
|
|
|
17
26
|
return base === ".env" || base.startsWith(".env.") || CONFIG_EXTS.has(extname(base));
|
|
18
27
|
}
|
|
19
28
|
|
|
29
|
+
/** target 是否在 base 子树内(纯路径判定,用于拦住软链逃逸) */
|
|
30
|
+
function within(base: string, target: string): boolean {
|
|
31
|
+
const rel = relative(base, target);
|
|
32
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(".." + sep) && !isAbsolute(rel));
|
|
33
|
+
}
|
|
34
|
+
|
|
20
35
|
/**
|
|
21
|
-
* 把输入路径解析为绝对路径;越界(
|
|
22
|
-
*
|
|
36
|
+
* 把输入路径解析为绝对路径;越界(anchor 子树之外、`..` 上跳)直接抛
|
|
37
|
+
* "scan path out of scope"——与「密码不进上下文」同级别的红线。
|
|
38
|
+
*
|
|
39
|
+
* anchor 必须由调用方显式传入「会话工作目录」(pi 的 `ctx.cwd`),不能取 `process.cwd()`:
|
|
40
|
+
* 扩展与 pi 主进程同进程,`process.cwd()` 是**宿主进程的 OS cwd**。pi-web 下宿主 cwd 是
|
|
41
|
+
* pi-web 包目录、TUI 下是 pi 的启动目录,都与会话 cwd 不同源(会话 cwd 存在 session header
|
|
42
|
+
* 里,`/resume` 后可与启动目录不同)。锚错对象的后果是双向的:
|
|
43
|
+
* - fail-closed:项目路径全被拒(pi-web 下的必然结果);
|
|
44
|
+
* - fail-open:宿主 cwd 子树反而被放行——红线保护了不相干的目录树。
|
|
45
|
+
* 因此锚点只能来自 `ctx.cwd`,绝不能由模型传入的 input 反推。
|
|
46
|
+
*
|
|
47
|
+
* 判定分两道:
|
|
48
|
+
* ① 字面路径必须在 anchor 子树内(拦 `..` 上跳与绝对路径);
|
|
49
|
+
* ② **真实路径**(realpath)也必须在 anchor 的真实路径子树内——字面合法 ≠ 真实位置合法:
|
|
50
|
+
* 仓库里一个 `dbconf -> /etc` 的软链会让 abs 字面在子树内、真实位置在外(仅比对
|
|
51
|
+
* 「root 的 realpath 与其子项」会让软链扫描根自我授权)。两端都取 realpath 再比对,
|
|
52
|
+
* 因此「项目本身位于软链路径下」(如 macOS `/tmp → /private/tmp`)不会被误伤。
|
|
23
53
|
*/
|
|
24
|
-
export function resolveRoot(input: string): string {
|
|
25
|
-
const cwd =
|
|
54
|
+
export function resolveRoot(input: string, anchor: string): string {
|
|
55
|
+
const cwd = resolve(anchor);
|
|
26
56
|
const abs = isAbsolute(input) ? resolve(input) : resolve(cwd, input);
|
|
27
57
|
const rel = relative(cwd, abs);
|
|
28
|
-
|
|
29
|
-
|
|
58
|
+
// 注意:不能写 rel.startsWith("..")——cwd 内合法的 `..foo/` 也会命中前缀,属假阳性误杀
|
|
59
|
+
if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) {
|
|
60
|
+
throw new Error(`scan path out of scope: ${input}(允许根目录: ${cwd})`);
|
|
61
|
+
}
|
|
62
|
+
let anchorReal: string;
|
|
63
|
+
try { anchorReal = realpathSync(cwd); } catch { throw new Error(`scan anchor does not exist: ${cwd}`); }
|
|
64
|
+
let absReal: string;
|
|
65
|
+
try { absReal = realpathSync(abs); } catch { throw new Error(`scan path does not exist: ${input}`); }
|
|
66
|
+
if (!within(anchorReal, absReal)) {
|
|
67
|
+
throw new Error(`scan path out of scope: ${input}(真实路径 ${absReal} 不在允许根目录 ${anchorReal} 子树内)`);
|
|
30
68
|
}
|
|
31
69
|
return abs;
|
|
32
70
|
}
|
|
33
71
|
|
|
34
|
-
|
|
35
|
-
|
|
72
|
+
export interface WalkResult {
|
|
73
|
+
files: string[];
|
|
74
|
+
/** 是否有目录未展开(超 MAX_DEPTH 深度上限或 MAX_DIRS 目录预算),静默截断会让模型误判「项目里没有更深的配置」 */
|
|
75
|
+
depthCapped: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 递归收集文本候选文件(上限 MAX_FILES);配置类文件单独收集不被挤占。
|
|
79
|
+
* 与 v1.3.1 的行为差异:
|
|
80
|
+
* ① 不再跳过 `.` 开头的目录(`.config/db.yml` 这类配置此前永远扫不到),改为靠 IGNORED_DIRS 排噪;
|
|
81
|
+
* ② 软链不再静默忽略:目标 realpath 在 root 子树内则按真实类型访问(拦越界逃逸);
|
|
82
|
+
* ③ 深度超限不再静默:通过 depthCapped 如实上报;
|
|
83
|
+
* ④ 输出确定:目录项排序 + 只用祖先链断环(不做全局 realpath 去重),
|
|
84
|
+
* 真目录与软链别名两条路径都保留,不随文件系统的 readdir 顺序变化。*/
|
|
85
|
+
export function walk(root: string): WalkResult {
|
|
36
86
|
const out: string[] = [];
|
|
37
87
|
const configs: string[] = [];
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
88
|
+
let rootReal = resolve(root);
|
|
89
|
+
try { rootReal = realpathSync(root); } catch { /* root 不存在时由 readdir 兜底 */ }
|
|
90
|
+
let depthCapped = false;
|
|
91
|
+
let dirsVisited = 0;
|
|
92
|
+
|
|
93
|
+
const push = (path: string, isConfig: boolean): void => {
|
|
94
|
+
if (isConfig) {
|
|
95
|
+
if (configs.length < MAX_FILES) configs.push(path);
|
|
96
|
+
} else if (out.length < MAX_FILES) {
|
|
97
|
+
out.push(path);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* ancestors 只装「当前遍历路径上」的目录 realpath,用于断环,**不做全局去重**:
|
|
103
|
+
* 全局去重会让真目录与软链别名按 readdir 顺序互相吞掉(谁先遍历到谁赢),
|
|
104
|
+
* 同一项目因此在 APFS/ext4 上产出不同的文件树——别名路径照样要走,只是不能成环。
|
|
105
|
+
*/
|
|
106
|
+
const visit = (dir: string, depth: number, ancestors: ReadonlySet<string>): void => {
|
|
107
|
+
if (depth > MAX_DEPTH || dirsVisited >= MAX_DIRS) { depthCapped = true; return; }
|
|
108
|
+
let real: string;
|
|
109
|
+
try { real = realpathSync(dir); } catch { return; }
|
|
110
|
+
if (ancestors.has(real)) return; // 软链环(a/self -> a):断环即可
|
|
111
|
+
dirsVisited++;
|
|
112
|
+
const next = new Set(ancestors);
|
|
113
|
+
next.add(real);
|
|
41
114
|
let entries: Dirent[];
|
|
42
115
|
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
116
|
+
// readdir 顺序随文件系统而变(APFS 插入序 / ext4 哈希序),排序后文件树才是确定的
|
|
117
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
43
118
|
for (const e of entries) {
|
|
44
119
|
if (IGNORED_DIRS.has(e.name)) continue;
|
|
45
|
-
if (e.isDirectory() && e.name.startsWith(".")) continue;
|
|
46
120
|
const p = join(dir, e.name);
|
|
47
|
-
if (e.isDirectory()) visit(p, depth + 1);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
121
|
+
if (e.isDirectory()) { visit(p, depth + 1, next); continue; }
|
|
122
|
+
if (e.isFile()) { push(p, isConfigFile(e.name)); continue; }
|
|
123
|
+
// 软链:readdir 的 Dirent 对软链 isDirectory/isFile 均为 false,需按 realpath 判定。
|
|
124
|
+
// 指向 root 子树之外的一律忽略——扫描目录树不能成为越界的第二个入口。
|
|
125
|
+
if (!e.isSymbolicLink()) continue;
|
|
126
|
+
let target: string;
|
|
127
|
+
try { target = realpathSync(p); } catch { continue; }
|
|
128
|
+
if (!within(rootReal, target)) continue;
|
|
129
|
+
let targetIsDir = false, targetIsFile = false;
|
|
130
|
+
try {
|
|
131
|
+
const st = statSync(target);
|
|
132
|
+
targetIsDir = st.isDirectory();
|
|
133
|
+
targetIsFile = st.isFile();
|
|
134
|
+
} catch { continue; }
|
|
135
|
+
if (targetIsDir) visit(p, depth + 1, next);
|
|
136
|
+
// 软链文件名可能不带配置后缀(.env 被链接成 env.local):名字与目标名任一像配置就按配置收
|
|
137
|
+
else if (targetIsFile) push(p, isConfigFile(e.name) || isConfigFile(basename(target)));
|
|
59
138
|
}
|
|
60
139
|
};
|
|
61
|
-
visit(root, 0);
|
|
62
|
-
return [...configs, ...out];
|
|
140
|
+
visit(root, 0, new Set<string>());
|
|
141
|
+
return { files: [...configs, ...out], depthCapped };
|
|
63
142
|
}
|
package/src/core/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// core/types.ts
|
|
2
|
+
import type { SourceTrust } from "./scan/trust.js";
|
|
2
3
|
export type DbTypeId = "postgresql" | "mysql" | "oracle" | "dm"
|
|
3
4
|
| "redis" | "elasticsearch" | "mongodb" | "hive" | "spark" | "neo4j";
|
|
4
5
|
export type DbFamily = "relational" | "kv" | "search" | "document" | "bigdata" | "graph";
|
|
@@ -58,6 +59,8 @@ export interface Candidate {
|
|
|
58
59
|
partial: Partial<ConnConfig>; // 归一化后的字段(含默认 name)
|
|
59
60
|
missing: string[]; // 待补字段名(incomplete 时)
|
|
60
61
|
source: string; // 来源描述
|
|
62
|
+
/** 来源可信度(插件按确定性规则从模型自述的 source 评估,供展示,不影响 status;不是独立取证) */
|
|
63
|
+
trust: SourceTrust;
|
|
61
64
|
warnings?: string[];
|
|
62
65
|
}
|
|
63
66
|
|
package/src/dialects/dm.ts
CHANGED
|
@@ -80,10 +80,12 @@ class DmDialect extends RelationalDialect {
|
|
|
80
80
|
protected async doExecute(client: unknown, stmt: string, opts: ExecOpts): Promise<{ columns: string[]; rows: unknown[][]; rowCount: number }> {
|
|
81
81
|
const dmConn = client as dmdb.Connection;
|
|
82
82
|
const maxRows = opts.maxRows;
|
|
83
|
+
// 上游 dmdb 的 ExecuteOptions 未声明 maxRows/fetchArraySize(运行时可接受或被忽略);
|
|
84
|
+
// 结果另有下方 .slice(0, maxRows) 兜底,故此处仅放宽类型断言,不改运行时行为。
|
|
83
85
|
const res = await dmConn.execute(stmt, [], {
|
|
84
86
|
maxRows,
|
|
85
87
|
fetchArraySize: maxRows,
|
|
86
|
-
});
|
|
88
|
+
} as dmdb.ExecuteOptions);
|
|
87
89
|
if (res.metaData && res.metaData.length > 0) {
|
|
88
90
|
const columns = res.metaData.map((m: any) => m.name);
|
|
89
91
|
const rows = (res.rows ?? []).slice(0, maxRows).map((r: any) => [...r]);
|
|
@@ -95,7 +97,8 @@ class DmDialect extends RelationalDialect {
|
|
|
95
97
|
async versionQuery(conn: DbConnection): Promise<string> {
|
|
96
98
|
const dmConn = conn.client as dmdb.Connection;
|
|
97
99
|
const res = await dmConn.execute("SELECT * FROM V$VERSION");
|
|
98
|
-
|
|
100
|
+
const rows = (res.rows as unknown[][] | undefined) ?? [];
|
|
101
|
+
return (rows[0]?.[0] as string | undefined) ?? "unknown";
|
|
99
102
|
}
|
|
100
103
|
|
|
101
104
|
async listTables(config: ConnConfig, pattern?: string): Promise<ListTablesResult> {
|
|
@@ -144,7 +144,7 @@ class ElasticsearchDialect extends SearchDialect {
|
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
protected async doSearch(client: unknown, config: ConnConfig, sql: string, kind: DslKind, opts: ExecOpts): Promise<{ columns: string[]; rows: unknown[][]; rowCount: number }> {
|
|
147
|
+
protected async doSearch(client: unknown, config: ConnConfig, sql: string, kind: DslKind, opts: ExecOpts): Promise<{ columns: string[]; rows: unknown[][]; rowCount: number; truncated?: boolean }> {
|
|
148
148
|
const es = client as ClientV8;
|
|
149
149
|
const index = config.database;
|
|
150
150
|
if (!index) throw new Error("ES 连接缺少默认 index(database 字段)");
|
|
@@ -160,14 +160,20 @@ class ElasticsearchDialect extends SearchDialect {
|
|
|
160
160
|
if (kind.endpoint === "_count") {
|
|
161
161
|
const res = await es.count({ index });
|
|
162
162
|
const count = (unwrap(res) as unknown as { count?: number }).count ?? 0;
|
|
163
|
-
|
|
163
|
+
// rowCount 为“取回行数”(1 行 = 计数值本身),实际计数在单元格里,避免“返回 88 行”却只给一行的误读
|
|
164
|
+
return { columns: ["count"], rows: [[count]], rowCount: 1 };
|
|
164
165
|
}
|
|
165
166
|
if (kind.endpoint === "_mget") {
|
|
166
|
-
|
|
167
|
+
// 注:_mget 需兼容 v7 客户端的 body 形态(本文件为 v7/v8/v9 三客户端分发),
|
|
168
|
+
// 而 v8/v9 的类型已把请求体改为顶层 docs,故按运行时通用形态传参并放宽参数类型。
|
|
169
|
+
const res = await es.mget({ index, body: { docs: [] } } as unknown as Parameters<ClientV8["mget"]>[0]);
|
|
167
170
|
return docsToRows(unwrap(res));
|
|
168
171
|
}
|
|
169
|
-
// _search:DSL 整体即 body
|
|
170
|
-
|
|
172
|
+
// _search:DSL 整体即 body;body 显式带数字 size 时不再注入顶层 size
|
|
173
|
+
// (尊重调用方分页与 size:0 纯聚合意图,否则顶层 size 会覆盖 body.size 使聚合退化为普通搜索)
|
|
174
|
+
const searchParams: Record<string, unknown> = { index, body };
|
|
175
|
+
if (typeof (body as { size?: unknown } | undefined)?.size !== "number") searchParams.size = opts.maxRows;
|
|
176
|
+
const res = await es.search(searchParams as Parameters<ClientV8["search"]>[0]);
|
|
171
177
|
return hitsToRows(unwrap(res));
|
|
172
178
|
}
|
|
173
179
|
throw new Error(`ES 写端点 ${kind.endpoint} 需走非只读确认流程执行,本方言 executeOn 仅执行读查询`);
|
|
@@ -233,12 +239,48 @@ class ElasticsearchDialect extends SearchDialect {
|
|
|
233
239
|
}
|
|
234
240
|
|
|
235
241
|
// ── 结果转换 ──────────────────────────────
|
|
236
|
-
|
|
237
|
-
|
|
242
|
+
interface EsHitsBody {
|
|
243
|
+
hits?: { total?: number | { value?: number }; hits?: Array<{ _id?: string; _source?: unknown }> };
|
|
244
|
+
aggregations?: Record<string, unknown>;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 搜索响应 → 行;纯聚合查询(无命中 + aggregations)展开为聚合行,命中存在时保持文档视图 */
|
|
248
|
+
export function hitsToRows(res: unknown): { columns: string[]; rows: unknown[][]; rowCount: number; truncated?: boolean } {
|
|
249
|
+
const body = res as EsHitsBody;
|
|
238
250
|
const hits = body.hits?.hits ?? [];
|
|
239
251
|
const total = typeof body.hits?.total === "number" ? body.hits.total : (body.hits?.total?.value ?? hits.length);
|
|
252
|
+
if (hits.length === 0 && body.aggregations !== undefined
|
|
253
|
+
&& body.aggregations !== null && typeof body.aggregations === "object") {
|
|
254
|
+
return aggsToRows(body.aggregations);
|
|
255
|
+
}
|
|
240
256
|
const rows = hits.map((h) => [h._id, typeof h._source === "object" ? JSON.stringify(h._source) : h._source]);
|
|
241
|
-
|
|
257
|
+
const rowCount = typeof total === "number" ? total : hits.length;
|
|
258
|
+
// total 为服务端统计(可能是 gte 下限),大于取回行数即已截断(供工具层提示与导出)
|
|
259
|
+
return { columns: ["_id", "_source"], rows, rowCount, truncated: rowCount > rows.length };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** 聚合结果 → 行:列 [aggregation, key, doc_count, value];metric 取值,桶内子聚合折叠为 value 列 JSON */
|
|
263
|
+
export function aggsToRows(aggs: Record<string, unknown>): { columns: string[]; rows: unknown[][]; rowCount: number } {
|
|
264
|
+
const rows: unknown[][] = [];
|
|
265
|
+
for (const [name, agg] of Object.entries(aggs)) {
|
|
266
|
+
if (agg === null || typeof agg !== "object") { rows.push([name, null, null, agg ?? null]); continue; }
|
|
267
|
+
const buckets = (agg as { buckets?: unknown }).buckets;
|
|
268
|
+
if (Array.isArray(buckets)) {
|
|
269
|
+
for (const b of buckets) {
|
|
270
|
+
if (b === null || typeof b !== "object") { rows.push([name, b, null, null]); continue; }
|
|
271
|
+
const bucket = b as Record<string, unknown>;
|
|
272
|
+
const key = bucket.key_as_string ?? bucket.key ?? null;
|
|
273
|
+
const docCount = typeof bucket.doc_count === "number" ? bucket.doc_count : null;
|
|
274
|
+
const extra = Object.entries(bucket).filter(([k]) => k !== "key" && k !== "key_as_string" && k !== "doc_count");
|
|
275
|
+
rows.push([name, key, docCount, extra.length > 0 ? JSON.stringify(Object.fromEntries(extra)) : null]);
|
|
276
|
+
}
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const metric = agg as { value?: unknown; value_as_string?: unknown };
|
|
280
|
+
if ("value" in metric) { rows.push([name, null, null, metric.value_as_string ?? metric.value ?? null]); continue; }
|
|
281
|
+
rows.push([name, null, null, JSON.stringify(agg)]);
|
|
282
|
+
}
|
|
283
|
+
return { columns: ["aggregation", "key", "doc_count", "value"], rows, rowCount: rows.length };
|
|
242
284
|
}
|
|
243
285
|
|
|
244
286
|
function docsToRows(res: unknown): { columns: string[]; rows: unknown[][]; rowCount: number } {
|
package/src/dialects/hive.ts
CHANGED
|
@@ -61,7 +61,9 @@ class HiveDialect extends BigDataDialect {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
protected async doConnect(config: ConnConfig, timeoutMs: number): Promise<DbConnection> {
|
|
64
|
-
|
|
64
|
+
// 上游 hive-driver 的 TCLIServiceTypes 声明不完整(缺 25 个请求类型),与其运行时实际
|
|
65
|
+
// 使用的 thrift 定义不一致;仅放宽类型断言,运行时对象未做任何改动。
|
|
66
|
+
const client = new hive.HiveClient(TCLIService, TCLIService_types as any);
|
|
65
67
|
await client.connect(
|
|
66
68
|
{ host: config.host ?? "localhost", port: config.port ?? DEFAULT_PORT },
|
|
67
69
|
new hive.connections.TcpConnection(),
|
package/src/dialects/mysql.ts
CHANGED
|
@@ -109,9 +109,11 @@ class MysqlDialect extends RelationalDialect {
|
|
|
109
109
|
try {
|
|
110
110
|
const tables = await this.withConnection(config, async (conn) => {
|
|
111
111
|
const mysqlConn = conn.client as mysql.Connection;
|
|
112
|
+
// config.database 为可选(ConnConfig),而 mysql2 的 ExecuteValues 不接受 undefined 元素;
|
|
113
|
+
// 此处仅放宽类型断言,运行时参数与既有行为完全一致。
|
|
112
114
|
const [rows] = await mysqlConn.execute(
|
|
113
115
|
"SELECT TABLE_NAME, TABLE_TYPE, TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME",
|
|
114
|
-
[config.database],
|
|
116
|
+
[config.database] as any[],
|
|
115
117
|
);
|
|
116
118
|
const all = (rows as any[]).map((r: any) => ({
|
|
117
119
|
schema: "",
|
|
@@ -149,7 +151,7 @@ class MysqlDialect extends RelationalDialect {
|
|
|
149
151
|
try {
|
|
150
152
|
const [commentRows] = await mysqlConn.execute(
|
|
151
153
|
"SELECT COLUMN_NAME, COLUMN_COMMENT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?",
|
|
152
|
-
[config.database, table],
|
|
154
|
+
[config.database, table] as any[],
|
|
153
155
|
);
|
|
154
156
|
const commentMap = new Map((commentRows as any[]).map((r: any) => [r.COLUMN_NAME, r.COLUMN_COMMENT]));
|
|
155
157
|
for (const col of cols) {
|
package/src/dialects/neo4j.ts
CHANGED
|
@@ -270,7 +270,7 @@ class Neo4jDialect extends GraphDialect {
|
|
|
270
270
|
const f = r._fields ?? [];
|
|
271
271
|
const types = f[3] as string[] | null;
|
|
272
272
|
if (!Array.isArray(types) || !types.includes(graphName)) continue;
|
|
273
|
-
indexLines.push(`${f[0]}(${f[1]}, ${f[5] ?? "online"}) ON ${isRel ? "rel" : "node"}(${types.join(":")}).(${(f[4] ?? []).join(",")})`);
|
|
273
|
+
indexLines.push(`${f[0]}(${f[1]}, ${f[5] ?? "online"}) ON ${isRel ? "rel" : "node"}(${types.join(":")}).(${((f[4] ?? []) as string[]).join(",")})`);
|
|
274
274
|
}
|
|
275
275
|
} catch { /* SHOW 失败降级 */ }
|
|
276
276
|
try {
|
|
@@ -281,7 +281,7 @@ class Neo4jDialect extends GraphDialect {
|
|
|
281
281
|
const types = f[3] as string[] | null;
|
|
282
282
|
if (!Array.isArray(types) || !types.includes(graphName)) continue;
|
|
283
283
|
for (const p of (f[4] ?? []) as string[]) if (String(f[1]).toUpperCase().includes("UNIQUE")) uniqueProps.add(p);
|
|
284
|
-
indexLines.push(`${f[0]}(${f[1]}) ON ${types.join(":")}.(${(f[4] ?? []).join(",")})`);
|
|
284
|
+
indexLines.push(`${f[0]}(${f[1]}) ON ${types.join(":")}.(${((f[4] ?? []) as string[]).join(",")})`);
|
|
285
285
|
}
|
|
286
286
|
} catch { /* 降级 */ }
|
|
287
287
|
|
package/src/dialects/oracle.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/dialects/oracle.ts
|
|
2
2
|
import oracledb from "oracledb";
|
|
3
3
|
import type { ConnConfig, DbConnection, ExecOpts, ParsedTarget,
|
|
4
|
-
ListTablesResult, DescribeTableResult, ColumnInfo, TestConnectionResult } from "../core/types.js";
|
|
4
|
+
ListTablesResult, DescribeTableResult, TableInfo, ColumnInfo, TestConnectionResult } from "../core/types.js";
|
|
5
5
|
import { RelationalDialect } from "./relational-dialect.js";
|
|
6
6
|
import { register, filterTables, type Fingerprints } from "./dialect.js";
|
|
7
7
|
|
|
@@ -107,7 +107,9 @@ class OracleDialect extends RelationalDialect {
|
|
|
107
107
|
WHERE T.OWNER NOT IN ('SYS', 'SYSTEM', 'DBSNMP', 'XDB')
|
|
108
108
|
ORDER BY T.OWNER, T.TABLE_NAME`,
|
|
109
109
|
);
|
|
110
|
-
|
|
110
|
+
// 显式标注 TableInfo[]:oracledb 无类型声明,res.rows 退化为 any,若不标注则
|
|
111
|
+
// filterTables 的泛型会回退到约束 { name; schema? },丢失 type/description 两个字段。
|
|
112
|
+
const all: TableInfo[] = (res.rows ?? []).map((r: any) => ({
|
|
111
113
|
schema: r[1],
|
|
112
114
|
name: r[0],
|
|
113
115
|
type: "TABLE",
|
package/src/dialects/redis.ts
CHANGED
|
@@ -93,9 +93,13 @@ class RedisDialect extends KvDialect {
|
|
|
93
93
|
let sampled = 0;
|
|
94
94
|
let cursor = "0";
|
|
95
95
|
// LIKE(%/_)→ SCAN 通配(*/?),全局替换(String.replace 单次替换是 bug)
|
|
96
|
-
const
|
|
96
|
+
const scanPattern = pattern ? pattern.split("").map((ch) => ch === "%" ? "*" : ch === "_" ? "?" : ch).join("") : null;
|
|
97
97
|
do {
|
|
98
|
-
|
|
98
|
+
// ioredis 的 scan 重载要求 MATCH/COUNT 固定次序,不能靠 spread 拼参;
|
|
99
|
+
// Redis 的 SCAN 命令本身不区分参数顺序,故两个分支与原来的命令语义完全一致。
|
|
100
|
+
const [next, keys] = scanPattern
|
|
101
|
+
? await redis.scan(cursor, "MATCH", scanPattern, "COUNT", 100)
|
|
102
|
+
: await redis.scan(cursor, "COUNT", 100);
|
|
99
103
|
cursor = next;
|
|
100
104
|
for (const key of keys) {
|
|
101
105
|
if (sampled >= 200) break;
|
|
@@ -166,7 +170,8 @@ class RedisDialect extends KvDialect {
|
|
|
166
170
|
} else if (type === "set") {
|
|
167
171
|
preview = JSON.stringify(await redis.smembers(key)).slice(0, 200);
|
|
168
172
|
} else if (type === "zset") {
|
|
169
|
-
|
|
173
|
+
// ioredis 6 的 zrange 重载把 stop 声明为 string|Buffer(漏了 number);索引 "9" 与数字 9 等价。
|
|
174
|
+
preview = JSON.stringify(await redis.zrange(key, 0, "9", "WITHSCORES")).slice(0, 200);
|
|
170
175
|
}
|
|
171
176
|
} catch { /* ignore */ }
|
|
172
177
|
return [
|
|
@@ -56,7 +56,10 @@ export abstract class RelationalDialect implements Dialect {
|
|
|
56
56
|
await this.withConnection(config, async (conn) => {
|
|
57
57
|
for (const s of stmts) last = await this.doExecute(conn.client, s, opts);
|
|
58
58
|
}, opts.timeoutSec * 1000);
|
|
59
|
-
|
|
59
|
+
// 结果集被 maxRows 截断时标注(各行返回 rowCount=服务端完整行数、rows 已按 maxRows 截取);
|
|
60
|
+
// 仅对结果集(有列)判定,写操作 rows 为空而 rowCount=影响行数,不得误标
|
|
61
|
+
const truncated = last.columns.length > 0 && last.rowCount > last.rows.length;
|
|
62
|
+
return { success: true, ...last, truncated, duration: `${Date.now() - start}ms` };
|
|
60
63
|
} catch (err: unknown) {
|
|
61
64
|
return { success: false, error: err instanceof Error ? err.message : String(err), duration: `${Date.now() - start}ms` };
|
|
62
65
|
}
|
package/src/dialects/spark.ts
CHANGED
|
@@ -58,7 +58,8 @@ class SparkDialect extends BigDataDialect {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
protected async doConnect(config: ConnConfig, timeoutMs: number): Promise<DbConnection> {
|
|
61
|
-
|
|
61
|
+
// 同 hive.ts:上游 hive-driver 的 TCLIServiceTypes 声明不完整,仅放宽类型断言,不动运行时。
|
|
62
|
+
const client = new hive.HiveClient(TCLIService, TCLIService_types as any);
|
|
62
63
|
await client.connect(
|
|
63
64
|
{ host: config.host ?? "localhost", port: config.port ?? DEFAULT_PORT },
|
|
64
65
|
new hive.connections.TcpConnection(),
|