@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/src/config.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// config.ts —— 连接配置与插件配置读写(含旧文件名兼容 + mtime 内存缓存)
|
|
2
2
|
// 注:本模块只做 JSON 文件 IO,不依赖任何 pi 运行时模块
|
|
3
|
-
import { existsSync, readFileSync, writeFileSync, statSync } from "node:fs";
|
|
4
|
-
import { homedir } from "node:os";
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, statSync, chmodSync } from "node:fs";
|
|
4
|
+
import { homedir, tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { registry } from "./dialects/index.js";
|
|
7
|
-
import type { ConnConfig, DbTypeId } from "./core/types.js";
|
|
7
|
+
import type { ConnConfig, DbTypeId, TestConnectionResult } from "./core/types.js";
|
|
8
8
|
|
|
9
9
|
// ── 路径 ──────────────────────────────────────────
|
|
10
10
|
|
|
@@ -23,6 +23,8 @@ export interface PluginConfig {
|
|
|
23
23
|
max_rows: number;
|
|
24
24
|
/** 单条 SQL 超时秒数 */
|
|
25
25
|
query_timeout: number;
|
|
26
|
+
/** 写操作审计日志(v1.1 UX 共识 Q4 修订:默认关闭,/db config 开启;reason 强校验不受此开关影响) */
|
|
27
|
+
audit_enabled: boolean;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export const DEFAULT_PLUGIN_CONFIG: PluginConfig = {
|
|
@@ -30,6 +32,7 @@ export const DEFAULT_PLUGIN_CONFIG: PluginConfig = {
|
|
|
30
32
|
confirm_before_exec: "write",
|
|
31
33
|
max_rows: 100,
|
|
32
34
|
query_timeout: 30,
|
|
35
|
+
audit_enabled: false,
|
|
33
36
|
};
|
|
34
37
|
|
|
35
38
|
// ── mtime 内存缓存 ────────────────────────────────
|
|
@@ -78,6 +81,8 @@ export function loadConfigs(): ConnConfig[] {
|
|
|
78
81
|
|
|
79
82
|
export function saveConfigs(configs: ConnConfig[]): void {
|
|
80
83
|
writeFileSync(CONFIG_FILE, JSON.stringify(configs, null, 2));
|
|
84
|
+
// 降险(v1.1 UX 共识 Q4):配置含明文密码,限制为仅当前用户可读写
|
|
85
|
+
try { chmodSync(CONFIG_FILE, 0o600); } catch { /* 只读文件系统等场景容错 */ }
|
|
81
86
|
invalidateConfigCache(CONFIG_FILE);
|
|
82
87
|
}
|
|
83
88
|
|
|
@@ -119,6 +124,7 @@ export function getConfigSummary(cfg: PluginConfig): string {
|
|
|
119
124
|
`执行确认: ${confirmLabel}`,
|
|
120
125
|
`最大行数: ${cfg.max_rows}`,
|
|
121
126
|
`查询超时: ${cfg.query_timeout}s`,
|
|
127
|
+
`审计日志: ${cfg.audit_enabled ? "开" : "关"}`,
|
|
122
128
|
].join("\n");
|
|
123
129
|
}
|
|
124
130
|
|
|
@@ -140,6 +146,8 @@ export interface ParsedConnectionString {
|
|
|
140
146
|
password?: string;
|
|
141
147
|
database?: string;
|
|
142
148
|
dbIndex?: number;
|
|
149
|
+
/** URI 查询参数(MongoDB: authSource/replicaSet/tls...) */
|
|
150
|
+
options?: Record<string, string>;
|
|
143
151
|
}
|
|
144
152
|
|
|
145
153
|
export function parseConnectionString(input: string): ParsedConnectionString | null {
|
|
@@ -154,11 +162,70 @@ export function parseConnectionString(input: string): ParsedConnectionString | n
|
|
|
154
162
|
if (p.password !== undefined) out.password = p.password;
|
|
155
163
|
if (p.database !== undefined) out.database = p.database;
|
|
156
164
|
if (p.dbIndex !== undefined) out.dbIndex = p.dbIndex;
|
|
165
|
+
if (p.options !== undefined) out.options = p.options;
|
|
157
166
|
return out;
|
|
158
167
|
}
|
|
159
168
|
return null;
|
|
160
169
|
}
|
|
161
170
|
|
|
171
|
+
// ── 使用/测试记录(v1.1 UX 共识 Q1:连接列表摘要的数据源)───
|
|
172
|
+
|
|
173
|
+
/** 回写最近使用时间(查询/列表/表结构成功后调用;找不到连接时静默跳过) */
|
|
174
|
+
export function recordLastUsed(configName: string): void {
|
|
175
|
+
const all = loadConfigs();
|
|
176
|
+
const c = all.find((x) => x.name === configName);
|
|
177
|
+
if (!c) return;
|
|
178
|
+
c.lastUsedAt = new Date().toISOString();
|
|
179
|
+
saveConfigs(all);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 回写最近一次测试连接结果(测试连接/编辑后自动测试/向导建连成功时调用) */
|
|
183
|
+
export function recordTestResult(configName: string, r: TestConnectionResult): void {
|
|
184
|
+
const all = loadConfigs();
|
|
185
|
+
const c = all.find((x) => x.name === configName);
|
|
186
|
+
if (!c) return;
|
|
187
|
+
c.lastTest = { ok: r.success, latency: r.latency, version: r.version, at: new Date().toISOString() };
|
|
188
|
+
saveConfigs(all);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 相对时间文案:刚刚 / N 分钟前 / N 小时前 / N 天前 / YYYY-MM-DD */
|
|
192
|
+
export function formatRelativeTime(iso: string): string {
|
|
193
|
+
const t = new Date(iso).getTime();
|
|
194
|
+
if (!Number.isFinite(t)) return "";
|
|
195
|
+
const diff = Date.now() - t;
|
|
196
|
+
const min = Math.floor(diff / 60_000);
|
|
197
|
+
if (min < 1) return "刚刚";
|
|
198
|
+
if (min < 60) return `${min} 分钟前`;
|
|
199
|
+
const hour = Math.floor(min / 60);
|
|
200
|
+
if (hour < 24) return `${hour} 小时前`;
|
|
201
|
+
const day = Math.floor(hour / 24);
|
|
202
|
+
if (day < 30) return `${day} 天前`;
|
|
203
|
+
return iso.slice(0, 10);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** 环境标签片段:[prod·强制只读] / [dev];无标签返回空串 */
|
|
207
|
+
export function envTagLabel(c: Pick<ConnConfig, "envTag" | "forceReadonly">): string {
|
|
208
|
+
if (!c.envTag) return "";
|
|
209
|
+
return `[${c.envTag}${c.forceReadonly ? "·强制只读" : ""}]`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** 连接摘要行(选择列表/查看列表共用):名称 [类型] ⭐默认 [prod·强制只读] · 上次使用 … · 测试 … */
|
|
213
|
+
export function connSummaryLine(c: ConnConfig): string {
|
|
214
|
+
const parts = [`${c.name} [${shortTypeLabel(c.type)}]`];
|
|
215
|
+
if (c.isDefault) parts.push("⭐默认");
|
|
216
|
+
const tag = envTagLabel(c);
|
|
217
|
+
if (tag) parts.push(tag);
|
|
218
|
+
if (c.lastUsedAt) {
|
|
219
|
+
const rel = formatRelativeTime(c.lastUsedAt);
|
|
220
|
+
if (rel) parts.push(`上次使用 ${rel}`);
|
|
221
|
+
}
|
|
222
|
+
if (c.lastTest) {
|
|
223
|
+
parts.push(`测试 ${c.lastTest.ok ? "✓" : "✗"}${c.lastTest.latency ?? ""}`);
|
|
224
|
+
}
|
|
225
|
+
const desc = c.description ? ` - ${c.description}` : "";
|
|
226
|
+
return parts.join(" · ") + desc;
|
|
227
|
+
}
|
|
228
|
+
|
|
162
229
|
// ── 默认连接(Spec §11.1 P0:database 参数可选,缺省走 isDefault 标记的连接)───
|
|
163
230
|
|
|
164
231
|
export function getDefaultConfig(configs: ConnConfig[]): ConnConfig | undefined {
|
|
@@ -171,9 +238,6 @@ export function setDefaultConfig(configs: ConnConfig[], id: string): ConnConfig[
|
|
|
171
238
|
|
|
172
239
|
// ── 查询结果导出(Spec §11.1 P0:长结果落盘不糊上下文)───
|
|
173
240
|
|
|
174
|
-
import { writeFileSync } from "node:fs";
|
|
175
|
-
import { join } from "node:path";
|
|
176
|
-
import { tmpdir } from "node:os";
|
|
177
241
|
import { toCsv } from "./core/export.js";
|
|
178
242
|
|
|
179
243
|
export interface QueryExport {
|
|
@@ -212,9 +276,9 @@ export function toRuntimeConfig(c: ConnConfig, defaultPort: number): ConnConfig
|
|
|
212
276
|
|
|
213
277
|
// 供 UI 层展示类型短标签(原 index.ts 内 5 处重复映射的收敛点之一)
|
|
214
278
|
export function shortTypeLabel(type: DbTypeId): string {
|
|
215
|
-
return ({ postgresql: "PG", mysql: "MySQL", oracle: "Oracle" } as Record<string, string>)[type] ?? type;
|
|
279
|
+
return ({ postgresql: "PG", mysql: "MySQL", oracle: "Oracle", mongodb: "MongoDB", neo4j: "Neo4j" } as Record<string, string>)[type] ?? type;
|
|
216
280
|
}
|
|
217
281
|
|
|
218
282
|
export function fullTypeLabel(type: DbTypeId): string {
|
|
219
|
-
return ({ postgresql: "PostgreSQL", mysql: "MySQL", oracle: "Oracle" } as Record<string, string>)[type] ?? type;
|
|
283
|
+
return ({ postgresql: "PostgreSQL", mysql: "MySQL", oracle: "Oracle", mongodb: "MongoDB", neo4j: "Neo4j" } as Record<string, string>)[type] ?? type;
|
|
220
284
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// core/audit.ts —— 写操作审计日志(v1.1 UX 共识 Q4 + 轮转共识:按本地日期一天一个 .jsonl,SQL 存完整原文不截断)
|
|
2
|
+
// 注:本模块只做文件 IO,不依赖任何 pi 运行时模块;审计失败静默降级,绝不阻断查询主流程
|
|
3
|
+
|
|
4
|
+
import { appendFileSync, chmodSync, mkdirSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import type { DbTypeId } from "./types.js";
|
|
8
|
+
|
|
9
|
+
/** 审计目录:~/.pi/agent/db-audit/,按天一个 <YYYY-MM-DD>.jsonl */
|
|
10
|
+
export const AUDIT_DIR = join(homedir(), ".pi", "agent", "db-audit");
|
|
11
|
+
|
|
12
|
+
export interface AuditEntry {
|
|
13
|
+
/** ISO 时间 */
|
|
14
|
+
time: string;
|
|
15
|
+
/** 执行时的项目路径(process.cwd()),跨项目区分用 */
|
|
16
|
+
project: string;
|
|
17
|
+
/** 连接名 */
|
|
18
|
+
connection: string;
|
|
19
|
+
/** 数据库类型 */
|
|
20
|
+
type: DbTypeId;
|
|
21
|
+
/** verdict.summary(如 "UPDATE orders(共 1 条语句)") */
|
|
22
|
+
summary: string;
|
|
23
|
+
/** AI 提供的执行理由(全文) */
|
|
24
|
+
reason: string;
|
|
25
|
+
/** 完整 SQL(共识:不截断) */
|
|
26
|
+
sql: string;
|
|
27
|
+
/** 执行时的生效只读标记(写成功恒为 false,保留字段以备语义扩展) */
|
|
28
|
+
readonly: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 本地日期 → YYYY-MM-DD(审计文件按本地天轮转) */
|
|
32
|
+
export function localDateStr(d: Date): string {
|
|
33
|
+
const y = d.getFullYear();
|
|
34
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
35
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
36
|
+
return `${y}-${m}-${day}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 审计文件路径:<dir>/<YYYY-MM-DD>.jsonl(按 time 的本地日期取天) */
|
|
40
|
+
export function auditFilePath(time: Date, dir: string = AUDIT_DIR): string {
|
|
41
|
+
return join(dir, `${localDateStr(time)}.jsonl`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 目录 mkdir / 文件 chmod 仅进程生命周期内首次执行(后续每次追加只剩 1 次 appendFileSync syscall)
|
|
45
|
+
const ensuredDirs = new Set<string>();
|
|
46
|
+
const chmoddedFiles = new Set<string>();
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 追加一条审计记录到按天轮转的 JSONL 文件(一行一条)。
|
|
50
|
+
* 同步写入保证返回结果前已落盘(单次开销亚毫秒级);失败静默降级不阻断主流程。
|
|
51
|
+
* @param entry 审计条目
|
|
52
|
+
* @param dir 审计目录,默认 AUDIT_DIR(测试可注入临时目录)
|
|
53
|
+
*/
|
|
54
|
+
export function appendAuditLog(entry: AuditEntry, dir: string = AUDIT_DIR): void {
|
|
55
|
+
try {
|
|
56
|
+
if (!ensuredDirs.has(dir)) {
|
|
57
|
+
mkdirSync(dir, { recursive: true });
|
|
58
|
+
ensuredDirs.add(dir);
|
|
59
|
+
}
|
|
60
|
+
const file = auditFilePath(new Date(entry.time), dir);
|
|
61
|
+
appendFileSync(file, JSON.stringify(entry) + "\n", "utf-8");
|
|
62
|
+
if (!chmoddedFiles.has(file)) {
|
|
63
|
+
try { chmodSync(file, 0o600); } catch { /* 平台不支持等场景容错 */ }
|
|
64
|
+
chmoddedFiles.add(file);
|
|
65
|
+
}
|
|
66
|
+
} catch { /* 审计失败不影响主流程 */ }
|
|
67
|
+
}
|
package/src/core/policy.ts
CHANGED
|
@@ -9,3 +9,16 @@ export function decide(verdict: Verdict, _readonly: boolean, confirm: ConfirmMod
|
|
|
9
9
|
if (confirm === "write" && verdict.isWrite) return "confirm";
|
|
10
10
|
return "run";
|
|
11
11
|
}
|
|
12
|
+
|
|
13
|
+
/** 生效只读 = 全局只读 ∨ 连接级强制只读(v1.1 UX 共识 Q3;isAllowed/ExecOpts 均用此值) */
|
|
14
|
+
export function effectiveReadonly(aiReadonly: boolean, config: { forceReadonly?: boolean }): boolean {
|
|
15
|
+
return aiReadonly || config.forceReadonly === true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 写操作是否缺失执行理由(v1.1 UX 共识 Q1/Q3:与确认策略解耦,isWrite 即要求)。
|
|
20
|
+
* 命中时工具层应拒绝执行并引导 AI 补充 reason(动机+影响范围)。
|
|
21
|
+
*/
|
|
22
|
+
export function writeRequiresReason(verdict: Verdict, reason?: string): boolean {
|
|
23
|
+
return verdict.ok === true && verdict.isWrite === true && !(typeof reason === "string" && reason.trim() !== "");
|
|
24
|
+
}
|
|
@@ -20,6 +20,7 @@ interface FieldBag {
|
|
|
20
20
|
username?: string; password?: string;
|
|
21
21
|
database?: string; dbIndex?: number;
|
|
22
22
|
ssl?: boolean;
|
|
23
|
+
options?: Record<string, string>;
|
|
23
24
|
url?: string;
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -41,6 +42,8 @@ const REQUIRED: Record<DbTypeId, string[]> = {
|
|
|
41
42
|
spark: ["host", "port", "database", "username"],
|
|
42
43
|
redis: ["host", "port", "password"],
|
|
43
44
|
elasticsearch: ["host", "port"],
|
|
45
|
+
neo4j: ["host", "port", "username", "password"], // 工作库可选(缺省 neo4j;社区版默认开认证)
|
|
46
|
+
mongodb: ["host", "port"], // 账号/工作库可选(本地无认证常见)
|
|
44
47
|
};
|
|
45
48
|
|
|
46
49
|
// ── 工具函数 ──────────────────────────────────────
|
|
@@ -78,7 +81,9 @@ function dialectFromImage(image: string): DbTypeId | null {
|
|
|
78
81
|
const i = image.toLowerCase();
|
|
79
82
|
if (/postgres/.test(i)) return "postgresql";
|
|
80
83
|
if (/(^|\/)(mysql|mariadb)/.test(i)) return "mysql";
|
|
84
|
+
if (/(^|\/)mongo/.test(i)) return "mongodb"; // mongo / mongodb 镜像(mongo-express 误报可忍变)
|
|
81
85
|
if (/redis/.test(i)) return "redis";
|
|
86
|
+
if (/neo4j/.test(i)) return "neo4j";
|
|
82
87
|
if (/elasticsearch/.test(i)) return "elasticsearch";
|
|
83
88
|
if (/dm8|dameng/.test(i)) return "dm";
|
|
84
89
|
if (/hive/.test(i)) return "hive";
|
|
@@ -109,7 +114,9 @@ export async function scanProject(
|
|
|
109
114
|
const dirEnvCache = new Map<string, Record<string, string> | undefined>();
|
|
110
115
|
|
|
111
116
|
const pushUrl = (url: string, file: string, profile: string, confidence: number, keyFields?: Partial<RawDbConfig>): void => {
|
|
112
|
-
|
|
117
|
+
// 先用完整 URL 路由(MongoDB 的 authSource/replicaSet 等在 query 里,不能剥);
|
|
118
|
+
// 失败再回退剥离 query 的旧路径(兼容 JDBC 带查询参数时各 parseUrl 的锚点匹配)
|
|
119
|
+
const parsed = parseUrlViaRegistry(url) ?? parseUrlViaRegistry(stripQuery(url));
|
|
113
120
|
if (!parsed) return;
|
|
114
121
|
raws.push({
|
|
115
122
|
dialectId: parsed.dialectId,
|
|
@@ -123,6 +130,7 @@ export async function scanProject(
|
|
|
123
130
|
database: parsed.database,
|
|
124
131
|
dbIndex: parsed.dbIndex,
|
|
125
132
|
ssl: parsed.ssl,
|
|
133
|
+
options: parsed.options,
|
|
126
134
|
},
|
|
127
135
|
file, profile, confidence,
|
|
128
136
|
});
|
|
@@ -160,7 +168,7 @@ export async function scanProject(
|
|
|
160
168
|
if (base === ".env" || base.startsWith(".env.")) {
|
|
161
169
|
const env = parseEnv(text);
|
|
162
170
|
for (const [k, v] of Object.entries(env)) {
|
|
163
|
-
if (/(^|_)(DATABASE_URL|DATASOURCE_URL|REDIS_URL|ELASTICSEARCH_URL|DB_URL|JDBC_URL)$|_URL$/i.test(k)) {
|
|
171
|
+
if (/(^|_)(DATABASE_URL|DATASOURCE_URL|REDIS_URL|ELASTICSEARCH_URL|MONGODB_URI|MONGO_URL|NEO4J_URI|NEO4J_URL|BOLT_URL|DB_URL|JDBC_URL)$|_URL$/i.test(k)) {
|
|
164
172
|
pushUrl(v, file, profile, weight);
|
|
165
173
|
}
|
|
166
174
|
}
|
|
@@ -175,9 +183,9 @@ export async function scanProject(
|
|
|
175
183
|
const bag: FieldBag = {
|
|
176
184
|
host: svc.name, // compose 网络内服务名即主机名
|
|
177
185
|
port: hostPortOf(svc.ports),
|
|
178
|
-
username: svc.env["POSTGRES_USER"] ?? svc.env["MYSQL_USER"] ?? svc.env["ES_USERNAME"],
|
|
179
|
-
password: svc.env["POSTGRES_PASSWORD"] ?? svc.env["MYSQL_ROOT_PASSWORD"] ?? svc.env["MYSQL_PASSWORD"] ?? svc.env["REDIS_PASSWORD"] ?? svc.env["ELASTIC_PASSWORD"],
|
|
180
|
-
database: svc.env["POSTGRES_DB"] ?? svc.env["MYSQL_DATABASE"],
|
|
186
|
+
username: svc.env["POSTGRES_USER"] ?? svc.env["MYSQL_USER"] ?? svc.env["ES_USERNAME"] ?? svc.env["MONGO_INITDB_ROOT_USERNAME"] ?? svc.env["NEO4J_AUTH"]?.split("/")[0],
|
|
187
|
+
password: svc.env["POSTGRES_PASSWORD"] ?? svc.env["MYSQL_ROOT_PASSWORD"] ?? svc.env["MYSQL_PASSWORD"] ?? svc.env["REDIS_PASSWORD"] ?? svc.env["ELASTIC_PASSWORD"] ?? svc.env["MONGO_INITDB_ROOT_PASSWORD"] ?? svc.env["NEO4J_PASSWORD"],
|
|
188
|
+
database: svc.env["POSTGRES_DB"] ?? svc.env["MYSQL_DATABASE"] ?? svc.env["MONGO_INITDB_DATABASE"],
|
|
181
189
|
};
|
|
182
190
|
raws.push({ dialectId, bag, file, profile, confidence: weight });
|
|
183
191
|
// compose 里也可能带完整 Spring URL
|
package/src/core/scan/parsers.ts
CHANGED
|
@@ -138,7 +138,7 @@ export function parseCompose(text: string): ComposeService[] {
|
|
|
138
138
|
|
|
139
139
|
// ── 通用 URL 正则(全文件扫描兜底)──────────────────
|
|
140
140
|
|
|
141
|
-
const URL_RE = /(?:jdbc:(?:postgresql|mysql|oracle|dm|hive2)|rediss?|postgresql|mysql):\/\/[^\s"'<>`]+|https?:\/\/[^\s"'<>`]*:9200[^\s"'<>`]*/g;
|
|
141
|
+
const URL_RE = /(?:jdbc:(?:postgresql|mysql|oracle|dm|hive2)|rediss?|mongodb\+srv|mongodb|neo4j\+s(sc)?|neo4j|bolt\+s(sc)?|bolt|postgresql|mysql):\/\/[^\s"'<>`]+|https?:\/\/[^\s"'<>`]*:9200[^\s"'<>`]*/g;
|
|
142
142
|
|
|
143
143
|
export function extractUrls(text: string): string[] {
|
|
144
144
|
const out = new Set<string>();
|
package/src/core/scan/spring.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// scan/spring.ts —— Spring 专属:datasource / data.redis / elasticsearch 键映射 + profile 分组
|
|
1
|
+
// scan/spring.ts —— Spring 专属:datasource / data.redis / elasticsearch / data.mongodb 键映射 + profile 分组
|
|
2
2
|
import type { DbTypeId } from "../types.js";
|
|
3
3
|
|
|
4
|
-
export type SpringGroup = "datasource" | "redis" | "es";
|
|
4
|
+
export type SpringGroup = "datasource" | "redis" | "es" | "mongo" | "neo4j";
|
|
5
5
|
|
|
6
6
|
export interface RawDbConfig {
|
|
7
7
|
group: SpringGroup;
|
|
@@ -45,11 +45,21 @@ const SPRING_KEYS: Array<[string, Field]> = [
|
|
|
45
45
|
["spring.data.elasticsearch.uris", "url"],
|
|
46
46
|
["spring.data.elasticsearch.username", "username"],
|
|
47
47
|
["spring.data.elasticsearch.password", "password"],
|
|
48
|
+
["spring.data.mongodb.uri", "url"], // Spring Boot 2.x+(含 3.x)
|
|
49
|
+
["spring.mongodb.uri", "url"], // Spring Boot 1.x 旧前缀
|
|
50
|
+
["spring.neo4j.uri", "url"], // Spring Boot 3.x
|
|
51
|
+
["spring.data.neo4j.uri", "url"], // Spring Boot 2.x 旧前缀
|
|
52
|
+
["spring.neo4j.authentication.username", "username"],
|
|
53
|
+
["spring.neo4j.authentication.password", "password"],
|
|
54
|
+
["spring.data.neo4j.username", "username"],
|
|
55
|
+
["spring.data.neo4j.password", "password"],
|
|
48
56
|
];
|
|
49
57
|
|
|
50
58
|
function groupOf(key: string): SpringGroup {
|
|
51
59
|
if (key.includes("redis")) return "redis";
|
|
52
60
|
if (key.includes("elasticsearch")) return "es";
|
|
61
|
+
if (key.includes("mongodb")) return "mongo"; // 独立分组:避免与 datasource 的 url 字段互相覆盖
|
|
62
|
+
if (key.includes("neo4j")) return "neo4j";
|
|
53
63
|
return "datasource";
|
|
54
64
|
}
|
|
55
65
|
|
|
@@ -64,7 +74,23 @@ export function springKeysToRaw(dotted: Record<string, string>, file: string, we
|
|
|
64
74
|
(raw as Record<string, unknown>)[field] = v;
|
|
65
75
|
groups.set(g, raw);
|
|
66
76
|
}
|
|
67
|
-
|
|
77
|
+
// baomidou dynamic-datasource(多数据源):spring.datasource.dynamic.datasource.<name>.<field>
|
|
78
|
+
// 每个 <name> 独立成候选(与 Spec 单组 datasource 键互不覆盖)
|
|
79
|
+
const dynamic = new Map<string, RawDbConfig>();
|
|
80
|
+
const DYNAMIC_PREFIX = "spring.datasource.dynamic.datasource.";
|
|
81
|
+
for (const [key, value] of Object.entries(dotted)) {
|
|
82
|
+
if (!key.startsWith(DYNAMIC_PREFIX) || value === "") continue;
|
|
83
|
+
const rest = key.slice(DYNAMIC_PREFIX.length); // "<name>.<field>"
|
|
84
|
+
const dot = rest.indexOf(".");
|
|
85
|
+
if (dot <= 0) continue;
|
|
86
|
+
const name = rest.slice(0, dot);
|
|
87
|
+
const field = rest.slice(dot + 1);
|
|
88
|
+
if (field !== "url" && field !== "jdbc-url" && field !== "username" && field !== "password") continue;
|
|
89
|
+
const raw = dynamic.get(name) ?? { group: "datasource" as const, profile: profileOf(basename(file)), file, weight };
|
|
90
|
+
(raw as Record<string, unknown>)[field === "jdbc-url" ? "url" : field] = value;
|
|
91
|
+
dynamic.set(name, raw);
|
|
92
|
+
}
|
|
93
|
+
return [...groups.values(), ...dynamic.values()];
|
|
68
94
|
}
|
|
69
95
|
|
|
70
96
|
function basename(p: string): string {
|
package/src/core/scan/walker.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// scan/walker.ts —— 目录漫步:默认忽略常见无关目录;resolveRoot 越界拒绝(Spec §8.5 红线)
|
|
2
2
|
import { readdirSync, statSync, type Dirent } from "node:fs";
|
|
3
|
-
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
4
|
|
|
5
5
|
const IGNORED_DIRS = new Set([
|
|
6
6
|
"node_modules", ".git", "target", "dist", "venv", "logs", "docs",
|
|
@@ -10,6 +10,13 @@ const IGNORED_DIRS = new Set([
|
|
|
10
10
|
const MAX_FILES = 2000;
|
|
11
11
|
const MAX_DEPTH = 8;
|
|
12
12
|
|
|
13
|
+
/** 扫描目标配置文件:不受普通文件配额挤占(大型 Java 工程源码文件可轻易冲爆 MAX_FILES) */
|
|
14
|
+
const CONFIG_EXTS = new Set([".yml", ".yaml", ".properties"]);
|
|
15
|
+
function isConfigFile(name: string): boolean {
|
|
16
|
+
const base = name.toLowerCase();
|
|
17
|
+
return base === ".env" || base.startsWith(".env.") || CONFIG_EXTS.has(extname(base));
|
|
18
|
+
}
|
|
19
|
+
|
|
13
20
|
/**
|
|
14
21
|
* 把输入路径解析为绝对路径;越界(cwd 子树之外、`..` 上跳、cwd 本身除外)
|
|
15
22
|
* 直接抛 "scan path out of scope"——与「密码不进上下文」同级别的红线。
|
|
@@ -24,11 +31,13 @@ export function resolveRoot(input: string): string {
|
|
|
24
31
|
return abs;
|
|
25
32
|
}
|
|
26
33
|
|
|
27
|
-
/** 递归收集文本候选文件(忽略无关目录与隐藏目录,上限 MAX_FILES
|
|
34
|
+
/** 递归收集文本候选文件(忽略无关目录与隐藏目录,上限 MAX_FILES);配置类文件单独收集不被挤占 */
|
|
28
35
|
export function walk(root: string): string[] {
|
|
29
36
|
const out: string[] = [];
|
|
37
|
+
const configs: string[] = [];
|
|
30
38
|
const visit = (dir: string, depth: number): void => {
|
|
31
|
-
|
|
39
|
+
// 不因 out 满而提前返回:必须走完整棵树,否则后遍历到的目录里的配置文件永远收不到
|
|
40
|
+
if (depth > MAX_DEPTH) return;
|
|
32
41
|
let entries: Dirent[];
|
|
33
42
|
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
34
43
|
for (const e of entries) {
|
|
@@ -37,11 +46,18 @@ export function walk(root: string): string[] {
|
|
|
37
46
|
const p = join(dir, e.name);
|
|
38
47
|
if (e.isDirectory()) visit(p, depth + 1);
|
|
39
48
|
else if (e.isFile()) {
|
|
40
|
-
try {
|
|
41
|
-
|
|
49
|
+
try {
|
|
50
|
+
if (statSync(p).isFile()) {
|
|
51
|
+
if (isConfigFile(e.name)) {
|
|
52
|
+
if (configs.length < MAX_FILES) configs.push(p);
|
|
53
|
+
} else if (out.length < MAX_FILES) {
|
|
54
|
+
out.push(p);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} catch { /* ignore */ }
|
|
42
58
|
}
|
|
43
59
|
}
|
|
44
60
|
};
|
|
45
61
|
visit(root, 0);
|
|
46
|
-
return out;
|
|
62
|
+
return [...configs, ...out];
|
|
47
63
|
}
|
package/src/core/types.ts
CHANGED
|
@@ -1,19 +1,28 @@
|
|
|
1
1
|
// core/types.ts
|
|
2
2
|
export type DbTypeId = "postgresql" | "mysql" | "oracle" | "dm"
|
|
3
|
-
| "redis" | "elasticsearch" | "hive" | "spark";
|
|
4
|
-
export type DbFamily = "relational" | "kv" | "search" | "bigdata";
|
|
3
|
+
| "redis" | "elasticsearch" | "mongodb" | "hive" | "spark" | "neo4j";
|
|
4
|
+
export type DbFamily = "relational" | "kv" | "search" | "document" | "bigdata" | "graph";
|
|
5
5
|
|
|
6
6
|
export interface ConnConfig {
|
|
7
7
|
id: string; name: string; type: DbTypeId;
|
|
8
8
|
description?: string; host?: string; port?: number;
|
|
9
9
|
username?: string; password?: string;
|
|
10
10
|
/** 家族语义:关系型=库名 / DM=schema / Redis=不用(用 dbIndex)/
|
|
11
|
-
ES=默认 index / Hive-Spark=database 名 */
|
|
11
|
+
ES=默认 index / Hive-Spark=database 名 / Neo4j=图数据库名(缺省 neo4j) */
|
|
12
12
|
database?: string;
|
|
13
13
|
dbIndex?: number; // Redis 库号
|
|
14
14
|
apiKey?: string; // ES 预留(二期)
|
|
15
15
|
options?: Record<string, string>; // Hive/Spark 会话变量等(旧文件 extraParams 读取时并入此字段)
|
|
16
|
+
// MongoDB: authSource/replicaSet/tls/authMechanism 等(options.srv=true 标记 SRV 连接)
|
|
16
17
|
isDefault?: boolean; // 默认连接标记(§11.1,G 阶段接线)
|
|
18
|
+
/** 环境标签(v1.1 UX 共识 Q3):dev/test/prod;prod 建议配 forceReadonly */
|
|
19
|
+
envTag?: "dev" | "test" | "prod";
|
|
20
|
+
/** 连接级强制只读:无视全局 ai_readonly,该连接永远只接受查询 */
|
|
21
|
+
forceReadonly?: boolean;
|
|
22
|
+
/** 最近一次成功使用时间(ISO);工具/菜单查询成功后回写 */
|
|
23
|
+
lastUsedAt?: string;
|
|
24
|
+
/** 最近一次测试连接结果(菜单/向导/编辑后自动测试时回写) */
|
|
25
|
+
lastTest?: { ok: boolean; latency?: string; version?: string; at: string };
|
|
17
26
|
createdAt: string;
|
|
18
27
|
}
|
|
19
28
|
|
|
@@ -21,6 +30,8 @@ export interface ParsedTarget {
|
|
|
21
30
|
host: string; port: number;
|
|
22
31
|
username?: string; password?: string;
|
|
23
32
|
database?: string; dbIndex?: number; ssl?: boolean;
|
|
33
|
+
/** URI 查询参数(MongoDB: authSource/replicaSet/tls...),由 parseUrl 解出、建连时回填 */
|
|
34
|
+
options?: Record<string, string>;
|
|
24
35
|
}
|
|
25
36
|
|
|
26
37
|
export interface DbConnection { type: DbTypeId; client: unknown; close(): Promise<void>; }
|
package/src/dialects/dm.ts
CHANGED
|
@@ -8,18 +8,24 @@ import { register, filterTables, type Fingerprints } from "./dialect.js";
|
|
|
8
8
|
// ── URL 解析(JDBC 主形态 + 原生 URI 双形态,Spec §7)───
|
|
9
9
|
|
|
10
10
|
const DM_DEFAULT_PORT = 5236;
|
|
11
|
-
const JDBC_RE = /^jdbc:dm:\/\/([^:/?#]+)(?::(\d+))
|
|
12
|
-
const NATIVE_RE = /^dm:\/\/([^:/?#@]+)(?::(\d+))
|
|
11
|
+
const JDBC_RE = /^jdbc:dm:\/\/([^:/?#]+)(?::(\d+))?(?:\/([^?#]*))?$/;
|
|
12
|
+
const NATIVE_RE = /^dm:\/\/([^:/?#@]+)(?::(\d+))?(?:\/([^?#]*))?$/;
|
|
13
13
|
|
|
14
14
|
function parseDmUrl(url: string): ParsedTarget | null {
|
|
15
|
-
const
|
|
15
|
+
const qIdx = url.indexOf("?");
|
|
16
|
+
const clean = qIdx >= 0 ? url.slice(0, qIdx) : url;
|
|
16
17
|
const m = clean.match(JDBC_RE) ?? clean.match(NATIVE_RE);
|
|
17
18
|
if (!m) return null;
|
|
18
19
|
const host = m[1];
|
|
19
20
|
const port = m[2] ? parseInt(m[2], 10) : DM_DEFAULT_PORT;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
// 库名 = URL 路径段;缺失时回退 query 的 schema= 参数(jdbc:dm://host:port?schema=x 常见形态)
|
|
22
|
+
let database = m[3] || undefined;
|
|
23
|
+
if (!database && qIdx >= 0) {
|
|
24
|
+
const schema = new URLSearchParams(url.slice(qIdx + 1)).get("schema");
|
|
25
|
+
if (schema) database = schema;
|
|
26
|
+
}
|
|
27
|
+
if (!host) return null;
|
|
28
|
+
return database !== undefined ? { host, port, database } : { host, port };
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
// ── DM(达梦)方言 ─────────────────────────────────
|
|
@@ -41,16 +47,29 @@ class DmDialect extends RelationalDialect {
|
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
displayUrl(config: ConnConfig): string {
|
|
44
|
-
return `jdbc:dm://${config.host}:${config.port}
|
|
50
|
+
return `jdbc:dm://${config.host}:${config.port}${config.database ? "/" + config.database : ""}`;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
protected async doConnect(config: ConnConfig, _timeoutMs: number): Promise<DbConnection> {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
let conn: dmdb.Connection;
|
|
55
|
+
try {
|
|
56
|
+
// loginEncrypt=false:跳过握手消息加密。dmdb 默认走 MD5/RSA 遗留算法,
|
|
57
|
+
// Node≥17(OpenSSL 3)报 digital envelope routines::unsupported;
|
|
58
|
+
// 若服务端强制加密,请以 NODE_OPTIONS=--openssl-legacy-provider 启动宿主
|
|
59
|
+
conn = await dmdb.getConnection({
|
|
60
|
+
user: config.username,
|
|
61
|
+
password: config.password,
|
|
62
|
+
connectString: `${config.host}:${config.port}`,
|
|
63
|
+
schema: config.database,
|
|
64
|
+
loginEncrypt: false,
|
|
65
|
+
});
|
|
66
|
+
} catch (err) {
|
|
67
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
68
|
+
if (/digital envelope routines|ERR_OSSL/i.test(msg)) {
|
|
69
|
+
throw new Error(`${msg}(DM 登录加密与 Node≥17 OpenSSL 3 不兼容;请以 NODE_OPTIONS=--openssl-legacy-provider 启动 pi)`);
|
|
70
|
+
}
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
54
73
|
return {
|
|
55
74
|
type: "dm",
|
|
56
75
|
client: conn,
|
|
@@ -84,7 +103,8 @@ class DmDialect extends RelationalDialect {
|
|
|
84
103
|
const tables = await this.withConnection(config, async (conn) => {
|
|
85
104
|
const dmConn = conn.client as dmdb.Connection;
|
|
86
105
|
const res = await dmConn.execute(
|
|
87
|
-
|
|
106
|
+
// 列名全部加表前缀:DM 对 JOIN 中的裸列名报 -2112 有歧义
|
|
107
|
+
`SELECT T.TABLE_NAME, T.OWNER, C.COMMENTS FROM ALL_TABLES T
|
|
88
108
|
LEFT JOIN ALL_TAB_COMMENTS C ON T.TABLE_NAME = C.TABLE_NAME AND T.OWNER = C.OWNER
|
|
89
109
|
WHERE T.OWNER NOT IN ('SYS', 'SYSDBA', 'SYSSSO', 'CTISYS')
|
|
90
110
|
ORDER BY T.OWNER, T.TABLE_NAME`,
|
|
@@ -110,18 +130,25 @@ class DmDialect extends RelationalDialect {
|
|
|
110
130
|
try {
|
|
111
131
|
const columns: ColumnInfo[] = await this.withConnection(config, async (conn) => {
|
|
112
132
|
const dmConn = conn.client as dmdb.Connection;
|
|
133
|
+
// schema 限定:"schema.table" 显式指定,否则用连接 schema(config.database,大小写保持原样,
|
|
134
|
+
// DM 建库时可能带引号存为小写);都无时不限定,保持旧兼容。避免同名表跨 schema 列重复
|
|
135
|
+
const dotIdx = table.indexOf(".");
|
|
136
|
+
const owner = (dotIdx > 0 ? table.slice(0, dotIdx) : config.database) || undefined;
|
|
137
|
+
const tableName = (dotIdx > 0 ? table.slice(dotIdx + 1) : table).toUpperCase();
|
|
138
|
+
const ownerFilter = owner ? " AND C.OWNER = :2" : "";
|
|
139
|
+
const binds = owner ? [tableName, owner] : [tableName];
|
|
113
140
|
const res = await dmConn.execute(
|
|
114
141
|
`SELECT
|
|
115
|
-
COLUMN_NAME,
|
|
116
|
-
DATA_TYPE || CASE WHEN DATA_PRECISION IS NOT NULL THEN '(' || DATA_PRECISION || ',' || DATA_SCALE || ')' WHEN DATA_LENGTH IS NOT NULL AND DATA_TYPE LIKE '%CHAR%' THEN '(' || DATA_LENGTH || ')' ELSE '' END,
|
|
117
|
-
NULLABLE,
|
|
118
|
-
DATA_DEFAULT,
|
|
119
|
-
COMMENTS
|
|
142
|
+
C.COLUMN_NAME,
|
|
143
|
+
C.DATA_TYPE || CASE WHEN C.DATA_PRECISION IS NOT NULL THEN '(' || C.DATA_PRECISION || ',' || C.DATA_SCALE || ')' WHEN C.DATA_LENGTH IS NOT NULL AND C.DATA_TYPE LIKE '%CHAR%' THEN '(' || C.DATA_LENGTH || ')' ELSE '' END,
|
|
144
|
+
C.NULLABLE,
|
|
145
|
+
C.DATA_DEFAULT,
|
|
146
|
+
COM.COMMENTS
|
|
120
147
|
FROM ALL_TAB_COLUMNS C
|
|
121
148
|
LEFT JOIN ALL_COL_COMMENTS COM ON C.TABLE_NAME = COM.TABLE_NAME AND C.COLUMN_NAME = COM.COLUMN_NAME AND C.OWNER = COM.OWNER
|
|
122
|
-
WHERE C.TABLE_NAME = :1 AND C.OWNER NOT IN ('SYS', 'SYSDBA', 'SYSSSO', 'CTISYS')
|
|
149
|
+
WHERE C.TABLE_NAME = :1${ownerFilter} AND C.OWNER NOT IN ('SYS', 'SYSDBA', 'SYSSSO', 'CTISYS')
|
|
123
150
|
ORDER BY C.COLUMN_ID`,
|
|
124
|
-
|
|
151
|
+
binds,
|
|
125
152
|
);
|
|
126
153
|
const cols: ColumnInfo[] = (res.rows ?? []).map((r: any) => ({
|
|
127
154
|
name: r[0],
|
|
@@ -132,14 +159,16 @@ class DmDialect extends RelationalDialect {
|
|
|
132
159
|
comment: r[4] || "",
|
|
133
160
|
}));
|
|
134
161
|
|
|
135
|
-
//
|
|
162
|
+
// 查主键(与列查询同 schema 限定)
|
|
136
163
|
try {
|
|
164
|
+
const ownerFilterPk = owner ? " AND c.OWNER = :2" : "";
|
|
165
|
+
const bindsPk = owner ? [tableName, owner] : [tableName];
|
|
137
166
|
const pkRes = await dmConn.execute(
|
|
138
167
|
`SELECT cc.COLUMN_NAME
|
|
139
168
|
FROM ALL_CONS_COLUMNS cc
|
|
140
169
|
JOIN ALL_CONSTRAINTS c ON cc.CONSTRAINT_NAME = c.CONSTRAINT_NAME AND cc.OWNER = c.OWNER
|
|
141
|
-
WHERE c.CONSTRAINT_TYPE = 'P' AND c.TABLE_NAME = :1`,
|
|
142
|
-
|
|
170
|
+
WHERE c.CONSTRAINT_TYPE = 'P' AND c.TABLE_NAME = :1${ownerFilterPk}`,
|
|
171
|
+
bindsPk,
|
|
143
172
|
);
|
|
144
173
|
const pkSet = new Set((pkRes.rows ?? []).map((r: any) => r[0]));
|
|
145
174
|
for (const col of cols) {
|