@bytebase/dbhub 0.12.0 → 0.13.1

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.
@@ -0,0 +1,1825 @@
1
+ // src/tools/builtin-tools.ts
2
+ var BUILTIN_TOOL_EXECUTE_SQL = "execute_sql";
3
+ var BUILTIN_TOOL_SEARCH_OBJECTS = "search_objects";
4
+ var BUILTIN_TOOLS = [
5
+ BUILTIN_TOOL_EXECUTE_SQL,
6
+ BUILTIN_TOOL_SEARCH_OBJECTS
7
+ ];
8
+
9
+ // src/connectors/interface.ts
10
+ var _ConnectorRegistry = class _ConnectorRegistry {
11
+ /**
12
+ * Register a new connector
13
+ */
14
+ static register(connector) {
15
+ _ConnectorRegistry.connectors.set(connector.id, connector);
16
+ }
17
+ /**
18
+ * Get a connector by ID
19
+ */
20
+ static getConnector(id) {
21
+ return _ConnectorRegistry.connectors.get(id) || null;
22
+ }
23
+ /**
24
+ * Get connector for a DSN string
25
+ * Tries to find a connector that can handle the given DSN format
26
+ */
27
+ static getConnectorForDSN(dsn) {
28
+ for (const connector of _ConnectorRegistry.connectors.values()) {
29
+ if (connector.dsnParser.isValidDSN(dsn)) {
30
+ return connector;
31
+ }
32
+ }
33
+ return null;
34
+ }
35
+ /**
36
+ * Get all available connector IDs
37
+ */
38
+ static getAvailableConnectors() {
39
+ return Array.from(_ConnectorRegistry.connectors.keys());
40
+ }
41
+ /**
42
+ * Get sample DSN for a specific connector
43
+ */
44
+ static getSampleDSN(connectorType) {
45
+ const connector = _ConnectorRegistry.getConnector(connectorType);
46
+ if (!connector) return null;
47
+ return connector.dsnParser.getSampleDSN();
48
+ }
49
+ /**
50
+ * Get all available sample DSNs
51
+ */
52
+ static getAllSampleDSNs() {
53
+ const samples = {};
54
+ for (const [id, connector] of _ConnectorRegistry.connectors.entries()) {
55
+ samples[id] = connector.dsnParser.getSampleDSN();
56
+ }
57
+ return samples;
58
+ }
59
+ };
60
+ _ConnectorRegistry.connectors = /* @__PURE__ */ new Map();
61
+ var ConnectorRegistry = _ConnectorRegistry;
62
+
63
+ // src/utils/ssh-tunnel.ts
64
+ import { Client } from "ssh2";
65
+ import { readFileSync } from "fs";
66
+ import { createServer } from "net";
67
+ var SSHTunnel = class {
68
+ constructor() {
69
+ this.sshClient = null;
70
+ this.localServer = null;
71
+ this.tunnelInfo = null;
72
+ this.isConnected = false;
73
+ }
74
+ /**
75
+ * Establish an SSH tunnel
76
+ * @param config SSH connection configuration
77
+ * @param options Tunnel options including target host and port
78
+ * @returns Promise resolving to tunnel information including local port
79
+ */
80
+ async establish(config, options) {
81
+ if (this.isConnected) {
82
+ throw new Error("SSH tunnel is already established");
83
+ }
84
+ return new Promise((resolve, reject) => {
85
+ this.sshClient = new Client();
86
+ const sshConfig = {
87
+ host: config.host,
88
+ port: config.port || 22,
89
+ username: config.username
90
+ };
91
+ if (config.password) {
92
+ sshConfig.password = config.password;
93
+ } else if (config.privateKey) {
94
+ try {
95
+ const privateKey = readFileSync(config.privateKey);
96
+ sshConfig.privateKey = privateKey;
97
+ if (config.passphrase) {
98
+ sshConfig.passphrase = config.passphrase;
99
+ }
100
+ } catch (error) {
101
+ reject(new Error(`Failed to read private key file: ${error instanceof Error ? error.message : String(error)}`));
102
+ return;
103
+ }
104
+ } else {
105
+ reject(new Error("Either password or privateKey must be provided for SSH authentication"));
106
+ return;
107
+ }
108
+ this.sshClient.on("error", (err) => {
109
+ this.cleanup();
110
+ reject(new Error(`SSH connection error: ${err.message}`));
111
+ });
112
+ this.sshClient.on("ready", () => {
113
+ console.error("SSH connection established");
114
+ this.localServer = createServer((localSocket) => {
115
+ this.sshClient.forwardOut(
116
+ "127.0.0.1",
117
+ 0,
118
+ options.targetHost,
119
+ options.targetPort,
120
+ (err, stream) => {
121
+ if (err) {
122
+ console.error("SSH forward error:", err);
123
+ localSocket.end();
124
+ return;
125
+ }
126
+ localSocket.pipe(stream).pipe(localSocket);
127
+ stream.on("error", (err2) => {
128
+ console.error("SSH stream error:", err2);
129
+ localSocket.end();
130
+ });
131
+ localSocket.on("error", (err2) => {
132
+ console.error("Local socket error:", err2);
133
+ stream.end();
134
+ });
135
+ }
136
+ );
137
+ });
138
+ const localPort = options.localPort || 0;
139
+ this.localServer.listen(localPort, "127.0.0.1", () => {
140
+ const address = this.localServer.address();
141
+ if (!address || typeof address === "string") {
142
+ this.cleanup();
143
+ reject(new Error("Failed to get local server address"));
144
+ return;
145
+ }
146
+ this.tunnelInfo = {
147
+ localPort: address.port,
148
+ targetHost: options.targetHost,
149
+ targetPort: options.targetPort
150
+ };
151
+ this.isConnected = true;
152
+ console.error(`SSH tunnel established: localhost:${address.port} -> ${options.targetHost}:${options.targetPort}`);
153
+ resolve(this.tunnelInfo);
154
+ });
155
+ this.localServer.on("error", (err) => {
156
+ this.cleanup();
157
+ reject(new Error(`Local server error: ${err.message}`));
158
+ });
159
+ });
160
+ this.sshClient.connect(sshConfig);
161
+ });
162
+ }
163
+ /**
164
+ * Close the SSH tunnel and clean up resources
165
+ */
166
+ async close() {
167
+ if (!this.isConnected) {
168
+ return;
169
+ }
170
+ return new Promise((resolve) => {
171
+ this.cleanup();
172
+ this.isConnected = false;
173
+ console.error("SSH tunnel closed");
174
+ resolve();
175
+ });
176
+ }
177
+ /**
178
+ * Clean up resources
179
+ */
180
+ cleanup() {
181
+ if (this.localServer) {
182
+ this.localServer.close();
183
+ this.localServer = null;
184
+ }
185
+ if (this.sshClient) {
186
+ this.sshClient.end();
187
+ this.sshClient = null;
188
+ }
189
+ this.tunnelInfo = null;
190
+ }
191
+ /**
192
+ * Get current tunnel information
193
+ */
194
+ getTunnelInfo() {
195
+ return this.tunnelInfo;
196
+ }
197
+ /**
198
+ * Check if tunnel is connected
199
+ */
200
+ getIsConnected() {
201
+ return this.isConnected;
202
+ }
203
+ };
204
+
205
+ // src/config/toml-loader.ts
206
+ import fs2 from "fs";
207
+ import path2 from "path";
208
+ import { homedir as homedir3 } from "os";
209
+ import toml from "@iarna/toml";
210
+
211
+ // src/config/env.ts
212
+ import dotenv from "dotenv";
213
+ import path from "path";
214
+ import fs from "fs";
215
+ import { fileURLToPath } from "url";
216
+ import { homedir as homedir2 } from "os";
217
+
218
+ // src/utils/ssh-config-parser.ts
219
+ import { readFileSync as readFileSync2, existsSync } from "fs";
220
+ import { homedir } from "os";
221
+ import { join } from "path";
222
+ import SSHConfig from "ssh-config";
223
+ var DEFAULT_SSH_KEYS = [
224
+ "~/.ssh/id_rsa",
225
+ "~/.ssh/id_ed25519",
226
+ "~/.ssh/id_ecdsa",
227
+ "~/.ssh/id_dsa"
228
+ ];
229
+ function expandTilde(filePath) {
230
+ if (filePath.startsWith("~/")) {
231
+ return join(homedir(), filePath.substring(2));
232
+ }
233
+ return filePath;
234
+ }
235
+ function fileExists(filePath) {
236
+ try {
237
+ return existsSync(expandTilde(filePath));
238
+ } catch {
239
+ return false;
240
+ }
241
+ }
242
+ function findDefaultSSHKey() {
243
+ for (const keyPath of DEFAULT_SSH_KEYS) {
244
+ if (fileExists(keyPath)) {
245
+ return expandTilde(keyPath);
246
+ }
247
+ }
248
+ return void 0;
249
+ }
250
+ function parseSSHConfig(hostAlias, configPath) {
251
+ const sshConfigPath = configPath;
252
+ if (!existsSync(sshConfigPath)) {
253
+ return null;
254
+ }
255
+ try {
256
+ const configContent = readFileSync2(sshConfigPath, "utf8");
257
+ const config = SSHConfig.parse(configContent);
258
+ const hostConfig = config.compute(hostAlias);
259
+ if (!hostConfig || !hostConfig.HostName && !hostConfig.User) {
260
+ return null;
261
+ }
262
+ const sshConfig = {};
263
+ if (hostConfig.HostName) {
264
+ sshConfig.host = hostConfig.HostName;
265
+ } else {
266
+ sshConfig.host = hostAlias;
267
+ }
268
+ if (hostConfig.Port) {
269
+ sshConfig.port = parseInt(hostConfig.Port, 10);
270
+ }
271
+ if (hostConfig.User) {
272
+ sshConfig.username = hostConfig.User;
273
+ }
274
+ if (hostConfig.IdentityFile) {
275
+ const identityFile = Array.isArray(hostConfig.IdentityFile) ? hostConfig.IdentityFile[0] : hostConfig.IdentityFile;
276
+ const expandedPath = expandTilde(identityFile);
277
+ if (fileExists(expandedPath)) {
278
+ sshConfig.privateKey = expandedPath;
279
+ }
280
+ }
281
+ if (!sshConfig.privateKey) {
282
+ const defaultKey = findDefaultSSHKey();
283
+ if (defaultKey) {
284
+ sshConfig.privateKey = defaultKey;
285
+ }
286
+ }
287
+ if (hostConfig.ProxyJump || hostConfig.ProxyCommand) {
288
+ console.error("Warning: ProxyJump/ProxyCommand in SSH config is not yet supported by DBHub");
289
+ }
290
+ if (!sshConfig.host || !sshConfig.username) {
291
+ return null;
292
+ }
293
+ return sshConfig;
294
+ } catch (error) {
295
+ console.error(`Error parsing SSH config: ${error instanceof Error ? error.message : String(error)}`);
296
+ return null;
297
+ }
298
+ }
299
+ function looksLikeSSHAlias(host) {
300
+ if (host.includes(".")) {
301
+ return false;
302
+ }
303
+ if (/^[\d:]+$/.test(host)) {
304
+ return false;
305
+ }
306
+ if (/^[0-9a-fA-F:]+$/.test(host) && host.includes(":")) {
307
+ return false;
308
+ }
309
+ return true;
310
+ }
311
+
312
+ // src/utils/safe-url.ts
313
+ var SafeURL = class {
314
+ /**
315
+ * Parse a URL and handle special characters in passwords
316
+ * This is a safe alternative to the URL constructor
317
+ *
318
+ * @param urlString - The DSN string to parse
319
+ */
320
+ constructor(urlString) {
321
+ this.protocol = "";
322
+ this.hostname = "";
323
+ this.port = "";
324
+ this.pathname = "";
325
+ this.username = "";
326
+ this.password = "";
327
+ this.searchParams = /* @__PURE__ */ new Map();
328
+ if (!urlString || urlString.trim() === "") {
329
+ throw new Error("URL string cannot be empty");
330
+ }
331
+ try {
332
+ const protocolSeparator = urlString.indexOf("://");
333
+ if (protocolSeparator !== -1) {
334
+ this.protocol = urlString.substring(0, protocolSeparator + 1);
335
+ urlString = urlString.substring(protocolSeparator + 3);
336
+ } else {
337
+ throw new Error('Invalid URL format: missing protocol (e.g., "mysql://")');
338
+ }
339
+ const questionMarkIndex = urlString.indexOf("?");
340
+ let queryParams = "";
341
+ if (questionMarkIndex !== -1) {
342
+ queryParams = urlString.substring(questionMarkIndex + 1);
343
+ urlString = urlString.substring(0, questionMarkIndex);
344
+ queryParams.split("&").forEach((pair) => {
345
+ const parts = pair.split("=");
346
+ if (parts.length === 2 && parts[0] && parts[1]) {
347
+ this.searchParams.set(parts[0], decodeURIComponent(parts[1]));
348
+ }
349
+ });
350
+ }
351
+ const atIndex = urlString.indexOf("@");
352
+ if (atIndex !== -1) {
353
+ const auth = urlString.substring(0, atIndex);
354
+ urlString = urlString.substring(atIndex + 1);
355
+ const colonIndex2 = auth.indexOf(":");
356
+ if (colonIndex2 !== -1) {
357
+ this.username = auth.substring(0, colonIndex2);
358
+ this.password = auth.substring(colonIndex2 + 1);
359
+ this.username = decodeURIComponent(this.username);
360
+ this.password = decodeURIComponent(this.password);
361
+ } else {
362
+ this.username = auth;
363
+ }
364
+ }
365
+ const pathSeparatorIndex = urlString.indexOf("/");
366
+ if (pathSeparatorIndex !== -1) {
367
+ this.pathname = urlString.substring(pathSeparatorIndex);
368
+ urlString = urlString.substring(0, pathSeparatorIndex);
369
+ }
370
+ const colonIndex = urlString.indexOf(":");
371
+ if (colonIndex !== -1) {
372
+ this.hostname = urlString.substring(0, colonIndex);
373
+ this.port = urlString.substring(colonIndex + 1);
374
+ } else {
375
+ this.hostname = urlString;
376
+ }
377
+ if (this.protocol === "") {
378
+ throw new Error("Invalid URL: protocol is required");
379
+ }
380
+ } catch (error) {
381
+ throw new Error(`Failed to parse URL: ${error instanceof Error ? error.message : String(error)}`);
382
+ }
383
+ }
384
+ /**
385
+ * Helper method to safely get a parameter from query string
386
+ *
387
+ * @param name - The parameter name to retrieve
388
+ * @returns The parameter value or null if not found
389
+ */
390
+ getSearchParam(name) {
391
+ return this.searchParams.has(name) ? this.searchParams.get(name) : null;
392
+ }
393
+ /**
394
+ * Helper method to iterate over all parameters
395
+ *
396
+ * @param callback - Function to call for each parameter
397
+ */
398
+ forEachSearchParam(callback) {
399
+ this.searchParams.forEach((value, key) => callback(value, key));
400
+ }
401
+ };
402
+
403
+ // src/utils/dsn-obfuscate.ts
404
+ function parseConnectionInfoFromDSN(dsn) {
405
+ if (!dsn) {
406
+ return null;
407
+ }
408
+ try {
409
+ const type = getDatabaseTypeFromDSN(dsn);
410
+ if (typeof type === "undefined") {
411
+ return null;
412
+ }
413
+ if (type === "sqlite") {
414
+ const prefix = "sqlite:///";
415
+ if (dsn.length > prefix.length) {
416
+ const rawPath = dsn.substring(prefix.length);
417
+ const firstChar = rawPath[0];
418
+ const isWindowsDrive = rawPath.length > 1 && rawPath[1] === ":";
419
+ const isSpecialPath = firstChar === ":" || firstChar === "." || firstChar === "~" || isWindowsDrive;
420
+ return {
421
+ type,
422
+ database: isSpecialPath ? rawPath : "/" + rawPath
423
+ };
424
+ }
425
+ return { type };
426
+ }
427
+ const url = new SafeURL(dsn);
428
+ const info = { type };
429
+ if (url.hostname) {
430
+ info.host = url.hostname;
431
+ }
432
+ if (url.port) {
433
+ info.port = parseInt(url.port, 10);
434
+ }
435
+ if (url.pathname && url.pathname.length > 1) {
436
+ info.database = url.pathname.substring(1);
437
+ }
438
+ if (url.username) {
439
+ info.user = url.username;
440
+ }
441
+ return info;
442
+ } catch {
443
+ return null;
444
+ }
445
+ }
446
+ function obfuscateDSNPassword(dsn) {
447
+ if (!dsn) {
448
+ return dsn;
449
+ }
450
+ try {
451
+ const type = getDatabaseTypeFromDSN(dsn);
452
+ if (type === "sqlite") {
453
+ return dsn;
454
+ }
455
+ const url = new SafeURL(dsn);
456
+ if (!url.password) {
457
+ return dsn;
458
+ }
459
+ const obfuscatedPassword = "*".repeat(Math.min(url.password.length, 8));
460
+ const protocol = dsn.split(":")[0];
461
+ let result;
462
+ if (url.username) {
463
+ result = `${protocol}://${url.username}:${obfuscatedPassword}@${url.hostname}`;
464
+ } else {
465
+ result = `${protocol}://${obfuscatedPassword}@${url.hostname}`;
466
+ }
467
+ if (url.port) {
468
+ result += `:${url.port}`;
469
+ }
470
+ result += url.pathname;
471
+ if (url.searchParams.size > 0) {
472
+ const params = [];
473
+ url.forEachSearchParam((value, key) => {
474
+ params.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
475
+ });
476
+ result += `?${params.join("&")}`;
477
+ }
478
+ return result;
479
+ } catch {
480
+ return dsn;
481
+ }
482
+ }
483
+ function getDatabaseTypeFromDSN(dsn) {
484
+ if (!dsn) {
485
+ return void 0;
486
+ }
487
+ const protocol = dsn.split(":")[0];
488
+ return protocolToConnectorType(protocol);
489
+ }
490
+ function protocolToConnectorType(protocol) {
491
+ const mapping = {
492
+ "postgres": "postgres",
493
+ "postgresql": "postgres",
494
+ "mysql": "mysql",
495
+ "mariadb": "mariadb",
496
+ "sqlserver": "sqlserver",
497
+ "sqlite": "sqlite"
498
+ };
499
+ return mapping[protocol];
500
+ }
501
+ function getDefaultPortForType(type) {
502
+ const ports = {
503
+ "postgres": 5432,
504
+ "mysql": 3306,
505
+ "mariadb": 3306,
506
+ "sqlserver": 1433,
507
+ "sqlite": void 0
508
+ };
509
+ return ports[type];
510
+ }
511
+
512
+ // src/config/env.ts
513
+ var __filename = fileURLToPath(import.meta.url);
514
+ var __dirname = path.dirname(__filename);
515
+ function parseCommandLineArgs() {
516
+ const args = process.argv.slice(2);
517
+ const parsedManually = {};
518
+ for (let i = 0; i < args.length; i++) {
519
+ const arg = args[i];
520
+ if (arg.startsWith("--")) {
521
+ const parts = arg.substring(2).split("=");
522
+ const key = parts[0];
523
+ if (key === "readonly") {
524
+ console.error("\nERROR: --readonly flag is no longer supported.");
525
+ console.error("Use dbhub.toml with [[tools]] configuration instead:\n");
526
+ console.error(" [[sources]]");
527
+ console.error(' id = "default"');
528
+ console.error(' dsn = "..."\n');
529
+ console.error(" [[tools]]");
530
+ console.error(' name = "execute_sql"');
531
+ console.error(' source = "default"');
532
+ console.error(" readonly = true\n");
533
+ console.error("See https://dbhub.ai/tools/execute-sql#read-only-mode for details.\n");
534
+ process.exit(1);
535
+ }
536
+ if (key === "max-rows") {
537
+ console.error("\nERROR: --max-rows flag is no longer supported.");
538
+ console.error("Use dbhub.toml with [[tools]] configuration instead:\n");
539
+ console.error(" [[sources]]");
540
+ console.error(' id = "default"');
541
+ console.error(' dsn = "..."\n');
542
+ console.error(" [[tools]]");
543
+ console.error(' name = "execute_sql"');
544
+ console.error(' source = "default"');
545
+ console.error(" max_rows = 1000\n");
546
+ console.error("See https://dbhub.ai/tools/execute-sql#row-limiting for details.\n");
547
+ process.exit(1);
548
+ }
549
+ const value = parts.length > 1 ? parts.slice(1).join("=") : void 0;
550
+ if (value) {
551
+ parsedManually[key] = value;
552
+ } else if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
553
+ parsedManually[key] = args[i + 1];
554
+ i++;
555
+ } else {
556
+ parsedManually[key] = "true";
557
+ }
558
+ }
559
+ }
560
+ return parsedManually;
561
+ }
562
+ function loadEnvFiles() {
563
+ const isDevelopment = process.env.NODE_ENV === "development" || process.argv[1]?.includes("tsx");
564
+ const envFileNames = isDevelopment ? [".env.local", ".env"] : [".env"];
565
+ const envPaths = [];
566
+ for (const fileName of envFileNames) {
567
+ envPaths.push(
568
+ fileName,
569
+ // Current working directory
570
+ path.join(__dirname, "..", "..", fileName),
571
+ // Two levels up (src/config -> src -> root)
572
+ path.join(process.cwd(), fileName)
573
+ // Explicit current working directory
574
+ );
575
+ }
576
+ for (const envPath of envPaths) {
577
+ console.error(`Checking for env file: ${envPath}`);
578
+ if (fs.existsSync(envPath)) {
579
+ dotenv.config({ path: envPath });
580
+ if (process.env.READONLY !== void 0) {
581
+ console.error("\nERROR: READONLY environment variable is no longer supported.");
582
+ console.error("Use dbhub.toml with [[tools]] configuration instead:\n");
583
+ console.error(" [[sources]]");
584
+ console.error(' id = "default"');
585
+ console.error(' dsn = "..."\n');
586
+ console.error(" [[tools]]");
587
+ console.error(' name = "execute_sql"');
588
+ console.error(' source = "default"');
589
+ console.error(" readonly = true\n");
590
+ console.error("See https://dbhub.ai/tools/execute-sql#read-only-mode for details.\n");
591
+ process.exit(1);
592
+ }
593
+ if (process.env.MAX_ROWS !== void 0) {
594
+ console.error("\nERROR: MAX_ROWS environment variable is no longer supported.");
595
+ console.error("Use dbhub.toml with [[tools]] configuration instead:\n");
596
+ console.error(" [[sources]]");
597
+ console.error(' id = "default"');
598
+ console.error(' dsn = "..."\n');
599
+ console.error(" [[tools]]");
600
+ console.error(' name = "execute_sql"');
601
+ console.error(' source = "default"');
602
+ console.error(" max_rows = 1000\n");
603
+ console.error("See https://dbhub.ai/tools/execute-sql#row-limiting for details.\n");
604
+ process.exit(1);
605
+ }
606
+ return path.basename(envPath);
607
+ }
608
+ }
609
+ return null;
610
+ }
611
+ function isDemoMode() {
612
+ const args = parseCommandLineArgs();
613
+ return args.demo === "true";
614
+ }
615
+ function buildDSNFromEnvParams() {
616
+ const dbType = process.env.DB_TYPE;
617
+ const dbHost = process.env.DB_HOST;
618
+ const dbUser = process.env.DB_USER;
619
+ const dbPassword = process.env.DB_PASSWORD;
620
+ const dbName = process.env.DB_NAME;
621
+ const dbPort = process.env.DB_PORT;
622
+ if (dbType?.toLowerCase() === "sqlite") {
623
+ if (!dbName) {
624
+ return null;
625
+ }
626
+ } else {
627
+ if (!dbType || !dbHost || !dbUser || !dbPassword || !dbName) {
628
+ return null;
629
+ }
630
+ }
631
+ const supportedTypes = ["postgres", "postgresql", "mysql", "mariadb", "sqlserver", "sqlite"];
632
+ if (!supportedTypes.includes(dbType.toLowerCase())) {
633
+ throw new Error(`Unsupported DB_TYPE: ${dbType}. Supported types: ${supportedTypes.join(", ")}`);
634
+ }
635
+ let port = dbPort;
636
+ if (!port) {
637
+ switch (dbType.toLowerCase()) {
638
+ case "postgres":
639
+ case "postgresql":
640
+ port = "5432";
641
+ break;
642
+ case "mysql":
643
+ case "mariadb":
644
+ port = "3306";
645
+ break;
646
+ case "sqlserver":
647
+ port = "1433";
648
+ break;
649
+ case "sqlite":
650
+ return {
651
+ dsn: `sqlite:///${dbName}`,
652
+ source: "individual environment variables"
653
+ };
654
+ default:
655
+ throw new Error(`Unknown database type for port determination: ${dbType}`);
656
+ }
657
+ }
658
+ const user = dbUser;
659
+ const password = dbPassword;
660
+ const dbNameStr = dbName;
661
+ const encodedUser = encodeURIComponent(user);
662
+ const encodedPassword = encodeURIComponent(password);
663
+ const encodedDbName = encodeURIComponent(dbNameStr);
664
+ const protocol = dbType.toLowerCase() === "postgresql" ? "postgres" : dbType.toLowerCase();
665
+ const dsn = `${protocol}://${encodedUser}:${encodedPassword}@${dbHost}:${port}/${encodedDbName}`;
666
+ return {
667
+ dsn,
668
+ source: "individual environment variables"
669
+ };
670
+ }
671
+ function resolveDSN() {
672
+ const args = parseCommandLineArgs();
673
+ if (isDemoMode()) {
674
+ return {
675
+ dsn: "sqlite:///:memory:",
676
+ source: "demo mode",
677
+ isDemo: true
678
+ };
679
+ }
680
+ if (args.dsn) {
681
+ return { dsn: args.dsn, source: "command line argument" };
682
+ }
683
+ if (process.env.DSN) {
684
+ return { dsn: process.env.DSN, source: "environment variable" };
685
+ }
686
+ const envParamsResult = buildDSNFromEnvParams();
687
+ if (envParamsResult) {
688
+ return envParamsResult;
689
+ }
690
+ const loadedEnvFile = loadEnvFiles();
691
+ if (loadedEnvFile && process.env.DSN) {
692
+ return { dsn: process.env.DSN, source: `${loadedEnvFile} file` };
693
+ }
694
+ if (loadedEnvFile) {
695
+ const envFileParamsResult = buildDSNFromEnvParams();
696
+ if (envFileParamsResult) {
697
+ return {
698
+ dsn: envFileParamsResult.dsn,
699
+ source: `${loadedEnvFile} file (individual parameters)`
700
+ };
701
+ }
702
+ }
703
+ return null;
704
+ }
705
+ function resolveTransport() {
706
+ const args = parseCommandLineArgs();
707
+ if (args.transport) {
708
+ const type = args.transport === "http" ? "http" : "stdio";
709
+ return { type, source: "command line argument" };
710
+ }
711
+ if (process.env.TRANSPORT) {
712
+ const type = process.env.TRANSPORT === "http" ? "http" : "stdio";
713
+ return { type, source: "environment variable" };
714
+ }
715
+ return { type: "stdio", source: "default" };
716
+ }
717
+ function resolvePort() {
718
+ const args = parseCommandLineArgs();
719
+ if (args.port) {
720
+ const port = parseInt(args.port, 10);
721
+ return { port, source: "command line argument" };
722
+ }
723
+ if (process.env.PORT) {
724
+ const port = parseInt(process.env.PORT, 10);
725
+ return { port, source: "environment variable" };
726
+ }
727
+ return { port: 8080, source: "default" };
728
+ }
729
+ function redactDSN(dsn) {
730
+ try {
731
+ const url = new URL(dsn);
732
+ if (url.password) {
733
+ url.password = "*******";
734
+ }
735
+ return url.toString();
736
+ } catch (error) {
737
+ return dsn.replace(/\/\/([^:]+):([^@]+)@/, "//$1:***@");
738
+ }
739
+ }
740
+ function resolveId() {
741
+ const args = parseCommandLineArgs();
742
+ if (args.id) {
743
+ return { id: args.id, source: "command line argument" };
744
+ }
745
+ if (process.env.ID) {
746
+ return { id: process.env.ID, source: "environment variable" };
747
+ }
748
+ return null;
749
+ }
750
+ function resolveSSHConfig() {
751
+ const args = parseCommandLineArgs();
752
+ const hasSSHArgs = args["ssh-host"] || process.env.SSH_HOST;
753
+ if (!hasSSHArgs) {
754
+ return null;
755
+ }
756
+ let config = {};
757
+ let sources = [];
758
+ let sshConfigHost;
759
+ if (args["ssh-host"]) {
760
+ sshConfigHost = args["ssh-host"];
761
+ config.host = args["ssh-host"];
762
+ sources.push("ssh-host from command line");
763
+ } else if (process.env.SSH_HOST) {
764
+ sshConfigHost = process.env.SSH_HOST;
765
+ config.host = process.env.SSH_HOST;
766
+ sources.push("SSH_HOST from environment");
767
+ }
768
+ if (sshConfigHost && looksLikeSSHAlias(sshConfigHost)) {
769
+ const sshConfigPath = path.join(homedir2(), ".ssh", "config");
770
+ console.error(`Attempting to parse SSH config for host '${sshConfigHost}' from: ${sshConfigPath}`);
771
+ const sshConfigData = parseSSHConfig(sshConfigHost, sshConfigPath);
772
+ if (sshConfigData) {
773
+ config = { ...sshConfigData };
774
+ sources.push(`SSH config for host '${sshConfigHost}'`);
775
+ }
776
+ }
777
+ if (args["ssh-port"]) {
778
+ config.port = parseInt(args["ssh-port"], 10);
779
+ sources.push("ssh-port from command line");
780
+ } else if (process.env.SSH_PORT) {
781
+ config.port = parseInt(process.env.SSH_PORT, 10);
782
+ sources.push("SSH_PORT from environment");
783
+ }
784
+ if (args["ssh-user"]) {
785
+ config.username = args["ssh-user"];
786
+ sources.push("ssh-user from command line");
787
+ } else if (process.env.SSH_USER) {
788
+ config.username = process.env.SSH_USER;
789
+ sources.push("SSH_USER from environment");
790
+ }
791
+ if (args["ssh-password"]) {
792
+ config.password = args["ssh-password"];
793
+ sources.push("ssh-password from command line");
794
+ } else if (process.env.SSH_PASSWORD) {
795
+ config.password = process.env.SSH_PASSWORD;
796
+ sources.push("SSH_PASSWORD from environment");
797
+ }
798
+ if (args["ssh-key"]) {
799
+ config.privateKey = args["ssh-key"];
800
+ if (config.privateKey.startsWith("~/")) {
801
+ config.privateKey = path.join(process.env.HOME || "", config.privateKey.substring(2));
802
+ }
803
+ sources.push("ssh-key from command line");
804
+ } else if (process.env.SSH_KEY) {
805
+ config.privateKey = process.env.SSH_KEY;
806
+ if (config.privateKey.startsWith("~/")) {
807
+ config.privateKey = path.join(process.env.HOME || "", config.privateKey.substring(2));
808
+ }
809
+ sources.push("SSH_KEY from environment");
810
+ }
811
+ if (args["ssh-passphrase"]) {
812
+ config.passphrase = args["ssh-passphrase"];
813
+ sources.push("ssh-passphrase from command line");
814
+ } else if (process.env.SSH_PASSPHRASE) {
815
+ config.passphrase = process.env.SSH_PASSPHRASE;
816
+ sources.push("SSH_PASSPHRASE from environment");
817
+ }
818
+ if (!config.host || !config.username) {
819
+ throw new Error("SSH tunnel configuration requires at least --ssh-host and --ssh-user");
820
+ }
821
+ if (!config.password && !config.privateKey) {
822
+ throw new Error("SSH tunnel configuration requires either --ssh-password or --ssh-key for authentication");
823
+ }
824
+ return {
825
+ config,
826
+ source: sources.join(", ")
827
+ };
828
+ }
829
+ async function resolveSourceConfigs() {
830
+ if (!isDemoMode()) {
831
+ const tomlConfig = loadTomlConfig();
832
+ if (tomlConfig) {
833
+ const idData = resolveId();
834
+ if (idData) {
835
+ throw new Error(
836
+ "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."
837
+ );
838
+ }
839
+ return tomlConfig;
840
+ }
841
+ }
842
+ const dsnResult = resolveDSN();
843
+ if (dsnResult) {
844
+ let dsnUrl;
845
+ try {
846
+ dsnUrl = new SafeURL(dsnResult.dsn);
847
+ } catch (error) {
848
+ throw new Error(
849
+ `Invalid DSN format: ${dsnResult.dsn}. Expected format: protocol://[user[:password]@]host[:port]/database`
850
+ );
851
+ }
852
+ const protocol = dsnUrl.protocol.replace(":", "");
853
+ let dbType;
854
+ if (protocol === "postgresql" || protocol === "postgres") {
855
+ dbType = "postgres";
856
+ } else if (protocol === "mysql") {
857
+ dbType = "mysql";
858
+ } else if (protocol === "mariadb") {
859
+ dbType = "mariadb";
860
+ } else if (protocol === "sqlserver") {
861
+ dbType = "sqlserver";
862
+ } else if (protocol === "sqlite") {
863
+ dbType = "sqlite";
864
+ } else {
865
+ throw new Error(`Unsupported database type in DSN: ${protocol}`);
866
+ }
867
+ const idData = resolveId();
868
+ const sourceId = idData?.id || "default";
869
+ const source = {
870
+ id: sourceId,
871
+ type: dbType,
872
+ dsn: dsnResult.dsn
873
+ };
874
+ const connectionInfo = parseConnectionInfoFromDSN(dsnResult.dsn);
875
+ if (connectionInfo) {
876
+ if (connectionInfo.host) {
877
+ source.host = connectionInfo.host;
878
+ }
879
+ if (connectionInfo.port !== void 0) {
880
+ source.port = connectionInfo.port;
881
+ }
882
+ if (connectionInfo.database) {
883
+ source.database = connectionInfo.database;
884
+ }
885
+ if (connectionInfo.user) {
886
+ source.user = connectionInfo.user;
887
+ }
888
+ }
889
+ const sshResult = resolveSSHConfig();
890
+ if (sshResult) {
891
+ source.ssh_host = sshResult.config.host;
892
+ source.ssh_port = sshResult.config.port;
893
+ source.ssh_user = sshResult.config.username;
894
+ source.ssh_password = sshResult.config.password;
895
+ source.ssh_key = sshResult.config.privateKey;
896
+ source.ssh_passphrase = sshResult.config.passphrase;
897
+ }
898
+ if (dsnResult.isDemo) {
899
+ const { getSqliteInMemorySetupSql } = await import("./demo-loader-PSMTLZ2T.js");
900
+ source.init_script = getSqliteInMemorySetupSql();
901
+ }
902
+ return {
903
+ sources: [source],
904
+ tools: [],
905
+ source: dsnResult.isDemo ? "demo mode" : dsnResult.source
906
+ };
907
+ }
908
+ return null;
909
+ }
910
+
911
+ // src/config/toml-loader.ts
912
+ function loadTomlConfig() {
913
+ const configPath = resolveTomlConfigPath();
914
+ if (!configPath) {
915
+ return null;
916
+ }
917
+ try {
918
+ const fileContent = fs2.readFileSync(configPath, "utf-8");
919
+ const parsedToml = toml.parse(fileContent);
920
+ validateTomlConfig(parsedToml, configPath);
921
+ const sources = processSourceConfigs(parsedToml.sources, configPath);
922
+ return {
923
+ sources,
924
+ tools: parsedToml.tools,
925
+ source: path2.basename(configPath)
926
+ };
927
+ } catch (error) {
928
+ if (error instanceof Error) {
929
+ throw new Error(
930
+ `Failed to load TOML configuration from ${configPath}: ${error.message}`
931
+ );
932
+ }
933
+ throw error;
934
+ }
935
+ }
936
+ function resolveTomlConfigPath() {
937
+ const args = parseCommandLineArgs();
938
+ if (args.config) {
939
+ const configPath = expandHomeDir(args.config);
940
+ if (!fs2.existsSync(configPath)) {
941
+ throw new Error(
942
+ `Configuration file specified by --config flag not found: ${configPath}`
943
+ );
944
+ }
945
+ return configPath;
946
+ }
947
+ const defaultConfigPath = path2.join(process.cwd(), "dbhub.toml");
948
+ if (fs2.existsSync(defaultConfigPath)) {
949
+ return defaultConfigPath;
950
+ }
951
+ return null;
952
+ }
953
+ function validateTomlConfig(config, configPath) {
954
+ if (!config.sources) {
955
+ throw new Error(
956
+ `Configuration file ${configPath} must contain a [[sources]] array. Example:
957
+
958
+ [[sources]]
959
+ id = "my_db"
960
+ dsn = "postgres://..."`
961
+ );
962
+ }
963
+ if (!Array.isArray(config.sources)) {
964
+ throw new Error(
965
+ `Configuration file ${configPath}: 'sources' must be an array. Use [[sources]] syntax for array of tables in TOML.`
966
+ );
967
+ }
968
+ if (config.sources.length === 0) {
969
+ throw new Error(
970
+ `Configuration file ${configPath}: sources array cannot be empty. Please define at least one source with [[sources]].`
971
+ );
972
+ }
973
+ const ids = /* @__PURE__ */ new Set();
974
+ const duplicates = [];
975
+ for (const source of config.sources) {
976
+ if (!source.id) {
977
+ throw new Error(
978
+ `Configuration file ${configPath}: each source must have an 'id' field. Example: [[sources]]
979
+ id = "my_db"`
980
+ );
981
+ }
982
+ if (ids.has(source.id)) {
983
+ duplicates.push(source.id);
984
+ } else {
985
+ ids.add(source.id);
986
+ }
987
+ }
988
+ if (duplicates.length > 0) {
989
+ throw new Error(
990
+ `Configuration file ${configPath}: duplicate source IDs found: ${duplicates.join(", ")}. Each source must have a unique 'id' field.`
991
+ );
992
+ }
993
+ for (const source of config.sources) {
994
+ validateSourceConfig(source, configPath);
995
+ }
996
+ if (config.tools) {
997
+ validateToolsConfig(config.tools, config.sources, configPath);
998
+ }
999
+ }
1000
+ function validateToolsConfig(tools, sources, configPath) {
1001
+ const toolSourcePairs = /* @__PURE__ */ new Set();
1002
+ for (const tool of tools) {
1003
+ if (!tool.name) {
1004
+ throw new Error(
1005
+ `Configuration file ${configPath}: all tools must have a 'name' field`
1006
+ );
1007
+ }
1008
+ if (!tool.source) {
1009
+ throw new Error(
1010
+ `Configuration file ${configPath}: tool '${tool.name}' must have a 'source' field`
1011
+ );
1012
+ }
1013
+ const pairKey = `${tool.name}:${tool.source}`;
1014
+ if (toolSourcePairs.has(pairKey)) {
1015
+ throw new Error(
1016
+ `Configuration file ${configPath}: duplicate tool configuration found for '${tool.name}' on source '${tool.source}'`
1017
+ );
1018
+ }
1019
+ toolSourcePairs.add(pairKey);
1020
+ if (!sources.some((s) => s.id === tool.source)) {
1021
+ throw new Error(
1022
+ `Configuration file ${configPath}: tool '${tool.name}' references unknown source '${tool.source}'`
1023
+ );
1024
+ }
1025
+ const isBuiltin = BUILTIN_TOOLS.includes(tool.name);
1026
+ const isExecuteSql = tool.name === BUILTIN_TOOL_EXECUTE_SQL;
1027
+ if (isBuiltin) {
1028
+ if (tool.description || tool.statement || tool.parameters) {
1029
+ throw new Error(
1030
+ `Configuration file ${configPath}: built-in tool '${tool.name}' cannot have description, statement, or parameters fields`
1031
+ );
1032
+ }
1033
+ if (!isExecuteSql && (tool.readonly !== void 0 || tool.max_rows !== void 0)) {
1034
+ throw new Error(
1035
+ `Configuration file ${configPath}: tool '${tool.name}' cannot have readonly or max_rows fields (these are only valid for ${BUILTIN_TOOL_EXECUTE_SQL} tool)`
1036
+ );
1037
+ }
1038
+ } else {
1039
+ if (!tool.description || !tool.statement) {
1040
+ throw new Error(
1041
+ `Configuration file ${configPath}: custom tool '${tool.name}' must have 'description' and 'statement' fields`
1042
+ );
1043
+ }
1044
+ if (tool.readonly !== void 0 || tool.max_rows !== void 0) {
1045
+ throw new Error(
1046
+ `Configuration file ${configPath}: custom tool '${tool.name}' cannot have readonly or max_rows fields (these are only valid for ${BUILTIN_TOOL_EXECUTE_SQL} tool)`
1047
+ );
1048
+ }
1049
+ }
1050
+ if (tool.max_rows !== void 0) {
1051
+ if (typeof tool.max_rows !== "number" || tool.max_rows <= 0) {
1052
+ throw new Error(
1053
+ `Configuration file ${configPath}: tool '${tool.name}' has invalid max_rows. Must be a positive integer.`
1054
+ );
1055
+ }
1056
+ }
1057
+ }
1058
+ }
1059
+ function validateSourceConfig(source, configPath) {
1060
+ const hasConnectionParams = source.type && (source.type === "sqlite" ? source.database : source.host);
1061
+ if (!source.dsn && !hasConnectionParams) {
1062
+ throw new Error(
1063
+ `Configuration file ${configPath}: source '${source.id}' must have either:
1064
+ - 'dsn' field (e.g., dsn = "postgres://user:pass@host:5432/dbname")
1065
+ - OR connection parameters (type, host, database, user, password)
1066
+ - For SQLite: type = "sqlite" and database path`
1067
+ );
1068
+ }
1069
+ if (source.type) {
1070
+ const validTypes = ["postgres", "mysql", "mariadb", "sqlserver", "sqlite"];
1071
+ if (!validTypes.includes(source.type)) {
1072
+ throw new Error(
1073
+ `Configuration file ${configPath}: source '${source.id}' has invalid type '${source.type}'. Valid types: ${validTypes.join(", ")}`
1074
+ );
1075
+ }
1076
+ }
1077
+ if (source.connection_timeout !== void 0) {
1078
+ if (typeof source.connection_timeout !== "number" || source.connection_timeout <= 0) {
1079
+ throw new Error(
1080
+ `Configuration file ${configPath}: source '${source.id}' has invalid connection_timeout. Must be a positive number (in seconds).`
1081
+ );
1082
+ }
1083
+ }
1084
+ if (source.request_timeout !== void 0) {
1085
+ if (typeof source.request_timeout !== "number" || source.request_timeout <= 0) {
1086
+ throw new Error(
1087
+ `Configuration file ${configPath}: source '${source.id}' has invalid request_timeout. Must be a positive number (in seconds).`
1088
+ );
1089
+ }
1090
+ }
1091
+ if (source.ssh_port !== void 0) {
1092
+ if (typeof source.ssh_port !== "number" || source.ssh_port <= 0 || source.ssh_port > 65535) {
1093
+ throw new Error(
1094
+ `Configuration file ${configPath}: source '${source.id}' has invalid ssh_port. Must be between 1 and 65535.`
1095
+ );
1096
+ }
1097
+ }
1098
+ }
1099
+ function processSourceConfigs(sources, configPath) {
1100
+ return sources.map((source) => {
1101
+ const processed = { ...source };
1102
+ if (processed.ssh_key) {
1103
+ processed.ssh_key = expandHomeDir(processed.ssh_key);
1104
+ }
1105
+ if (processed.type === "sqlite" && processed.database) {
1106
+ processed.database = expandHomeDir(processed.database);
1107
+ }
1108
+ if (processed.dsn && processed.dsn.startsWith("sqlite:///~")) {
1109
+ processed.dsn = `sqlite:///${expandHomeDir(processed.dsn.substring(11))}`;
1110
+ }
1111
+ if (processed.dsn) {
1112
+ const connectionInfo = parseConnectionInfoFromDSN(processed.dsn);
1113
+ if (connectionInfo) {
1114
+ if (!processed.type && connectionInfo.type) {
1115
+ processed.type = connectionInfo.type;
1116
+ }
1117
+ if (!processed.host && connectionInfo.host) {
1118
+ processed.host = connectionInfo.host;
1119
+ }
1120
+ if (processed.port === void 0 && connectionInfo.port !== void 0) {
1121
+ processed.port = connectionInfo.port;
1122
+ }
1123
+ if (!processed.database && connectionInfo.database) {
1124
+ processed.database = connectionInfo.database;
1125
+ }
1126
+ if (!processed.user && connectionInfo.user) {
1127
+ processed.user = connectionInfo.user;
1128
+ }
1129
+ }
1130
+ }
1131
+ return processed;
1132
+ });
1133
+ }
1134
+ function expandHomeDir(filePath) {
1135
+ if (filePath.startsWith("~/")) {
1136
+ return path2.join(homedir3(), filePath.substring(2));
1137
+ }
1138
+ return filePath;
1139
+ }
1140
+ function buildDSNFromSource(source) {
1141
+ if (source.dsn) {
1142
+ return source.dsn;
1143
+ }
1144
+ if (!source.type) {
1145
+ throw new Error(
1146
+ `Source '${source.id}': 'type' field is required when 'dsn' is not provided`
1147
+ );
1148
+ }
1149
+ if (source.type === "sqlite") {
1150
+ if (!source.database) {
1151
+ throw new Error(
1152
+ `Source '${source.id}': 'database' field is required for SQLite`
1153
+ );
1154
+ }
1155
+ return `sqlite:///${source.database}`;
1156
+ }
1157
+ if (!source.host || !source.user || !source.password || !source.database) {
1158
+ throw new Error(
1159
+ `Source '${source.id}': missing required connection parameters. Required: type, host, user, password, database`
1160
+ );
1161
+ }
1162
+ const port = source.port || getDefaultPortForType(source.type);
1163
+ if (!port) {
1164
+ throw new Error(`Source '${source.id}': unable to determine port`);
1165
+ }
1166
+ const encodedUser = encodeURIComponent(source.user);
1167
+ const encodedPassword = encodeURIComponent(source.password);
1168
+ const encodedDatabase = encodeURIComponent(source.database);
1169
+ let dsn = `${source.type}://${encodedUser}:${encodedPassword}@${source.host}:${port}/${encodedDatabase}`;
1170
+ if (source.type === "sqlserver" && source.instanceName) {
1171
+ dsn += `?instanceName=${encodeURIComponent(source.instanceName)}`;
1172
+ }
1173
+ return dsn;
1174
+ }
1175
+
1176
+ // src/connectors/manager.ts
1177
+ var managerInstance = null;
1178
+ var ConnectorManager = class {
1179
+ // Ordered list of source IDs (first is default)
1180
+ constructor() {
1181
+ // Maps for multi-source support
1182
+ this.connectors = /* @__PURE__ */ new Map();
1183
+ this.sshTunnels = /* @__PURE__ */ new Map();
1184
+ this.executeOptions = /* @__PURE__ */ new Map();
1185
+ this.sourceConfigs = /* @__PURE__ */ new Map();
1186
+ // Store original source configs
1187
+ this.sourceIds = [];
1188
+ if (!managerInstance) {
1189
+ managerInstance = this;
1190
+ }
1191
+ }
1192
+ /**
1193
+ * Initialize and connect to multiple databases using source configurations
1194
+ * This is the new multi-source connection method
1195
+ */
1196
+ async connectWithSources(sources) {
1197
+ if (sources.length === 0) {
1198
+ throw new Error("No sources provided");
1199
+ }
1200
+ for (const source of sources) {
1201
+ await this.connectSource(source);
1202
+ }
1203
+ }
1204
+ /**
1205
+ * Connect to a single source (helper for connectWithSources)
1206
+ */
1207
+ async connectSource(source) {
1208
+ const sourceId = source.id;
1209
+ const dsn = buildDSNFromSource(source);
1210
+ let actualDSN = dsn;
1211
+ if (source.ssh_host) {
1212
+ if (!source.ssh_user) {
1213
+ throw new Error(
1214
+ `Source '${sourceId}': SSH tunnel requires ssh_user`
1215
+ );
1216
+ }
1217
+ const sshConfig = {
1218
+ host: source.ssh_host,
1219
+ port: source.ssh_port || 22,
1220
+ username: source.ssh_user,
1221
+ password: source.ssh_password,
1222
+ privateKey: source.ssh_key,
1223
+ passphrase: source.ssh_passphrase
1224
+ };
1225
+ if (!sshConfig.password && !sshConfig.privateKey) {
1226
+ throw new Error(
1227
+ `Source '${sourceId}': SSH tunnel requires either ssh_password or ssh_key`
1228
+ );
1229
+ }
1230
+ const url = new URL(dsn);
1231
+ const targetHost = url.hostname;
1232
+ const targetPort = parseInt(url.port) || this.getDefaultPort(dsn);
1233
+ const tunnel = new SSHTunnel();
1234
+ const tunnelInfo = await tunnel.establish(sshConfig, {
1235
+ targetHost,
1236
+ targetPort
1237
+ });
1238
+ url.hostname = "127.0.0.1";
1239
+ url.port = tunnelInfo.localPort.toString();
1240
+ actualDSN = url.toString();
1241
+ this.sshTunnels.set(sourceId, tunnel);
1242
+ console.error(
1243
+ ` SSH tunnel established through localhost:${tunnelInfo.localPort}`
1244
+ );
1245
+ }
1246
+ const connectorPrototype = ConnectorRegistry.getConnectorForDSN(actualDSN);
1247
+ if (!connectorPrototype) {
1248
+ throw new Error(
1249
+ `Source '${sourceId}': No connector found for DSN: ${actualDSN}`
1250
+ );
1251
+ }
1252
+ const connector = connectorPrototype.clone();
1253
+ connector.sourceId = sourceId;
1254
+ const config = {};
1255
+ if (source.connection_timeout !== void 0) {
1256
+ config.connectionTimeoutSeconds = source.connection_timeout;
1257
+ }
1258
+ if (connector.id === "sqlserver" && source.request_timeout !== void 0) {
1259
+ config.requestTimeoutSeconds = source.request_timeout;
1260
+ }
1261
+ if (source.readonly !== void 0) {
1262
+ config.readonly = source.readonly;
1263
+ }
1264
+ await connector.connect(actualDSN, source.init_script, config);
1265
+ this.connectors.set(sourceId, connector);
1266
+ this.sourceIds.push(sourceId);
1267
+ this.sourceConfigs.set(sourceId, source);
1268
+ const options = {};
1269
+ if (source.max_rows !== void 0) {
1270
+ options.maxRows = source.max_rows;
1271
+ }
1272
+ if (source.readonly !== void 0) {
1273
+ options.readonly = source.readonly;
1274
+ }
1275
+ this.executeOptions.set(sourceId, options);
1276
+ }
1277
+ /**
1278
+ * Close all database connections
1279
+ */
1280
+ async disconnect() {
1281
+ for (const [sourceId, connector] of this.connectors.entries()) {
1282
+ try {
1283
+ await connector.disconnect();
1284
+ console.error(`Disconnected from source '${sourceId || "(default)"}'`);
1285
+ } catch (error) {
1286
+ console.error(`Error disconnecting from source '${sourceId}':`, error);
1287
+ }
1288
+ }
1289
+ for (const [sourceId, tunnel] of this.sshTunnels.entries()) {
1290
+ try {
1291
+ await tunnel.close();
1292
+ } catch (error) {
1293
+ console.error(`Error closing SSH tunnel for source '${sourceId}':`, error);
1294
+ }
1295
+ }
1296
+ this.connectors.clear();
1297
+ this.sshTunnels.clear();
1298
+ this.executeOptions.clear();
1299
+ this.sourceConfigs.clear();
1300
+ this.sourceIds = [];
1301
+ }
1302
+ /**
1303
+ * Get a connector by source ID
1304
+ * If sourceId is not provided, returns the default (first) connector
1305
+ */
1306
+ getConnector(sourceId) {
1307
+ const id = sourceId || this.sourceIds[0];
1308
+ const connector = this.connectors.get(id);
1309
+ if (!connector) {
1310
+ if (sourceId) {
1311
+ throw new Error(
1312
+ `Source '${sourceId}' not found. Available sources: ${this.sourceIds.join(", ")}`
1313
+ );
1314
+ } else {
1315
+ throw new Error("No sources connected. Call connectWithSources() first.");
1316
+ }
1317
+ }
1318
+ return connector;
1319
+ }
1320
+ /**
1321
+ * Get all available connector types
1322
+ */
1323
+ static getAvailableConnectors() {
1324
+ return ConnectorRegistry.getAvailableConnectors();
1325
+ }
1326
+ /**
1327
+ * Get sample DSNs for all available connectors
1328
+ */
1329
+ static getAllSampleDSNs() {
1330
+ return ConnectorRegistry.getAllSampleDSNs();
1331
+ }
1332
+ /**
1333
+ * Get the current active connector instance
1334
+ * This is used by resource and tool handlers
1335
+ * @param sourceId - Optional source ID. If not provided, returns default (first) connector
1336
+ */
1337
+ static getCurrentConnector(sourceId) {
1338
+ if (!managerInstance) {
1339
+ throw new Error("ConnectorManager not initialized");
1340
+ }
1341
+ return managerInstance.getConnector(sourceId);
1342
+ }
1343
+ /**
1344
+ * Get execute options for SQL execution
1345
+ * @param sourceId - Optional source ID. If not provided, returns default options
1346
+ */
1347
+ getExecuteOptions(sourceId) {
1348
+ const id = sourceId || this.sourceIds[0];
1349
+ return this.executeOptions.get(id) || {};
1350
+ }
1351
+ /**
1352
+ * Get the current execute options
1353
+ * This is used by tool handlers
1354
+ * @param sourceId - Optional source ID. If not provided, returns default options
1355
+ */
1356
+ static getCurrentExecuteOptions(sourceId) {
1357
+ if (!managerInstance) {
1358
+ throw new Error("ConnectorManager not initialized");
1359
+ }
1360
+ return managerInstance.getExecuteOptions(sourceId);
1361
+ }
1362
+ /**
1363
+ * Get all available source IDs
1364
+ */
1365
+ getSourceIds() {
1366
+ return [...this.sourceIds];
1367
+ }
1368
+ /** Get all available source IDs */
1369
+ static getAvailableSourceIds() {
1370
+ if (!managerInstance) {
1371
+ throw new Error("ConnectorManager not initialized");
1372
+ }
1373
+ return managerInstance.getSourceIds();
1374
+ }
1375
+ /**
1376
+ * Get source configuration by ID
1377
+ * @param sourceId - Source ID. If not provided, returns default (first) source config
1378
+ */
1379
+ getSourceConfig(sourceId) {
1380
+ if (this.connectors.size === 0) {
1381
+ return null;
1382
+ }
1383
+ const id = sourceId || this.sourceIds[0];
1384
+ return this.sourceConfigs.get(id) || null;
1385
+ }
1386
+ /**
1387
+ * Get all source configurations
1388
+ */
1389
+ getAllSourceConfigs() {
1390
+ return this.sourceIds.map((id) => this.sourceConfigs.get(id));
1391
+ }
1392
+ /**
1393
+ * Get source configuration by ID (static method for external access)
1394
+ */
1395
+ static getSourceConfig(sourceId) {
1396
+ if (!managerInstance) {
1397
+ throw new Error("ConnectorManager not initialized");
1398
+ }
1399
+ return managerInstance.getSourceConfig(sourceId);
1400
+ }
1401
+ /**
1402
+ * Get all source configurations (static method for external access)
1403
+ */
1404
+ static getAllSourceConfigs() {
1405
+ if (!managerInstance) {
1406
+ throw new Error("ConnectorManager not initialized");
1407
+ }
1408
+ return managerInstance.getAllSourceConfigs();
1409
+ }
1410
+ /**
1411
+ * Get default port for a database based on DSN protocol
1412
+ */
1413
+ getDefaultPort(dsn) {
1414
+ const type = getDatabaseTypeFromDSN(dsn);
1415
+ if (!type) {
1416
+ return 0;
1417
+ }
1418
+ return getDefaultPortForType(type) ?? 0;
1419
+ }
1420
+ };
1421
+
1422
+ // src/utils/sql-parser.ts
1423
+ function stripCommentsAndStrings(sql) {
1424
+ let result = "";
1425
+ let i = 0;
1426
+ while (i < sql.length) {
1427
+ if (sql[i] === "-" && sql[i + 1] === "-") {
1428
+ while (i < sql.length && sql[i] !== "\n") {
1429
+ i++;
1430
+ }
1431
+ result += " ";
1432
+ continue;
1433
+ }
1434
+ if (sql[i] === "/" && sql[i + 1] === "*") {
1435
+ i += 2;
1436
+ while (i < sql.length && !(sql[i] === "*" && sql[i + 1] === "/")) {
1437
+ i++;
1438
+ }
1439
+ i += 2;
1440
+ result += " ";
1441
+ continue;
1442
+ }
1443
+ if (sql[i] === "'") {
1444
+ i++;
1445
+ while (i < sql.length) {
1446
+ if (sql[i] === "'" && sql[i + 1] === "'") {
1447
+ i += 2;
1448
+ } else if (sql[i] === "'") {
1449
+ i++;
1450
+ break;
1451
+ } else {
1452
+ i++;
1453
+ }
1454
+ }
1455
+ result += " ";
1456
+ continue;
1457
+ }
1458
+ if (sql[i] === '"') {
1459
+ i++;
1460
+ while (i < sql.length) {
1461
+ if (sql[i] === '"' && sql[i + 1] === '"') {
1462
+ i += 2;
1463
+ } else if (sql[i] === '"') {
1464
+ i++;
1465
+ break;
1466
+ } else {
1467
+ i++;
1468
+ }
1469
+ }
1470
+ result += " ";
1471
+ continue;
1472
+ }
1473
+ result += sql[i];
1474
+ i++;
1475
+ }
1476
+ return result;
1477
+ }
1478
+
1479
+ // src/utils/parameter-mapper.ts
1480
+ var PARAMETER_STYLES = {
1481
+ postgres: "numbered",
1482
+ // $1, $2, $3
1483
+ mysql: "positional",
1484
+ // ?, ?, ?
1485
+ mariadb: "positional",
1486
+ // ?, ?, ?
1487
+ sqlserver: "named",
1488
+ // @p1, @p2, @p3
1489
+ sqlite: "positional"
1490
+ // ?, ?, ?
1491
+ };
1492
+ function detectParameterStyle(statement) {
1493
+ const cleanedSQL = stripCommentsAndStrings(statement);
1494
+ if (/\$\d+/.test(cleanedSQL)) {
1495
+ return "numbered";
1496
+ }
1497
+ if (/@p\d+/.test(cleanedSQL)) {
1498
+ return "named";
1499
+ }
1500
+ if (/\?/.test(cleanedSQL)) {
1501
+ return "positional";
1502
+ }
1503
+ return "none";
1504
+ }
1505
+ function validateParameterStyle(statement, connectorType) {
1506
+ const detectedStyle = detectParameterStyle(statement);
1507
+ const expectedStyle = PARAMETER_STYLES[connectorType];
1508
+ if (detectedStyle === "none") {
1509
+ return;
1510
+ }
1511
+ if (detectedStyle !== expectedStyle) {
1512
+ const examples = {
1513
+ numbered: "$1, $2, $3",
1514
+ positional: "?, ?, ?",
1515
+ named: "@p1, @p2, @p3"
1516
+ };
1517
+ throw new Error(
1518
+ `Invalid parameter syntax for ${connectorType}. Expected ${expectedStyle} style (${examples[expectedStyle]}), but found ${detectedStyle} style in statement.`
1519
+ );
1520
+ }
1521
+ }
1522
+ function countParameters(statement) {
1523
+ const style = detectParameterStyle(statement);
1524
+ const cleanedSQL = stripCommentsAndStrings(statement);
1525
+ switch (style) {
1526
+ case "numbered": {
1527
+ const matches = cleanedSQL.match(/\$\d+/g);
1528
+ if (!matches) return 0;
1529
+ const numbers = matches.map((m) => parseInt(m.slice(1), 10));
1530
+ const uniqueIndices = Array.from(new Set(numbers)).sort((a, b) => a - b);
1531
+ const maxIndex = Math.max(...uniqueIndices);
1532
+ for (let i = 1; i <= maxIndex; i++) {
1533
+ if (!uniqueIndices.includes(i)) {
1534
+ throw new Error(
1535
+ `Non-sequential numbered parameters detected. Found placeholders: ${uniqueIndices.map((n) => `$${n}`).join(", ")}. Parameters must be sequential starting from $1 (missing $${i}).`
1536
+ );
1537
+ }
1538
+ }
1539
+ return maxIndex;
1540
+ }
1541
+ case "named": {
1542
+ const matches = cleanedSQL.match(/@p\d+/g);
1543
+ if (!matches) return 0;
1544
+ const numbers = matches.map((m) => parseInt(m.slice(2), 10));
1545
+ const uniqueIndices = Array.from(new Set(numbers)).sort((a, b) => a - b);
1546
+ const maxIndex = Math.max(...uniqueIndices);
1547
+ for (let i = 1; i <= maxIndex; i++) {
1548
+ if (!uniqueIndices.includes(i)) {
1549
+ throw new Error(
1550
+ `Non-sequential named parameters detected. Found placeholders: ${uniqueIndices.map((n) => `@p${n}`).join(", ")}. Parameters must be sequential starting from @p1 (missing @p${i}).`
1551
+ );
1552
+ }
1553
+ }
1554
+ return maxIndex;
1555
+ }
1556
+ case "positional": {
1557
+ return (cleanedSQL.match(/\?/g) || []).length;
1558
+ }
1559
+ default:
1560
+ return 0;
1561
+ }
1562
+ }
1563
+ function validateParameters(statement, parameters, connectorType) {
1564
+ validateParameterStyle(statement, connectorType);
1565
+ const paramCount = countParameters(statement);
1566
+ const definedCount = parameters?.length || 0;
1567
+ if (paramCount !== definedCount) {
1568
+ throw new Error(
1569
+ `Parameter count mismatch: SQL statement has ${paramCount} parameter(s), but ${definedCount} parameter(s) defined in tool configuration.`
1570
+ );
1571
+ }
1572
+ }
1573
+ function mapArgumentsToArray(parameters, args) {
1574
+ if (!parameters || parameters.length === 0) {
1575
+ return [];
1576
+ }
1577
+ return parameters.map((param) => {
1578
+ const value = args[param.name];
1579
+ if (value !== void 0) {
1580
+ return value;
1581
+ }
1582
+ if (param.default !== void 0) {
1583
+ return param.default;
1584
+ }
1585
+ if (param.required !== false) {
1586
+ throw new Error(
1587
+ `Required parameter '${param.name}' is missing and has no default value.`
1588
+ );
1589
+ }
1590
+ return null;
1591
+ });
1592
+ }
1593
+
1594
+ // src/tools/registry.ts
1595
+ var ToolRegistry = class {
1596
+ constructor(config) {
1597
+ this.toolsBySource = this.buildRegistry(config);
1598
+ }
1599
+ /**
1600
+ * Check if a tool name is a built-in tool
1601
+ */
1602
+ isBuiltinTool(toolName) {
1603
+ return BUILTIN_TOOLS.includes(toolName);
1604
+ }
1605
+ /**
1606
+ * Validate a custom tool parameter definition
1607
+ */
1608
+ validateParameter(toolName, param) {
1609
+ if (!param.name || param.name.trim() === "") {
1610
+ throw new Error(`Tool '${toolName}' has parameter missing 'name' field`);
1611
+ }
1612
+ if (!param.type) {
1613
+ throw new Error(
1614
+ `Tool '${toolName}', parameter '${param.name}' missing 'type' field`
1615
+ );
1616
+ }
1617
+ const validTypes = ["string", "integer", "float", "boolean", "array"];
1618
+ if (!validTypes.includes(param.type)) {
1619
+ throw new Error(
1620
+ `Tool '${toolName}', parameter '${param.name}' has invalid type '${param.type}'. Valid types: ${validTypes.join(", ")}`
1621
+ );
1622
+ }
1623
+ if (!param.description || param.description.trim() === "") {
1624
+ throw new Error(
1625
+ `Tool '${toolName}', parameter '${param.name}' missing 'description' field`
1626
+ );
1627
+ }
1628
+ if (param.allowed_values) {
1629
+ if (!Array.isArray(param.allowed_values)) {
1630
+ throw new Error(
1631
+ `Tool '${toolName}', parameter '${param.name}': allowed_values must be an array`
1632
+ );
1633
+ }
1634
+ if (param.allowed_values.length === 0) {
1635
+ throw new Error(
1636
+ `Tool '${toolName}', parameter '${param.name}': allowed_values cannot be empty`
1637
+ );
1638
+ }
1639
+ }
1640
+ if (param.default !== void 0 && param.allowed_values) {
1641
+ if (!param.allowed_values.includes(param.default)) {
1642
+ throw new Error(
1643
+ `Tool '${toolName}', parameter '${param.name}': default value '${param.default}' is not in allowed_values: ${param.allowed_values.join(", ")}`
1644
+ );
1645
+ }
1646
+ }
1647
+ }
1648
+ /**
1649
+ * Validate a custom tool configuration
1650
+ */
1651
+ validateCustomTool(toolConfig, availableSources) {
1652
+ if (!toolConfig.name || toolConfig.name.trim() === "") {
1653
+ throw new Error("Tool definition missing required field: name");
1654
+ }
1655
+ if (!toolConfig.description || toolConfig.description.trim() === "") {
1656
+ throw new Error(
1657
+ `Tool '${toolConfig.name}' missing required field: description`
1658
+ );
1659
+ }
1660
+ if (!toolConfig.source || toolConfig.source.trim() === "") {
1661
+ throw new Error(
1662
+ `Tool '${toolConfig.name}' missing required field: source`
1663
+ );
1664
+ }
1665
+ if (!toolConfig.statement || toolConfig.statement.trim() === "") {
1666
+ throw new Error(
1667
+ `Tool '${toolConfig.name}' missing required field: statement`
1668
+ );
1669
+ }
1670
+ if (!availableSources.includes(toolConfig.source)) {
1671
+ throw new Error(
1672
+ `Tool '${toolConfig.name}' references unknown source '${toolConfig.source}'. Available sources: ${availableSources.join(", ")}`
1673
+ );
1674
+ }
1675
+ for (const builtinName of BUILTIN_TOOLS) {
1676
+ if (toolConfig.name === builtinName || toolConfig.name.startsWith(`${builtinName}_`)) {
1677
+ throw new Error(
1678
+ `Tool name '${toolConfig.name}' conflicts with built-in tool naming pattern. Custom tools cannot use names starting with: ${BUILTIN_TOOLS.join(", ")}`
1679
+ );
1680
+ }
1681
+ }
1682
+ const sourceConfig = ConnectorManager.getSourceConfig(toolConfig.source);
1683
+ const connectorType = sourceConfig.type;
1684
+ try {
1685
+ validateParameters(
1686
+ toolConfig.statement,
1687
+ toolConfig.parameters,
1688
+ connectorType
1689
+ );
1690
+ } catch (error) {
1691
+ throw new Error(
1692
+ `Tool '${toolConfig.name}' validation failed: ${error.message}`
1693
+ );
1694
+ }
1695
+ if (toolConfig.parameters) {
1696
+ for (const param of toolConfig.parameters) {
1697
+ this.validateParameter(toolConfig.name, param);
1698
+ }
1699
+ }
1700
+ }
1701
+ /**
1702
+ * Build the internal registry mapping sources to their enabled tools
1703
+ */
1704
+ buildRegistry(config) {
1705
+ const registry = /* @__PURE__ */ new Map();
1706
+ const availableSources = config.sources.map((s) => s.id);
1707
+ const customToolNames = /* @__PURE__ */ new Set();
1708
+ for (const tool of config.tools || []) {
1709
+ if (!this.isBuiltinTool(tool.name)) {
1710
+ this.validateCustomTool(tool, availableSources);
1711
+ if (customToolNames.has(tool.name)) {
1712
+ throw new Error(
1713
+ `Duplicate tool name '${tool.name}'. Tool names must be unique.`
1714
+ );
1715
+ }
1716
+ customToolNames.add(tool.name);
1717
+ }
1718
+ const existing = registry.get(tool.source) || [];
1719
+ existing.push(tool);
1720
+ registry.set(tool.source, existing);
1721
+ }
1722
+ for (const source of config.sources) {
1723
+ if (!registry.has(source.id)) {
1724
+ const defaultTools = BUILTIN_TOOLS.map((name) => {
1725
+ if (name === "execute_sql") {
1726
+ return { name: "execute_sql", source: source.id };
1727
+ } else {
1728
+ return { name: "search_objects", source: source.id };
1729
+ }
1730
+ });
1731
+ registry.set(source.id, defaultTools);
1732
+ }
1733
+ }
1734
+ return registry;
1735
+ }
1736
+ /**
1737
+ * Get all enabled tool configs for a specific source
1738
+ */
1739
+ getEnabledToolConfigs(sourceId) {
1740
+ return this.toolsBySource.get(sourceId) || [];
1741
+ }
1742
+ /**
1743
+ * Get built-in tool configuration for a specific source
1744
+ * Returns undefined if tool is not enabled or not a built-in
1745
+ */
1746
+ getBuiltinToolConfig(toolName, sourceId) {
1747
+ if (!this.isBuiltinTool(toolName)) {
1748
+ return void 0;
1749
+ }
1750
+ const tools = this.getEnabledToolConfigs(sourceId);
1751
+ return tools.find((t) => t.name === toolName);
1752
+ }
1753
+ /**
1754
+ * Get all unique tools across all sources (for tools/list response)
1755
+ * Returns the union of all enabled tools
1756
+ */
1757
+ getAllTools() {
1758
+ const seen = /* @__PURE__ */ new Set();
1759
+ const result = [];
1760
+ for (const tools of this.toolsBySource.values()) {
1761
+ for (const tool of tools) {
1762
+ if (!seen.has(tool.name)) {
1763
+ seen.add(tool.name);
1764
+ result.push(tool);
1765
+ }
1766
+ }
1767
+ }
1768
+ return result;
1769
+ }
1770
+ /**
1771
+ * Get all custom tools (non-builtin) across all sources
1772
+ */
1773
+ getCustomTools() {
1774
+ return this.getAllTools().filter((tool) => !this.isBuiltinTool(tool.name));
1775
+ }
1776
+ /**
1777
+ * Get all built-in tool names that are enabled across any source
1778
+ */
1779
+ getEnabledBuiltinToolNames() {
1780
+ const enabledBuiltins = /* @__PURE__ */ new Set();
1781
+ for (const tools of this.toolsBySource.values()) {
1782
+ for (const tool of tools) {
1783
+ if (this.isBuiltinTool(tool.name)) {
1784
+ enabledBuiltins.add(tool.name);
1785
+ }
1786
+ }
1787
+ }
1788
+ return Array.from(enabledBuiltins);
1789
+ }
1790
+ };
1791
+ var globalRegistry = null;
1792
+ function initializeToolRegistry(config) {
1793
+ globalRegistry = new ToolRegistry(config);
1794
+ }
1795
+ function getToolRegistry() {
1796
+ if (!globalRegistry) {
1797
+ throw new Error(
1798
+ "Tool registry not initialized. Call initializeToolRegistry first."
1799
+ );
1800
+ }
1801
+ return globalRegistry;
1802
+ }
1803
+
1804
+ export {
1805
+ ConnectorRegistry,
1806
+ SafeURL,
1807
+ parseConnectionInfoFromDSN,
1808
+ obfuscateDSNPassword,
1809
+ getDatabaseTypeFromDSN,
1810
+ getDefaultPortForType,
1811
+ stripCommentsAndStrings,
1812
+ isDemoMode,
1813
+ resolveTransport,
1814
+ resolvePort,
1815
+ redactDSN,
1816
+ resolveSourceConfigs,
1817
+ BUILTIN_TOOL_EXECUTE_SQL,
1818
+ BUILTIN_TOOL_SEARCH_OBJECTS,
1819
+ buildDSNFromSource,
1820
+ ConnectorManager,
1821
+ mapArgumentsToArray,
1822
+ ToolRegistry,
1823
+ initializeToolRegistry,
1824
+ getToolRegistry
1825
+ };