@dbstudio/cli 0.1.7
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/dist/index.d.ts +2 -0
- package/dist/index.js +1372 -0
- package/package.json +36 -0
- package/src/agents/index.ts +458 -0
- package/src/commands/connect.ts +418 -0
- package/src/commands/disconnect.ts +37 -0
- package/src/commands/status.ts +54 -0
- package/src/drivers/index.ts +58 -0
- package/src/drivers/libsql.ts +189 -0
- package/src/drivers/mysql.ts +199 -0
- package/src/drivers/postgres.ts +224 -0
- package/src/drivers/sqlite.ts +206 -0
- package/src/index.ts +28 -0
- package/tsconfig.json +15 -0
- package/tsup.config.ts +11 -0
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dbstudio/cli",
|
|
3
|
+
"version": "0.1.7",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"dbstudio": "./dist/index.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"dev": "tsup --watch",
|
|
10
|
+
"build": "tsup",
|
|
11
|
+
"start": "bun run dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@dbstudio/types": "workspace:*",
|
|
15
|
+
"@libsql/client": "^0.15.15",
|
|
16
|
+
"chalk": "^5.4.1",
|
|
17
|
+
"commander": "^13.1.0",
|
|
18
|
+
"dotenv": "^16.1.4",
|
|
19
|
+
"mysql2": "^3.14.1",
|
|
20
|
+
"ora": "^8.2.0",
|
|
21
|
+
"pg": "^8.16.0",
|
|
22
|
+
"prompts": "^2.4.2",
|
|
23
|
+
"ssh2": "^1.17.0",
|
|
24
|
+
"ws": "^8.18.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
28
|
+
"@types/pg": "^8.15.2",
|
|
29
|
+
"@types/prompts": "^2.4.9",
|
|
30
|
+
"@types/ssh2": "^1.15.5",
|
|
31
|
+
"@types/ws": "^8.5.13",
|
|
32
|
+
"tsdown": "^0.11.4",
|
|
33
|
+
"tsup": "^8.5.1",
|
|
34
|
+
"typescript": "^5"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { createServer, type Server } from "node:net";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import type {
|
|
6
|
+
AuthMessage,
|
|
7
|
+
DatabaseConfig,
|
|
8
|
+
GetTableSchemaMessage,
|
|
9
|
+
GetTablesMessage,
|
|
10
|
+
InsertRowMessage,
|
|
11
|
+
QueryMessage,
|
|
12
|
+
UpdateRowMessage,
|
|
13
|
+
WSMessage,
|
|
14
|
+
} from "@dbstudio/types";
|
|
15
|
+
import { Client } from "ssh2";
|
|
16
|
+
import WebSocket from "ws";
|
|
17
|
+
import { createDriver, type DatabaseDriver } from "../drivers";
|
|
18
|
+
|
|
19
|
+
const CONFIG_DIR = path.join(os.homedir(), ".dbstudio");
|
|
20
|
+
const STATUS_FILE = path.join(CONFIG_DIR, "status.json");
|
|
21
|
+
|
|
22
|
+
interface AgentConfig {
|
|
23
|
+
serverUrl: string;
|
|
24
|
+
token: string;
|
|
25
|
+
dbConfig: DatabaseConfig;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class Agent {
|
|
29
|
+
private ws: WebSocket | null = null;
|
|
30
|
+
private driver: DatabaseDriver | null = null;
|
|
31
|
+
private config: AgentConfig;
|
|
32
|
+
private reconnectAttempts = 0;
|
|
33
|
+
private maxReconnectAttempts = 5;
|
|
34
|
+
private pingInterval: ReturnType<typeof setInterval> | null = null;
|
|
35
|
+
private sshClient: Client | null = null;
|
|
36
|
+
private tunnelServer: Server | null = null;
|
|
37
|
+
private isReconnecting = false;
|
|
38
|
+
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
39
|
+
|
|
40
|
+
constructor(config: AgentConfig) {
|
|
41
|
+
this.config = config;
|
|
42
|
+
this.ensureConfigDir();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private ensureConfigDir(): void {
|
|
46
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
47
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async connect(): Promise<void> {
|
|
52
|
+
// Set up SSH tunnel if configured
|
|
53
|
+
if (this.config.dbConfig.ssh) {
|
|
54
|
+
await this.setupSshTunnel();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Connect to database first
|
|
58
|
+
this.driver = createDriver(this.config.dbConfig);
|
|
59
|
+
await this.driver.connect();
|
|
60
|
+
|
|
61
|
+
// Connect to WebSocket server
|
|
62
|
+
await this.connectWebSocket();
|
|
63
|
+
|
|
64
|
+
// Save status
|
|
65
|
+
this.saveStatus("connected");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private async setupSshTunnel(): Promise<void> {
|
|
69
|
+
const { ssh, host, port, type } = this.config.dbConfig;
|
|
70
|
+
if (!ssh) return;
|
|
71
|
+
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
this.sshClient = new Client();
|
|
74
|
+
|
|
75
|
+
this.sshClient
|
|
76
|
+
.on("ready", () => {
|
|
77
|
+
// Create local server to forward traffic
|
|
78
|
+
this.tunnelServer = createServer((sock) => {
|
|
79
|
+
this.sshClient?.forwardOut(
|
|
80
|
+
"127.0.0.1",
|
|
81
|
+
sock.remotePort || 0,
|
|
82
|
+
host || "localhost",
|
|
83
|
+
port || (type === "postgresql" ? 5432 : 3306),
|
|
84
|
+
(err, stream) => {
|
|
85
|
+
if (err) {
|
|
86
|
+
sock.end();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
sock.pipe(stream).pipe(sock);
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
this.tunnelServer.listen(0, "127.0.0.1", () => {
|
|
95
|
+
const addr = this.tunnelServer?.address();
|
|
96
|
+
if (addr && typeof addr !== "string") {
|
|
97
|
+
// Update config to point to local tunnel
|
|
98
|
+
this.config.dbConfig.host = "127.0.0.1";
|
|
99
|
+
this.config.dbConfig.port = addr.port;
|
|
100
|
+
console.log(
|
|
101
|
+
`SSH Tunnel established: 127.0.0.1:${addr.port} -> ${host}:${port || (type === "postgresql" ? 5432 : 3306)}`,
|
|
102
|
+
);
|
|
103
|
+
resolve();
|
|
104
|
+
} else {
|
|
105
|
+
reject(new Error("Failed to get tunnel address"));
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
this.tunnelServer.on("error", reject);
|
|
110
|
+
})
|
|
111
|
+
.on("error", reject)
|
|
112
|
+
.connect({
|
|
113
|
+
host: ssh.host,
|
|
114
|
+
port: ssh.port || 22,
|
|
115
|
+
username: ssh.username,
|
|
116
|
+
password: ssh.password,
|
|
117
|
+
privateKey: ssh.privateKey,
|
|
118
|
+
passphrase: ssh.passphrase,
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private async connectWebSocket(): Promise<void> {
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
this.ws = new WebSocket(this.config.serverUrl);
|
|
126
|
+
|
|
127
|
+
this.ws.on("open", () => {
|
|
128
|
+
console.log("WebSocket connected, authenticating...");
|
|
129
|
+
this.authenticate();
|
|
130
|
+
this.startPingInterval();
|
|
131
|
+
resolve();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
this.ws.on("message", (data) => {
|
|
135
|
+
this.handleMessage(data.toString());
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
this.ws.on("close", () => {
|
|
139
|
+
console.log("WebSocket closed");
|
|
140
|
+
this.stopPingInterval();
|
|
141
|
+
this.handleReconnect();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
this.ws.on("error", (error) => {
|
|
145
|
+
console.error("WebSocket error:", error.message);
|
|
146
|
+
reject(error);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private authenticate(): void {
|
|
152
|
+
const authMessage: AuthMessage = {
|
|
153
|
+
id: crypto.randomUUID(),
|
|
154
|
+
type: "auth",
|
|
155
|
+
timestamp: Date.now(),
|
|
156
|
+
payload: {
|
|
157
|
+
token: this.config.token,
|
|
158
|
+
connectionId: crypto.randomUUID(),
|
|
159
|
+
dbConfig: this.config.dbConfig,
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
this.send(authMessage);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private async handleMessage(data: string): Promise<void> {
|
|
166
|
+
try {
|
|
167
|
+
const message = JSON.parse(data) as WSMessage;
|
|
168
|
+
|
|
169
|
+
switch (message.type) {
|
|
170
|
+
case "auth_success":
|
|
171
|
+
console.log("Authenticated successfully");
|
|
172
|
+
break;
|
|
173
|
+
|
|
174
|
+
case "auth_error":
|
|
175
|
+
console.error("Authentication failed:", message.payload.error);
|
|
176
|
+
await this.disconnect();
|
|
177
|
+
break;
|
|
178
|
+
|
|
179
|
+
case "ping":
|
|
180
|
+
this.send({ id: message.id, type: "pong", timestamp: Date.now() });
|
|
181
|
+
break;
|
|
182
|
+
|
|
183
|
+
case "pong":
|
|
184
|
+
// Received pong from server, nothing to do
|
|
185
|
+
break;
|
|
186
|
+
|
|
187
|
+
case "query":
|
|
188
|
+
await this.handleQuery(message as QueryMessage);
|
|
189
|
+
break;
|
|
190
|
+
|
|
191
|
+
case "get_tables":
|
|
192
|
+
await this.handleGetTables(message as GetTablesMessage);
|
|
193
|
+
break;
|
|
194
|
+
|
|
195
|
+
case "get_table_schema":
|
|
196
|
+
await this.handleGetTableSchema(message as GetTableSchemaMessage);
|
|
197
|
+
break;
|
|
198
|
+
|
|
199
|
+
case "insert_row":
|
|
200
|
+
await this.handleInsertRow(message as InsertRowMessage);
|
|
201
|
+
break;
|
|
202
|
+
|
|
203
|
+
case "update_row":
|
|
204
|
+
await this.handleUpdateRow(message as UpdateRowMessage);
|
|
205
|
+
break;
|
|
206
|
+
|
|
207
|
+
default:
|
|
208
|
+
console.log("Unknown message type:", message.type);
|
|
209
|
+
}
|
|
210
|
+
} catch (error) {
|
|
211
|
+
console.error("Error handling message:", error);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private async handleQuery(message: QueryMessage): Promise<void> {
|
|
216
|
+
if (!this.driver) return;
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const result = await this.driver.query(
|
|
220
|
+
message.payload.sql,
|
|
221
|
+
message.payload.params,
|
|
222
|
+
);
|
|
223
|
+
this.send({
|
|
224
|
+
id: message.id,
|
|
225
|
+
type: "query_result",
|
|
226
|
+
timestamp: Date.now(),
|
|
227
|
+
payload: result,
|
|
228
|
+
});
|
|
229
|
+
} catch (error) {
|
|
230
|
+
this.send({
|
|
231
|
+
id: message.id,
|
|
232
|
+
type: "query_error",
|
|
233
|
+
timestamp: Date.now(),
|
|
234
|
+
payload: {
|
|
235
|
+
message: error instanceof Error ? error.message : "Query failed",
|
|
236
|
+
code: (error as any)?.code,
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private async handleGetTables(message: GetTablesMessage): Promise<void> {
|
|
243
|
+
if (!this.driver) return;
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
const tables = await this.driver.getTables(message.payload.schema);
|
|
247
|
+
this.send({
|
|
248
|
+
id: message.id,
|
|
249
|
+
type: "tables_result",
|
|
250
|
+
timestamp: Date.now(),
|
|
251
|
+
payload: { tables },
|
|
252
|
+
});
|
|
253
|
+
} catch (error) {
|
|
254
|
+
console.error("Error in handleGetTables:", error);
|
|
255
|
+
this.send({
|
|
256
|
+
id: message.id,
|
|
257
|
+
type: "query_error",
|
|
258
|
+
timestamp: Date.now(),
|
|
259
|
+
payload: {
|
|
260
|
+
message:
|
|
261
|
+
error instanceof Error ? error.message : "Failed to get tables",
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private async handleGetTableSchema(
|
|
268
|
+
message: GetTableSchemaMessage,
|
|
269
|
+
): Promise<void> {
|
|
270
|
+
if (!this.driver) return;
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
const schema = await this.driver.getTableSchema(
|
|
274
|
+
message.payload.table,
|
|
275
|
+
message.payload.schema,
|
|
276
|
+
);
|
|
277
|
+
this.send({
|
|
278
|
+
id: message.id,
|
|
279
|
+
type: "table_schema_result",
|
|
280
|
+
timestamp: Date.now(),
|
|
281
|
+
payload: schema,
|
|
282
|
+
});
|
|
283
|
+
} catch (error) {
|
|
284
|
+
this.send({
|
|
285
|
+
id: message.id,
|
|
286
|
+
type: "query_error",
|
|
287
|
+
timestamp: Date.now(),
|
|
288
|
+
payload: {
|
|
289
|
+
message:
|
|
290
|
+
error instanceof Error ? error.message : "Failed to get schema",
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private async handleInsertRow(message: InsertRowMessage): Promise<void> {
|
|
297
|
+
if (!this.driver) return;
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
const result = await this.driver.insertRow(
|
|
301
|
+
message.payload.table,
|
|
302
|
+
message.payload.data,
|
|
303
|
+
);
|
|
304
|
+
this.send({
|
|
305
|
+
id: message.id,
|
|
306
|
+
type: "query_result",
|
|
307
|
+
timestamp: Date.now(),
|
|
308
|
+
payload: result,
|
|
309
|
+
});
|
|
310
|
+
} catch (error) {
|
|
311
|
+
this.send({
|
|
312
|
+
id: message.id,
|
|
313
|
+
type: "query_error",
|
|
314
|
+
timestamp: Date.now(),
|
|
315
|
+
payload: {
|
|
316
|
+
message:
|
|
317
|
+
error instanceof Error ? error.message : "Failed to insert row",
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private async handleUpdateRow(message: UpdateRowMessage): Promise<void> {
|
|
324
|
+
if (!this.driver) return;
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
const result = await this.driver.updateRow(
|
|
328
|
+
message.payload.table,
|
|
329
|
+
message.payload.data,
|
|
330
|
+
message.payload.where,
|
|
331
|
+
);
|
|
332
|
+
this.send({
|
|
333
|
+
id: message.id,
|
|
334
|
+
type: "query_result",
|
|
335
|
+
timestamp: Date.now(),
|
|
336
|
+
payload: result,
|
|
337
|
+
});
|
|
338
|
+
} catch (error) {
|
|
339
|
+
this.send({
|
|
340
|
+
id: message.id,
|
|
341
|
+
type: "query_error",
|
|
342
|
+
timestamp: Date.now(),
|
|
343
|
+
payload: {
|
|
344
|
+
message:
|
|
345
|
+
error instanceof Error ? error.message : "Failed to update row",
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private send(message: WSMessage | Record<string, unknown>): void {
|
|
352
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
353
|
+
this.ws.send(JSON.stringify(message));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private startPingInterval(): void {
|
|
358
|
+
this.pingInterval = setInterval(() => {
|
|
359
|
+
this.send({
|
|
360
|
+
id: crypto.randomUUID(),
|
|
361
|
+
type: "ping",
|
|
362
|
+
timestamp: Date.now(),
|
|
363
|
+
});
|
|
364
|
+
}, 30000);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private stopPingInterval(): void {
|
|
368
|
+
if (this.pingInterval) {
|
|
369
|
+
clearInterval(this.pingInterval);
|
|
370
|
+
this.pingInterval = null;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private clearReconnect(): void {
|
|
375
|
+
if (this.reconnectTimeout) {
|
|
376
|
+
clearTimeout(this.reconnectTimeout);
|
|
377
|
+
this.reconnectTimeout = null;
|
|
378
|
+
}
|
|
379
|
+
this.isReconnecting = false;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private async handleReconnect(): Promise<void> {
|
|
383
|
+
if (this.isReconnecting) return;
|
|
384
|
+
this.isReconnecting = true;
|
|
385
|
+
|
|
386
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
387
|
+
console.error("Max reconnect attempts reached");
|
|
388
|
+
this.saveStatus("error");
|
|
389
|
+
this.isReconnecting = false;
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
this.reconnectAttempts++;
|
|
394
|
+
const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 30000);
|
|
395
|
+
console.log(`Reconnecting in ${delay / 1000}s...`);
|
|
396
|
+
|
|
397
|
+
this.saveStatus("connecting");
|
|
398
|
+
|
|
399
|
+
if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout);
|
|
400
|
+
this.reconnectTimeout = setTimeout(async () => {
|
|
401
|
+
try {
|
|
402
|
+
await this.connectWebSocket();
|
|
403
|
+
this.reconnectAttempts = 0;
|
|
404
|
+
this.saveStatus("connected");
|
|
405
|
+
} catch (_error) {
|
|
406
|
+
// Failed again, allow next reconnect trigger
|
|
407
|
+
} finally {
|
|
408
|
+
this.isReconnecting = false;
|
|
409
|
+
this.reconnectTimeout = null;
|
|
410
|
+
}
|
|
411
|
+
}, delay);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async disconnect(): Promise<void> {
|
|
415
|
+
this.stopPingInterval();
|
|
416
|
+
this.clearReconnect();
|
|
417
|
+
|
|
418
|
+
if (this.ws) {
|
|
419
|
+
this.ws.close();
|
|
420
|
+
this.ws = null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (this.driver) {
|
|
424
|
+
await this.driver.disconnect();
|
|
425
|
+
this.driver = null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (this.tunnelServer) {
|
|
429
|
+
this.tunnelServer.close();
|
|
430
|
+
this.tunnelServer = null;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (this.sshClient) {
|
|
434
|
+
this.sshClient.end();
|
|
435
|
+
this.sshClient = null;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
this.saveStatus("disconnected");
|
|
439
|
+
this.removeStatus();
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private saveStatus(status: string): void {
|
|
443
|
+
const statusData = {
|
|
444
|
+
status,
|
|
445
|
+
database: this.config.dbConfig.database || this.config.dbConfig.filepath,
|
|
446
|
+
type: this.config.dbConfig.type,
|
|
447
|
+
connectedAt: Date.now(),
|
|
448
|
+
pid: process.pid,
|
|
449
|
+
};
|
|
450
|
+
fs.writeFileSync(STATUS_FILE, JSON.stringify(statusData, null, 2));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private removeStatus(): void {
|
|
454
|
+
if (fs.existsSync(STATUS_FILE)) {
|
|
455
|
+
fs.unlinkSync(STATUS_FILE);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|