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