@nsyan/db 1.0.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/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@nsyan/db",
3
+ "version": "1.0.0",
4
+ "description": "AI 接入数据库扩展 —— 四大家族方言架构,支持 PostgreSQL/MySQL/Oracle/达梦/Redis/Elasticsearch/Hive/Spark,提供查询/表结构/扫描建连工具给 LLM",
5
+ "keywords": [
6
+ "pi-extension",
7
+ "pi-package",
8
+ "database",
9
+ "postgresql",
10
+ "mysql",
11
+ "oracle",
12
+ "dameng",
13
+ "dm",
14
+ "redis",
15
+ "elasticsearch",
16
+ "hive",
17
+ "spark"
18
+ ],
19
+ "license": "MIT",
20
+ "main": "index.ts",
21
+ "pi": {
22
+ "extensions": [
23
+ "./index.ts"
24
+ ]
25
+ },
26
+ "peerDependencies": {
27
+ "@earendil-works/pi-coding-agent": "*",
28
+ "typebox": "*"
29
+ },
30
+ "files": [
31
+ "index.ts",
32
+ "src/",
33
+ "README.md"
34
+ ],
35
+ "exports": {
36
+ ".": "./index.ts",
37
+ "./core": "./src/core/index.ts",
38
+ "./dialects": "./src/dialects/index.ts"
39
+ },
40
+ "dependencies": {
41
+ "@elastic/elasticsearch": "^9.5.1",
42
+ "dmdb": "^1.0.52452",
43
+ "es7": "npm:@elastic/elasticsearch@7",
44
+ "hive-driver": "^1.0.1",
45
+ "ioredis": "^6.0.0",
46
+ "mysql2": "^3.23.1",
47
+ "oracledb": "^7.0.1",
48
+ "pg": "^8.22.0"
49
+ },
50
+ "devDependencies": {
51
+ "@types/pg": "^8.20.0",
52
+ "tsx": "^4.23.13"
53
+ },
54
+ "scripts": {
55
+ "test": "tsx --test \"test/**/*.test.ts\""
56
+ }
57
+ }
package/src/config.ts ADDED
@@ -0,0 +1,220 @@
1
+ // config.ts —— 连接配置与插件配置读写(含旧文件名兼容 + mtime 内存缓存)
2
+ // 注:本模块只做 JSON 文件 IO,不依赖任何 pi 运行时模块
3
+ import { existsSync, readFileSync, writeFileSync, statSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { registry } from "./dialects/index.js";
7
+ import type { ConnConfig, DbTypeId } from "./core/types.js";
8
+
9
+ // ── 路径 ──────────────────────────────────────────
10
+
11
+ export const CONFIG_FILE = join(homedir(), ".pi", "agent", "db-configs.json");
12
+ export const PLUGIN_CONFIG_FILE = join(homedir(), ".pi", "agent", "db-config.json");
13
+ const LEGACY_PLUGIN_CONFIG_FILE = join(homedir(), ".pi", "agent", "db-plugin-config.json");
14
+
15
+ // ── 插件全局配置 ──────────────────────────────────
16
+
17
+ export interface PluginConfig {
18
+ /** AI 是否只能执行 SELECT(禁止写入) */
19
+ ai_readonly: boolean;
20
+ /** SQL 执行前确认策略: never=不确认 / write=写操作前确认 / always=每次都确认 */
21
+ confirm_before_exec: "never" | "write" | "always";
22
+ /** 查询返回的最大行数 */
23
+ max_rows: number;
24
+ /** 单条 SQL 超时秒数 */
25
+ query_timeout: number;
26
+ }
27
+
28
+ export const DEFAULT_PLUGIN_CONFIG: PluginConfig = {
29
+ ai_readonly: true,
30
+ confirm_before_exec: "write",
31
+ max_rows: 100,
32
+ query_timeout: 30,
33
+ };
34
+
35
+ // ── mtime 内存缓存 ────────────────────────────────
36
+
37
+ // 注:必须用 Map 按文件名 key——单槽缓存在 db-configs.json 与 db-config.json 交替读时永不命中
38
+ const caches = new Map<string, { mtime: number; data: unknown }>();
39
+
40
+ function readJsonCached<T>(file: string, fallback: T): T {
41
+ if (!existsSync(file)) return fallback;
42
+ const mtime = statSync(file).mtimeMs;
43
+ const hit = caches.get(file);
44
+ if (hit && hit.mtime === mtime) return hit.data as T;
45
+ try {
46
+ const data = JSON.parse(readFileSync(file, "utf-8")) as T;
47
+ caches.set(file, { mtime, data });
48
+ return data;
49
+ } catch { return fallback; }
50
+ }
51
+
52
+ export function invalidateConfigCache(file?: string): void {
53
+ if (file) caches.delete(file); else caches.clear();
54
+ }
55
+
56
+ // ── 连接配置读写(存储态即 ConnConfig[],Spec §2.0 配置类型裁决)───
57
+
58
+ interface LegacyStoredConfig extends Record<string, unknown> {
59
+ extraParams?: Record<string, string>;
60
+ }
61
+
62
+ function migrateStored(raw: unknown): ConnConfig {
63
+ const c = raw as LegacyStoredConfig & Partial<ConnConfig>;
64
+ const { extraParams, ...rest } = c;
65
+ const out = rest as ConnConfig;
66
+ // 旧文件 extraParams(历史 DbConfig 字段,代码未使用)并入 options
67
+ if (extraParams && typeof extraParams === "object") {
68
+ out.options = { ...(out.options ?? {}), ...extraParams };
69
+ }
70
+ return out;
71
+ }
72
+
73
+ export function loadConfigs(): ConnConfig[] {
74
+ const raw = readJsonCached<unknown[]>(CONFIG_FILE, []);
75
+ if (!Array.isArray(raw)) return [];
76
+ return raw.map(migrateStored);
77
+ }
78
+
79
+ export function saveConfigs(configs: ConnConfig[]): void {
80
+ writeFileSync(CONFIG_FILE, JSON.stringify(configs, null, 2));
81
+ invalidateConfigCache(CONFIG_FILE);
82
+ }
83
+
84
+ // ── 插件全局配置读写(含旧名 db-plugin-config.json 兼容)───
85
+
86
+ export function loadPluginConfig(): PluginConfig {
87
+ if (!existsSync(PLUGIN_CONFIG_FILE)) {
88
+ // 兼容旧版配置文件名
89
+ if (existsSync(LEGACY_PLUGIN_CONFIG_FILE)) {
90
+ try {
91
+ const raw = JSON.parse(readFileSync(LEGACY_PLUGIN_CONFIG_FILE, "utf-8"));
92
+ return { ...DEFAULT_PLUGIN_CONFIG, ...raw };
93
+ } catch {
94
+ return { ...DEFAULT_PLUGIN_CONFIG };
95
+ }
96
+ }
97
+ return { ...DEFAULT_PLUGIN_CONFIG };
98
+ }
99
+ try {
100
+ const raw = JSON.parse(readFileSync(PLUGIN_CONFIG_FILE, "utf-8"));
101
+ return { ...DEFAULT_PLUGIN_CONFIG, ...raw };
102
+ } catch {
103
+ return { ...DEFAULT_PLUGIN_CONFIG };
104
+ }
105
+ }
106
+
107
+ export function savePluginConfig(cfg: PluginConfig): void {
108
+ writeFileSync(PLUGIN_CONFIG_FILE, JSON.stringify(cfg, null, 2));
109
+ invalidateConfigCache(PLUGIN_CONFIG_FILE);
110
+ }
111
+
112
+ export function getConfigSummary(cfg: PluginConfig): string {
113
+ const readonlyLabel = cfg.ai_readonly ? "是" : "否";
114
+ const confirmLabel =
115
+ cfg.confirm_before_exec === "never" ? "不确认" :
116
+ cfg.confirm_before_exec === "write" ? "写操作确认" : "每次都确认";
117
+ return [
118
+ `AI 只读: ${readonlyLabel}`,
119
+ `执行确认: ${confirmLabel}`,
120
+ `最大行数: ${cfg.max_rows}`,
121
+ `查询超时: ${cfg.query_timeout}s`,
122
+ ].join("\n");
123
+ }
124
+
125
+ // ── 辅助: 查找数据库配置(忽略大小写和首尾空格) ────
126
+
127
+ export function findConfig(configs: ConnConfig[], name: string): ConnConfig | undefined {
128
+ const target = name.trim().toLowerCase();
129
+ return configs.find((c) => c.name.toLowerCase() === target);
130
+ }
131
+
132
+ // ── 一键连接串解析(Spec §11.1 P0)──────────────
133
+ // 遍历 registry 各方言 parseUrl,首个非 null 胜出(各方言兼收 JDBC + 原生 URI 双形态,Spec §7)
134
+
135
+ export interface ParsedConnectionString {
136
+ dialectId: DbTypeId;
137
+ host: string;
138
+ port: number;
139
+ username?: string;
140
+ password?: string;
141
+ database?: string;
142
+ dbIndex?: number;
143
+ }
144
+
145
+ export function parseConnectionString(input: string): ParsedConnectionString | null {
146
+ const url = input.trim();
147
+ if (!url) return null;
148
+ for (const d of registry.values()) {
149
+ const p = d.parseUrl(url);
150
+ if (!p) continue;
151
+ // 仅保留有值字段(undefined 键会让 deepEqual 语义变脏,也避免下游覆盖默认值)
152
+ const out: ParsedConnectionString = { dialectId: d.id, host: p.host, port: p.port };
153
+ if (p.username !== undefined) out.username = p.username;
154
+ if (p.password !== undefined) out.password = p.password;
155
+ if (p.database !== undefined) out.database = p.database;
156
+ if (p.dbIndex !== undefined) out.dbIndex = p.dbIndex;
157
+ return out;
158
+ }
159
+ return null;
160
+ }
161
+
162
+ // ── 默认连接(Spec §11.1 P0:database 参数可选,缺省走 isDefault 标记的连接)───
163
+
164
+ export function getDefaultConfig(configs: ConnConfig[]): ConnConfig | undefined {
165
+ return configs.find((c) => c.isDefault);
166
+ }
167
+
168
+ export function setDefaultConfig(configs: ConnConfig[], id: string): ConnConfig[] {
169
+ return configs.map((c) => ({ ...c, isDefault: c.id === id }));
170
+ }
171
+
172
+ // ── 查询结果导出(Spec §11.1 P0:长结果落盘不糊上下文)───
173
+
174
+ import { writeFileSync } from "node:fs";
175
+ import { join } from "node:path";
176
+ import { tmpdir } from "node:os";
177
+ import { toCsv } from "./core/export.js";
178
+
179
+ export interface QueryExport {
180
+ csvPath: string;
181
+ jsonPath: string;
182
+ }
183
+
184
+ /** 查询结果落盘 /tmp(CSV + JSON),返回路径;失败抛错由调用方容错 */
185
+ export function writeQueryExport(
186
+ columns: string[],
187
+ rows: unknown[][],
188
+ baseName: string,
189
+ ): QueryExport {
190
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
191
+ const base = join(tmpdir(), `${baseName}-${stamp}`);
192
+ const csvPath = `${base}.csv`;
193
+ const jsonPath = `${base}.json`;
194
+ writeFileSync(csvPath, toCsv(columns, rows), "utf-8");
195
+ writeFileSync(jsonPath, JSON.stringify({ columns, rows }, null, 2), "utf-8");
196
+ return { csvPath, jsonPath };
197
+ }
198
+
199
+ // ── 运行时兜底(carry-over:替代已删除的 toConnConfig)───
200
+ // ConnConfig.host/port/database 为可选(非关系型家族不用);关系型 doConnect
201
+ // 要求必填,此处在调用点补齐,保证 undefined 永不流入方言层。
202
+
203
+ export function toRuntimeConfig(c: ConnConfig, defaultPort: number): ConnConfig {
204
+ return {
205
+ ...c,
206
+ host: c.host || "localhost",
207
+ port: c.port || defaultPort,
208
+ username: c.username ?? "",
209
+ password: c.password ?? "",
210
+ };
211
+ }
212
+
213
+ // 供 UI 层展示类型短标签(原 index.ts 内 5 处重复映射的收敛点之一)
214
+ export function shortTypeLabel(type: DbTypeId): string {
215
+ return ({ postgresql: "PG", mysql: "MySQL", oracle: "Oracle" } as Record<string, string>)[type] ?? type;
216
+ }
217
+
218
+ export function fullTypeLabel(type: DbTypeId): string {
219
+ return ({ postgresql: "PostgreSQL", mysql: "MySQL", oracle: "Oracle" } as Record<string, string>)[type] ?? type;
220
+ }
@@ -0,0 +1,14 @@
1
+ // core/export.ts —— 查询结果导出纯函数(CSV/JSON 落盘由调用方执行;本模块零依赖可测)
2
+
3
+ function csvEscape(v: unknown): string {
4
+ const s = v === null || v === undefined ? "" : String(v);
5
+ if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
6
+ return s;
7
+ }
8
+
9
+ /** 行数据 → CSV 文本(首行表头;含逗号/引号/换行的值按 RFC 4180 转义) */
10
+ export function toCsv(columns: string[], rows: unknown[][]): string {
11
+ const lines = [columns.map(csvEscape).join(",")];
12
+ for (const row of rows) lines.push(row.map(csvEscape).join(","));
13
+ return lines.join("\n");
14
+ }
@@ -0,0 +1,7 @@
1
+ // core/index.ts —— core 聚合 re-export(package.json exports "./core" 的入口)
2
+ export type { DbTypeId, DbFamily, ConnConfig, ParsedTarget, DbConnection, ExecOpts,
3
+ CandidateStatus, Candidate, TableInfo, ColumnInfo, QueryResult, ListTablesResult,
4
+ DescribeTableResult, TestConnectionResult } from "./types.js";
5
+ export { stripComments, splitStatements, isWriteStatement, isDropStatement } from "./sql-text.js";
6
+ export { decide } from "./policy.js";
7
+ export type { ConfirmMode } from "./policy.js";
@@ -0,0 +1,11 @@
1
+ // core/policy.ts
2
+ import type { Verdict } from "../dialects/dialect.js";
3
+
4
+ export type ConfirmMode = "never" | "write" | "always";
5
+
6
+ export function decide(verdict: Verdict, _readonly: boolean, confirm: ConfirmMode): "run" | "confirm" | "deny" {
7
+ if (!verdict.ok) return "deny";
8
+ if (confirm === "always") return "confirm";
9
+ if (confirm === "write" && verdict.isWrite) return "confirm";
10
+ return "run";
11
+ }
@@ -0,0 +1,286 @@
1
+ // scan/candidates.ts —— scanProject 组装:walker → parsers → spring/placeholders → scoring → Candidate[]
2
+ // 状态机:ready(可直接测连)/ incomplete(缺字段)/ encrypted(jasypt ENC,只标注不建)/ exists(同名已存在)
3
+ // 红线:绝不静默建连(写盘由 index.ts 向导确认后执行);密码掩码在输出层(index.ts)做,
4
+ // 本模块的 partial 保留真实值仅供 TUI 补录。
5
+ import { readFileSync } from "node:fs";
6
+ import { basename, dirname, extname, join, relative } from "node:path";
7
+ import { registry } from "../../dialects/index.js";
8
+ import { loadConfigs } from "../../config.js";
9
+ import type { Candidate, ConnConfig, DbTypeId, ParsedTarget } from "../types.js";
10
+ import { resolveRoot, walk } from "./walker.js";
11
+ import { parseCompose, parseEnv, parseProperties, parseSimpleYaml, extractUrls } from "./parsers.js";
12
+ import { springKeysToRaw, profileOf, type RawDbConfig } from "./spring.js";
13
+ import { resolvePlaceholder } from "./placeholders.js";
14
+ import { isExcluded, sourceWeight } from "./scoring.js";
15
+
16
+ // ── 内部表示 ──────────────────────────────────────
17
+
18
+ interface FieldBag {
19
+ host?: string; port?: number;
20
+ username?: string; password?: string;
21
+ database?: string; dbIndex?: number;
22
+ ssl?: boolean;
23
+ url?: string;
24
+ }
25
+
26
+ interface RawCand {
27
+ dialectId: DbTypeId;
28
+ bag: FieldBag;
29
+ file: string;
30
+ profile: string;
31
+ confidence: number;
32
+ }
33
+
34
+ // 各家族建连必需字段(missing 的判定依据;空串同样视为缺失)
35
+ const REQUIRED: Record<DbTypeId, string[]> = {
36
+ postgresql: ["host", "port", "database", "username", "password"],
37
+ mysql: ["host", "port", "database", "username", "password"],
38
+ oracle: ["host", "port", "database", "username", "password"],
39
+ dm: ["host", "port", "database", "username", "password"],
40
+ hive: ["host", "port", "database", "username"],
41
+ spark: ["host", "port", "database", "username"],
42
+ redis: ["host", "port", "password"],
43
+ elasticsearch: ["host", "port"],
44
+ };
45
+
46
+ // ── 工具函数 ──────────────────────────────────────
47
+
48
+ function flattenYaml(node: unknown, prefix = "", out: Record<string, string> = {}): Record<string, string> {
49
+ if (node && typeof node === "object" && !Array.isArray(node)) {
50
+ for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
51
+ flattenYaml(v, prefix ? `${prefix}.${k}` : k, out);
52
+ }
53
+ } else if (typeof node === "string" || typeof node === "number") {
54
+ out[prefix] = String(node);
55
+ }
56
+ return out;
57
+ }
58
+
59
+ function toInt(v: unknown): number | undefined {
60
+ const n = typeof v === "number" ? v : parseInt(String(v), 10);
61
+ return Number.isFinite(n) ? n : undefined;
62
+ }
63
+
64
+ function stripQuery(url: string): string {
65
+ return url.split(/[?#]/)[0];
66
+ }
67
+
68
+ /** 遍历 registry 各方言 parseUrl,首个非 null 胜出(与 index.ts parseDbUrl 同一模式)。 */
69
+ function parseUrlViaRegistry(url: string): ({ dialectId: DbTypeId } & ParsedTarget) | null {
70
+ for (const d of registry.values()) {
71
+ const p = d.parseUrl(url);
72
+ if (p) return { dialectId: d.id, ...p };
73
+ }
74
+ return null;
75
+ }
76
+
77
+ function dialectFromImage(image: string): DbTypeId | null {
78
+ const i = image.toLowerCase();
79
+ if (/postgres/.test(i)) return "postgresql";
80
+ if (/(^|\/)(mysql|mariadb)/.test(i)) return "mysql";
81
+ if (/redis/.test(i)) return "redis";
82
+ if (/elasticsearch/.test(i)) return "elasticsearch";
83
+ if (/dm8|dameng/.test(i)) return "dm";
84
+ if (/hive/.test(i)) return "hive";
85
+ return null;
86
+ }
87
+
88
+ /** 同目录 .env 缓存(${KEY} 无 default 时的第二优先级) */
89
+ function loadDirEnv(file: string, cache: Map<string, Record<string, string> | undefined>): Record<string, string> | undefined {
90
+ const dir = dirname(file);
91
+ if (cache.has(dir)) return cache.get(dir);
92
+ let env: Record<string, string> | undefined;
93
+ try {
94
+ env = parseEnv(readFileSync(join(dir, ".env"), "utf-8"));
95
+ } catch { env = undefined; }
96
+ cache.set(dir, env);
97
+ return env;
98
+ }
99
+
100
+ // ── 主流程 ────────────────────────────────────────
101
+
102
+ export async function scanProject(
103
+ rootInput: string,
104
+ opts?: { existingNames?: string[] },
105
+ ): Promise<Candidate[]> {
106
+ const root = resolveRoot(rootInput); // 越界直接抛(红线)
107
+ const files = walk(root);
108
+ const raws: RawCand[] = [];
109
+ const dirEnvCache = new Map<string, Record<string, string> | undefined>();
110
+
111
+ const pushUrl = (url: string, file: string, profile: string, confidence: number, keyFields?: Partial<RawDbConfig>): void => {
112
+ const parsed = parseUrlViaRegistry(stripQuery(url));
113
+ if (!parsed) return;
114
+ raws.push({
115
+ dialectId: parsed.dialectId,
116
+ // URL 内嵌账号密码与 spring 键字段合并:显式键(spring.datasource.username/password)优先
117
+ bag: {
118
+ url,
119
+ host: parsed.host,
120
+ port: parsed.port,
121
+ username: keyFields?.username ?? parsed.username,
122
+ password: keyFields?.password ?? parsed.password,
123
+ database: parsed.database,
124
+ dbIndex: parsed.dbIndex,
125
+ ssl: parsed.ssl,
126
+ },
127
+ file, profile, confidence,
128
+ });
129
+ };
130
+
131
+ const pushRaw = (raw: RawDbConfig): void => {
132
+ if (raw.url) {
133
+ pushUrl(raw.url, raw.file, raw.profile, raw.weight, raw);
134
+ return;
135
+ }
136
+ // 无 URL 的键式候选:redis/es 可凭 host 判定;关系型无 URL 无法建连,跳过
137
+ let dialectId: DbTypeId | null = null;
138
+ if (raw.group === "redis" && raw.host) dialectId = "redis";
139
+ else if (raw.group === "es" && raw.host) dialectId = "elasticsearch";
140
+ if (!dialectId) return;
141
+ raws.push({
142
+ dialectId,
143
+ bag: { host: raw.host, port: toInt(raw.port), username: raw.username, password: raw.password, dbIndex: toInt(raw.dbIndex) },
144
+ file: raw.file, profile: raw.profile, confidence: raw.weight,
145
+ });
146
+ };
147
+
148
+ for (const file of files) {
149
+ const rel = relative(root, file).split("\\").join("/");
150
+ if (isExcluded(rel)) continue;
151
+ const ext = extname(file).toLowerCase();
152
+ const base = basename(file).toLowerCase();
153
+ let text: string;
154
+ try { text = readFileSync(file, "utf-8"); } catch { continue; }
155
+ if (text.length > 512 * 1024) continue; // 大文件跳过
156
+
157
+ const weight = sourceWeight(rel);
158
+ const profile = profileOf(base);
159
+
160
+ if (base === ".env" || base.startsWith(".env.")) {
161
+ const env = parseEnv(text);
162
+ 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)) {
164
+ pushUrl(v, file, profile, weight);
165
+ }
166
+ }
167
+ continue;
168
+ }
169
+
170
+ if (ext === ".yml" || ext === ".yaml") {
171
+ if (base.startsWith("docker-compose") || base.startsWith("compose")) {
172
+ for (const svc of parseCompose(text)) {
173
+ const dialectId = svc.image ? dialectFromImage(svc.image) : null;
174
+ if (!dialectId) continue;
175
+ const bag: FieldBag = {
176
+ host: svc.name, // compose 网络内服务名即主机名
177
+ 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"],
181
+ };
182
+ raws.push({ dialectId, bag, file, profile, confidence: weight });
183
+ // compose 里也可能带完整 Spring URL
184
+ for (const [k, v] of Object.entries(svc.env)) {
185
+ if (/URL$/.test(k)) pushUrl(v, file, profile, weight);
186
+ }
187
+ }
188
+ } else {
189
+ const dotted = flattenYaml(parseSimpleYaml(text));
190
+ for (const raw of springKeysToRaw(dotted, file, weight)) pushRaw(raw);
191
+ }
192
+ } else if (ext === ".properties") {
193
+ const dotted = parseProperties(text);
194
+ for (const raw of springKeysToRaw(dotted, file, weight)) pushRaw(raw);
195
+ }
196
+
197
+ // 通用 URL 正则全文件扫(兜底,低权重)
198
+ for (const url of extractUrls(text)) pushUrl(url, file, profile, 0.5);
199
+ }
200
+
201
+ // ── 占位符解析 + 状态机 ──────────────────────────
202
+ const projectName = basename(root);
203
+ const existingNames = new Set(opts?.existingNames ?? loadConfigs().map((c) => c.name));
204
+
205
+ const resolved: RawCand[] = [];
206
+ const seen = new Map<string, number>(); // dedupe key → index in resolved
207
+ for (const raw of raws) {
208
+ const env = loadDirEnv(raw.file, dirEnvCache);
209
+ const bag: FieldBag = {};
210
+ for (const [k, v] of Object.entries(raw.bag)) {
211
+ if (v === undefined) continue;
212
+ if (typeof v !== "string") { (bag as Record<string, unknown>)[k] = v; continue; }
213
+ const r = resolvePlaceholder(v, env);
214
+ if (r.resolved) (bag as Record<string, unknown>)[k] = r.value;
215
+ // 未解析的占位符:字段从 bag 消失 → 落入 missing
216
+ }
217
+
218
+ // 去重:同一实例(dialect|host|port|database)只保留一条;
219
+ // 后到的低置信度候选(如同文件通用 URL 兆底)把自身字段补入已有候选(凭据增强),不新增
220
+ const key = `${raw.dialectId}|${bag.host ?? ""}|${bag.port ?? ""}|${bag.database ?? ""}`;
221
+ const prevIdx = seen.get(key);
222
+ if (prevIdx !== undefined) {
223
+ const prev = resolved[prevIdx];
224
+ if (raw.confidence > prev.confidence) {
225
+ prev.confidence = raw.confidence;
226
+ prev.profile = raw.profile;
227
+ }
228
+ for (const [k, v] of Object.entries(bag)) {
229
+ if ((prev.bag as Record<string, unknown>)[k] === undefined && v !== undefined) {
230
+ (prev.bag as Record<string, unknown>)[k] = v;
231
+ }
232
+ }
233
+ continue;
234
+ }
235
+ seen.set(key, resolved.length);
236
+ resolved.push({ ...raw, bag });
237
+ }
238
+
239
+ // 确定性输出:置信度降序 → 默认名升序(同名多 profile 时 default 在前,消费方 find 可预期)
240
+ const nameOf = (r: RawCand): string => `${projectName}-${r.profile}-${r.dialectId}`;
241
+ resolved.sort((a, b) => b.confidence - a.confidence || nameOf(a).localeCompare(nameOf(b)));
242
+
243
+ const candidates: Candidate[] = resolved.map((raw) => {
244
+ const defaultName = `${projectName}-${raw.profile}-${raw.dialectId}`;
245
+ const missing: string[] = [];
246
+ for (const f of REQUIRED[raw.dialectId]) {
247
+ const v = (raw.bag as Record<string, unknown>)[f];
248
+ if (v === undefined || v === "") missing.push(f);
249
+ }
250
+ let status: Candidate["status"];
251
+ const pwd = raw.bag.password;
252
+ if (typeof pwd === "string" && pwd.startsWith("ENC(")) {
253
+ status = "encrypted";
254
+ } else if (existingNames.has(defaultName)) {
255
+ status = "exists";
256
+ } else if (missing.length === 0) {
257
+ status = "ready";
258
+ } else {
259
+ status = "incomplete";
260
+ }
261
+ const partial: Partial<ConnConfig> = {
262
+ name: defaultName,
263
+ type: raw.dialectId,
264
+ ...raw.bag,
265
+ };
266
+ return {
267
+ status,
268
+ dialectId: raw.dialectId,
269
+ partial,
270
+ missing,
271
+ source: { file: raw.file, profile: raw.profile, confidence: raw.confidence },
272
+ };
273
+ });
274
+
275
+ return candidates;
276
+ }
277
+
278
+ /** compose 端口映射取主机侧端口:"5432:5432" → 5432;"127.0.0.1:5432:5432" → 5432 */
279
+ function hostPortOf(ports: string[]): number | undefined {
280
+ for (const p of ports) {
281
+ const parts = p.split(":");
282
+ const n = parseInt(parts[parts.length - 2] ?? parts[0], 10);
283
+ if (Number.isFinite(n)) return n;
284
+ }
285
+ return undefined;
286
+ }