morpheus-cli 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -7
- package/dist/channels/telegram.js +173 -0
- package/dist/cli/commands/restart.js +15 -14
- package/dist/cli/commands/start.js +17 -12
- package/dist/config/manager.js +31 -0
- package/dist/config/mcp-manager.js +19 -1
- package/dist/config/schemas.js +2 -0
- package/dist/http/api.js +222 -0
- package/dist/runtime/memory/session-embedding-worker.js +3 -3
- package/dist/runtime/memory/trinity-db.js +203 -0
- package/dist/runtime/neo.js +16 -26
- package/dist/runtime/oracle.js +16 -8
- package/dist/runtime/session-embedding-scheduler.js +1 -1
- package/dist/runtime/tasks/dispatcher.js +21 -0
- package/dist/runtime/tasks/repository.js +4 -0
- package/dist/runtime/tasks/worker.js +4 -1
- package/dist/runtime/tools/__tests__/tools.test.js +1 -3
- package/dist/runtime/tools/factory.js +1 -1
- package/dist/runtime/tools/index.js +1 -3
- package/dist/runtime/tools/morpheus-tools.js +742 -0
- package/dist/runtime/tools/neo-tool.js +19 -9
- package/dist/runtime/tools/trinity-tool.js +98 -0
- package/dist/runtime/trinity-connector.js +611 -0
- package/dist/runtime/trinity-crypto.js +52 -0
- package/dist/runtime/trinity.js +246 -0
- package/dist/runtime/webhooks/dispatcher.js +73 -2
- package/dist/runtime/webhooks/repository.js +7 -0
- package/dist/ui/assets/index-DP2V4kRd.js +112 -0
- package/dist/ui/assets/index-mglRG5Zw.css +1 -0
- package/dist/ui/index.html +2 -2
- package/dist/ui/sw.js +1 -1
- package/package.json +6 -1
- package/dist/runtime/tools/analytics-tools.js +0 -139
- package/dist/runtime/tools/config-tools.js +0 -64
- package/dist/runtime/tools/diagnostic-tools.js +0 -153
- package/dist/runtime/tools/task-query-tool.js +0 -76
- package/dist/ui/assets/index-20lLB1sM.js +0 -112
- package/dist/ui/assets/index-BJ56bRfs.css +0 -1
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
const SQL_OPERATION_LABELS = {
|
|
2
|
+
read: 'SELECT (leitura)',
|
|
3
|
+
insert: 'INSERT (inserção)',
|
|
4
|
+
update: 'UPDATE (atualização)',
|
|
5
|
+
delete: 'DELETE (exclusão)',
|
|
6
|
+
ddl: 'DDL — CREATE / ALTER / DROP (alteração de schema)',
|
|
7
|
+
};
|
|
8
|
+
const MONGO_OPERATION_LABELS = {
|
|
9
|
+
read: 'find / aggregate (leitura)',
|
|
10
|
+
insert: 'insertOne / insertMany (inserção)',
|
|
11
|
+
update: 'updateOne / updateMany (atualização)',
|
|
12
|
+
delete: 'deleteOne / deleteMany (exclusão)',
|
|
13
|
+
ddl: 'createCollection / dropCollection / createIndex (alteração de schema)',
|
|
14
|
+
};
|
|
15
|
+
function detectSqlOperation(query) {
|
|
16
|
+
const first = query.trimStart().match(/^\s*(\w+)/i)?.[1]?.toUpperCase() ?? '';
|
|
17
|
+
switch (first) {
|
|
18
|
+
case 'SELECT':
|
|
19
|
+
case 'EXPLAIN':
|
|
20
|
+
case 'SHOW':
|
|
21
|
+
case 'DESCRIBE':
|
|
22
|
+
case 'DESC':
|
|
23
|
+
return 'read';
|
|
24
|
+
case 'INSERT':
|
|
25
|
+
return 'insert';
|
|
26
|
+
case 'UPDATE':
|
|
27
|
+
return 'update';
|
|
28
|
+
case 'DELETE':
|
|
29
|
+
return 'delete';
|
|
30
|
+
case 'CREATE':
|
|
31
|
+
case 'ALTER':
|
|
32
|
+
case 'DROP':
|
|
33
|
+
case 'TRUNCATE':
|
|
34
|
+
case 'RENAME':
|
|
35
|
+
case 'INDEX':
|
|
36
|
+
return 'ddl';
|
|
37
|
+
default:
|
|
38
|
+
return 'read';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function detectMongoOperation(operation) {
|
|
42
|
+
switch (operation) {
|
|
43
|
+
case 'find':
|
|
44
|
+
case 'aggregate':
|
|
45
|
+
case 'countDocuments':
|
|
46
|
+
return 'read';
|
|
47
|
+
case 'insertOne':
|
|
48
|
+
case 'insertMany':
|
|
49
|
+
return 'insert';
|
|
50
|
+
case 'updateOne':
|
|
51
|
+
case 'updateMany':
|
|
52
|
+
case 'replaceOne':
|
|
53
|
+
return 'update';
|
|
54
|
+
case 'deleteOne':
|
|
55
|
+
case 'deleteMany':
|
|
56
|
+
return 'delete';
|
|
57
|
+
case 'createCollection':
|
|
58
|
+
case 'dropCollection':
|
|
59
|
+
case 'createIndex':
|
|
60
|
+
case 'dropIndex':
|
|
61
|
+
return 'ddl';
|
|
62
|
+
default:
|
|
63
|
+
return 'read';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function assertPermission(db, op, labels) {
|
|
67
|
+
const allowed = {
|
|
68
|
+
read: db.allow_read,
|
|
69
|
+
insert: db.allow_insert,
|
|
70
|
+
update: db.allow_update,
|
|
71
|
+
delete: db.allow_delete,
|
|
72
|
+
ddl: db.allow_ddl,
|
|
73
|
+
};
|
|
74
|
+
if (!allowed[op]) {
|
|
75
|
+
throw new Error(`Permissão negada: a operação "${labels[op]}" não está habilitada para o banco "${db.name}". ` +
|
|
76
|
+
`Habilite esta permissão nas configurações do banco de dados.`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
80
|
+
function defaultPort(type) {
|
|
81
|
+
switch (type) {
|
|
82
|
+
case 'postgresql': return 5432;
|
|
83
|
+
case 'mysql': return 3306;
|
|
84
|
+
case 'mongodb': return 27017;
|
|
85
|
+
default: return 0;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// ─── PostgreSQL ──────────────────────────────────────────────────────────────
|
|
89
|
+
async function pgTestConnection(db) {
|
|
90
|
+
const { Client } = await import('pg');
|
|
91
|
+
const client = new Client(buildPgConfig(db));
|
|
92
|
+
try {
|
|
93
|
+
await client.connect();
|
|
94
|
+
await client.query('SELECT 1');
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
await client.end().catch(() => { });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async function pgIntrospectTables(client) {
|
|
105
|
+
const tablesResult = await client.query(`
|
|
106
|
+
SELECT table_name
|
|
107
|
+
FROM information_schema.tables
|
|
108
|
+
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
|
|
109
|
+
ORDER BY table_name
|
|
110
|
+
`);
|
|
111
|
+
const tables = [];
|
|
112
|
+
for (const tableRow of tablesResult.rows) {
|
|
113
|
+
const colsResult = await client.query(`
|
|
114
|
+
SELECT column_name, data_type, is_nullable
|
|
115
|
+
FROM information_schema.columns
|
|
116
|
+
WHERE table_schema = 'public' AND table_name = $1
|
|
117
|
+
ORDER BY ordinal_position
|
|
118
|
+
`, [tableRow.table_name]);
|
|
119
|
+
tables.push({
|
|
120
|
+
name: tableRow.table_name,
|
|
121
|
+
columns: colsResult.rows.map((c) => ({
|
|
122
|
+
name: c.column_name,
|
|
123
|
+
type: c.data_type,
|
|
124
|
+
nullable: c.is_nullable === 'YES',
|
|
125
|
+
})),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return tables;
|
|
129
|
+
}
|
|
130
|
+
async function pgIntrospect(db) {
|
|
131
|
+
const { Client } = await import('pg');
|
|
132
|
+
// Multi-database mode: no database_name and no connection_string
|
|
133
|
+
if (!db.database_name && !db.connection_string) {
|
|
134
|
+
const adminClient = new Client({
|
|
135
|
+
host: db.host || 'localhost',
|
|
136
|
+
port: db.port || 5432,
|
|
137
|
+
database: 'postgres',
|
|
138
|
+
user: db.username || undefined,
|
|
139
|
+
password: db.password || undefined,
|
|
140
|
+
});
|
|
141
|
+
await adminClient.connect();
|
|
142
|
+
let dbNames;
|
|
143
|
+
try {
|
|
144
|
+
const result = await adminClient.query(`SELECT datname FROM pg_database WHERE datistemplate = false AND datallowconn = true ORDER BY datname`);
|
|
145
|
+
dbNames = result.rows.map((r) => r.datname);
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
await adminClient.end().catch(() => { });
|
|
149
|
+
}
|
|
150
|
+
const databases = [];
|
|
151
|
+
for (const dbName of dbNames) {
|
|
152
|
+
const dbClient = new Client({
|
|
153
|
+
host: db.host || 'localhost',
|
|
154
|
+
port: db.port || 5432,
|
|
155
|
+
database: dbName,
|
|
156
|
+
user: db.username || undefined,
|
|
157
|
+
password: db.password || undefined,
|
|
158
|
+
});
|
|
159
|
+
try {
|
|
160
|
+
await dbClient.connect();
|
|
161
|
+
const tables = await pgIntrospectTables(dbClient);
|
|
162
|
+
databases.push({ name: dbName, tables });
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
databases.push({ name: dbName, tables: [] });
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
await dbClient.end().catch(() => { });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { databases, tables: [] };
|
|
172
|
+
}
|
|
173
|
+
// Single-database mode (existing behaviour)
|
|
174
|
+
const client = new Client(buildPgConfig(db));
|
|
175
|
+
await client.connect();
|
|
176
|
+
try {
|
|
177
|
+
return { tables: await pgIntrospectTables(client) };
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
await client.end().catch(() => { });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async function pgExecuteQuery(db, query, params) {
|
|
184
|
+
assertPermission(db, detectSqlOperation(query), SQL_OPERATION_LABELS);
|
|
185
|
+
const { Client } = await import('pg');
|
|
186
|
+
const client = new Client(buildPgConfig(db));
|
|
187
|
+
await client.connect();
|
|
188
|
+
try {
|
|
189
|
+
const result = await client.query(query, params);
|
|
190
|
+
return { rows: result.rows, rowCount: result.rowCount ?? result.rows.length };
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
await client.end().catch(() => { });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function buildPgConfig(db) {
|
|
197
|
+
if (db.connection_string)
|
|
198
|
+
return { connectionString: db.connection_string };
|
|
199
|
+
return {
|
|
200
|
+
host: db.host || 'localhost',
|
|
201
|
+
port: db.port || 5432,
|
|
202
|
+
database: db.database_name || undefined,
|
|
203
|
+
user: db.username || undefined,
|
|
204
|
+
password: db.password || undefined,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
// ─── MySQL ───────────────────────────────────────────────────────────────────
|
|
208
|
+
async function mysqlTestConnection(db) {
|
|
209
|
+
const mysql2 = await import('mysql2/promise');
|
|
210
|
+
const conn = await mysql2.createConnection(buildMysqlConfig(db));
|
|
211
|
+
try {
|
|
212
|
+
await conn.query('SELECT 1');
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
await conn.end().catch(() => { });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async function mysqlIntrospectSchema(conn, schemaName) {
|
|
223
|
+
const [tableRows] = await conn.query(`
|
|
224
|
+
SELECT table_name
|
|
225
|
+
FROM information_schema.tables
|
|
226
|
+
WHERE table_schema = ? AND table_type = 'BASE TABLE'
|
|
227
|
+
ORDER BY table_name
|
|
228
|
+
`, [schemaName]);
|
|
229
|
+
const tables = [];
|
|
230
|
+
for (const tableRow of tableRows) {
|
|
231
|
+
const tableName = tableRow.table_name ?? tableRow.TABLE_NAME ?? '';
|
|
232
|
+
if (!tableName)
|
|
233
|
+
continue;
|
|
234
|
+
const [colRows] = await conn.query(`
|
|
235
|
+
SELECT column_name, data_type, is_nullable
|
|
236
|
+
FROM information_schema.columns
|
|
237
|
+
WHERE table_schema = ? AND table_name = ?
|
|
238
|
+
ORDER BY ordinal_position
|
|
239
|
+
`, [schemaName, tableName]);
|
|
240
|
+
tables.push({
|
|
241
|
+
name: tableName,
|
|
242
|
+
columns: colRows.map((c) => ({
|
|
243
|
+
name: c.column_name ?? c.COLUMN_NAME ?? '',
|
|
244
|
+
type: c.data_type ?? c.DATA_TYPE ?? '',
|
|
245
|
+
nullable: (c.is_nullable ?? c.IS_NULLABLE) === 'YES',
|
|
246
|
+
})),
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return tables;
|
|
250
|
+
}
|
|
251
|
+
async function mysqlIntrospect(db) {
|
|
252
|
+
const mysql2 = await import('mysql2/promise');
|
|
253
|
+
// Multi-database mode: no database_name and no connection_string
|
|
254
|
+
if (!db.database_name && !db.connection_string) {
|
|
255
|
+
const conn = await mysql2.createConnection({
|
|
256
|
+
host: db.host || 'localhost',
|
|
257
|
+
port: db.port || 3306,
|
|
258
|
+
user: db.username || undefined,
|
|
259
|
+
password: db.password || undefined,
|
|
260
|
+
});
|
|
261
|
+
try {
|
|
262
|
+
const SYSTEM_DBS = new Set(['information_schema', 'performance_schema', 'mysql', 'sys']);
|
|
263
|
+
const [dbRows] = await conn.query(`SHOW DATABASES`);
|
|
264
|
+
const dbNames = dbRows
|
|
265
|
+
.map((r) => r.Database)
|
|
266
|
+
.filter((n) => !SYSTEM_DBS.has(n));
|
|
267
|
+
const databases = [];
|
|
268
|
+
for (const dbName of dbNames) {
|
|
269
|
+
try {
|
|
270
|
+
const tables = await mysqlIntrospectSchema(conn, dbName);
|
|
271
|
+
databases.push({ name: dbName, tables });
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
databases.push({ name: dbName, tables: [] });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return { databases, tables: [] };
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
await conn.end().catch(() => { });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// Single-database mode (existing behaviour)
|
|
284
|
+
const conn = await mysql2.createConnection(buildMysqlConfig(db));
|
|
285
|
+
try {
|
|
286
|
+
const schema = db.database_name;
|
|
287
|
+
return { tables: await mysqlIntrospectSchema(conn, schema) };
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
await conn.end().catch(() => { });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
async function mysqlExecuteQuery(db, query, params) {
|
|
294
|
+
assertPermission(db, detectSqlOperation(query), SQL_OPERATION_LABELS);
|
|
295
|
+
const mysql2 = await import('mysql2/promise');
|
|
296
|
+
const conn = await mysql2.createConnection(buildMysqlConfig(db));
|
|
297
|
+
try {
|
|
298
|
+
const [rows] = await conn.query(query, params);
|
|
299
|
+
const resultRows = Array.isArray(rows) ? rows : [rows];
|
|
300
|
+
return { rows: resultRows, rowCount: resultRows.length };
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
await conn.end().catch(() => { });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function buildMysqlConfig(db) {
|
|
307
|
+
if (db.connection_string)
|
|
308
|
+
return db.connection_string;
|
|
309
|
+
return {
|
|
310
|
+
host: db.host || 'localhost',
|
|
311
|
+
port: db.port || 3306,
|
|
312
|
+
database: db.database_name || undefined,
|
|
313
|
+
user: db.username || undefined,
|
|
314
|
+
password: db.password || undefined,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
// ─── SQLite ──────────────────────────────────────────────────────────────────
|
|
318
|
+
async function sqliteTestConnection(db) {
|
|
319
|
+
const filePath = db.connection_string || db.database_name;
|
|
320
|
+
if (!filePath)
|
|
321
|
+
return false;
|
|
322
|
+
try {
|
|
323
|
+
const { default: Database } = await import('better-sqlite3');
|
|
324
|
+
const sqliteDb = new Database(filePath, { readonly: true, fileMustExist: true });
|
|
325
|
+
sqliteDb.close();
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
async function sqliteIntrospect(db) {
|
|
333
|
+
const filePath = db.connection_string || db.database_name;
|
|
334
|
+
if (!filePath)
|
|
335
|
+
throw new Error('SQLite database file path not specified');
|
|
336
|
+
const { default: Database } = await import('better-sqlite3');
|
|
337
|
+
const sqliteDb = new Database(filePath, { readonly: true, fileMustExist: true });
|
|
338
|
+
try {
|
|
339
|
+
const tableRows = sqliteDb
|
|
340
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`)
|
|
341
|
+
.all();
|
|
342
|
+
const tables = [];
|
|
343
|
+
for (const tableRow of tableRows) {
|
|
344
|
+
const colRows = sqliteDb.pragma(`table_info(${tableRow.name})`);
|
|
345
|
+
tables.push({
|
|
346
|
+
name: tableRow.name,
|
|
347
|
+
columns: colRows.map((c) => ({
|
|
348
|
+
name: c.name,
|
|
349
|
+
type: c.type,
|
|
350
|
+
nullable: c.notnull === 0,
|
|
351
|
+
})),
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
return { tables };
|
|
355
|
+
}
|
|
356
|
+
finally {
|
|
357
|
+
sqliteDb.close();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
async function sqliteExecuteQuery(db, query) {
|
|
361
|
+
const op = detectSqlOperation(query);
|
|
362
|
+
assertPermission(db, op, SQL_OPERATION_LABELS);
|
|
363
|
+
const filePath = db.connection_string || db.database_name;
|
|
364
|
+
if (!filePath)
|
|
365
|
+
throw new Error('SQLite database file path not specified');
|
|
366
|
+
const { default: Database } = await import('better-sqlite3');
|
|
367
|
+
// Open readonly only if no write permission is needed
|
|
368
|
+
const readonly = op === 'read';
|
|
369
|
+
const sqliteDb = new Database(filePath, { readonly, fileMustExist: true });
|
|
370
|
+
try {
|
|
371
|
+
const isSelect = op === 'read';
|
|
372
|
+
if (isSelect) {
|
|
373
|
+
const rows = sqliteDb.prepare(query).all();
|
|
374
|
+
return { rows, rowCount: rows.length };
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
const info = sqliteDb.prepare(query).run();
|
|
378
|
+
return { rows: [], rowCount: info.changes };
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
finally {
|
|
382
|
+
sqliteDb.close();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// ─── MongoDB (Mongoose) ───────────────────────────────────────────────────────
|
|
386
|
+
async function mongoCreateConnection(uri, timeoutMs) {
|
|
387
|
+
const { default: mongoose } = await import('mongoose');
|
|
388
|
+
const conn = mongoose.createConnection(uri, {
|
|
389
|
+
serverSelectionTimeoutMS: timeoutMs,
|
|
390
|
+
connectTimeoutMS: timeoutMs,
|
|
391
|
+
});
|
|
392
|
+
await conn.asPromise();
|
|
393
|
+
return conn;
|
|
394
|
+
}
|
|
395
|
+
function mongoGetDb(conn, databaseName) {
|
|
396
|
+
if (databaseName)
|
|
397
|
+
return conn.useDb(databaseName, { useCache: true }).db;
|
|
398
|
+
return conn.db;
|
|
399
|
+
}
|
|
400
|
+
async function mongoTestConnection(db) {
|
|
401
|
+
const uri = db.connection_string || buildMongoUri(db);
|
|
402
|
+
let conn;
|
|
403
|
+
try {
|
|
404
|
+
conn = await mongoCreateConnection(uri, 5000);
|
|
405
|
+
const rawDb = mongoGetDb(conn, db.database_name);
|
|
406
|
+
await rawDb.command({ ping: 1 });
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
finally {
|
|
413
|
+
await conn?.close().catch(() => { });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
async function mongoIntrospectCollections(rawDb) {
|
|
417
|
+
const collections = await rawDb.listCollections().toArray();
|
|
418
|
+
const tables = [];
|
|
419
|
+
for (const col of collections) {
|
|
420
|
+
const docs = await rawDb.collection(col.name).find().limit(100).toArray();
|
|
421
|
+
const fieldSet = new Set();
|
|
422
|
+
for (const doc of docs) {
|
|
423
|
+
for (const key of Object.keys(doc))
|
|
424
|
+
fieldSet.add(key);
|
|
425
|
+
}
|
|
426
|
+
tables.push({
|
|
427
|
+
name: col.name,
|
|
428
|
+
columns: Array.from(fieldSet).map((f) => ({ name: f, type: 'mixed' })),
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
return tables;
|
|
432
|
+
}
|
|
433
|
+
async function mongoIntrospect(db) {
|
|
434
|
+
const uri = db.connection_string || buildMongoUri(db);
|
|
435
|
+
const conn = await mongoCreateConnection(uri, 10000);
|
|
436
|
+
try {
|
|
437
|
+
// Multi-database mode: no database_name specified
|
|
438
|
+
if (!db.database_name) {
|
|
439
|
+
const SYSTEM_DBS = new Set(['admin', 'config', 'local']);
|
|
440
|
+
const adminResult = await conn.db.admin().listDatabases();
|
|
441
|
+
const databases = [];
|
|
442
|
+
for (const dbEntry of adminResult.databases) {
|
|
443
|
+
if (SYSTEM_DBS.has(dbEntry.name))
|
|
444
|
+
continue;
|
|
445
|
+
try {
|
|
446
|
+
const rawDb = conn.useDb(dbEntry.name, { useCache: true }).db;
|
|
447
|
+
const tables = await mongoIntrospectCollections(rawDb);
|
|
448
|
+
databases.push({ name: dbEntry.name, tables });
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
databases.push({ name: dbEntry.name, tables: [] });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return { databases, tables: [] };
|
|
455
|
+
}
|
|
456
|
+
// Single-database mode (existing behaviour)
|
|
457
|
+
const rawDb = mongoGetDb(conn, db.database_name);
|
|
458
|
+
return { tables: await mongoIntrospectCollections(rawDb) };
|
|
459
|
+
}
|
|
460
|
+
finally {
|
|
461
|
+
await conn.close().catch(() => { });
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
async function mongoExecuteQuery(db, query) {
|
|
465
|
+
// Parse query first to know the operation before connecting
|
|
466
|
+
let parsed;
|
|
467
|
+
try {
|
|
468
|
+
parsed = JSON.parse(query);
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
throw new Error('MongoDB queries must be JSON: { "collection": "name", "operation": "find|insertOne|updateOne|...", ... }');
|
|
472
|
+
}
|
|
473
|
+
const { collection: colName, operation = 'find', filter = {}, pipeline, options = {}, document, documents, update, replacement, keys, indexName } = parsed;
|
|
474
|
+
// Check permission before connecting
|
|
475
|
+
const op = detectMongoOperation(operation);
|
|
476
|
+
assertPermission(db, op, MONGO_OPERATION_LABELS);
|
|
477
|
+
// createCollection / dropCollection / createIndex / dropIndex don't require collection
|
|
478
|
+
if (!colName && !['createCollection', 'dropCollection', 'createIndex', 'dropIndex'].includes(operation)) {
|
|
479
|
+
throw new Error('MongoDB query must include "collection" field');
|
|
480
|
+
}
|
|
481
|
+
const uri = db.connection_string || buildMongoUri(db);
|
|
482
|
+
const conn = await mongoCreateConnection(uri, 10000);
|
|
483
|
+
try {
|
|
484
|
+
const rawDb = mongoGetDb(conn, db.database_name);
|
|
485
|
+
const col = colName ? rawDb.collection(colName) : null;
|
|
486
|
+
// ── Read operations ──────────────────────────────────────────────────────
|
|
487
|
+
if (operation === 'aggregate') {
|
|
488
|
+
const rows = await col.aggregate(pipeline ?? []).toArray();
|
|
489
|
+
return { rows: rows.map((r) => ({ ...r, _id: r._id?.toString() })), rowCount: rows.length };
|
|
490
|
+
}
|
|
491
|
+
if (operation === 'countDocuments') {
|
|
492
|
+
const count = await col.countDocuments(filter);
|
|
493
|
+
return { rows: [{ count }], rowCount: 1 };
|
|
494
|
+
}
|
|
495
|
+
if (operation === 'find') {
|
|
496
|
+
const limit = options.limit ?? 100;
|
|
497
|
+
const rows = await col.find(filter, { ...options, limit }).toArray();
|
|
498
|
+
return {
|
|
499
|
+
rows: rows.map((r) => ({ ...r, _id: r._id?.toString() })),
|
|
500
|
+
rowCount: rows.length,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
// ── Insert operations ────────────────────────────────────────────────────
|
|
504
|
+
if (operation === 'insertOne') {
|
|
505
|
+
if (!document)
|
|
506
|
+
throw new Error('"document" field required for insertOne');
|
|
507
|
+
const result = await col.insertOne(document);
|
|
508
|
+
return { rows: [{ insertedId: result.insertedId?.toString(), acknowledged: result.acknowledged }], rowCount: 1 };
|
|
509
|
+
}
|
|
510
|
+
if (operation === 'insertMany') {
|
|
511
|
+
if (!documents || !Array.isArray(documents))
|
|
512
|
+
throw new Error('"documents" array required for insertMany');
|
|
513
|
+
const result = await col.insertMany(documents);
|
|
514
|
+
return { rows: [{ insertedCount: result.insertedCount, acknowledged: result.acknowledged }], rowCount: result.insertedCount };
|
|
515
|
+
}
|
|
516
|
+
// ── Update operations ────────────────────────────────────────────────────
|
|
517
|
+
if (operation === 'updateOne') {
|
|
518
|
+
if (!update)
|
|
519
|
+
throw new Error('"update" field required for updateOne');
|
|
520
|
+
const result = await col.updateOne(filter, update, options);
|
|
521
|
+
return { rows: [{ matchedCount: result.matchedCount, modifiedCount: result.modifiedCount, acknowledged: result.acknowledged }], rowCount: result.modifiedCount };
|
|
522
|
+
}
|
|
523
|
+
if (operation === 'updateMany') {
|
|
524
|
+
if (!update)
|
|
525
|
+
throw new Error('"update" field required for updateMany');
|
|
526
|
+
const result = await col.updateMany(filter, update, options);
|
|
527
|
+
return { rows: [{ matchedCount: result.matchedCount, modifiedCount: result.modifiedCount, acknowledged: result.acknowledged }], rowCount: result.modifiedCount };
|
|
528
|
+
}
|
|
529
|
+
if (operation === 'replaceOne') {
|
|
530
|
+
if (!replacement)
|
|
531
|
+
throw new Error('"replacement" field required for replaceOne');
|
|
532
|
+
const result = await col.replaceOne(filter, replacement, options);
|
|
533
|
+
return { rows: [{ matchedCount: result.matchedCount, modifiedCount: result.modifiedCount, acknowledged: result.acknowledged }], rowCount: result.modifiedCount };
|
|
534
|
+
}
|
|
535
|
+
// ── Delete operations ────────────────────────────────────────────────────
|
|
536
|
+
if (operation === 'deleteOne') {
|
|
537
|
+
const result = await col.deleteOne(filter);
|
|
538
|
+
return { rows: [{ deletedCount: result.deletedCount, acknowledged: result.acknowledged }], rowCount: result.deletedCount };
|
|
539
|
+
}
|
|
540
|
+
if (operation === 'deleteMany') {
|
|
541
|
+
const result = await col.deleteMany(filter);
|
|
542
|
+
return { rows: [{ deletedCount: result.deletedCount, acknowledged: result.acknowledged }], rowCount: result.deletedCount };
|
|
543
|
+
}
|
|
544
|
+
// ── DDL operations ───────────────────────────────────────────────────────
|
|
545
|
+
if (operation === 'createCollection') {
|
|
546
|
+
if (!colName)
|
|
547
|
+
throw new Error('"collection" field required for createCollection');
|
|
548
|
+
await rawDb.createCollection(colName, options);
|
|
549
|
+
return { rows: [{ created: colName }], rowCount: 1 };
|
|
550
|
+
}
|
|
551
|
+
if (operation === 'dropCollection') {
|
|
552
|
+
if (!colName)
|
|
553
|
+
throw new Error('"collection" field required for dropCollection');
|
|
554
|
+
await rawDb.dropCollection(colName);
|
|
555
|
+
return { rows: [{ dropped: colName }], rowCount: 1 };
|
|
556
|
+
}
|
|
557
|
+
if (operation === 'createIndex') {
|
|
558
|
+
if (!keys)
|
|
559
|
+
throw new Error('"keys" field required for createIndex');
|
|
560
|
+
const indexName2 = await col.createIndex(keys, options);
|
|
561
|
+
return { rows: [{ indexName: indexName2 }], rowCount: 1 };
|
|
562
|
+
}
|
|
563
|
+
if (operation === 'dropIndex') {
|
|
564
|
+
if (!indexName)
|
|
565
|
+
throw new Error('"indexName" field required for dropIndex');
|
|
566
|
+
await col.dropIndex(indexName);
|
|
567
|
+
return { rows: [{ dropped: indexName }], rowCount: 1 };
|
|
568
|
+
}
|
|
569
|
+
throw new Error(`Unknown MongoDB operation: "${operation}"`);
|
|
570
|
+
}
|
|
571
|
+
finally {
|
|
572
|
+
await conn.close().catch(() => { });
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
function buildMongoUri(db) {
|
|
576
|
+
const user = db.username ? encodeURIComponent(db.username) : '';
|
|
577
|
+
const pass = db.password ? encodeURIComponent(db.password) : '';
|
|
578
|
+
const auth = user ? `${user}:${pass}@` : '';
|
|
579
|
+
const host = db.host || 'localhost';
|
|
580
|
+
const port = db.port || 27017;
|
|
581
|
+
const dbName = db.database_name || 'test';
|
|
582
|
+
return `mongodb://${auth}${host}:${port}/${dbName}`;
|
|
583
|
+
}
|
|
584
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
585
|
+
export async function testConnection(db) {
|
|
586
|
+
switch (db.type) {
|
|
587
|
+
case 'postgresql': return pgTestConnection(db);
|
|
588
|
+
case 'mysql': return mysqlTestConnection(db);
|
|
589
|
+
case 'sqlite': return sqliteTestConnection(db);
|
|
590
|
+
case 'mongodb': return mongoTestConnection(db);
|
|
591
|
+
default: throw new Error(`Unsupported database type: ${db.type}`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
export async function introspectSchema(db) {
|
|
595
|
+
switch (db.type) {
|
|
596
|
+
case 'postgresql': return pgIntrospect(db);
|
|
597
|
+
case 'mysql': return mysqlIntrospect(db);
|
|
598
|
+
case 'sqlite': return sqliteIntrospect(db);
|
|
599
|
+
case 'mongodb': return mongoIntrospect(db);
|
|
600
|
+
default: throw new Error(`Unsupported database type: ${db.type}`);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
export async function executeQuery(db, query, params) {
|
|
604
|
+
switch (db.type) {
|
|
605
|
+
case 'postgresql': return pgExecuteQuery(db, query, params);
|
|
606
|
+
case 'mysql': return mysqlExecuteQuery(db, query, params);
|
|
607
|
+
case 'sqlite': return sqliteExecuteQuery(db, query);
|
|
608
|
+
case 'mongodb': return mongoExecuteQuery(db, query);
|
|
609
|
+
default: throw new Error(`Unsupported database type: ${db.type}`);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
|
|
2
|
+
function getDerivedKey() {
|
|
3
|
+
const secret = process.env.MORPHEUS_SECRET;
|
|
4
|
+
if (!secret) {
|
|
5
|
+
throw new Error('MORPHEUS_SECRET environment variable is required for credential encryption. ' +
|
|
6
|
+
'Set it before saving database credentials.');
|
|
7
|
+
}
|
|
8
|
+
// Derive a 32-byte key from the secret using scrypt
|
|
9
|
+
return scryptSync(secret, 'morpheus-trinity-salt-v1', 32);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Encrypts a plaintext string using AES-256-GCM.
|
|
13
|
+
* Returns a string in format: base64(iv):base64(authTag):base64(ciphertext)
|
|
14
|
+
*/
|
|
15
|
+
export function encrypt(plaintext) {
|
|
16
|
+
const key = getDerivedKey();
|
|
17
|
+
const iv = randomBytes(16);
|
|
18
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
19
|
+
const encrypted = Buffer.concat([
|
|
20
|
+
cipher.update(plaintext, 'utf8'),
|
|
21
|
+
cipher.final(),
|
|
22
|
+
]);
|
|
23
|
+
const authTag = cipher.getAuthTag();
|
|
24
|
+
return [
|
|
25
|
+
iv.toString('base64'),
|
|
26
|
+
authTag.toString('base64'),
|
|
27
|
+
encrypted.toString('base64'),
|
|
28
|
+
].join(':');
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Decrypts a ciphertext string produced by encrypt().
|
|
32
|
+
*/
|
|
33
|
+
export function decrypt(ciphertext) {
|
|
34
|
+
const key = getDerivedKey();
|
|
35
|
+
const parts = ciphertext.split(':');
|
|
36
|
+
if (parts.length !== 3) {
|
|
37
|
+
throw new Error('Invalid encrypted value format — expected iv:authTag:ciphertext');
|
|
38
|
+
}
|
|
39
|
+
const iv = Buffer.from(parts[0], 'base64');
|
|
40
|
+
const authTag = Buffer.from(parts[1], 'base64');
|
|
41
|
+
const encrypted = Buffer.from(parts[2], 'base64');
|
|
42
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
43
|
+
decipher.setAuthTag(authTag);
|
|
44
|
+
return Buffer.concat([
|
|
45
|
+
decipher.update(encrypted),
|
|
46
|
+
decipher.final(),
|
|
47
|
+
]).toString('utf8');
|
|
48
|
+
}
|
|
49
|
+
/** Returns true if MORPHEUS_SECRET is set (credentials can be stored). */
|
|
50
|
+
export function canEncrypt() {
|
|
51
|
+
return !!process.env.MORPHEUS_SECRET;
|
|
52
|
+
}
|