sandal-db 1.0.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +318 -0
  3. package/dist/cli.js +2458 -0
  4. package/package.json +67 -0
package/dist/cli.js ADDED
@@ -0,0 +1,2458 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+ import chalk3 from "chalk";
6
+ import ora2 from "ora";
7
+ import { input as input2, select, confirm as confirm2 } from "@inquirer/prompts";
8
+
9
+ // src/config/index.ts
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import os from "node:os";
13
+ import dotenv from "dotenv";
14
+ dotenv.config();
15
+ var CONFIG_DIR_NAME = ".sandal";
16
+ var LEGACY_CONFIG_DIR_NAME = ".db-agent";
17
+ var CONFIG_FILE_NAME = "config.json";
18
+ function getConfigDirPath() {
19
+ return path.join(os.homedir(), CONFIG_DIR_NAME);
20
+ }
21
+ function getConfigFilePath() {
22
+ return path.join(getConfigDirPath(), CONFIG_FILE_NAME);
23
+ }
24
+ function getLegacyConfigFilePath() {
25
+ return path.join(os.homedir(), LEGACY_CONFIG_DIR_NAME, CONFIG_FILE_NAME);
26
+ }
27
+ function readConfig() {
28
+ const filePath = getConfigFilePath();
29
+ try {
30
+ if (fs.existsSync(filePath)) {
31
+ const content = fs.readFileSync(filePath, "utf-8");
32
+ return JSON.parse(content);
33
+ }
34
+ const legacyPath = getLegacyConfigFilePath();
35
+ if (fs.existsSync(legacyPath)) {
36
+ const content = fs.readFileSync(legacyPath, "utf-8");
37
+ return JSON.parse(content);
38
+ }
39
+ return {};
40
+ } catch {
41
+ return {};
42
+ }
43
+ }
44
+ function writeConfig(newConfig) {
45
+ const dirPath = getConfigDirPath();
46
+ const filePath = getConfigFilePath();
47
+ if (!fs.existsSync(dirPath)) {
48
+ fs.mkdirSync(dirPath, { recursive: true, mode: 448 });
49
+ }
50
+ const existing = readConfig();
51
+ const merged = {
52
+ ...existing,
53
+ ...newConfig
54
+ };
55
+ fs.writeFileSync(filePath, JSON.stringify(merged, null, 2), {
56
+ encoding: "utf-8",
57
+ mode: 384
58
+ });
59
+ try {
60
+ fs.chmodSync(filePath, 384);
61
+ } catch {
62
+ }
63
+ }
64
+ function maskUrl(rawUrl) {
65
+ if (!rawUrl) return "";
66
+ try {
67
+ const url = new URL(rawUrl);
68
+ if (url.password) {
69
+ url.password = "***";
70
+ }
71
+ if (url.username && url.username.length > 2) {
72
+ url.username = url.username[0] + "***";
73
+ }
74
+ return url.toString();
75
+ } catch {
76
+ return rawUrl.replace(/(:\/\/)([^:@\s]+):([^@\s]+)@/g, "$1$2:***@");
77
+ }
78
+ }
79
+ function maskApiKey(rawKey) {
80
+ if (!rawKey) return "";
81
+ const trimmed = rawKey.trim();
82
+ if (trimmed.length <= 8) {
83
+ return "***";
84
+ }
85
+ return `${trimmed.slice(0, 4)}...${trimmed.slice(-4)}`;
86
+ }
87
+ function getDefaultModelForProvider(provider) {
88
+ switch (provider) {
89
+ case "google":
90
+ return "gemini-2.5-flash";
91
+ case "openai":
92
+ return "gpt-4o";
93
+ case "anthropic":
94
+ return "claude-3-5-sonnet-latest";
95
+ }
96
+ }
97
+ function resolveCredentials(options) {
98
+ const config = readConfig();
99
+ let dbUrl = options.cliDbUrl;
100
+ let dbSource = "cli";
101
+ if (!dbUrl) {
102
+ const envUrl = process.env.DATABASE_URL || process.env.DB_URL;
103
+ if (envUrl) {
104
+ dbUrl = envUrl;
105
+ dbSource = "env";
106
+ } else if (config.dbUrl) {
107
+ dbUrl = config.dbUrl;
108
+ dbSource = "config";
109
+ } else {
110
+ dbSource = "none";
111
+ }
112
+ }
113
+ let provider = "google";
114
+ if (options.cliProvider) {
115
+ const p = options.cliProvider.toLowerCase();
116
+ if (p === "openai" || p === "anthropic" || p === "google") {
117
+ provider = p;
118
+ }
119
+ } else if (config.defaultProvider) {
120
+ provider = config.defaultProvider;
121
+ } else if (process.env.GEMINI_API_KEY) {
122
+ provider = "google";
123
+ } else if (process.env.OPENAI_API_KEY) {
124
+ provider = "openai";
125
+ } else if (process.env.ANTHROPIC_API_KEY) {
126
+ provider = "anthropic";
127
+ }
128
+ let apiKey = options.cliApiKey;
129
+ let keySource = "cli";
130
+ if (!apiKey) {
131
+ if (provider === "google") {
132
+ if (process.env.GEMINI_API_KEY) {
133
+ apiKey = process.env.GEMINI_API_KEY;
134
+ keySource = "env";
135
+ } else if (config.geminiApiKey) {
136
+ apiKey = config.geminiApiKey;
137
+ keySource = "config";
138
+ }
139
+ } else if (provider === "openai") {
140
+ if (process.env.OPENAI_API_KEY) {
141
+ apiKey = process.env.OPENAI_API_KEY;
142
+ keySource = "env";
143
+ } else if (config.openaiApiKey) {
144
+ apiKey = config.openaiApiKey;
145
+ keySource = "config";
146
+ }
147
+ } else if (provider === "anthropic") {
148
+ if (process.env.ANTHROPIC_API_KEY) {
149
+ apiKey = process.env.ANTHROPIC_API_KEY;
150
+ keySource = "env";
151
+ } else if (config.anthropicApiKey) {
152
+ apiKey = config.anthropicApiKey;
153
+ keySource = "config";
154
+ }
155
+ }
156
+ if (!apiKey) {
157
+ if (process.env.GEMINI_API_KEY || config.geminiApiKey) {
158
+ provider = "google";
159
+ apiKey = process.env.GEMINI_API_KEY || config.geminiApiKey;
160
+ keySource = process.env.GEMINI_API_KEY ? "env" : "config";
161
+ } else if (process.env.OPENAI_API_KEY || config.openaiApiKey) {
162
+ provider = "openai";
163
+ apiKey = process.env.OPENAI_API_KEY || config.openaiApiKey;
164
+ keySource = process.env.OPENAI_API_KEY ? "env" : "config";
165
+ } else if (process.env.ANTHROPIC_API_KEY || config.anthropicApiKey) {
166
+ provider = "anthropic";
167
+ apiKey = process.env.ANTHROPIC_API_KEY || config.anthropicApiKey;
168
+ keySource = process.env.ANTHROPIC_API_KEY ? "env" : "config";
169
+ } else {
170
+ keySource = "none";
171
+ }
172
+ }
173
+ }
174
+ const model = options.cliModel || config.defaultModel || getDefaultModelForProvider(provider);
175
+ return {
176
+ dbUrl,
177
+ apiKey,
178
+ provider,
179
+ model,
180
+ source: {
181
+ dbUrl: dbSource,
182
+ apiKey: keySource
183
+ }
184
+ };
185
+ }
186
+
187
+ // src/db/postgres.ts
188
+ import pg from "pg";
189
+ var { Pool } = pg;
190
+ var PostgresAdapter = class {
191
+ type = "postgres";
192
+ pool = null;
193
+ connectionUrl;
194
+ cachedSchema = null;
195
+ databaseName = "postgres";
196
+ constructor(connectionUrl) {
197
+ this.connectionUrl = connectionUrl;
198
+ try {
199
+ const parsed = new URL(connectionUrl);
200
+ this.databaseName = parsed.pathname.replace(/^\//, "") || "postgres";
201
+ } catch {
202
+ this.databaseName = "postgres";
203
+ }
204
+ }
205
+ getMaskedUrl() {
206
+ return maskUrl(this.connectionUrl);
207
+ }
208
+ async connect() {
209
+ if (this.pool) return;
210
+ this.pool = new Pool({
211
+ connectionString: this.connectionUrl,
212
+ connectionTimeoutMillis: 1e4,
213
+ idleTimeoutMillis: 3e4
214
+ });
215
+ const client = await this.pool.connect();
216
+ try {
217
+ const res = await client.query("SELECT current_database() as db_name;");
218
+ if (res.rows[0]?.db_name) {
219
+ this.databaseName = res.rows[0].db_name;
220
+ }
221
+ } finally {
222
+ client.release();
223
+ }
224
+ }
225
+ async disconnect() {
226
+ if (this.pool) {
227
+ await this.pool.end();
228
+ this.pool = null;
229
+ }
230
+ this.cachedSchema = null;
231
+ }
232
+ isConnected() {
233
+ return this.pool !== null;
234
+ }
235
+ async inspectSchema(forceRefresh = false) {
236
+ if (this.cachedSchema && !forceRefresh) {
237
+ return this.cachedSchema;
238
+ }
239
+ if (!this.pool) {
240
+ await this.connect();
241
+ }
242
+ const client = await this.pool.connect();
243
+ try {
244
+ const tablesResult = await client.query(`
245
+ SELECT table_schema, table_name
246
+ FROM information_schema.tables
247
+ WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
248
+ AND table_type = 'BASE TABLE'
249
+ ORDER BY table_schema, table_name;
250
+ `);
251
+ const columnsResult = await client.query(`
252
+ SELECT
253
+ c.table_schema,
254
+ c.table_name,
255
+ c.column_name,
256
+ c.data_type,
257
+ c.is_nullable,
258
+ c.column_default,
259
+ CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as is_primary_key
260
+ FROM information_schema.columns c
261
+ LEFT JOIN (
262
+ SELECT ku.table_schema, ku.table_name, ku.column_name
263
+ FROM information_schema.table_constraints tc
264
+ JOIN information_schema.key_column_usage ku
265
+ ON tc.constraint_name = ku.constraint_name
266
+ AND tc.table_schema = ku.table_schema
267
+ WHERE tc.constraint_type = 'PRIMARY KEY'
268
+ ) pk ON c.table_schema = pk.table_schema
269
+ AND c.table_name = pk.table_name
270
+ AND c.column_name = pk.column_name
271
+ WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema')
272
+ ORDER BY c.table_schema, c.table_name, c.ordinal_position;
273
+ `);
274
+ const indexesResult = await client.query(`
275
+ SELECT
276
+ schemaname as table_schema,
277
+ tablename as table_name,
278
+ indexname,
279
+ indexdef
280
+ FROM pg_indexes
281
+ WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
282
+ ORDER BY schemaname, tablename, indexname;
283
+ `);
284
+ const foreignKeysResult = await client.query(`
285
+ SELECT
286
+ tc.constraint_name,
287
+ tc.table_schema,
288
+ tc.table_name,
289
+ kcu.column_name,
290
+ ccu.table_name AS foreign_table_name,
291
+ ccu.column_name AS foreign_column_name
292
+ FROM information_schema.table_constraints AS tc
293
+ JOIN information_schema.key_column_usage AS kcu
294
+ ON tc.constraint_name = kcu.constraint_name
295
+ AND tc.table_schema = kcu.table_schema
296
+ JOIN information_schema.constraint_column_usage AS ccu
297
+ ON ccu.constraint_name = tc.constraint_name
298
+ AND ccu.table_schema = tc.table_schema
299
+ WHERE tc.constraint_type = 'FOREIGN KEY'
300
+ AND tc.table_schema NOT IN ('pg_catalog', 'information_schema');
301
+ `);
302
+ const rowCountsResult = await client.query(`
303
+ SELECT
304
+ nspname AS table_schema,
305
+ relname AS table_name,
306
+ reltuples::bigint AS approx_row_count
307
+ FROM pg_class C
308
+ LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace)
309
+ WHERE nspname NOT IN ('pg_catalog', 'information_schema')
310
+ AND relkind = 'r';
311
+ `);
312
+ const rowCountsMap = /* @__PURE__ */ new Map();
313
+ for (const row of rowCountsResult.rows) {
314
+ rowCountsMap.set(`${row.table_schema}.${row.table_name}`, Math.max(0, Number(row.approx_row_count)));
315
+ }
316
+ const columnsMap = /* @__PURE__ */ new Map();
317
+ for (const row of columnsResult.rows) {
318
+ const key = `${row.table_schema}.${row.table_name}`;
319
+ const list = columnsMap.get(key) || [];
320
+ list.push({
321
+ name: row.column_name,
322
+ dataType: row.data_type,
323
+ isNullable: row.is_nullable === "YES",
324
+ defaultValue: row.column_default,
325
+ isPrimaryKey: Boolean(row.is_primary_key)
326
+ });
327
+ columnsMap.set(key, list);
328
+ }
329
+ const indexesMap = /* @__PURE__ */ new Map();
330
+ for (const row of indexesResult.rows) {
331
+ const key = `${row.table_schema}.${row.table_name}`;
332
+ const list = indexesMap.get(key) || [];
333
+ const isUnique = /create\s+unique\s+index/i.test(row.indexdef);
334
+ const isPrimary = /_pkey$/i.test(row.indexname) || /primary key/i.test(row.indexdef);
335
+ list.push({
336
+ name: row.indexname,
337
+ tableName: row.table_name,
338
+ columnNames: [],
339
+ // extracted from indexdef
340
+ isUnique,
341
+ isPrimary,
342
+ definition: row.indexdef
343
+ });
344
+ indexesMap.set(key, list);
345
+ }
346
+ const foreignKeysMap = /* @__PURE__ */ new Map();
347
+ for (const row of foreignKeysResult.rows) {
348
+ const key = `${row.table_schema}.${row.table_name}`;
349
+ const list = foreignKeysMap.get(key) || [];
350
+ list.push({
351
+ constraintName: row.constraint_name,
352
+ columnName: row.column_name,
353
+ foreignTableName: row.foreign_table_name,
354
+ foreignColumnName: row.foreign_column_name
355
+ });
356
+ foreignKeysMap.set(key, list);
357
+ }
358
+ const tables = tablesResult.rows.map((r) => {
359
+ const key = `${r.table_schema}.${r.table_name}`;
360
+ return {
361
+ name: r.table_name,
362
+ schema: r.table_schema,
363
+ columns: columnsMap.get(key) || [],
364
+ indexes: indexesMap.get(key) || [],
365
+ foreignKeys: foreignKeysMap.get(key) || [],
366
+ approximateRowCount: rowCountsMap.get(key) ?? 0
367
+ };
368
+ });
369
+ this.cachedSchema = {
370
+ type: "postgres",
371
+ databaseName: this.databaseName,
372
+ tables,
373
+ inspectedAt: /* @__PURE__ */ new Date()
374
+ };
375
+ return this.cachedSchema;
376
+ } finally {
377
+ client.release();
378
+ }
379
+ }
380
+ async executeQuery(query) {
381
+ if (!this.pool) {
382
+ await this.connect();
383
+ }
384
+ const sql = query.sql || query.rawDisplay;
385
+ const startTime = Date.now();
386
+ const client = await this.pool.connect();
387
+ try {
388
+ const res = await client.query(sql, query.params || []);
389
+ const durationMs = Date.now() - startTime;
390
+ let rows = [];
391
+ let fields = [];
392
+ let rowCount = 0;
393
+ if (Array.isArray(res)) {
394
+ const lastResult = res[res.length - 1];
395
+ rows = lastResult?.rows || [];
396
+ fields = (lastResult?.fields || []).map((f) => f.name);
397
+ rowCount = lastResult?.rowCount ?? rows.length;
398
+ } else if (res) {
399
+ rows = res.rows || [];
400
+ fields = (res.fields || []).map((f) => f.name);
401
+ rowCount = res.rowCount ?? rows.length;
402
+ }
403
+ return {
404
+ success: true,
405
+ rows,
406
+ fields,
407
+ rowCount,
408
+ affectedRows: rowCount,
409
+ durationMs,
410
+ command: Array.isArray(res) ? res[res.length - 1]?.command : res?.command
411
+ };
412
+ } catch (err) {
413
+ const durationMs = Date.now() - startTime;
414
+ return {
415
+ success: false,
416
+ durationMs,
417
+ error: err.message || String(err)
418
+ };
419
+ } finally {
420
+ client.release();
421
+ }
422
+ }
423
+ async dryRunCount(query) {
424
+ if (!this.pool) {
425
+ await this.connect();
426
+ }
427
+ const sql = (query.sql || query.rawDisplay).trim();
428
+ const deleteMatch = sql.match(/^DELETE\s+FROM\s+([^\s;()]+)(?:\s+WHERE\s+([\s\S]+?))?(?:;)?$/i);
429
+ if (deleteMatch) {
430
+ const table = deleteMatch[1];
431
+ const whereClause = deleteMatch[2];
432
+ const countSql = whereClause ? `SELECT COUNT(*) as count FROM ${table} WHERE ${whereClause}` : `SELECT COUNT(*) as count FROM ${table}`;
433
+ try {
434
+ const client = await this.pool.connect();
435
+ try {
436
+ const res = await client.query(countSql, query.params || []);
437
+ return Number(res.rows[0]?.count ?? 0);
438
+ } finally {
439
+ client.release();
440
+ }
441
+ } catch {
442
+ return null;
443
+ }
444
+ }
445
+ const updateMatch = sql.match(/^UPDATE\s+([^\s;()]+)\s+SET\s+[\s\S]+?(?:\s+WHERE\s+([\s\S]+?))?(?:;)?$/i);
446
+ if (updateMatch) {
447
+ const table = updateMatch[1];
448
+ const whereClause = updateMatch[2];
449
+ const countSql = whereClause ? `SELECT COUNT(*) as count FROM ${table} WHERE ${whereClause}` : `SELECT COUNT(*) as count FROM ${table}`;
450
+ try {
451
+ const client = await this.pool.connect();
452
+ try {
453
+ const res = await client.query(countSql);
454
+ return Number(res.rows[0]?.count ?? 0);
455
+ } finally {
456
+ client.release();
457
+ }
458
+ } catch {
459
+ return null;
460
+ }
461
+ }
462
+ const truncateOrDropMatch = sql.match(/^(?:TRUNCATE(?:\s+TABLE)?|DROP\s+TABLE(?:\s+IF\s+EXISTS)?)\s+([^\s;()]+)/i);
463
+ if (truncateOrDropMatch) {
464
+ const table = truncateOrDropMatch[1];
465
+ try {
466
+ const client = await this.pool.connect();
467
+ try {
468
+ const res = await client.query(`SELECT COUNT(*) as count FROM ${table}`);
469
+ return Number(res.rows[0]?.count ?? 0);
470
+ } finally {
471
+ client.release();
472
+ }
473
+ } catch {
474
+ return null;
475
+ }
476
+ }
477
+ return null;
478
+ }
479
+ async explainQuery(query) {
480
+ if (!this.pool) {
481
+ await this.connect();
482
+ }
483
+ const sql = (query.sql || query.rawDisplay).trim();
484
+ if (!/^(SELECT|UPDATE|DELETE|INSERT)/i.test(sql)) {
485
+ return "";
486
+ }
487
+ const client = await this.pool.connect();
488
+ try {
489
+ const res = await client.query(`EXPLAIN ${sql}`, query.params || []);
490
+ return res.rows.map((r) => Object.values(r)[0]).join("\n");
491
+ } catch (err) {
492
+ return `Explain failed: ${err.message}`;
493
+ } finally {
494
+ client.release();
495
+ }
496
+ }
497
+ };
498
+
499
+ // src/db/mongo.ts
500
+ import { MongoClient } from "mongodb";
501
+ var MongoAdapter = class {
502
+ type = "mongodb";
503
+ client = null;
504
+ db = null;
505
+ connectionUrl;
506
+ cachedSchema = null;
507
+ databaseName = "admin";
508
+ constructor(connectionUrl) {
509
+ this.connectionUrl = connectionUrl;
510
+ try {
511
+ const parsed = new URL(connectionUrl);
512
+ const dbName = parsed.pathname.replace(/^\//, "");
513
+ if (dbName) {
514
+ this.databaseName = dbName;
515
+ }
516
+ } catch {
517
+ }
518
+ }
519
+ getMaskedUrl() {
520
+ return maskUrl(this.connectionUrl);
521
+ }
522
+ async connect() {
523
+ if (this.client) return;
524
+ this.client = new MongoClient(this.connectionUrl, {
525
+ serverSelectionTimeoutMS: 1e4,
526
+ connectTimeoutMS: 1e4
527
+ });
528
+ await this.client.connect();
529
+ this.db = this.client.db(this.databaseName === "admin" ? void 0 : this.databaseName);
530
+ this.databaseName = this.db.databaseName;
531
+ }
532
+ async disconnect() {
533
+ if (this.client) {
534
+ await this.client.close();
535
+ this.client = null;
536
+ this.db = null;
537
+ }
538
+ this.cachedSchema = null;
539
+ }
540
+ isConnected() {
541
+ return this.client !== null && this.db !== null;
542
+ }
543
+ async inspectSchema(forceRefresh = false) {
544
+ if (this.cachedSchema && !forceRefresh) {
545
+ return this.cachedSchema;
546
+ }
547
+ if (!this.isConnected()) {
548
+ await this.connect();
549
+ }
550
+ const db = this.db;
551
+ const collectionsList = await db.listCollections().toArray();
552
+ const collectionSchemas = [];
553
+ for (const colInfo of collectionsList) {
554
+ const colName = colInfo.name;
555
+ if (colName.startsWith("system.")) continue;
556
+ const collection = db.collection(colName);
557
+ let documentCount = 0;
558
+ try {
559
+ documentCount = await collection.estimatedDocumentCount();
560
+ } catch {
561
+ }
562
+ const sampleDocs = await collection.find({}).limit(20).toArray();
563
+ const fieldMap = /* @__PURE__ */ new Map();
564
+ let sampleDoc = sampleDocs[0];
565
+ for (const doc of sampleDocs) {
566
+ this.extractFields(doc, "", fieldMap);
567
+ }
568
+ const fields = Array.from(fieldMap.entries()).map(([name, types]) => ({
569
+ name,
570
+ types: Array.from(types)
571
+ }));
572
+ let indexes = [];
573
+ try {
574
+ const rawIndexes = await collection.indexes();
575
+ indexes = rawIndexes.map((idx) => ({
576
+ name: idx.name || "unknown",
577
+ tableName: colName,
578
+ columnNames: Object.keys(idx.key || {}),
579
+ isUnique: Boolean(idx.unique),
580
+ isPrimary: idx.name === "_id_",
581
+ definition: JSON.stringify(idx.key)
582
+ }));
583
+ } catch {
584
+ }
585
+ collectionSchemas.push({
586
+ name: colName,
587
+ fields,
588
+ indexes,
589
+ documentCount,
590
+ sampleDocument: sampleDoc
591
+ });
592
+ }
593
+ this.cachedSchema = {
594
+ type: "mongodb",
595
+ databaseName: this.databaseName,
596
+ collections: collectionSchemas,
597
+ inspectedAt: /* @__PURE__ */ new Date()
598
+ };
599
+ return this.cachedSchema;
600
+ }
601
+ extractFields(obj, prefix, fieldMap) {
602
+ if (!obj || typeof obj !== "object" || Array.isArray(obj) || obj instanceof Date) {
603
+ return;
604
+ }
605
+ for (const [key, value] of Object.entries(obj)) {
606
+ const fullKey = prefix ? `${prefix}.${key}` : key;
607
+ let type = typeof value;
608
+ if (value === null) type = "null";
609
+ else if (Array.isArray(value)) type = "array";
610
+ else if (value instanceof Date) type = "date";
611
+ else if (value && typeof value === "object" && value._bsontype) type = value._bsontype;
612
+ if (!fieldMap.has(fullKey)) {
613
+ fieldMap.set(fullKey, /* @__PURE__ */ new Set());
614
+ }
615
+ fieldMap.get(fullKey).add(type);
616
+ if (value && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date) && !value._bsontype) {
617
+ this.extractFields(value, fullKey, fieldMap);
618
+ }
619
+ }
620
+ }
621
+ async executeQuery(query) {
622
+ if (!this.isConnected()) {
623
+ await this.connect();
624
+ }
625
+ const startTime = Date.now();
626
+ const db = this.db;
627
+ try {
628
+ if (query.operation === "command" || query.rawCommand) {
629
+ const cmd = query.rawCommand || (typeof query.rawDisplay === "string" ? JSON.parse(query.rawDisplay) : query.rawDisplay);
630
+ const result = await db.command(cmd);
631
+ const durationMs2 = Date.now() - startTime;
632
+ return {
633
+ success: true,
634
+ durationMs: durationMs2,
635
+ rawOutput: result,
636
+ rowCount: 1,
637
+ rows: [result]
638
+ };
639
+ }
640
+ let collectionName = query.collection;
641
+ let operation = query.operation;
642
+ let filter = query.filter || {};
643
+ let update = query.update || {};
644
+ let pipeline = query.pipeline || [];
645
+ let document = query.document;
646
+ let documents = query.documents;
647
+ let options = query.options || {};
648
+ if (!collectionName || !operation) {
649
+ try {
650
+ const parsed = JSON.parse(query.rawDisplay);
651
+ collectionName = parsed.collection || collectionName;
652
+ operation = parsed.operation || operation;
653
+ filter = parsed.filter || filter;
654
+ update = parsed.update || update;
655
+ pipeline = parsed.pipeline || pipeline;
656
+ document = parsed.document || document;
657
+ documents = parsed.documents || documents;
658
+ options = parsed.options || options;
659
+ } catch {
660
+ }
661
+ }
662
+ if (!collectionName) {
663
+ throw new Error("Mongo operation requires a target collection.");
664
+ }
665
+ const collection = db.collection(collectionName);
666
+ let rows = [];
667
+ let rowCount = 0;
668
+ let affectedRows = 0;
669
+ switch (operation) {
670
+ case "find": {
671
+ const limit = options.limit || 100;
672
+ const cursor = collection.find(filter, options).limit(limit);
673
+ rows = await cursor.toArray();
674
+ rowCount = rows.length;
675
+ break;
676
+ }
677
+ case "aggregate": {
678
+ const cursor = collection.aggregate(pipeline, options);
679
+ rows = await cursor.toArray();
680
+ rowCount = rows.length;
681
+ break;
682
+ }
683
+ case "countDocuments": {
684
+ const count = await collection.countDocuments(filter, options);
685
+ rows = [{ count }];
686
+ rowCount = 1;
687
+ break;
688
+ }
689
+ case "insertOne": {
690
+ const res = await collection.insertOne(document || filter, options);
691
+ rows = [{ insertedId: res.insertedId, acknowledged: res.acknowledged }];
692
+ rowCount = 1;
693
+ affectedRows = 1;
694
+ break;
695
+ }
696
+ case "insertMany": {
697
+ const docs = documents || (Array.isArray(document) ? document : [document]);
698
+ const res = await collection.insertMany(docs, options);
699
+ rows = [{ insertedCount: res.insertedCount, acknowledged: res.acknowledged }];
700
+ rowCount = res.insertedCount;
701
+ affectedRows = res.insertedCount;
702
+ break;
703
+ }
704
+ case "updateOne": {
705
+ const res = await collection.updateOne(filter, update, options);
706
+ rows = [{ matchedCount: res.matchedCount, modifiedCount: res.modifiedCount, upsertedId: res.upsertedId }];
707
+ rowCount = res.modifiedCount;
708
+ affectedRows = res.modifiedCount;
709
+ break;
710
+ }
711
+ case "updateMany": {
712
+ const res = await collection.updateMany(filter, update, options);
713
+ rows = [{ matchedCount: res.matchedCount, modifiedCount: res.modifiedCount, upsertedId: res.upsertedId }];
714
+ rowCount = res.modifiedCount;
715
+ affectedRows = res.modifiedCount;
716
+ break;
717
+ }
718
+ case "deleteOne": {
719
+ const res = await collection.deleteOne(filter, options);
720
+ rows = [{ deletedCount: res.deletedCount }];
721
+ rowCount = res.deletedCount;
722
+ affectedRows = res.deletedCount;
723
+ break;
724
+ }
725
+ case "deleteMany": {
726
+ const res = await collection.deleteMany(filter, options);
727
+ rows = [{ deletedCount: res.deletedCount }];
728
+ rowCount = res.deletedCount;
729
+ affectedRows = res.deletedCount;
730
+ break;
731
+ }
732
+ case "drop": {
733
+ const res = await collection.drop();
734
+ rows = [{ dropped: res }];
735
+ rowCount = res ? 1 : 0;
736
+ break;
737
+ }
738
+ case "createIndex": {
739
+ const keys = options.keys || filter;
740
+ const indexOptions = options.indexOptions || {};
741
+ const indexName = await collection.createIndex(keys, indexOptions);
742
+ rows = [{ createdIndex: indexName }];
743
+ rowCount = 1;
744
+ break;
745
+ }
746
+ case "createCollection": {
747
+ await db.createCollection(collectionName, options);
748
+ rows = [{ createdCollection: collectionName }];
749
+ rowCount = 1;
750
+ break;
751
+ }
752
+ default:
753
+ throw new Error(`Unsupported MongoDB operation: ${operation}`);
754
+ }
755
+ const durationMs = Date.now() - startTime;
756
+ const fields = rows.length > 0 && typeof rows[0] === "object" ? Object.keys(rows[0]) : [];
757
+ return {
758
+ success: true,
759
+ rows,
760
+ rowCount,
761
+ affectedRows,
762
+ fields,
763
+ durationMs,
764
+ command: operation
765
+ };
766
+ } catch (err) {
767
+ const durationMs = Date.now() - startTime;
768
+ return {
769
+ success: false,
770
+ durationMs,
771
+ error: err.message || String(err)
772
+ };
773
+ }
774
+ }
775
+ async dryRunCount(query) {
776
+ if (!this.isConnected()) {
777
+ await this.connect();
778
+ }
779
+ try {
780
+ let collectionName = query.collection;
781
+ let operation = query.operation;
782
+ let filter = query.filter || {};
783
+ if (!collectionName || !operation) {
784
+ try {
785
+ const parsed = JSON.parse(query.rawDisplay);
786
+ collectionName = parsed.collection || collectionName;
787
+ operation = parsed.operation || operation;
788
+ filter = parsed.filter || filter;
789
+ } catch {
790
+ }
791
+ }
792
+ if (!collectionName) return null;
793
+ const collection = this.db.collection(collectionName);
794
+ if (operation === "deleteOne") {
795
+ const count = await collection.countDocuments(filter);
796
+ return Math.min(1, count);
797
+ }
798
+ if (operation === "deleteMany" || operation === "updateOne" || operation === "updateMany") {
799
+ return await collection.countDocuments(filter);
800
+ }
801
+ if (operation === "drop") {
802
+ return await collection.estimatedDocumentCount();
803
+ }
804
+ return null;
805
+ } catch {
806
+ return null;
807
+ }
808
+ }
809
+ async explainQuery(query) {
810
+ if (!this.isConnected()) {
811
+ await this.connect();
812
+ }
813
+ try {
814
+ let collectionName = query.collection;
815
+ let operation = query.operation || "find";
816
+ let filter = query.filter || {};
817
+ let pipeline = query.pipeline || [];
818
+ if (!collectionName) {
819
+ try {
820
+ const parsed = JSON.parse(query.rawDisplay);
821
+ collectionName = parsed.collection || collectionName;
822
+ operation = parsed.operation || operation;
823
+ filter = parsed.filter || filter;
824
+ pipeline = parsed.pipeline || pipeline;
825
+ } catch {
826
+ return "";
827
+ }
828
+ }
829
+ if (!collectionName) return "";
830
+ const collection = this.db.collection(collectionName);
831
+ if (operation === "find") {
832
+ const explanation = await collection.find(filter).explain("executionStats");
833
+ return JSON.stringify(explanation, null, 2);
834
+ }
835
+ if (operation === "aggregate") {
836
+ const explanation = await collection.aggregate(pipeline).explain("executionStats");
837
+ return JSON.stringify(explanation, null, 2);
838
+ }
839
+ return "";
840
+ } catch (err) {
841
+ return `Explain failed: ${err.message}`;
842
+ }
843
+ }
844
+ };
845
+
846
+ // src/db/connection.ts
847
+ function detectDatabaseType(url) {
848
+ const trimmed = url.trim().toLowerCase();
849
+ if (trimmed.startsWith("postgres://") || trimmed.startsWith("postgresql://")) {
850
+ return "postgres";
851
+ }
852
+ if (trimmed.startsWith("mongodb://") || trimmed.startsWith("mongodb+srv://")) {
853
+ return "mongodb";
854
+ }
855
+ throw new Error(
856
+ `Unsupported database URL scheme. Expected postgres://, postgresql://, mongodb://, or mongodb+srv://. Received: ${url.slice(0, 15)}...`
857
+ );
858
+ }
859
+ function createDatabaseAdapter(url) {
860
+ const type = detectDatabaseType(url);
861
+ if (type === "postgres") {
862
+ return new PostgresAdapter(url);
863
+ }
864
+ if (type === "mongodb") {
865
+ return new MongoAdapter(url);
866
+ }
867
+ throw new Error(`Unknown database type: ${type}`);
868
+ }
869
+
870
+ // src/agent/llm.ts
871
+ import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
872
+ import { ChatOpenAI } from "@langchain/openai";
873
+ import { ChatAnthropic } from "@langchain/anthropic";
874
+ function createChatModel(options) {
875
+ const { provider, model, apiKey, temperature = 0 } = options;
876
+ switch (provider) {
877
+ case "google":
878
+ return new ChatGoogleGenerativeAI({
879
+ model,
880
+ apiKey,
881
+ temperature,
882
+ maxRetries: 2
883
+ });
884
+ case "openai":
885
+ return new ChatOpenAI({
886
+ modelName: model,
887
+ apiKey,
888
+ temperature,
889
+ maxRetries: 2
890
+ });
891
+ case "anthropic":
892
+ return new ChatAnthropic({
893
+ modelName: model,
894
+ anthropicApiKey: apiKey,
895
+ temperature,
896
+ maxRetries: 2
897
+ });
898
+ default:
899
+ throw new Error(`Unsupported LLM provider: ${provider}`);
900
+ }
901
+ }
902
+
903
+ // src/repl.ts
904
+ import readline from "node:readline";
905
+ import chalk2 from "chalk";
906
+ import boxen2 from "boxen";
907
+ import ora from "ora";
908
+ import Table from "cli-table3";
909
+
910
+ // src/agent/graph.ts
911
+ import { Annotation, StateGraph, START, END, MemorySaver } from "@langchain/langgraph";
912
+ import { HumanMessage as HumanMessage2, SystemMessage, AIMessage } from "@langchain/core/messages";
913
+
914
+ // src/safety/classifier.ts
915
+ var DEFAULT_DANGEROUS_UPDATE_THRESHOLD = 50;
916
+ function classifyQuery(query, dbType, options = {}) {
917
+ const rowThreshold = options.rowThresholdForDangerousUpdate ?? DEFAULT_DANGEROUS_UPDATE_THRESHOLD;
918
+ const rawText = (query.sql || query.rawDisplay || "").trim();
919
+ if (dbType === "postgres") {
920
+ return classifyPostgresQuery(rawText, query, rowThreshold, options);
921
+ } else {
922
+ return classifyMongoQuery(rawText, query, rowThreshold, options);
923
+ }
924
+ }
925
+ function classifyPostgresQuery(sql, query, rowThreshold, options) {
926
+ const cleanSql = sql.replace(/\/\*[\s\S]*?\*\/|--.*$/gm, "").trim();
927
+ const upper = cleanSql.toUpperCase();
928
+ const warnings = [];
929
+ const isDropDb = /DROP\s+DATABASE/i.test(cleanSql);
930
+ const isDropSchemaCascade = /DROP\s+SCHEMA\s+[\s\S]*?CASCADE/i.test(cleanSql);
931
+ if (isDropDb || isDropSchemaCascade) {
932
+ return {
933
+ category: "full_wipe",
934
+ isDestructive: true,
935
+ isStructural: false,
936
+ isFullWipe: true,
937
+ requiresLiteralWord: true,
938
+ literalWord: "DROP DATABASE",
939
+ hasWhereClause: false,
940
+ warnings: ["This operation permanently deletes an entire database or schema hierarchy."],
941
+ explanation: "Permanently deletes database or schema cascade."
942
+ };
943
+ }
944
+ const isCreateIndex = /CREATE\s+(UNIQUE\s+)?INDEX/i.test(cleanSql);
945
+ const isCreateTable = /CREATE\s+TABLE/i.test(cleanSql);
946
+ const isCreateSchema = /CREATE\s+SCHEMA/i.test(cleanSql);
947
+ const isAlterAdd = /ALTER\s+TABLE\s+([^\s;]+)\s+ADD\s+(COLUMN|CONSTRAINT)/i.test(cleanSql);
948
+ const isAlterOtherSafe = /ALTER\s+TABLE\s+([^\s;]+)\s+(ALTER\s+COLUMN|RENAME)/i.test(cleanSql);
949
+ if (isCreateIndex) {
950
+ const isConcurrently = /CREATE\s+(UNIQUE\s+)?INDEX\s+CONCURRENTLY/i.test(cleanSql);
951
+ let suggestion;
952
+ if (!isConcurrently) {
953
+ warnings.push(
954
+ "Index is not being created CONCURRENTLY. This will lock write operations on the table during index creation."
955
+ );
956
+ suggestion = cleanSql.replace(/CREATE\s+(UNIQUE\s+)?INDEX/i, (m) => `${m} CONCURRENTLY`);
957
+ }
958
+ const tableMatch = cleanSql.match(/ON\s+([^\s;(]+)/i);
959
+ const tableName = tableMatch ? tableMatch[1] : void 0;
960
+ return {
961
+ category: "structural",
962
+ isDestructive: false,
963
+ isStructural: true,
964
+ isFullWipe: false,
965
+ requiresLiteralWord: false,
966
+ hasWhereClause: false,
967
+ tableOrCollection: tableName,
968
+ explanation: `Creates an index on ${tableName || "table"}${isConcurrently ? " concurrently (non-blocking)" : " (may lock table during build)"}.`,
969
+ warnings,
970
+ suggestedAlternative: suggestion
971
+ };
972
+ }
973
+ if (isCreateTable) {
974
+ const match = cleanSql.match(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([^\s;(]+)/i);
975
+ const tableName = match ? match[1] : void 0;
976
+ return {
977
+ category: "structural",
978
+ isDestructive: false,
979
+ isStructural: true,
980
+ isFullWipe: false,
981
+ requiresLiteralWord: false,
982
+ hasWhereClause: false,
983
+ tableOrCollection: tableName,
984
+ explanation: `Creates a new table "${tableName || "unknown"}".`,
985
+ warnings
986
+ };
987
+ }
988
+ if (isCreateSchema) {
989
+ const match = cleanSql.match(/CREATE\s+SCHEMA\s+(?:IF\s+NOT\s+EXISTS\s+)?([^\s;(]+)/i);
990
+ const schemaName = match ? match[1] : void 0;
991
+ return {
992
+ category: "structural",
993
+ isDestructive: false,
994
+ isStructural: true,
995
+ isFullWipe: false,
996
+ requiresLiteralWord: false,
997
+ hasWhereClause: false,
998
+ tableOrCollection: schemaName,
999
+ explanation: `Creates a new schema "${schemaName || "unknown"}".`,
1000
+ warnings
1001
+ };
1002
+ }
1003
+ if (isAlterAdd || isAlterOtherSafe) {
1004
+ const match = cleanSql.match(/ALTER\s+TABLE\s+([^\s;(]+)/i);
1005
+ const tableName = match ? match[1] : void 0;
1006
+ return {
1007
+ category: "structural",
1008
+ isDestructive: false,
1009
+ isStructural: true,
1010
+ isFullWipe: false,
1011
+ requiresLiteralWord: false,
1012
+ hasWhereClause: false,
1013
+ tableOrCollection: tableName,
1014
+ explanation: `Alters table "${tableName || "unknown"}" to add or modify structural attributes.`,
1015
+ warnings
1016
+ };
1017
+ }
1018
+ const isAlterDrop = /ALTER\s+TABLE\s+([^\s;]+)\s+DROP\s+COLUMN/i.test(cleanSql);
1019
+ if (isAlterDrop) {
1020
+ const match = cleanSql.match(/ALTER\s+TABLE\s+([^\s;(]+)/i);
1021
+ const tableName = match ? match[1] : void 0;
1022
+ return {
1023
+ category: "dangerous",
1024
+ isDestructive: true,
1025
+ isStructural: false,
1026
+ isFullWipe: false,
1027
+ requiresLiteralWord: false,
1028
+ hasWhereClause: false,
1029
+ tableOrCollection: tableName,
1030
+ explanation: `Removes a column from table "${tableName || "unknown"}", permanently discarding column data.`,
1031
+ warnings: ["Removing a column is permanent and irreversible."]
1032
+ };
1033
+ }
1034
+ const isDropTable = /DROP\s+TABLE/i.test(cleanSql);
1035
+ const isTruncate = /TRUNCATE(\s+TABLE)?/i.test(cleanSql);
1036
+ if (isDropTable || isTruncate) {
1037
+ const match = cleanSql.match(/(?:DROP\s+TABLE|TRUNCATE(?:\s+TABLE)?)\s+(?:IF\s+EXISTS\s+)?([^\s;(]+)/i);
1038
+ const tableName = match ? match[1] : void 0;
1039
+ return {
1040
+ category: "dangerous",
1041
+ isDestructive: true,
1042
+ isStructural: false,
1043
+ isFullWipe: false,
1044
+ requiresLiteralWord: true,
1045
+ literalWord: isDropTable ? "DROP TABLE" : "TRUNCATE",
1046
+ hasWhereClause: false,
1047
+ tableOrCollection: tableName,
1048
+ explanation: `Permanently removes all data from table "${tableName || "unknown"}".`,
1049
+ warnings: ["All rows will be permanently deleted."]
1050
+ };
1051
+ }
1052
+ const isDelete = /^DELETE\s+FROM/i.test(cleanSql);
1053
+ if (isDelete) {
1054
+ const match = cleanSql.match(/^DELETE\s+FROM\s+([^\s;(]+)(?:\s+WHERE\s+([\s\S]+))?/i);
1055
+ const tableName = match ? match[1] : void 0;
1056
+ const hasWhere = Boolean(match && match[2] && match[2].trim().length > 0);
1057
+ if (!hasWhere) {
1058
+ return {
1059
+ category: "dangerous",
1060
+ isDestructive: true,
1061
+ isStructural: false,
1062
+ isFullWipe: false,
1063
+ requiresLiteralWord: true,
1064
+ literalWord: "DELETE ALL",
1065
+ hasWhereClause: false,
1066
+ tableOrCollection: tableName,
1067
+ explanation: `Deletes ALL rows from table "${tableName || "unknown"}" (no WHERE clause present).`,
1068
+ warnings: ["No WHERE clause specified: EVERY row in the table will be deleted!"]
1069
+ };
1070
+ }
1071
+ return {
1072
+ category: "dangerous",
1073
+ isDestructive: true,
1074
+ isStructural: false,
1075
+ isFullWipe: false,
1076
+ requiresLiteralWord: false,
1077
+ hasWhereClause: true,
1078
+ tableOrCollection: tableName,
1079
+ explanation: `Deletes filtered rows from table "${tableName || "unknown"}".`,
1080
+ warnings: ["Mutates existing database records."]
1081
+ };
1082
+ }
1083
+ const isUpdate = /^UPDATE\s+/i.test(cleanSql);
1084
+ if (isUpdate) {
1085
+ const match = cleanSql.match(/^UPDATE\s+([^\s;(]+)\s+SET\s+[\s\S]+?(?:\s+WHERE\s+([\s\S]+))?$/i);
1086
+ const tableName = match ? match[1] : void 0;
1087
+ const hasWhere = Boolean(match && match[2] && match[2].trim().length > 0);
1088
+ if (!hasWhere) {
1089
+ return {
1090
+ category: "dangerous",
1091
+ isDestructive: true,
1092
+ isStructural: false,
1093
+ isFullWipe: false,
1094
+ requiresLiteralWord: true,
1095
+ literalWord: "UPDATE ALL",
1096
+ hasWhereClause: false,
1097
+ tableOrCollection: tableName,
1098
+ explanation: `Updates ALL rows in table "${tableName || "unknown"}" (no WHERE clause present).`,
1099
+ warnings: ["No WHERE clause specified: EVERY row in the table will be updated!"]
1100
+ };
1101
+ }
1102
+ return {
1103
+ category: "write",
1104
+ isDestructive: false,
1105
+ isStructural: false,
1106
+ isFullWipe: false,
1107
+ requiresLiteralWord: false,
1108
+ hasWhereClause: true,
1109
+ tableOrCollection: tableName,
1110
+ explanation: `Updates filtered rows in table "${tableName || "unknown"}".`,
1111
+ warnings: ["Mutates existing database records."]
1112
+ };
1113
+ }
1114
+ const isInsert = /^INSERT\s+INTO/i.test(cleanSql);
1115
+ if (isInsert) {
1116
+ const match = cleanSql.match(/^INSERT\s+INTO\s+([^\s;(]+)/i);
1117
+ const tableName = match ? match[1] : void 0;
1118
+ return {
1119
+ category: "write",
1120
+ isDestructive: false,
1121
+ isStructural: false,
1122
+ isFullWipe: false,
1123
+ requiresLiteralWord: false,
1124
+ hasWhereClause: false,
1125
+ tableOrCollection: tableName,
1126
+ explanation: `Inserts new records into table "${tableName || "unknown"}".`,
1127
+ warnings: []
1128
+ };
1129
+ }
1130
+ if (/^(SELECT|EXPLAIN|SHOW|WITH)\b/i.test(cleanSql)) {
1131
+ return {
1132
+ category: "read",
1133
+ isDestructive: false,
1134
+ isStructural: false,
1135
+ isFullWipe: false,
1136
+ requiresLiteralWord: false,
1137
+ hasWhereClause: /WHERE/i.test(cleanSql),
1138
+ explanation: "Read-only query.",
1139
+ warnings: []
1140
+ };
1141
+ }
1142
+ return {
1143
+ category: "write",
1144
+ isDestructive: false,
1145
+ isStructural: false,
1146
+ isFullWipe: false,
1147
+ requiresLiteralWord: false,
1148
+ hasWhereClause: false,
1149
+ explanation: "Database operation.",
1150
+ warnings: ["Non-standard query statement."]
1151
+ };
1152
+ }
1153
+ function classifyMongoQuery(rawText, query, rowThreshold, options) {
1154
+ let collectionName = query.collection;
1155
+ let operation = query.operation;
1156
+ let filter = query.filter;
1157
+ if (!collectionName || !operation) {
1158
+ try {
1159
+ const parsed = JSON.parse(rawText);
1160
+ collectionName = parsed.collection || collectionName;
1161
+ operation = parsed.operation || operation;
1162
+ filter = parsed.filter || filter;
1163
+ } catch {
1164
+ }
1165
+ }
1166
+ const warnings = [];
1167
+ if (operation === "command" && (query.rawCommand?.dropDatabase || rawText.includes("dropDatabase"))) {
1168
+ return {
1169
+ category: "full_wipe",
1170
+ isDestructive: true,
1171
+ isStructural: false,
1172
+ isFullWipe: true,
1173
+ requiresLiteralWord: true,
1174
+ literalWord: "DROP DATABASE",
1175
+ hasWhereClause: false,
1176
+ warnings: ["This operation permanently drops the entire MongoDB database."],
1177
+ explanation: "Drops the entire database."
1178
+ };
1179
+ }
1180
+ if (operation === "createIndex") {
1181
+ const isBackground = query.options?.background === true;
1182
+ let suggestion;
1183
+ if (!isBackground) {
1184
+ warnings.push("Index is not specified with background: true. It may block other operations during build.");
1185
+ }
1186
+ return {
1187
+ category: "structural",
1188
+ isDestructive: false,
1189
+ isStructural: true,
1190
+ isFullWipe: false,
1191
+ requiresLiteralWord: false,
1192
+ hasWhereClause: false,
1193
+ tableOrCollection: collectionName,
1194
+ explanation: `Creates an index on collection "${collectionName || "collection"}".`,
1195
+ warnings,
1196
+ suggestedAlternative: suggestion
1197
+ };
1198
+ }
1199
+ if (operation === "createCollection") {
1200
+ return {
1201
+ category: "structural",
1202
+ isDestructive: false,
1203
+ isStructural: true,
1204
+ isFullWipe: false,
1205
+ requiresLiteralWord: false,
1206
+ hasWhereClause: false,
1207
+ tableOrCollection: collectionName,
1208
+ explanation: `Creates collection "${collectionName}".`,
1209
+ warnings
1210
+ };
1211
+ }
1212
+ if (operation === "drop") {
1213
+ return {
1214
+ category: "dangerous",
1215
+ isDestructive: true,
1216
+ isStructural: false,
1217
+ isFullWipe: false,
1218
+ requiresLiteralWord: true,
1219
+ literalWord: "DROP COLLECTION",
1220
+ hasWhereClause: false,
1221
+ tableOrCollection: collectionName,
1222
+ explanation: `Drops collection "${collectionName}", permanently deleting all documents and indexes.`,
1223
+ warnings: ["All documents in this collection will be removed."]
1224
+ };
1225
+ }
1226
+ const hasFilter = filter && Object.keys(filter).length > 0;
1227
+ if (operation === "deleteMany" || operation === "deleteOne") {
1228
+ if (!hasFilter) {
1229
+ return {
1230
+ category: "dangerous",
1231
+ isDestructive: true,
1232
+ isStructural: false,
1233
+ isFullWipe: false,
1234
+ requiresLiteralWord: true,
1235
+ literalWord: "DELETE ALL",
1236
+ hasWhereClause: false,
1237
+ tableOrCollection: collectionName,
1238
+ explanation: `Deletes documents from "${collectionName}" with an empty filter (deletes all documents).`,
1239
+ warnings: ["No filter specified: all documents matching the empty filter will be deleted!"]
1240
+ };
1241
+ }
1242
+ return {
1243
+ category: "dangerous",
1244
+ isDestructive: true,
1245
+ isStructural: false,
1246
+ isFullWipe: false,
1247
+ requiresLiteralWord: false,
1248
+ hasWhereClause: true,
1249
+ tableOrCollection: collectionName,
1250
+ explanation: `Deletes filtered documents from collection "${collectionName}".`,
1251
+ warnings: ["Mutates existing database documents."]
1252
+ };
1253
+ }
1254
+ if (operation === "updateMany" || operation === "updateOne") {
1255
+ if (!hasFilter) {
1256
+ return {
1257
+ category: "dangerous",
1258
+ isDestructive: true,
1259
+ isStructural: false,
1260
+ isFullWipe: false,
1261
+ requiresLiteralWord: true,
1262
+ literalWord: "UPDATE ALL",
1263
+ hasWhereClause: false,
1264
+ tableOrCollection: collectionName,
1265
+ explanation: `Updates documents in "${collectionName}" with an empty filter (updates all documents).`,
1266
+ warnings: ["No filter specified: every document in the collection will be updated!"]
1267
+ };
1268
+ }
1269
+ return {
1270
+ category: "write",
1271
+ isDestructive: false,
1272
+ isStructural: false,
1273
+ isFullWipe: false,
1274
+ requiresLiteralWord: false,
1275
+ hasWhereClause: true,
1276
+ tableOrCollection: collectionName,
1277
+ explanation: `Updates documents in collection "${collectionName}".`,
1278
+ warnings: ["Mutates existing database documents."]
1279
+ };
1280
+ }
1281
+ if (operation === "insertOne" || operation === "insertMany") {
1282
+ return {
1283
+ category: "write",
1284
+ isDestructive: false,
1285
+ isStructural: false,
1286
+ isFullWipe: false,
1287
+ requiresLiteralWord: false,
1288
+ hasWhereClause: false,
1289
+ tableOrCollection: collectionName,
1290
+ explanation: `Inserts document(s) into collection "${collectionName}".`,
1291
+ warnings: []
1292
+ };
1293
+ }
1294
+ if (operation === "find" || operation === "aggregate" || operation === "countDocuments") {
1295
+ return {
1296
+ category: "read",
1297
+ isDestructive: false,
1298
+ isStructural: false,
1299
+ isFullWipe: false,
1300
+ requiresLiteralWord: false,
1301
+ hasWhereClause: Boolean(hasFilter),
1302
+ tableOrCollection: collectionName,
1303
+ explanation: "Read-only operation.",
1304
+ warnings: []
1305
+ };
1306
+ }
1307
+ return {
1308
+ category: "read",
1309
+ isDestructive: false,
1310
+ isStructural: false,
1311
+ isFullWipe: false,
1312
+ requiresLiteralWord: false,
1313
+ hasWhereClause: false,
1314
+ explanation: "Database operation.",
1315
+ warnings: []
1316
+ };
1317
+ }
1318
+
1319
+ // src/safety/confirm.ts
1320
+ import chalk from "chalk";
1321
+ import boxen from "boxen";
1322
+ import { confirm, input } from "@inquirer/prompts";
1323
+ async function requestUserConfirmation(query, safety, affectedRows) {
1324
+ const queryDisplay = (query.sql || query.rawDisplay || "").trim();
1325
+ if (safety.isFullWipe) {
1326
+ const boxContent = [
1327
+ chalk.bold.red("\u{1F6A8} CRITICAL: FULL DATABASE / ALL TABLES WIPE ATTEMPTED \u{1F6A8}"),
1328
+ "",
1329
+ chalk.yellow(queryDisplay),
1330
+ "",
1331
+ ...safety.warnings.length ? [chalk.red(safety.warnings.join("\n")), ""] : [],
1332
+ chalk.bold.white(`Type ${chalk.red.underline(safety.literalWord || "DROP DATABASE")} to proceed:`)
1333
+ ].join("\n");
1334
+ console.log(
1335
+ boxen(boxContent, {
1336
+ padding: 1,
1337
+ borderColor: "red",
1338
+ borderStyle: "double",
1339
+ margin: { top: 1, bottom: 1 }
1340
+ })
1341
+ );
1342
+ const typed = await input({
1343
+ message: chalk.red.bold(`Type "${safety.literalWord || "DROP DATABASE"}" to confirm full wipe:`)
1344
+ });
1345
+ if (typed.trim() === safety.literalWord) {
1346
+ return { confirmed: true };
1347
+ }
1348
+ return {
1349
+ confirmed: false,
1350
+ reason: `Confirmation word mismatch (expected "${safety.literalWord}"). Operation aborted.`
1351
+ };
1352
+ }
1353
+ if (safety.category === "dangerous") {
1354
+ const lines2 = [
1355
+ chalk.bold.hex("#FFA500")("\u26A0\uFE0F DANGEROUS OPERATION"),
1356
+ "",
1357
+ chalk.white(queryDisplay),
1358
+ ""
1359
+ ];
1360
+ if (affectedRows !== null) {
1361
+ lines2.push(
1362
+ chalk.bold.yellow(`Affected rows: ${affectedRows.toLocaleString()}`),
1363
+ ""
1364
+ );
1365
+ }
1366
+ if (safety.warnings.length > 0) {
1367
+ for (const w of safety.warnings) {
1368
+ lines2.push(chalk.red(`\u2022 ${w}`));
1369
+ }
1370
+ lines2.push("");
1371
+ }
1372
+ console.log(
1373
+ boxen(lines2.join("\n"), {
1374
+ padding: 1,
1375
+ borderColor: "yellow",
1376
+ borderStyle: "round",
1377
+ margin: { top: 1, bottom: 1 }
1378
+ })
1379
+ );
1380
+ if (safety.requiresLiteralWord && safety.literalWord) {
1381
+ const typed = await input({
1382
+ message: chalk.hex("#FFA500").bold(
1383
+ `Destructive operation with no filter. Type "${safety.literalWord}" to confirm:`
1384
+ )
1385
+ });
1386
+ if (typed.trim() === safety.literalWord) {
1387
+ return { confirmed: true };
1388
+ }
1389
+ return {
1390
+ confirmed: false,
1391
+ reason: `Confirmation word mismatch (expected "${safety.literalWord}"). Operation cancelled.`
1392
+ };
1393
+ }
1394
+ const answer2 = await confirm({
1395
+ message: chalk.hex("#FFA500").bold("Proceed with dangerous operation?"),
1396
+ default: false
1397
+ });
1398
+ return { confirmed: answer2 };
1399
+ }
1400
+ if (safety.isStructural) {
1401
+ const lines2 = [
1402
+ chalk.bold.cyan("\u{1F6E0}\uFE0F STRUCTURAL DDL OPERATION"),
1403
+ "",
1404
+ chalk.white(queryDisplay),
1405
+ "",
1406
+ chalk.cyan(`Effect: ${safety.explanation || "Modifies database structure."}`)
1407
+ ];
1408
+ if (safety.suggestedAlternative) {
1409
+ lines2.push(
1410
+ "",
1411
+ chalk.yellow("\u{1F4A1} Recommendation:"),
1412
+ chalk.gray(safety.suggestedAlternative)
1413
+ );
1414
+ }
1415
+ if (safety.warnings.length > 0) {
1416
+ lines2.push("");
1417
+ for (const w of safety.warnings) {
1418
+ lines2.push(chalk.yellow(`\u2022 ${w}`));
1419
+ }
1420
+ }
1421
+ console.log(
1422
+ boxen(lines2.join("\n"), {
1423
+ padding: 1,
1424
+ borderColor: "cyan",
1425
+ borderStyle: "round",
1426
+ margin: { top: 1, bottom: 1 }
1427
+ })
1428
+ );
1429
+ const answer2 = await confirm({
1430
+ message: chalk.cyan.bold("Apply this structural change?"),
1431
+ default: true
1432
+ });
1433
+ return { confirmed: answer2 };
1434
+ }
1435
+ if (safety.category === "write") {
1436
+ const lines2 = [
1437
+ chalk.bold.magenta("\u270F\uFE0F DATABASE WRITE OPERATION"),
1438
+ "",
1439
+ chalk.white(queryDisplay)
1440
+ ];
1441
+ if (affectedRows !== null) {
1442
+ lines2.push("", chalk.magenta(`Estimated affected rows: ${affectedRows.toLocaleString()}`));
1443
+ }
1444
+ console.log(
1445
+ boxen(lines2.join("\n"), {
1446
+ padding: 1,
1447
+ borderColor: "magenta",
1448
+ borderStyle: "round",
1449
+ margin: { top: 1, bottom: 1 }
1450
+ })
1451
+ );
1452
+ const answer2 = await confirm({
1453
+ message: chalk.magenta.bold("Execute write query?"),
1454
+ default: false
1455
+ });
1456
+ return { confirmed: answer2 };
1457
+ }
1458
+ const lines = [
1459
+ chalk.bold.green("\u{1F50D} READ QUERY"),
1460
+ "",
1461
+ chalk.white(queryDisplay)
1462
+ ];
1463
+ console.log(
1464
+ boxen(lines.join("\n"), {
1465
+ padding: 1,
1466
+ borderColor: "green",
1467
+ borderStyle: "round",
1468
+ margin: { top: 1, bottom: 1 }
1469
+ })
1470
+ );
1471
+ const answer = await confirm({
1472
+ message: chalk.green.bold("Execute read query?"),
1473
+ default: true
1474
+ });
1475
+ return { confirmed: answer };
1476
+ }
1477
+
1478
+ // src/agent/guardrails.ts
1479
+ import { HumanMessage } from "@langchain/core/messages";
1480
+
1481
+ // src/agent/prompts.ts
1482
+ var REFUSAL_MESSAGE = "I'm scoped to database operations on your connected DB only (schema inspection, queries, CRUD, analysis). I can't help with that here.";
1483
+ function getSystemPrompt(dbType, schemaSummary) {
1484
+ return `You are "SANDAL" (Safe Agentic Natural-language Database Access Layer), an expert, production-grade agentic database assistant.
1485
+ You are connected directly to a live ${dbType === "postgres" ? "PostgreSQL" : "MongoDB"} database.
1486
+
1487
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1488
+ STRICT SCOPE & SAFETY RULES
1489
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1490
+ 1. You are strictly scoped to database operations on the connected database:
1491
+ - Schema inspection and navigation
1492
+ - Data analysis, querying, filtering, sorting, aggregations, and statistics
1493
+ - CRUD operations (SELECT/find, INSERT/insertOne, UPDATE/updateOne, DELETE/deleteOne)
1494
+ - Structural DDL operations (CREATE TABLE, CREATE INDEX, ALTER TABLE ADD COLUMN, createCollection, createIndex)
1495
+ 2. REFUSE ANY OUT-OF-SCOPE REQUEST:
1496
+ - If the user asks for general programming (e.g. "write a Python script", "build a web scraper"), general Q&A, creative writing, math tutoring, or any non-database task, politely refuse immediately with:
1497
+ "${REFUSAL_MESSAGE}"
1498
+ 3. NEVER generate or suggest shell/bash commands or OS actions.
1499
+ 4. NEVER expose raw credentials, passwords, or API keys in your responses.
1500
+ 5. NEVER hallucinate data. Ground your answers ONLY in the actual query results returned from the database.
1501
+
1502
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1503
+ QUERY GENERATION RULES
1504
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1505
+ ${dbType === "postgres" ? `\u2022 Target Dialect: PostgreSQL.
1506
+ \u2022 Produce valid SQL. For queries with variables, prefer parameterized queries or clean literals when executing directly.
1507
+ \u2022 For UPDATE or DELETE queries, ALWAYS include a specific, narrow WHERE clause unless the user explicitly requested modifying all rows.
1508
+ \u2022 For index creation, prefer "CREATE INDEX CONCURRENTLY" to avoid write-locking tables.
1509
+ \u2022 Return your query formatted in a json object:
1510
+ {
1511
+ "type": "postgres",
1512
+ "sql": "<the SQL statement>",
1513
+ "params": [],
1514
+ "targetTables": ["table1"],
1515
+ "explanation": "<brief summary of what the query does>"
1516
+ }` : `\u2022 Target Dialect: MongoDB.
1517
+ \u2022 Produce valid MongoDB operations in structured JSON format:
1518
+ {
1519
+ "type": "mongodb",
1520
+ "collection": "<collection_name>",
1521
+ "operation": "find" | "aggregate" | "countDocuments" | "insertOne" | "insertMany" | "updateOne" | "updateMany" | "deleteOne" | "deleteMany" | "createIndex" | "createCollection",
1522
+ "filter": {},
1523
+ "update": {},
1524
+ "pipeline": [],
1525
+ "document": {},
1526
+ "options": {},
1527
+ "explanation": "<brief summary of what the operation does>"
1528
+ }
1529
+ \u2022 For UPDATE or DELETE operations, ALWAYS include a specific filter unless the user explicitly requested modifying all documents.
1530
+ \u2022 For createIndex, prefer options like {"background": true}.`}
1531
+
1532
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1533
+ CURRENT DATABASE SCHEMA CONTEXT
1534
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1535
+ ${schemaSummary}
1536
+ `;
1537
+ }
1538
+ function formatSchemaForPrompt(schema) {
1539
+ if (schema.type === "postgres") {
1540
+ if (!schema.tables || schema.tables.length === 0) {
1541
+ return "No tables found in database.";
1542
+ }
1543
+ const lines = [`Database: ${schema.databaseName}`, "Tables:"];
1544
+ for (const t of schema.tables) {
1545
+ const colDefs = t.columns.map((c) => `${c.name} (${c.dataType}${c.isPrimaryKey ? ", PK" : ""}${c.isNullable ? "" : ", NOT NULL"})`).join(", ");
1546
+ const idxDefs = t.indexes.map((i) => i.name).join(", ");
1547
+ const fkDefs = t.foreignKeys.map((f) => `${f.columnName}->${f.foreignTableName}.${f.foreignColumnName}`).join(", ");
1548
+ lines.push(`- Table: ${t.schema}.${t.name} (~${t.approximateRowCount ?? 0} rows)`);
1549
+ lines.push(` Columns: ${colDefs}`);
1550
+ if (idxDefs) lines.push(` Indexes: ${idxDefs}`);
1551
+ if (fkDefs) lines.push(` Foreign Keys: ${fkDefs}`);
1552
+ }
1553
+ return lines.join("\n");
1554
+ } else {
1555
+ if (!schema.collections || schema.collections.length === 0) {
1556
+ return "No collections found in database.";
1557
+ }
1558
+ const lines = [`Database: ${schema.databaseName}`, "Collections:"];
1559
+ for (const c of schema.collections) {
1560
+ const fieldList = c.fields.map((f) => `${f.name} [${f.types.join("|")}]`).join(", ");
1561
+ lines.push(`- Collection: ${c.name} (~${c.documentCount ?? 0} documents)`);
1562
+ if (fieldList) lines.push(` Fields: ${fieldList}`);
1563
+ if (c.indexes.length) {
1564
+ lines.push(` Indexes: ${c.indexes.map((i) => i.name).join(", ")}`);
1565
+ }
1566
+ }
1567
+ return lines.join("\n");
1568
+ }
1569
+ }
1570
+ var INTENT_CLASSIFICATION_PROMPT = `You are a strict security and intent classifier for a dedicated DATABASE OPERATIONS assistant.
1571
+ Determine whether the following user message is a "database_task" or "out_of_scope".
1572
+
1573
+ Definitions:
1574
+ - "database_task": The user asks about database schema, tables, collections, fields, running queries, counting rows, inserting/updating/deleting data, creating tables or indexes, filtering, grouping, aggregating, or analyzing database records, or conversational follow-ups directly relating to the database session.
1575
+ - "out_of_scope": The user asks for general coding help (e.g., "write a python script to scrape a website", "create a React app", "write a bash script"), general programming questions, creative writing, math tutoring, conversational chat unrelated to the DB, weather, philosophy, history, or anything not acting on the connected database.
1576
+
1577
+ Respond with EXACTLY ONE JSON OBJECT and nothing else:
1578
+ {"classification": "database_task"}
1579
+ OR
1580
+ {"classification": "out_of_scope", "reason": "<brief explanation>"}
1581
+
1582
+ User message:
1583
+ `;
1584
+
1585
+ // src/agent/guardrails.ts
1586
+ var OBVIOUS_OUT_OF_SCOPE_PATTERNS = [
1587
+ /write\s+(?:me\s+)?(?:a\s+)?python\s+script/i,
1588
+ /scrape\s+(?:a\s+)?website/i,
1589
+ /write\s+(?:me\s+)?(?:a\s+)?(?:bash|shell|sh|powershell)\s+script/i,
1590
+ /create\s+(?:a\s+)?(?:react|vue|angular|flask|django|express)\s+app/i,
1591
+ /write\s+(?:me\s+)?(?:a\s+)?(?:poem|essay|story|song|joke)/i,
1592
+ /who\s+(?:was|is)\s+/i,
1593
+ /what\s+is\s+the\s+capital\s+of/i,
1594
+ /how\s+to\s+cook/i,
1595
+ /solve\s+(?:this\s+)?(?:math|equation|integral)/i,
1596
+ /help\s+me\s+with\s+(?:my\s+)?homework/i,
1597
+ /acting\s+as\s+a\s+general\s+chatbot/i
1598
+ ];
1599
+ var OBVIOUS_DATABASE_PATTERNS = [
1600
+ /^(?:SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|EXPLAIN|SHOW|DESCRIBE|WITH)\b/i,
1601
+ /\b(?:table|tables|collection|collections|schema|column|columns|index|indexes|foreign key|primary key)\b/i,
1602
+ /\b(?:find|aggregate|count|where|group by|order by|having|limit|join|left join|inner join)\b/i,
1603
+ /\b(?:how many (?:users|rows|records|orders|items|products|documents))\b/i,
1604
+ /\b(?:database|postgres|mongodb|mongo|pg_)\b/i
1605
+ ];
1606
+ async function classifyIntent(input3, model) {
1607
+ const trimmed = input3.trim();
1608
+ for (const pattern of OBVIOUS_OUT_OF_SCOPE_PATTERNS) {
1609
+ if (pattern.test(trimmed)) {
1610
+ return {
1611
+ isDatabaseTask: false,
1612
+ reason: "Matched out-of-scope pattern"
1613
+ };
1614
+ }
1615
+ }
1616
+ for (const pattern of OBVIOUS_DATABASE_PATTERNS) {
1617
+ if (pattern.test(trimmed)) {
1618
+ return {
1619
+ isDatabaseTask: true
1620
+ };
1621
+ }
1622
+ }
1623
+ if (/^(?:tables|collections|schema|indexes|views|stats|count|info)$/i.test(trimmed)) {
1624
+ return { isDatabaseTask: true };
1625
+ }
1626
+ if (!model) {
1627
+ return { isDatabaseTask: true };
1628
+ }
1629
+ try {
1630
+ const response = await model.invoke([
1631
+ new HumanMessage(INTENT_CLASSIFICATION_PROMPT + "\n" + trimmed)
1632
+ ]);
1633
+ const content = typeof response.content === "string" ? response.content : JSON.stringify(response.content);
1634
+ const jsonMatch = content.match(/\{[\s\S]*?\}/);
1635
+ if (jsonMatch) {
1636
+ const parsed = JSON.parse(jsonMatch[0]);
1637
+ if (parsed.classification === "out_of_scope") {
1638
+ return {
1639
+ isDatabaseTask: false,
1640
+ reason: parsed.reason || "Classified as out of scope by intent guardrail"
1641
+ };
1642
+ }
1643
+ return { isDatabaseTask: true };
1644
+ }
1645
+ return { isDatabaseTask: true };
1646
+ } catch (err) {
1647
+ return { isDatabaseTask: true };
1648
+ }
1649
+ }
1650
+ function checkStrictWipe(classification, options = {}) {
1651
+ const strictMode = options.strictMode !== false;
1652
+ const allowFullWipe = options.allowFullWipe === true;
1653
+ if (classification.isFullWipe && strictMode && !allowFullWipe) {
1654
+ return {
1655
+ blocked: true,
1656
+ message: "Operation blocked by --strict mode: full database or all-table drop is prohibited. To allow this, start sandal-db with --allow-full-wipe."
1657
+ };
1658
+ }
1659
+ return { blocked: false };
1660
+ }
1661
+
1662
+ // src/agent/graph.ts
1663
+ var AgentStateAnnotation = Annotation.Root({
1664
+ userInput: Annotation({
1665
+ reducer: (_, update) => update,
1666
+ default: () => ""
1667
+ }),
1668
+ schema: Annotation({
1669
+ reducer: (curr, update) => update ?? curr,
1670
+ default: () => void 0
1671
+ }),
1672
+ schemaSummary: Annotation({
1673
+ reducer: (curr, update) => update ?? curr ?? "",
1674
+ default: () => ""
1675
+ }),
1676
+ targetEntities: Annotation({
1677
+ reducer: (_, update) => update ?? [],
1678
+ default: () => []
1679
+ }),
1680
+ generatedQuery: Annotation({
1681
+ reducer: (_, update) => update,
1682
+ default: () => void 0
1683
+ }),
1684
+ safety: Annotation({
1685
+ reducer: (_, update) => update,
1686
+ default: () => void 0
1687
+ }),
1688
+ affectedRowCount: Annotation({
1689
+ reducer: (_, update) => update,
1690
+ default: () => null
1691
+ }),
1692
+ confirmed: Annotation({
1693
+ reducer: (_, update) => update ?? false,
1694
+ default: () => false
1695
+ }),
1696
+ cancelReason: Annotation({
1697
+ reducer: (_, update) => update,
1698
+ default: () => void 0
1699
+ }),
1700
+ queryResult: Annotation({
1701
+ reducer: (_, update) => update,
1702
+ default: () => void 0
1703
+ }),
1704
+ analysis: Annotation({
1705
+ reducer: (_, update) => update,
1706
+ default: () => void 0
1707
+ }),
1708
+ stats: Annotation({
1709
+ reducer: (_, update) => update,
1710
+ default: () => void 0
1711
+ }),
1712
+ conclusion: Annotation({
1713
+ reducer: (_, update) => update,
1714
+ default: () => void 0
1715
+ }),
1716
+ requiresSchemaRefresh: Annotation({
1717
+ reducer: (_, update) => update ?? false,
1718
+ default: () => false
1719
+ }),
1720
+ messages: Annotation({
1721
+ reducer: (curr, update) => curr.concat(update ?? []),
1722
+ default: () => []
1723
+ })
1724
+ });
1725
+ function createDatabaseAgent(config) {
1726
+ const { adapter, model, classifierOptions = {} } = config;
1727
+ async function inspectSchemaNode(state) {
1728
+ let schema = state.schema;
1729
+ const forceRefresh = state.requiresSchemaRefresh || !schema;
1730
+ if (forceRefresh) {
1731
+ schema = await adapter.inspectSchema(true);
1732
+ }
1733
+ const summary = schema ? formatSchemaForPrompt(schema) : "";
1734
+ return {
1735
+ schema,
1736
+ schemaSummary: summary,
1737
+ requiresSchemaRefresh: false
1738
+ };
1739
+ }
1740
+ async function identifyTargetsNode(state) {
1741
+ const prompt = `Based on this user request: "${state.userInput}"
1742
+ And the available database schema:
1743
+ ${state.schemaSummary}
1744
+
1745
+ List the relevant table names (PostgreSQL) or collection names (MongoDB) needed to fulfill the request.
1746
+ Respond with a JSON array of string names only, e.g. ["users", "orders"]. Do not include markdown codeblocks or extra text.`;
1747
+ try {
1748
+ const response = await model.invoke([new HumanMessage2(prompt)]);
1749
+ const content = typeof response.content === "string" ? response.content.trim() : "";
1750
+ const match = content.match(/\[[\s\S]*?\]/);
1751
+ const targets = match ? JSON.parse(match[0]) : [];
1752
+ return { targetEntities: Array.isArray(targets) ? targets : [] };
1753
+ } catch {
1754
+ return { targetEntities: [] };
1755
+ }
1756
+ }
1757
+ async function generateQueryNode(state) {
1758
+ const systemPrompt = getSystemPrompt(adapter.type, state.schemaSummary);
1759
+ const historyMessages = state.messages.slice(-6);
1760
+ const userPrompt = `User request: "${state.userInput}"
1761
+ Target entities: ${JSON.stringify(state.targetEntities)}
1762
+
1763
+ Generate the appropriate database query to execute.
1764
+ Ensure you return valid JSON formatted as specified in the system prompt.
1765
+ Do not wrap with markdown or code fences.`;
1766
+ const response = await model.invoke([
1767
+ new SystemMessage(systemPrompt),
1768
+ ...historyMessages,
1769
+ new HumanMessage2(userPrompt)
1770
+ ]);
1771
+ const content = typeof response.content === "string" ? response.content.trim() : "";
1772
+ let parsed = null;
1773
+ try {
1774
+ const jsonMatch = content.match(/\{[\s\S]*\}/);
1775
+ if (jsonMatch) {
1776
+ parsed = JSON.parse(jsonMatch[0]);
1777
+ }
1778
+ } catch {
1779
+ }
1780
+ let query;
1781
+ if (adapter.type === "postgres") {
1782
+ const sql = parsed?.sql || content.replace(/```(?:sql|json)?/g, "").trim();
1783
+ query = {
1784
+ sql,
1785
+ params: parsed?.params || [],
1786
+ rawDisplay: sql
1787
+ };
1788
+ } else {
1789
+ if (parsed && parsed.collection && parsed.operation) {
1790
+ query = {
1791
+ collection: parsed.collection,
1792
+ operation: parsed.operation,
1793
+ filter: parsed.filter,
1794
+ update: parsed.update,
1795
+ pipeline: parsed.pipeline,
1796
+ document: parsed.document,
1797
+ documents: parsed.documents,
1798
+ options: parsed.options,
1799
+ rawDisplay: JSON.stringify(parsed, null, 2)
1800
+ };
1801
+ } else {
1802
+ query = {
1803
+ rawDisplay: content
1804
+ };
1805
+ }
1806
+ }
1807
+ return { generatedQuery: query };
1808
+ }
1809
+ async function classifySafetyNode(state) {
1810
+ if (!state.generatedQuery) {
1811
+ return {
1812
+ confirmed: false,
1813
+ cancelReason: "No query was generated."
1814
+ };
1815
+ }
1816
+ const safety = classifyQuery(state.generatedQuery, adapter.type, classifierOptions);
1817
+ const strictCheck = checkStrictWipe(safety, classifierOptions);
1818
+ if (strictCheck.blocked) {
1819
+ return {
1820
+ safety,
1821
+ confirmed: false,
1822
+ cancelReason: strictCheck.message
1823
+ };
1824
+ }
1825
+ let affectedRows = null;
1826
+ if (safety.isDestructive || safety.category === "write" || safety.category === "dangerous") {
1827
+ try {
1828
+ affectedRows = await adapter.dryRunCount(state.generatedQuery);
1829
+ } catch {
1830
+ affectedRows = null;
1831
+ }
1832
+ }
1833
+ return {
1834
+ safety,
1835
+ affectedRowCount: affectedRows
1836
+ };
1837
+ }
1838
+ async function requestConfirmationNode(state) {
1839
+ if (state.cancelReason) {
1840
+ return { confirmed: false };
1841
+ }
1842
+ if (!state.generatedQuery || !state.safety) {
1843
+ return { confirmed: false, cancelReason: "Missing query or safety classification." };
1844
+ }
1845
+ const result = await requestUserConfirmation(
1846
+ state.generatedQuery,
1847
+ state.safety,
1848
+ state.affectedRowCount ?? null
1849
+ );
1850
+ return {
1851
+ confirmed: result.confirmed,
1852
+ cancelReason: result.reason || (result.confirmed ? void 0 : "Execution cancelled by user.")
1853
+ };
1854
+ }
1855
+ async function executeQueryNode(state) {
1856
+ if (!state.confirmed || !state.generatedQuery) {
1857
+ return {};
1858
+ }
1859
+ const result = await adapter.executeQuery(state.generatedQuery);
1860
+ const isStructuralSuccess = Boolean(state.safety?.isStructural && result.success);
1861
+ return {
1862
+ queryResult: result,
1863
+ requiresSchemaRefresh: isStructuralSuccess
1864
+ };
1865
+ }
1866
+ async function analyzeResultsNode(state) {
1867
+ const res = state.queryResult;
1868
+ if (!res || !res.success || !res.rows || res.rows.length === 0) {
1869
+ return { stats: null };
1870
+ }
1871
+ const firstRow = res.rows[0];
1872
+ if (typeof firstRow !== "object" || firstRow === null) {
1873
+ return { stats: null };
1874
+ }
1875
+ const numericFields = Object.keys(firstRow).filter((k) => {
1876
+ const val = firstRow[k];
1877
+ return typeof val === "number" || !isNaN(Number(val)) && typeof val === "string" && val.trim() !== "";
1878
+ });
1879
+ const stats = {
1880
+ totalRows: res.rowCount ?? res.rows.length,
1881
+ columns: {}
1882
+ };
1883
+ for (const field of numericFields) {
1884
+ const nums = res.rows.map((r) => Number(r[field])).filter((n) => !isNaN(n));
1885
+ if (nums.length > 0) {
1886
+ const sum = nums.reduce((a, b) => a + b, 0);
1887
+ const avg = sum / nums.length;
1888
+ const min = Math.min(...nums);
1889
+ const max = Math.max(...nums);
1890
+ stats.columns[field] = {
1891
+ count: nums.length,
1892
+ sum,
1893
+ average: Math.round(avg * 100) / 100,
1894
+ min,
1895
+ max
1896
+ };
1897
+ }
1898
+ }
1899
+ return { stats };
1900
+ }
1901
+ async function produceConclusionNode(state) {
1902
+ if (!state.confirmed || state.cancelReason) {
1903
+ const conclusion = state.cancelReason || "Operation was not executed.";
1904
+ return {
1905
+ conclusion,
1906
+ messages: [new HumanMessage2(state.userInput), new AIMessage(conclusion)]
1907
+ };
1908
+ }
1909
+ const res = state.queryResult;
1910
+ if (!res) {
1911
+ const conclusion = "No query was executed.";
1912
+ return {
1913
+ conclusion,
1914
+ messages: [new HumanMessage2(state.userInput), new AIMessage(conclusion)]
1915
+ };
1916
+ }
1917
+ if (!res.success) {
1918
+ const conclusion = `Query failed with error: ${res.error} (took ${res.durationMs}ms)`;
1919
+ return {
1920
+ conclusion,
1921
+ messages: [new HumanMessage2(state.userInput), new AIMessage(conclusion)]
1922
+ };
1923
+ }
1924
+ const prompt = `User request: "${state.userInput}"
1925
+ Executed Query:
1926
+ ${state.generatedQuery?.sql || state.generatedQuery?.rawDisplay}
1927
+
1928
+ Query Results Summary:
1929
+ - Success: ${res.success}
1930
+ - Rows returned: ${res.rowCount ?? 0}
1931
+ - Affected rows: ${res.affectedRows ?? 0}
1932
+ - Execution time: ${res.durationMs}ms
1933
+ - Sample data: ${JSON.stringify((res.rows || []).slice(0, 10), null, 2)}
1934
+ - Computed statistics: ${state.stats ? JSON.stringify(state.stats, null, 2) : "None"}
1935
+
1936
+ Provide a clear, concise, and direct natural-language response answering the user's request.
1937
+ Ground your response strictly in the query results above. Never hallucinate rows or numbers.`;
1938
+ try {
1939
+ const response = await model.invoke([new HumanMessage2(prompt)]);
1940
+ const conclusion = typeof response.content === "string" ? response.content : JSON.stringify(response.content);
1941
+ return {
1942
+ conclusion,
1943
+ messages: [new HumanMessage2(state.userInput), new AIMessage(conclusion)]
1944
+ };
1945
+ } catch (err) {
1946
+ const conclusion = `Query executed successfully (${res.rowCount ?? 0} rows, ${res.durationMs}ms).`;
1947
+ return {
1948
+ conclusion,
1949
+ messages: [new HumanMessage2(state.userInput), new AIMessage(conclusion)]
1950
+ };
1951
+ }
1952
+ }
1953
+ const workflow = new StateGraph(AgentStateAnnotation).addNode("inspect_schema", inspectSchemaNode).addNode("identify_targets", identifyTargetsNode).addNode("generate_query", generateQueryNode).addNode("classify_safety", classifySafetyNode).addNode("request_confirmation", requestConfirmationNode).addNode("execute_query", executeQueryNode).addNode("analyze_results", analyzeResultsNode).addNode("produce_conclusion", produceConclusionNode).addEdge(START, "inspect_schema").addEdge("inspect_schema", "identify_targets").addEdge("identify_targets", "generate_query").addEdge("generate_query", "classify_safety").addEdge("classify_safety", "request_confirmation").addConditionalEdges("request_confirmation", (state) => {
1954
+ if (state.confirmed && !state.cancelReason) {
1955
+ return "execute_query";
1956
+ }
1957
+ return "produce_conclusion";
1958
+ }).addEdge("execute_query", "analyze_results").addEdge("analyze_results", "produce_conclusion").addEdge("produce_conclusion", END);
1959
+ const checkpointer = new MemorySaver();
1960
+ const app = workflow.compile({ checkpointer });
1961
+ return app;
1962
+ }
1963
+
1964
+ // src/repl.ts
1965
+ var ReplSession = class {
1966
+ adapter;
1967
+ model;
1968
+ provider;
1969
+ modelName;
1970
+ strictMode;
1971
+ allowFullWipe;
1972
+ rowThreshold;
1973
+ isRunning = true;
1974
+ sessionId = `session-${Date.now()}`;
1975
+ agent;
1976
+ constructor(options) {
1977
+ this.adapter = options.adapter;
1978
+ this.model = options.model;
1979
+ this.provider = options.provider;
1980
+ this.modelName = options.modelName;
1981
+ this.strictMode = options.strictMode;
1982
+ this.allowFullWipe = options.allowFullWipe;
1983
+ this.rowThreshold = options.rowThreshold ?? 50;
1984
+ const classifierOptions = {
1985
+ strictMode: this.strictMode,
1986
+ allowFullWipe: this.allowFullWipe,
1987
+ rowThresholdForDangerousUpdate: this.rowThreshold
1988
+ };
1989
+ this.agent = createDatabaseAgent({
1990
+ adapter: this.adapter,
1991
+ model: this.model,
1992
+ classifierOptions
1993
+ });
1994
+ }
1995
+ async start() {
1996
+ this.setupSignalHandlers();
1997
+ this.printWelcomeBanner();
1998
+ const rl = readline.createInterface({
1999
+ input: process.stdin,
2000
+ output: process.stdout,
2001
+ terminal: true
2002
+ });
2003
+ const promptText = chalk2.cyan(`sandal [${this.adapter.type}]> `);
2004
+ const askQuestion = (query) => {
2005
+ return new Promise((resolve) => rl.question(query, resolve));
2006
+ };
2007
+ while (this.isRunning) {
2008
+ const input3 = await askQuestion(promptText);
2009
+ const trimmed = input3.trim();
2010
+ if (!trimmed) continue;
2011
+ if (["exit", "quit", ".exit", ".quit"].includes(trimmed.toLowerCase())) {
2012
+ break;
2013
+ }
2014
+ if (trimmed === ".clear") {
2015
+ console.clear();
2016
+ continue;
2017
+ }
2018
+ if (trimmed === ".help") {
2019
+ this.printHelp();
2020
+ continue;
2021
+ }
2022
+ if (trimmed === ".tables" || trimmed === ".collections") {
2023
+ await this.handleListEntities();
2024
+ continue;
2025
+ }
2026
+ if (trimmed.startsWith(".schema")) {
2027
+ const entityName = trimmed.replace(/^\.schema\s*/, "").trim();
2028
+ await this.handleShowSchema(entityName);
2029
+ continue;
2030
+ }
2031
+ if (trimmed === ".refresh") {
2032
+ const spinner = ora(chalk2.blue("Refreshing schema cache...")).start();
2033
+ try {
2034
+ await this.adapter.inspectSchema(true);
2035
+ spinner.succeed(chalk2.green("Schema refreshed successfully."));
2036
+ } catch (err) {
2037
+ spinner.fail(chalk2.red(`Failed to refresh schema: ${err.message}`));
2038
+ }
2039
+ continue;
2040
+ }
2041
+ const intentSpinner = ora(chalk2.blue("Analyzing intent...")).start();
2042
+ const intent = await classifyIntent(trimmed, this.model);
2043
+ intentSpinner.stop();
2044
+ if (!intent.isDatabaseTask) {
2045
+ console.log(
2046
+ boxen2(
2047
+ `${chalk2.bold.red("\u{1F6E1}\uFE0F GUARDRAIL ENFORCEMENT")}
2048
+
2049
+ ${chalk2.yellow(REFUSAL_MESSAGE)}
2050
+
2051
+ ${chalk2.gray(
2052
+ `Reason: ${intent.reason || "Non-database request rejected"}`
2053
+ )}`,
2054
+ {
2055
+ padding: 1,
2056
+ borderColor: "red",
2057
+ borderStyle: "round",
2058
+ margin: { top: 1, bottom: 1 }
2059
+ }
2060
+ )
2061
+ );
2062
+ continue;
2063
+ }
2064
+ const agentSpinner = ora(chalk2.cyan("Agent planning query...")).start();
2065
+ try {
2066
+ const result = await this.agent.invoke(
2067
+ {
2068
+ userInput: trimmed
2069
+ },
2070
+ {
2071
+ configurable: {
2072
+ thread_id: this.sessionId
2073
+ }
2074
+ }
2075
+ );
2076
+ agentSpinner.stop();
2077
+ if (result.queryResult && result.queryResult.success && result.queryResult.rows) {
2078
+ this.renderRowsTable(result.queryResult);
2079
+ }
2080
+ if (result.conclusion) {
2081
+ console.log("\n" + chalk2.green("\u{1F916} Answer:"));
2082
+ console.log(chalk2.white(result.conclusion) + "\n");
2083
+ }
2084
+ } catch (err) {
2085
+ agentSpinner.fail(chalk2.red(`Execution failed: ${err.message}`));
2086
+ }
2087
+ }
2088
+ rl.close();
2089
+ await this.shutdown();
2090
+ }
2091
+ renderRowsTable(queryResult) {
2092
+ const rows = queryResult.rows;
2093
+ if (!rows || rows.length === 0) {
2094
+ console.log(chalk2.gray("(0 rows returned)"));
2095
+ return;
2096
+ }
2097
+ const fields = queryResult.fields && queryResult.fields.length > 0 ? queryResult.fields : Object.keys(rows[0] || {});
2098
+ if (fields.length === 0) return;
2099
+ const displayRows = rows.slice(0, 25);
2100
+ const table = new Table({
2101
+ head: fields.map((f) => chalk2.cyan.bold(f)),
2102
+ style: { head: [], border: ["gray"] }
2103
+ });
2104
+ for (const r of displayRows) {
2105
+ table.push(
2106
+ fields.map((f) => {
2107
+ const val = r[f];
2108
+ if (val === null || val === void 0) return chalk2.gray("NULL");
2109
+ if (typeof val === "object") {
2110
+ try {
2111
+ return JSON.stringify(val);
2112
+ } catch {
2113
+ return String(val);
2114
+ }
2115
+ }
2116
+ return String(val);
2117
+ })
2118
+ );
2119
+ }
2120
+ console.log(table.toString());
2121
+ if (rows.length > 25) {
2122
+ console.log(chalk2.yellow(`Showing first 25 of ${rows.length.toLocaleString()} rows.`));
2123
+ }
2124
+ console.log(
2125
+ chalk2.gray(
2126
+ `Total rows: ${queryResult.rowCount ?? rows.length} | Duration: ${queryResult.durationMs ?? 0}ms`
2127
+ )
2128
+ );
2129
+ }
2130
+ async handleListEntities() {
2131
+ const spinner = ora(chalk2.blue("Loading schema...")).start();
2132
+ try {
2133
+ const schema = await this.adapter.inspectSchema();
2134
+ spinner.stop();
2135
+ if (schema.type === "postgres") {
2136
+ const tables = schema.tables || [];
2137
+ if (tables.length === 0) {
2138
+ console.log(chalk2.yellow("No tables found in public schema."));
2139
+ return;
2140
+ }
2141
+ console.log(chalk2.bold.cyan(`
2142
+ Tables in "${schema.databaseName}":`));
2143
+ for (const t of tables) {
2144
+ console.log(
2145
+ ` \u2022 ${chalk2.white.bold(t.schema + "." + t.name)} (~${(t.approximateRowCount ?? 0).toLocaleString()} rows, ${t.columns.length} columns)`
2146
+ );
2147
+ }
2148
+ console.log("");
2149
+ } else {
2150
+ const collections = schema.collections || [];
2151
+ if (collections.length === 0) {
2152
+ console.log(chalk2.yellow("No collections found."));
2153
+ return;
2154
+ }
2155
+ console.log(chalk2.bold.cyan(`
2156
+ Collections in "${schema.databaseName}":`));
2157
+ for (const c of collections) {
2158
+ console.log(
2159
+ ` \u2022 ${chalk2.white.bold(c.name)} (~${(c.documentCount ?? 0).toLocaleString()} documents, ${c.fields.length} inferred fields)`
2160
+ );
2161
+ }
2162
+ console.log("");
2163
+ }
2164
+ } catch (err) {
2165
+ spinner.fail(chalk2.red(`Failed to fetch entities: ${err.message}`));
2166
+ }
2167
+ }
2168
+ async handleShowSchema(entityName) {
2169
+ const spinner = ora(chalk2.blue("Loading schema details...")).start();
2170
+ try {
2171
+ const schema = await this.adapter.inspectSchema();
2172
+ spinner.stop();
2173
+ if (!entityName) {
2174
+ console.log("\n" + formatSchemaForPrompt(schema) + "\n");
2175
+ return;
2176
+ }
2177
+ if (schema.type === "postgres") {
2178
+ const table = schema.tables?.find(
2179
+ (t) => t.name.toLowerCase() === entityName.toLowerCase() || `${t.schema}.${t.name}`.toLowerCase() === entityName.toLowerCase()
2180
+ );
2181
+ if (!table) {
2182
+ console.log(chalk2.red(`Table "${entityName}" not found.`));
2183
+ return;
2184
+ }
2185
+ console.log(chalk2.bold.cyan(`
2186
+ Schema for table: ${table.schema}.${table.name}`));
2187
+ const tableDetails = new Table({
2188
+ head: ["Column", "Type", "Nullable", "Default", "Primary Key"].map((h) => chalk2.cyan.bold(h))
2189
+ });
2190
+ for (const col of table.columns) {
2191
+ tableDetails.push([
2192
+ col.name,
2193
+ col.dataType,
2194
+ col.isNullable ? "YES" : "NO",
2195
+ col.defaultValue || "-",
2196
+ col.isPrimaryKey ? "PK" : ""
2197
+ ]);
2198
+ }
2199
+ console.log(tableDetails.toString());
2200
+ } else {
2201
+ const collection = schema.collections?.find((c) => c.name.toLowerCase() === entityName.toLowerCase());
2202
+ if (!collection) {
2203
+ console.log(chalk2.red(`Collection "${entityName}" not found.`));
2204
+ return;
2205
+ }
2206
+ console.log(chalk2.bold.cyan(`
2207
+ Schema for collection: ${collection.name}`));
2208
+ const colDetails = new Table({
2209
+ head: ["Field", "Inferred Types"].map((h) => chalk2.cyan.bold(h))
2210
+ });
2211
+ for (const f of collection.fields) {
2212
+ colDetails.push([f.name, f.types.join(", ")]);
2213
+ }
2214
+ console.log(colDetails.toString());
2215
+ }
2216
+ } catch (err) {
2217
+ spinner.fail(chalk2.red(`Failed to inspect schema: ${err.message}`));
2218
+ }
2219
+ }
2220
+ printWelcomeBanner() {
2221
+ const banner = [
2222
+ chalk2.bold.cyan("SANDAL: Safe Agentic Natural-language Database Access Layer"),
2223
+ chalk2.gray("LangGraph + Multi-Provider AI (Gemini, OpenAI, Anthropic)"),
2224
+ "",
2225
+ `${chalk2.bold("Database:")} ${chalk2.green(this.adapter.type.toUpperCase())} (${this.adapter.getMaskedUrl()})`,
2226
+ `${chalk2.bold("LLM Provider:")} ${chalk2.blue(this.provider)} [${chalk2.white(this.modelName)}]`,
2227
+ `${chalk2.bold("Strict Mode:")} ${this.strictMode ? chalk2.green("ON (Full wipes blocked)") : chalk2.yellow("OFF")}`,
2228
+ `${chalk2.bold("Allow Full Wipe:")} ${this.allowFullWipe ? chalk2.red("ENABLED") : chalk2.gray("NO")}`,
2229
+ "",
2230
+ chalk2.gray("Type your natural language request or SQL/Mongo query."),
2231
+ chalk2.gray("Commands: .tables, .schema [name], .refresh, .help, exit")
2232
+ ].join("\n");
2233
+ console.log(
2234
+ boxen2(banner, {
2235
+ padding: 1,
2236
+ borderColor: "cyan",
2237
+ borderStyle: "round",
2238
+ margin: { top: 1, bottom: 1 }
2239
+ })
2240
+ );
2241
+ }
2242
+ printHelp() {
2243
+ console.log(
2244
+ boxen2(
2245
+ [
2246
+ chalk2.bold.cyan("SANDAL REPL Commands:"),
2247
+ "",
2248
+ `${chalk2.bold(".tables / .collections")} List all tables or collections with row counts`,
2249
+ `${chalk2.bold(".schema [name]")} Show columns, types, and indexes for a table`,
2250
+ `${chalk2.bold(".refresh")} Force refresh cached schema introspection`,
2251
+ `${chalk2.bold(".clear")} Clear terminal screen`,
2252
+ `${chalk2.bold(".help")} Display this command reference`,
2253
+ `${chalk2.bold("exit / quit")} Gracefully disconnect and exit`,
2254
+ "",
2255
+ chalk2.bold.yellow("Example Natural Language Requests:"),
2256
+ ' "Show top 10 users signed up in the last 30 days"',
2257
+ ' "Calculate total revenue grouped by product category"',
2258
+ ' "Add a concurrent index on orders(created_at)"',
2259
+ ' "Delete users where status is inactive"'
2260
+ ].join("\n"),
2261
+ {
2262
+ padding: 1,
2263
+ borderColor: "gray",
2264
+ borderStyle: "single",
2265
+ margin: { top: 0, bottom: 1 }
2266
+ }
2267
+ )
2268
+ );
2269
+ }
2270
+ setupSignalHandlers() {
2271
+ const handleExit = async () => {
2272
+ this.isRunning = false;
2273
+ await this.shutdown();
2274
+ process.exit(0);
2275
+ };
2276
+ process.on("SIGINT", handleExit);
2277
+ process.on("SIGTERM", handleExit);
2278
+ }
2279
+ async shutdown() {
2280
+ if (this.adapter.isConnected()) {
2281
+ try {
2282
+ await this.adapter.disconnect();
2283
+ } catch {
2284
+ }
2285
+ }
2286
+ console.log(chalk2.cyan("\nDatabase connection closed. Goodbye! \u{1F44B}\n"));
2287
+ }
2288
+ };
2289
+
2290
+ // src/cli.ts
2291
+ var program = new Command();
2292
+ program.name("sandal-db").description("SANDAL - Safe Agentic Natural-language Database Access Layer").version("1.0.0").argument("[dbUrl]", "Database connection URL (PostgreSQL or MongoDB)").option("-p, --provider <provider>", "LLM provider: google | openai | anthropic").option("-m, --model <model>", "LLM model name (e.g. gemini-2.5-flash, gpt-4o, claude-3-5-sonnet-latest)").option("-k, --key <key>", "API key for the chosen LLM provider").option("--strict", "Strict safety mode: hard-blocks full database wipes (default: true)", true).option("--no-strict", "Disable strict safety mode").option("--allow-full-wipe", "Explicitly allow full database / all-table wipe queries with confirmation", false).option("--threshold <number>", "Row count threshold for classifying updates as dangerous", "50").action(async (cliDbUrl, options) => {
2293
+ try {
2294
+ await runCli({
2295
+ cliDbUrl,
2296
+ cliProvider: options.provider,
2297
+ cliModel: options.model,
2298
+ cliApiKey: options.key,
2299
+ strictMode: options.strict !== false,
2300
+ allowFullWipe: Boolean(options.allowFullWipe),
2301
+ rowThreshold: parseInt(options.threshold, 10) || 50
2302
+ });
2303
+ } catch (err) {
2304
+ console.error(chalk3.red(`
2305
+ Error: ${err.message}`));
2306
+ process.exit(1);
2307
+ }
2308
+ });
2309
+ var configCmd = program.command("config").description("Manage stored database credentials and API keys in ~/.sandal/config.json").option("--set-db <url>", "Set default database connection URL").option("--set-key <key>", "Set API key for the default provider").option("--set-gemini <key>", "Set Google Gemini API key").option("--set-openai <key>", "Set OpenAI API key").option("--set-anthropic <key>", "Set Anthropic API key").option("--set-provider <provider>", "Set default LLM provider (google | openai | anthropic)").option("--set-model <model>", "Set default model name").option("--show", "Display current stored configuration (with masked secrets)").option("--path", "Print the path to the configuration file").action((opts) => {
2310
+ if (opts.path) {
2311
+ console.log(getConfigFilePath());
2312
+ return;
2313
+ }
2314
+ if (opts.show) {
2315
+ const cfg = readConfig();
2316
+ console.log(chalk3.bold.cyan("\nSaved Configuration (~/.sandal/config.json):"));
2317
+ console.log(` Database URL: ${cfg.dbUrl ? chalk3.green(maskUrl(cfg.dbUrl)) : chalk3.gray("Not set")}`);
2318
+ console.log(` Default Provider: ${cfg.defaultProvider ? chalk3.blue(cfg.defaultProvider) : chalk3.gray("Not set (defaults to google)")}`);
2319
+ console.log(` Default Model: ${cfg.defaultModel ? chalk3.white(cfg.defaultModel) : chalk3.gray("Default for provider")}`);
2320
+ console.log(` Gemini Key: ${cfg.geminiApiKey ? chalk3.yellow(maskApiKey(cfg.geminiApiKey)) : chalk3.gray("Not set")}`);
2321
+ console.log(` OpenAI Key: ${cfg.openaiApiKey ? chalk3.yellow(maskApiKey(cfg.openaiApiKey)) : chalk3.gray("Not set")}`);
2322
+ console.log(` Anthropic Key: ${cfg.anthropicApiKey ? chalk3.yellow(maskApiKey(cfg.anthropicApiKey)) : chalk3.gray("Not set")}
2323
+ `);
2324
+ return;
2325
+ }
2326
+ const updates = {};
2327
+ if (opts.setDb) {
2328
+ detectDatabaseType(opts.setDb);
2329
+ updates.dbUrl = opts.setDb;
2330
+ console.log(chalk3.green(`Updated default database URL: ${maskUrl(opts.setDb)}`));
2331
+ }
2332
+ if (opts.setProvider) {
2333
+ const p = opts.setProvider.toLowerCase();
2334
+ if (!["google", "openai", "anthropic"].includes(p)) {
2335
+ console.error(chalk3.red("Invalid provider. Expected google, openai, or anthropic."));
2336
+ process.exit(1);
2337
+ }
2338
+ updates.defaultProvider = p;
2339
+ console.log(chalk3.green(`Updated default provider: ${p}`));
2340
+ }
2341
+ if (opts.setModel) {
2342
+ updates.defaultModel = opts.setModel;
2343
+ console.log(chalk3.green(`Updated default model: ${opts.setModel}`));
2344
+ }
2345
+ if (opts.setGemini) {
2346
+ updates.geminiApiKey = opts.setGemini;
2347
+ console.log(chalk3.green("Updated Gemini API key."));
2348
+ }
2349
+ if (opts.setOpenai) {
2350
+ updates.openaiApiKey = opts.setOpenai;
2351
+ console.log(chalk3.green("Updated OpenAI API key."));
2352
+ }
2353
+ if (opts.setAnthropic) {
2354
+ updates.anthropicApiKey = opts.setAnthropic;
2355
+ console.log(chalk3.green("Updated Anthropic API key."));
2356
+ }
2357
+ if (opts.setKey) {
2358
+ const current = readConfig();
2359
+ const prov = updates.defaultProvider || current.defaultProvider || "google";
2360
+ if (prov === "google") updates.geminiApiKey = opts.setKey;
2361
+ else if (prov === "openai") updates.openaiApiKey = opts.setKey;
2362
+ else if (prov === "anthropic") updates.anthropicApiKey = opts.setKey;
2363
+ console.log(chalk3.green(`Updated API key for provider "${prov}".`));
2364
+ }
2365
+ if (Object.keys(updates).length > 0) {
2366
+ writeConfig(updates);
2367
+ console.log(chalk3.gray(`Saved to ${getConfigFilePath()}`));
2368
+ } else {
2369
+ configCmd.help();
2370
+ }
2371
+ });
2372
+ async function runCli(opts) {
2373
+ let resolved = resolveCredentials({
2374
+ cliDbUrl: opts.cliDbUrl,
2375
+ cliProvider: opts.cliProvider,
2376
+ cliModel: opts.cliModel,
2377
+ cliApiKey: opts.cliApiKey
2378
+ });
2379
+ let dbUrl = resolved.dbUrl;
2380
+ if (!dbUrl) {
2381
+ console.log(chalk3.yellow("\nNo database connection URL found in CLI flags, environment, or config."));
2382
+ dbUrl = await input2({
2383
+ message: "Enter your database URL (postgres://... or mongodb://...):",
2384
+ validate: (val) => {
2385
+ try {
2386
+ detectDatabaseType(val);
2387
+ return true;
2388
+ } catch (e) {
2389
+ return e.message;
2390
+ }
2391
+ }
2392
+ });
2393
+ const saveDb = await confirm2({
2394
+ message: "Save this database URL to ~/.sandal/config.json for future runs?",
2395
+ default: true
2396
+ });
2397
+ if (saveDb) {
2398
+ writeConfig({ dbUrl });
2399
+ }
2400
+ } else if (opts.cliDbUrl && resolved.source.dbUrl === "cli") {
2401
+ const currentCfg = readConfig();
2402
+ if (currentCfg.dbUrl !== opts.cliDbUrl) {
2403
+ writeConfig({ dbUrl: opts.cliDbUrl });
2404
+ }
2405
+ }
2406
+ let provider = resolved.provider;
2407
+ let apiKey = resolved.apiKey;
2408
+ if (!apiKey) {
2409
+ console.log(chalk3.yellow("\nNo API key found in environment or config."));
2410
+ provider = await select({
2411
+ message: "Select your LLM provider:",
2412
+ choices: [
2413
+ { name: "Google Gemini (GEMINI_API_KEY)", value: "google" },
2414
+ { name: "OpenAI (OPENAI_API_KEY)", value: "openai" },
2415
+ { name: "Anthropic (ANTHROPIC_API_KEY)", value: "anthropic" }
2416
+ ]
2417
+ });
2418
+ apiKey = await input2({
2419
+ message: `Enter your ${provider.toUpperCase()} API key:`,
2420
+ validate: (val) => val.trim().length > 0 ? true : "API key cannot be empty."
2421
+ });
2422
+ const saveKey = await confirm2({
2423
+ message: "Save this API key to ~/.sandal/config.json?",
2424
+ default: true
2425
+ });
2426
+ if (saveKey) {
2427
+ if (provider === "google") writeConfig({ geminiApiKey: apiKey, defaultProvider: "google" });
2428
+ else if (provider === "openai") writeConfig({ openaiApiKey: apiKey, defaultProvider: "openai" });
2429
+ else if (provider === "anthropic") writeConfig({ anthropicApiKey: apiKey, defaultProvider: "anthropic" });
2430
+ }
2431
+ }
2432
+ const modelName = opts.cliModel || resolved.model || getDefaultModelForProvider(provider);
2433
+ const dbSpinner = ora2(`Connecting to database (${maskUrl(dbUrl)})...`).start();
2434
+ const adapter = createDatabaseAdapter(dbUrl);
2435
+ try {
2436
+ await adapter.connect();
2437
+ dbSpinner.succeed(chalk3.green(`Connected to ${adapter.type.toUpperCase()} database: ${adapter.databaseName}`));
2438
+ } catch (err) {
2439
+ dbSpinner.fail(chalk3.red(`Failed to connect to database: ${err.message}`));
2440
+ process.exit(1);
2441
+ }
2442
+ const model = createChatModel({
2443
+ provider,
2444
+ model: modelName,
2445
+ apiKey
2446
+ });
2447
+ const repl = new ReplSession({
2448
+ adapter,
2449
+ model,
2450
+ provider,
2451
+ modelName,
2452
+ strictMode: opts.strictMode,
2453
+ allowFullWipe: opts.allowFullWipe,
2454
+ rowThreshold: opts.rowThreshold
2455
+ });
2456
+ await repl.start();
2457
+ }
2458
+ program.parse(process.argv);