@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 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
- let privateKeyBuffer;
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
- targetConfig.password,
270
- privateKey,
271
- targetConfig.passphrase,
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 loadEnvFiles() {
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
- dotenv.config({ path: envPath });
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
- if (args.config) {
997
- const configPath = expandHomeDir(args.config);
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: source.ssh_proxy_jump || resolvedSSHConfig?.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)) {