@bytebase/dbhub 0.22.6 → 0.24.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 +4 -0
- package/dist/{chunk-BXXHWEQS.js → chunk-D2BIPC7V.js} +10 -1
- package/dist/{chunk-RTB262PR.js → chunk-DUCNPMNW.js} +54 -1
- package/dist/chunk-JO7YQPBQ.js +130 -0
- package/dist/{chunk-WPXPKRDU.js → chunk-MQR6VX5F.js} +173 -72
- package/dist/index.js +14 -103
- package/dist/{mariadb-JPEKVFEW.js → mariadb-VZMU7LYA.js} +66 -46
- package/dist/{mysql-EK6TCER5.js → mysql-MHIZK2UY.js} +65 -46
- package/dist/{postgres-BKBCGDZS.js → postgres-ZQFXCBYA.js} +8 -2
- package/dist/{registry-IPRZ55YC.js → registry-ZU6ZFNQ2.js} +1 -1
- package/dist/{sqlite-CQ4LCBB6.js → sqlite-JH5CNV5E.js} +8 -2
- package/dist/{sqlserver-47APXMSF.js → sqlserver-LHWZ7WOP.js} +140 -5
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -80,6 +80,10 @@ docker run --rm --init \
|
|
|
80
80
|
npx @bytebase/dbhub@latest --transport http --port 8080 --dsn "postgres://user:password@localhost:5432/dbname?sslmode=disable"
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
+
**MCP Bundle (one-click install):**
|
|
84
|
+
|
|
85
|
+
Download `dbhub-<version>.mcpb` from the [latest release](https://github.com/bytebase/dbhub/releases/latest) and install it in any [MCPB-compatible client](https://github.com/modelcontextprotocol/mcpb) — Claude Desktop (double-click, or drag into Settings → Extensions), Claude Code, or MCP for Windows — then enter your database connection string. The bundle runs locally over stdio, is **read-only by design** (writes are rejected and the database session is set to read-only at the engine level), and needs no remote endpoint or OAuth setup — ideal for giving non-technical teammates curated, read-only database access. Pair it with a least-privilege, read-only database account. See the [MCP Bundle guide](https://dbhub.ai/mcpb) for details and for packaging your own bundle.
|
|
86
|
+
|
|
83
87
|
**Demo Mode:**
|
|
84
88
|
|
|
85
89
|
```bash
|
|
@@ -122,6 +122,15 @@ var SQLRowLimiter = class {
|
|
|
122
122
|
}
|
|
123
123
|
};
|
|
124
124
|
|
|
125
|
+
// src/utils/resource-cleanup.ts
|
|
126
|
+
async function closeQuietly(close) {
|
|
127
|
+
try {
|
|
128
|
+
await close();
|
|
129
|
+
} catch {
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
125
133
|
export {
|
|
126
|
-
SQLRowLimiter
|
|
134
|
+
SQLRowLimiter,
|
|
135
|
+
closeQuietly
|
|
127
136
|
};
|
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
import {
|
|
2
|
+
obfuscateDSNPassword
|
|
3
|
+
} from "./chunk-JEZZN2YZ.js";
|
|
4
|
+
|
|
5
|
+
// src/utils/dsn-database.ts
|
|
6
|
+
var MissingDatabaseError = class extends Error {
|
|
7
|
+
constructor(message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "MissingDatabaseError";
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
function requireDatabaseInDSN(database, dsn, label) {
|
|
13
|
+
if (database) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
throw new MissingDatabaseError(
|
|
17
|
+
`${label} DSN must name a database.
|
|
18
|
+
Provided: ${obfuscateDSNPassword(dsn)}
|
|
19
|
+
Add the database to the DSN, e.g. ...:3306/mydb
|
|
20
|
+
To work with several databases, define one [[sources]] entry per database in a TOML config file: https://dbhub.ai/config/toml`
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
1
24
|
// src/utils/multi-statement-result-parser.ts
|
|
2
25
|
function isMetadataObject(element) {
|
|
3
26
|
if (!element || typeof element !== "object" || Array.isArray(element)) {
|
|
@@ -54,7 +77,37 @@ function parseQueryResults(results) {
|
|
|
54
77
|
return results;
|
|
55
78
|
}
|
|
56
79
|
|
|
80
|
+
// src/utils/readonly-transaction.ts
|
|
81
|
+
async function withReadOnlyTransaction(conn, readonly, supportsReadOnlyTransaction, execute) {
|
|
82
|
+
if (!readonly) {
|
|
83
|
+
return execute();
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
await conn.query(
|
|
87
|
+
supportsReadOnlyTransaction ? "START TRANSACTION READ ONLY" : "START TRANSACTION"
|
|
88
|
+
);
|
|
89
|
+
const result = await execute();
|
|
90
|
+
await conn.query(supportsReadOnlyTransaction ? "COMMIT" : "ROLLBACK");
|
|
91
|
+
return result;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
try {
|
|
94
|
+
await conn.query("ROLLBACK");
|
|
95
|
+
} catch {
|
|
96
|
+
}
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/utils/server-flavor.ts
|
|
102
|
+
function isTiDBVersion(version) {
|
|
103
|
+
return typeof version === "string" && /tidb/i.test(version);
|
|
104
|
+
}
|
|
105
|
+
|
|
57
106
|
export {
|
|
107
|
+
MissingDatabaseError,
|
|
108
|
+
requireDatabaseInDSN,
|
|
58
109
|
extractAffectedRows,
|
|
59
|
-
parseQueryResults
|
|
110
|
+
parseQueryResults,
|
|
111
|
+
withReadOnlyTransaction,
|
|
112
|
+
isTiDBVersion
|
|
60
113
|
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {
|
|
2
|
+
stripCommentsAndStrings
|
|
3
|
+
} from "./chunk-JEZZN2YZ.js";
|
|
4
|
+
|
|
5
|
+
// src/utils/allowed-keywords.ts
|
|
6
|
+
var allowedKeywords = {
|
|
7
|
+
postgres: ["select", "with", "explain", "show"],
|
|
8
|
+
mysql: ["select", "with", "explain", "show", "describe", "desc"],
|
|
9
|
+
mariadb: ["select", "with", "explain", "show", "describe", "desc"],
|
|
10
|
+
sqlite: ["select", "with", "explain", "pragma"],
|
|
11
|
+
// SQL Server has no native EXPLAIN statement; the connector translates a
|
|
12
|
+
// leading `EXPLAIN` into a SET SHOWPLAN_XML request (see SQLServerConnector).
|
|
13
|
+
sqlserver: ["select", "with", "explain"]
|
|
14
|
+
};
|
|
15
|
+
var mutatingKeywords = [
|
|
16
|
+
"insert",
|
|
17
|
+
"update",
|
|
18
|
+
"delete",
|
|
19
|
+
"drop",
|
|
20
|
+
"alter",
|
|
21
|
+
"create",
|
|
22
|
+
"truncate",
|
|
23
|
+
"merge",
|
|
24
|
+
"grant",
|
|
25
|
+
"revoke",
|
|
26
|
+
"rename"
|
|
27
|
+
];
|
|
28
|
+
var mutatingPattern = new RegExp(
|
|
29
|
+
`\\b(?:${mutatingKeywords.join("|")})\\b`,
|
|
30
|
+
"i"
|
|
31
|
+
);
|
|
32
|
+
var mutatingPatternWithReplace = new RegExp(
|
|
33
|
+
`\\b(?:${mutatingKeywords.join("|")}|replace\\s+(?:(?:low_priority|delayed)\\s+)?into)\\b`,
|
|
34
|
+
"i"
|
|
35
|
+
);
|
|
36
|
+
var sqlServerDynamicSqlKeywords = [
|
|
37
|
+
"execute",
|
|
38
|
+
"exec",
|
|
39
|
+
"sp_executesql",
|
|
40
|
+
"xp_cmdshell"
|
|
41
|
+
];
|
|
42
|
+
var sqlServerDynamicSqlPattern = new RegExp(
|
|
43
|
+
`\\b(?:${sqlServerDynamicSqlKeywords.join("|")})\\b`,
|
|
44
|
+
"i"
|
|
45
|
+
);
|
|
46
|
+
var sqlServerPassThroughKeywords = [
|
|
47
|
+
"openquery",
|
|
48
|
+
"openrowset",
|
|
49
|
+
"opendatasource"
|
|
50
|
+
];
|
|
51
|
+
var sqlServerPassThroughPattern = new RegExp(
|
|
52
|
+
`\\b(?:${sqlServerPassThroughKeywords.join("|")})\\s*\\(`,
|
|
53
|
+
"i"
|
|
54
|
+
);
|
|
55
|
+
var mutatingPatternSqlServer = new RegExp(
|
|
56
|
+
`\\b(?:${[...mutatingKeywords, ...sqlServerDynamicSqlKeywords].join("|")})\\b`,
|
|
57
|
+
"i"
|
|
58
|
+
);
|
|
59
|
+
var mutatingPatterns = {
|
|
60
|
+
postgres: mutatingPattern,
|
|
61
|
+
mysql: mutatingPatternWithReplace,
|
|
62
|
+
mariadb: mutatingPatternWithReplace,
|
|
63
|
+
sqlite: mutatingPatternWithReplace,
|
|
64
|
+
sqlserver: mutatingPatternSqlServer
|
|
65
|
+
};
|
|
66
|
+
var selectIntoPattern = /\bselect\b[\s\S]+\binto\b/i;
|
|
67
|
+
var sqliteReadOnlyArgPragmas = /* @__PURE__ */ new Set([
|
|
68
|
+
"table_info",
|
|
69
|
+
"index_info",
|
|
70
|
+
"index_list",
|
|
71
|
+
"foreign_key_list"
|
|
72
|
+
]);
|
|
73
|
+
var sqlitePragmaParenPattern = /^pragma\s+(?:[a-z0-9_]+\.)?([a-z0-9_]+)\s*\(/;
|
|
74
|
+
var explainAnalyzePattern = /^explain\s+(?:\([^)]*\banalyze\b(?!\s*(?:=\s*)?(?:false|off|0)\b)[^)]*\)|\banalyze\b(?!\s*(?:=\s*)?(?:false|off|0)\b)(?:\s+verbose\b)?)/i;
|
|
75
|
+
function isReadOnlySQL(sql, connectorType) {
|
|
76
|
+
return checkReadOnly(
|
|
77
|
+
stripCommentsAndStrings(sql, connectorType).trim().toLowerCase(),
|
|
78
|
+
connectorType
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
function checkReadOnly(cleanedSQL, connectorType) {
|
|
82
|
+
if (!cleanedSQL) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
const firstWord = cleanedSQL.match(/\S+/)?.[0] ?? "";
|
|
86
|
+
const keywordList = allowedKeywords[connectorType] || [];
|
|
87
|
+
if (!keywordList.includes(firstWord)) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
if (connectorType === "sqlserver" && sqlServerPassThroughPattern.test(cleanedSQL)) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
if (firstWord === "with") {
|
|
94
|
+
const pattern = mutatingPatterns[connectorType] ?? mutatingPattern;
|
|
95
|
+
if (pattern.test(cleanedSQL)) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (firstWord === "pragma" && connectorType === "sqlite") {
|
|
100
|
+
if (cleanedSQL.includes("=")) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
const parenMatch = cleanedSQL.match(sqlitePragmaParenPattern);
|
|
104
|
+
if (parenMatch && !sqliteReadOnlyArgPragmas.has(parenMatch[1])) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if ((firstWord === "select" || firstWord === "with") && selectIntoPattern.test(cleanedSQL)) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
if (firstWord === "explain") {
|
|
112
|
+
const m = explainAnalyzePattern.exec(cleanedSQL);
|
|
113
|
+
if (m) {
|
|
114
|
+
const afterExplain = cleanedSQL.slice(m[0].length).trim();
|
|
115
|
+
if (afterExplain && !checkReadOnly(afterExplain, connectorType)) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export {
|
|
124
|
+
allowedKeywords,
|
|
125
|
+
sqlServerDynamicSqlKeywords,
|
|
126
|
+
sqlServerDynamicSqlPattern,
|
|
127
|
+
sqlServerPassThroughKeywords,
|
|
128
|
+
sqlServerPassThroughPattern,
|
|
129
|
+
isReadOnlySQL
|
|
130
|
+
};
|
|
@@ -68,7 +68,8 @@ function findDefaultSSHKey() {
|
|
|
68
68
|
}
|
|
69
69
|
return void 0;
|
|
70
70
|
}
|
|
71
|
-
function parseSSHConfig(hostAlias, configPath) {
|
|
71
|
+
function parseSSHConfig(hostAlias, configPath, options = {}) {
|
|
72
|
+
const { requireUser = true } = options;
|
|
72
73
|
const sshConfigPath = resolveSymlink(configPath);
|
|
73
74
|
if (!isFile(sshConfigPath)) {
|
|
74
75
|
return null;
|
|
@@ -77,7 +78,7 @@ function parseSSHConfig(hostAlias, configPath) {
|
|
|
77
78
|
const configContent = readFileSync(sshConfigPath, "utf8");
|
|
78
79
|
const config = SSHConfig.parse(configContent);
|
|
79
80
|
const hostConfig = config.compute(hostAlias);
|
|
80
|
-
if (!hostConfig || !hostConfig.HostName && !hostConfig.User) {
|
|
81
|
+
if (!hostConfig || !hostConfig.HostName && !hostConfig.User && !hostConfig.Port && !hostConfig.IdentityFile && !hostConfig.ProxyJump) {
|
|
81
82
|
return null;
|
|
82
83
|
}
|
|
83
84
|
const sshConfig = {};
|
|
@@ -111,10 +112,10 @@ function parseSSHConfig(hostAlias, configPath) {
|
|
|
111
112
|
if (hostConfig.ProxyCommand) {
|
|
112
113
|
console.error("Warning: ProxyCommand in SSH config is not supported by DBHub. Use ProxyJump instead.");
|
|
113
114
|
}
|
|
114
|
-
if (!sshConfig.host || !sshConfig.username) {
|
|
115
|
+
if (!sshConfig.host || requireUser && !sshConfig.username) {
|
|
115
116
|
return null;
|
|
116
117
|
}
|
|
117
|
-
return sshConfig;
|
|
118
|
+
return requireUser ? sshConfig : sshConfig;
|
|
118
119
|
} catch (error) {
|
|
119
120
|
console.error(`Error parsing SSH config: ${error instanceof Error ? error.message : String(error)}`);
|
|
120
121
|
return null;
|
|
@@ -193,6 +194,45 @@ function parseJumpHosts(proxyJump) {
|
|
|
193
194
|
}
|
|
194
195
|
return proxyJump.split(",").map((s) => s.trim()).filter((s) => s.length > 0).map(parseJumpHost);
|
|
195
196
|
}
|
|
197
|
+
function resolveJumpHosts(proxyJump, configPath, visited = /* @__PURE__ */ new Set()) {
|
|
198
|
+
if (!proxyJump || proxyJump.trim() === "" || proxyJump.toLowerCase() === "none") {
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
const resolved = [];
|
|
202
|
+
for (const token of proxyJump.split(",").map((s) => s.trim()).filter((s) => s.length > 0)) {
|
|
203
|
+
const hop = parseJumpHost(token);
|
|
204
|
+
if (!looksLikeSSHAlias(hop.host)) {
|
|
205
|
+
resolved.push(hop);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (visited.has(hop.host)) {
|
|
209
|
+
throw new Error(`Cycle detected in SSH ProxyJump chain at alias "${hop.host}"`);
|
|
210
|
+
}
|
|
211
|
+
const aliasConfig = parseSSHConfig(hop.host, configPath, { requireUser: false });
|
|
212
|
+
if (!aliasConfig) {
|
|
213
|
+
resolved.push(hop);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (aliasConfig.proxyJump) {
|
|
217
|
+
resolved.push(...resolveJumpHosts(aliasConfig.proxyJump, configPath, new Set(visited).add(hop.host)));
|
|
218
|
+
}
|
|
219
|
+
resolved.push({
|
|
220
|
+
host: aliasConfig.host,
|
|
221
|
+
// An explicit `:port` on the token wins; otherwise use the alias's Port (default 22).
|
|
222
|
+
port: tokenHasExplicitPort(token) ? hop.port : aliasConfig.port ?? 22,
|
|
223
|
+
// An explicit `user@` on the token wins; otherwise the alias's User.
|
|
224
|
+
username: hop.username ?? aliasConfig.username,
|
|
225
|
+
privateKey: aliasConfig.privateKey,
|
|
226
|
+
passphrase: aliasConfig.passphrase
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return resolved;
|
|
230
|
+
}
|
|
231
|
+
function tokenHasExplicitPort(token) {
|
|
232
|
+
const atIndex = token.indexOf("@");
|
|
233
|
+
const hostPart = atIndex !== -1 ? token.slice(atIndex + 1) : token;
|
|
234
|
+
return hostPart.startsWith("[") ? /\]:\d+$/.test(hostPart) : /:\d+$/.test(hostPart);
|
|
235
|
+
}
|
|
196
236
|
|
|
197
237
|
// src/utils/ssh-tunnel.ts
|
|
198
238
|
var SSHTunnel = class {
|
|
@@ -215,29 +255,8 @@ var SSHTunnel = class {
|
|
|
215
255
|
}
|
|
216
256
|
this.isConnected = true;
|
|
217
257
|
try {
|
|
218
|
-
const jumpHosts = config.proxyJump ? parseJumpHosts(config.proxyJump) : [];
|
|
219
|
-
|
|
220
|
-
if (config.privateKey) {
|
|
221
|
-
try {
|
|
222
|
-
const resolvedKeyPath = resolveSymlink(config.privateKey);
|
|
223
|
-
privateKeyBuffer = readFileSync2(resolvedKeyPath);
|
|
224
|
-
} catch {
|
|
225
|
-
try {
|
|
226
|
-
const decoded = Buffer.from(config.privateKey, "base64");
|
|
227
|
-
const text = decoded.toString("utf8");
|
|
228
|
-
if (text.includes("PRIVATE KEY")) {
|
|
229
|
-
privateKeyBuffer = decoded;
|
|
230
|
-
} else {
|
|
231
|
-
throw new Error(`SSH key is neither a valid file path nor a base64-encoded private key`);
|
|
232
|
-
}
|
|
233
|
-
} catch (decodeError) {
|
|
234
|
-
if (decodeError instanceof Error && decodeError.message.includes("neither a valid file path")) {
|
|
235
|
-
throw decodeError;
|
|
236
|
-
}
|
|
237
|
-
throw new Error(`SSH key is neither a valid file path nor a base64-encoded private key`);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
}
|
|
258
|
+
const jumpHosts = config.resolvedJumpHosts ?? (config.proxyJump ? parseJumpHosts(config.proxyJump) : []);
|
|
259
|
+
const privateKeyBuffer = config.privateKey ? this.loadPrivateKey(config.privateKey) : void 0;
|
|
241
260
|
if (!config.password && !privateKeyBuffer) {
|
|
242
261
|
throw new Error("Either password or privateKey must be provided for SSH authentication");
|
|
243
262
|
}
|
|
@@ -248,6 +267,30 @@ var SSHTunnel = class {
|
|
|
248
267
|
throw error;
|
|
249
268
|
}
|
|
250
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* Load an SSH private key, supporting both a file path (with symlink resolution)
|
|
272
|
+
* and base64-encoded key content.
|
|
273
|
+
*/
|
|
274
|
+
loadPrivateKey(key) {
|
|
275
|
+
try {
|
|
276
|
+
const resolvedKeyPath = resolveSymlink(key);
|
|
277
|
+
return readFileSync2(resolvedKeyPath);
|
|
278
|
+
} catch {
|
|
279
|
+
try {
|
|
280
|
+
const decoded = Buffer.from(key, "base64");
|
|
281
|
+
const text = decoded.toString("utf8");
|
|
282
|
+
if (text.includes("PRIVATE KEY")) {
|
|
283
|
+
return decoded;
|
|
284
|
+
}
|
|
285
|
+
throw new Error("SSH key is neither a valid file path nor a base64-encoded private key");
|
|
286
|
+
} catch (decodeError) {
|
|
287
|
+
if (decodeError instanceof Error && decodeError.message.includes("neither a valid file path")) {
|
|
288
|
+
throw decodeError;
|
|
289
|
+
}
|
|
290
|
+
throw new Error("SSH key is neither a valid file path nor a base64-encoded private key");
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
251
294
|
/**
|
|
252
295
|
* Establish a chain of SSH connections through jump hosts.
|
|
253
296
|
* @returns The final SSH client connected to the target host
|
|
@@ -257,6 +300,9 @@ var SSHTunnel = class {
|
|
|
257
300
|
for (let i = 0; i < jumpHosts.length; i++) {
|
|
258
301
|
const jumpHost = jumpHosts[i];
|
|
259
302
|
const nextHost = i + 1 < jumpHosts.length ? jumpHosts[i + 1] : { host: targetConfig.host, port: targetConfig.port || 22 };
|
|
303
|
+
const hopPrivateKey = jumpHost.privateKey ? this.loadPrivateKey(jumpHost.privateKey) : privateKey;
|
|
304
|
+
const hopPassword = targetConfig.password;
|
|
305
|
+
const hopPassphrase = jumpHost.passphrase ?? targetConfig.passphrase;
|
|
260
306
|
let client = null;
|
|
261
307
|
let forwardStream;
|
|
262
308
|
try {
|
|
@@ -266,9 +312,9 @@ var SSHTunnel = class {
|
|
|
266
312
|
port: jumpHost.port,
|
|
267
313
|
username: jumpHost.username || targetConfig.username
|
|
268
314
|
},
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
315
|
+
hopPassword,
|
|
316
|
+
hopPrivateKey,
|
|
317
|
+
hopPassphrase,
|
|
272
318
|
previousStream,
|
|
273
319
|
`jump host ${i + 1}`,
|
|
274
320
|
targetConfig.keepaliveInterval,
|
|
@@ -534,55 +580,59 @@ function parseCommandLineArgs() {
|
|
|
534
580
|
}
|
|
535
581
|
return parsedManually;
|
|
536
582
|
}
|
|
537
|
-
function
|
|
583
|
+
function findEnvFile() {
|
|
538
584
|
const isDevelopment = process.env.NODE_ENV === "development" || process.argv[1]?.includes("tsx");
|
|
539
585
|
const envFileNames = isDevelopment ? [".env.local", ".env"] : [".env"];
|
|
540
586
|
const envPaths = [];
|
|
541
587
|
for (const fileName of envFileNames) {
|
|
542
588
|
envPaths.push(
|
|
543
|
-
fileName,
|
|
589
|
+
path.join(process.cwd(), fileName),
|
|
544
590
|
// Current working directory
|
|
545
|
-
path.join(__dirname, "..", "..", fileName)
|
|
591
|
+
path.join(__dirname, "..", "..", fileName)
|
|
546
592
|
// Two levels up (src/config -> src -> root)
|
|
547
|
-
path.join(process.cwd(), fileName)
|
|
548
|
-
// Explicit current working directory
|
|
549
593
|
);
|
|
550
594
|
}
|
|
551
595
|
for (const envPath of envPaths) {
|
|
552
|
-
console.error(`Checking for env file: ${envPath}`);
|
|
553
596
|
if (fs.existsSync(envPath)) {
|
|
554
|
-
|
|
555
|
-
if (process.env.READONLY !== void 0) {
|
|
556
|
-
console.error("\nERROR: READONLY environment variable is no longer supported.");
|
|
557
|
-
console.error("Use dbhub.toml with [[tools]] configuration instead:\n");
|
|
558
|
-
console.error(" [[sources]]");
|
|
559
|
-
console.error(' id = "default"');
|
|
560
|
-
console.error(' dsn = "..."\n');
|
|
561
|
-
console.error(" [[tools]]");
|
|
562
|
-
console.error(' name = "execute_sql"');
|
|
563
|
-
console.error(' source = "default"');
|
|
564
|
-
console.error(" readonly = true\n");
|
|
565
|
-
console.error("See https://dbhub.ai/tools/execute-sql#read-only-mode for details.\n");
|
|
566
|
-
process.exit(1);
|
|
567
|
-
}
|
|
568
|
-
if (process.env.MAX_ROWS !== void 0) {
|
|
569
|
-
console.error("\nERROR: MAX_ROWS environment variable is no longer supported.");
|
|
570
|
-
console.error("Use dbhub.toml with [[tools]] configuration instead:\n");
|
|
571
|
-
console.error(" [[sources]]");
|
|
572
|
-
console.error(' id = "default"');
|
|
573
|
-
console.error(' dsn = "..."\n');
|
|
574
|
-
console.error(" [[tools]]");
|
|
575
|
-
console.error(' name = "execute_sql"');
|
|
576
|
-
console.error(' source = "default"');
|
|
577
|
-
console.error(" max_rows = 1000\n");
|
|
578
|
-
console.error("See https://dbhub.ai/tools/execute-sql#row-limiting for details.\n");
|
|
579
|
-
process.exit(1);
|
|
580
|
-
}
|
|
581
|
-
return path.basename(envPath);
|
|
597
|
+
return envPath;
|
|
582
598
|
}
|
|
583
599
|
}
|
|
584
600
|
return null;
|
|
585
601
|
}
|
|
602
|
+
function loadEnvFiles() {
|
|
603
|
+
const envPath = findEnvFile();
|
|
604
|
+
if (envPath) {
|
|
605
|
+
dotenv.config({ path: envPath });
|
|
606
|
+
if (process.env.READONLY !== void 0) {
|
|
607
|
+
console.error("\nERROR: READONLY environment variable is no longer supported.");
|
|
608
|
+
console.error("Use dbhub.toml with [[tools]] configuration instead:\n");
|
|
609
|
+
console.error(" [[sources]]");
|
|
610
|
+
console.error(' id = "default"');
|
|
611
|
+
console.error(' dsn = "..."\n');
|
|
612
|
+
console.error(" [[tools]]");
|
|
613
|
+
console.error(' name = "execute_sql"');
|
|
614
|
+
console.error(' source = "default"');
|
|
615
|
+
console.error(" readonly = true\n");
|
|
616
|
+
console.error("See https://dbhub.ai/tools/execute-sql#read-only-mode for details.\n");
|
|
617
|
+
process.exit(1);
|
|
618
|
+
}
|
|
619
|
+
if (process.env.MAX_ROWS !== void 0) {
|
|
620
|
+
console.error("\nERROR: MAX_ROWS environment variable is no longer supported.");
|
|
621
|
+
console.error("Use dbhub.toml with [[tools]] configuration instead:\n");
|
|
622
|
+
console.error(" [[sources]]");
|
|
623
|
+
console.error(' id = "default"');
|
|
624
|
+
console.error(' dsn = "..."\n');
|
|
625
|
+
console.error(" [[tools]]");
|
|
626
|
+
console.error(' name = "execute_sql"');
|
|
627
|
+
console.error(' source = "default"');
|
|
628
|
+
console.error(" max_rows = 1000\n");
|
|
629
|
+
console.error("See https://dbhub.ai/tools/execute-sql#row-limiting for details.\n");
|
|
630
|
+
process.exit(1);
|
|
631
|
+
}
|
|
632
|
+
return path.basename(envPath);
|
|
633
|
+
}
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
586
636
|
function isDemoMode() {
|
|
587
637
|
const args = parseCommandLineArgs();
|
|
588
638
|
return args.demo === "true";
|
|
@@ -877,6 +927,9 @@ function resolveSSHConfig() {
|
|
|
877
927
|
};
|
|
878
928
|
}
|
|
879
929
|
async function resolveSourceConfigs() {
|
|
930
|
+
if (parseCommandLineArgs().config) {
|
|
931
|
+
loadEnvFiles();
|
|
932
|
+
}
|
|
880
933
|
if (!isDemoMode()) {
|
|
881
934
|
const tomlConfig = loadTomlConfig();
|
|
882
935
|
if (tomlConfig) {
|
|
@@ -886,6 +939,11 @@ async function resolveSourceConfigs() {
|
|
|
886
939
|
"The --id flag cannot be used with TOML configuration. TOML config defines source IDs directly. Either remove the --id flag or use command-line DSN configuration instead."
|
|
887
940
|
);
|
|
888
941
|
}
|
|
942
|
+
if (parseCommandLineArgs().dsn) {
|
|
943
|
+
throw new Error(
|
|
944
|
+
`The --dsn flag cannot be used with TOML configuration (${tomlConfig.source}). TOML config defines database sources directly and supports multiple databases, while a DSN configures a single database. Either remove the --dsn flag or drop the --config flag.`
|
|
945
|
+
);
|
|
946
|
+
}
|
|
889
947
|
return tomlConfig;
|
|
890
948
|
}
|
|
891
949
|
}
|
|
@@ -993,8 +1051,9 @@ function loadTomlConfig() {
|
|
|
993
1051
|
}
|
|
994
1052
|
function resolveTomlConfigPath() {
|
|
995
1053
|
const args = parseCommandLineArgs();
|
|
996
|
-
|
|
997
|
-
|
|
1054
|
+
const configValue = requireFlagValue("config", args, "./dbhub.toml");
|
|
1055
|
+
if (configValue) {
|
|
1056
|
+
const configPath = expandHomeDir(configValue);
|
|
998
1057
|
if (!fs2.existsSync(configPath)) {
|
|
999
1058
|
throw new Error(
|
|
1000
1059
|
`Configuration file specified by --config flag not found: ${configPath}`
|
|
@@ -1002,10 +1061,6 @@ function resolveTomlConfigPath() {
|
|
|
1002
1061
|
}
|
|
1003
1062
|
return configPath;
|
|
1004
1063
|
}
|
|
1005
|
-
const defaultConfigPath = path2.join(process.cwd(), "dbhub.toml");
|
|
1006
|
-
if (fs2.existsSync(defaultConfigPath)) {
|
|
1007
|
-
return defaultConfigPath;
|
|
1008
|
-
}
|
|
1009
1064
|
return null;
|
|
1010
1065
|
}
|
|
1011
1066
|
function validateTomlConfig(config, configPath) {
|
|
@@ -1160,6 +1215,11 @@ function validateDSNFieldConflicts(source, configPath) {
|
|
|
1160
1215
|
if (source.database && info.database && source.database !== info.database) {
|
|
1161
1216
|
conflict("database", source.database, info.database);
|
|
1162
1217
|
}
|
|
1218
|
+
if (source.database && !info.database) {
|
|
1219
|
+
throw new Error(
|
|
1220
|
+
`Configuration file ${configPath}: source '${source.id}' has a 'database' field but the DSN names no database. The field is ignored at connection time \u2014 add the database to the DSN, or use individual connection parameters instead of a DSN.`
|
|
1221
|
+
);
|
|
1222
|
+
}
|
|
1163
1223
|
if (source.user && info.user && source.user !== info.user) {
|
|
1164
1224
|
conflict("user", source.user, info.user);
|
|
1165
1225
|
}
|
|
@@ -1367,6 +1427,30 @@ function validateSourceConfig(source, configPath) {
|
|
|
1367
1427
|
);
|
|
1368
1428
|
}
|
|
1369
1429
|
}
|
|
1430
|
+
if (source.charset !== void 0) {
|
|
1431
|
+
if (source.type !== "mysql" && source.type !== "mariadb") {
|
|
1432
|
+
throw new Error(
|
|
1433
|
+
`Configuration file ${configPath}: source '${source.id}' has 'charset' but it is only supported for MySQL and MariaDB sources.`
|
|
1434
|
+
);
|
|
1435
|
+
}
|
|
1436
|
+
if (typeof source.charset !== "string" || source.charset.trim() === "") {
|
|
1437
|
+
throw new Error(
|
|
1438
|
+
`Configuration file ${configPath}: source '${source.id}' has invalid charset '${source.charset}'. Must be a non-empty string naming a character set (e.g. "utf8mb4").`
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
if (source.collation !== void 0) {
|
|
1443
|
+
if (source.type !== "mysql" && source.type !== "mariadb") {
|
|
1444
|
+
throw new Error(
|
|
1445
|
+
`Configuration file ${configPath}: source '${source.id}' has 'collation' but it is only supported for MySQL and MariaDB sources.`
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
if (typeof source.collation !== "string" || source.collation.trim() === "") {
|
|
1449
|
+
throw new Error(
|
|
1450
|
+
`Configuration file ${configPath}: source '${source.id}' has invalid collation '${source.collation}'. Must be a non-empty string naming a collation (e.g. "utf8mb4_0900_ai_ci").`
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1370
1454
|
if (source.readonly !== void 0) {
|
|
1371
1455
|
throw new Error(
|
|
1372
1456
|
`Configuration file ${configPath}: source '${source.id}' has 'readonly' field, but readonly must be configured per-tool, not per-source. Move 'readonly' to [[tools]] configuration instead.`
|
|
@@ -1735,13 +1819,23 @@ var ConnectorManager = class {
|
|
|
1735
1819
|
console.error(` - ${sourceId}: ${redactDSN(dsn)}`);
|
|
1736
1820
|
let actualDSN = dsn;
|
|
1737
1821
|
if (source.ssh_host) {
|
|
1822
|
+
const sshConfigPath = getDefaultSSHConfigPath();
|
|
1738
1823
|
let resolvedSSHConfig = null;
|
|
1739
1824
|
if (looksLikeSSHAlias(source.ssh_host)) {
|
|
1740
|
-
const sshConfigPath = getDefaultSSHConfigPath();
|
|
1741
1825
|
console.error(` Resolving SSH config for host '${source.ssh_host}' from: ${sshConfigPath}`);
|
|
1742
1826
|
resolvedSSHConfig = parseSSHConfig(source.ssh_host, sshConfigPath);
|
|
1743
1827
|
}
|
|
1744
1828
|
const username = source.ssh_user || resolvedSSHConfig?.username;
|
|
1829
|
+
const proxyJump = source.ssh_proxy_jump || resolvedSSHConfig?.proxyJump;
|
|
1830
|
+
let resolvedJumpHosts;
|
|
1831
|
+
try {
|
|
1832
|
+
resolvedJumpHosts = proxyJump ? resolveJumpHosts(proxyJump, sshConfigPath) : void 0;
|
|
1833
|
+
} catch (error) {
|
|
1834
|
+
if (error && typeof error === "object") {
|
|
1835
|
+
error[TUNNEL_ERROR_MARKER] = true;
|
|
1836
|
+
}
|
|
1837
|
+
throw error;
|
|
1838
|
+
}
|
|
1745
1839
|
const sshConfig = {
|
|
1746
1840
|
host: resolvedSSHConfig?.host || source.ssh_host,
|
|
1747
1841
|
port: source.ssh_port || resolvedSSHConfig?.port || 22,
|
|
@@ -1749,7 +1843,8 @@ var ConnectorManager = class {
|
|
|
1749
1843
|
password: source.ssh_password,
|
|
1750
1844
|
privateKey: source.ssh_key || resolvedSSHConfig?.privateKey,
|
|
1751
1845
|
passphrase: source.ssh_passphrase,
|
|
1752
|
-
proxyJump
|
|
1846
|
+
proxyJump,
|
|
1847
|
+
resolvedJumpHosts,
|
|
1753
1848
|
keepaliveInterval: source.ssh_keepalive_interval,
|
|
1754
1849
|
keepaliveCountMax: source.ssh_keepalive_count_max
|
|
1755
1850
|
};
|
|
@@ -1808,6 +1903,12 @@ var ConnectorManager = class {
|
|
|
1808
1903
|
if (source.timezone) {
|
|
1809
1904
|
config.timezone = source.timezone;
|
|
1810
1905
|
}
|
|
1906
|
+
if (source.charset) {
|
|
1907
|
+
config.charset = source.charset;
|
|
1908
|
+
}
|
|
1909
|
+
if (source.collation) {
|
|
1910
|
+
config.collation = source.collation;
|
|
1911
|
+
}
|
|
1811
1912
|
await connector.connect(actualDSN, source.init_script, config);
|
|
1812
1913
|
this.connectors.set(sourceId, connector);
|
|
1813
1914
|
if (!this.sourceIds.includes(sourceId)) {
|