mpx-db 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,40 +1,45 @@
1
1
  {
2
2
  "name": "mpx-db",
3
- "version": "1.1.2",
4
- "description": "Database management CLI - Connect, query, migrate, and manage databases from the terminal",
3
+ "version": "1.2.0",
4
+ "description": "Database management CLI. Connect, query, migrate, and manage databases. AI-native with JSON output and MCP server.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
7
7
  "mpx-db": "bin/mpx-db.js"
8
8
  },
9
9
  "type": "module",
10
10
  "scripts": {
11
- "test": "node --test test/**/*.test.js",
11
+ "test": "node --test test/*.test.js",
12
12
  "test:watch": "node --test --watch test/**/*.test.js",
13
13
  "dev": "node bin/mpx-db.js"
14
14
  },
15
+ "funding": "https://mesaplex.com/pricing",
15
16
  "keywords": [
16
- "database",
17
17
  "cli",
18
+ "devtools",
19
+ "mesaplex",
20
+ "ai-native",
21
+ "mcp",
22
+ "model-context-protocol",
23
+ "automation",
24
+ "json-output",
25
+ "database",
18
26
  "migration",
19
27
  "postgresql",
20
28
  "mysql",
21
29
  "sqlite",
22
30
  "schema",
23
- "sql",
24
- "mcp",
25
- "ai-native",
26
- "model-context-protocol",
27
- "automation",
28
- "json-output"
31
+ "sql"
29
32
  ],
30
33
  "author": "Mesaplex <support@mesaplex.com>",
31
- "license": "MIT",
34
+ "license": "SEE LICENSE IN LICENSE",
32
35
  "repository": {
33
36
  "type": "git",
34
37
  "url": "git+https://github.com/mesaplexdev/mpx-db.git"
35
38
  },
36
39
  "homepage": "https://github.com/mesaplexdev/mpx-db#readme",
37
- "bugs": "https://github.com/mesaplexdev/mpx-db/issues",
40
+ "bugs": {
41
+ "url": "https://github.com/mesaplexdev/mpx-db/issues"
42
+ },
38
43
  "engines": {
39
44
  "node": ">=18.0.0"
40
45
  },
@@ -44,8 +49,7 @@
44
49
  "chalk": "^5.3.0",
45
50
  "cli-table3": "^0.6.5",
46
51
  "commander": "^12.1.0",
47
- "inquirer": "^10.0.0",
48
- "yaml": "^2.6.1"
52
+ "pdfkit": "^0.17.2"
49
53
  },
50
54
  "peerDependencies": {
51
55
  "mysql2": "^3.11.5",
@@ -64,6 +68,6 @@
64
68
  "bin/",
65
69
  "README.md",
66
70
  "LICENSE",
67
- "package.json"
71
+ "CHANGELOG.md"
68
72
  ]
69
73
  }
package/src/cli.js CHANGED
@@ -31,15 +31,24 @@ program
31
31
  .version(pkg.version)
32
32
  .option('--json', 'Output as JSON (machine-readable)')
33
33
  .option('-q, --quiet', 'Suppress non-essential output')
34
+ .option('--no-color', 'Disable colored output')
34
35
  .option('--schema', 'Output JSON schema describing all commands and flags')
35
- .hook('preAction', (thisCommand) => {
36
+ .option('--pdf <file>', 'Export results as a PDF report');
37
+
38
+ // Error handling — must be set BEFORE .command() so subcommands inherit exitOverride
39
+ program.exitOverride();
40
+ program.configureOutput({
41
+ writeErr: () => {} // Suppress Commander's own error output; we handle it in the catch below
42
+ });
43
+
44
+ program.hook('preAction', (thisCommand) => {
36
45
  // Merge parent options with command options
37
46
  const parentOpts = thisCommand.parent?.opts() || {};
38
47
  const opts = thisCommand.opts();
39
48
  globalOptions = { ...parentOpts, ...opts };
40
49
 
41
- // Disable chalk if JSON mode
42
- if (globalOptions.json) {
50
+ // Disable chalk if JSON mode or --no-color
51
+ if (globalOptions.json || globalOptions.color === false) {
43
52
  chalk.level = 0;
44
53
  }
45
54
  });
@@ -74,7 +83,8 @@ program
74
83
  .description('Execute a SQL query')
75
84
  .argument('<target>', 'Connection name or URL')
76
85
  .argument('<sql>', 'SQL query to execute')
77
- .action((target, sql) => handleQuery(target, sql, globalOptions));
86
+ .option('--pdf <file>', 'Export query results as PDF report')
87
+ .action((target, sql, options) => handleQuery(target, sql, { ...globalOptions, ...options }));
78
88
 
79
89
  // Info command
80
90
  program
@@ -107,7 +117,8 @@ schema
107
117
  .command('dump')
108
118
  .description('Dump database schema as SQL')
109
119
  .argument('<target>', 'Connection name or URL')
110
- .action((target) => dumpSchema(target, globalOptions));
120
+ .option('--pdf <file>', 'Export schema as PDF report')
121
+ .action((target, options) => dumpSchema(target, { ...globalOptions, ...options }));
111
122
 
112
123
  // Migration commands
113
124
  const migrate = program
@@ -218,7 +229,7 @@ program
218
229
  if (jsonMode) {
219
230
  console.log(JSON.stringify({ error: err.message, code: 'ERR_UPDATE' }, null, 2));
220
231
  } else {
221
- console.error(chalk.red.bold('\n❌ Update check failed:'), err.message);
232
+ console.error(chalk.red('Error:'), err.message);
222
233
  console.error('');
223
234
  }
224
235
  process.exit(1);
@@ -245,9 +256,6 @@ if (process.argv.includes('--schema')) {
245
256
  process.exit(0);
246
257
  }
247
258
 
248
- // Error handling
249
- program.exitOverride();
250
-
251
259
  try {
252
260
  await program.parseAsync(process.argv);
253
261
  } catch (err) {
@@ -256,7 +264,8 @@ try {
256
264
  process.exit(0);
257
265
  }
258
266
  if (err.code !== 'commander.help' && err.code !== 'commander.helpDisplayed') {
259
- console.error(chalk.red(`Error: ${err.message}`));
260
- process.exit(1);
267
+ const msg = err.message.startsWith('error:') ? `Error: ${err.message.slice(7)}` : `Error: ${err.message}`;
268
+ console.error(chalk.red(msg));
269
+ process.exit(2);
261
270
  }
262
271
  }
@@ -120,6 +120,7 @@ export async function removeConnection(name, options = {}) {
120
120
  name,
121
121
  message: deleted ? 'Connection deleted' : 'Connection not found'
122
122
  }, null, 2));
123
+ if (!deleted) process.exit(1);
123
124
  return;
124
125
  }
125
126
 
@@ -128,5 +129,6 @@ export async function removeConnection(name, options = {}) {
128
129
  console.log(chalk.green(`✓ Deleted connection "${name}"`));
129
130
  } else {
130
131
  console.log(chalk.yellow(`Connection "${name}" not found`));
132
+ process.exit(1);
131
133
  }
132
134
  }
@@ -14,10 +14,12 @@ export async function exportData(target, tableName, options = {}) {
14
14
  db = await createConnection(connectionString);
15
15
 
16
16
  // Query all data
17
- const rows = await db.query(`SELECT * FROM ${tableName}`);
17
+ const rows = await db.query(`SELECT * FROM ${db.quoteIdentifier(tableName)}`);
18
18
 
19
19
  if (rows.length === 0) {
20
- if (!options.quiet) {
20
+ if (options.json) {
21
+ console.log(JSON.stringify({ success: true, rows: [], rowCount: 0 }, null, 2));
22
+ } else if (!options.quiet) {
21
23
  console.log(chalk.yellow('No data to export'));
22
24
  }
23
25
  return;
@@ -7,6 +7,44 @@ import { resolveConnection } from './query.js';
7
7
 
8
8
  const MIGRATIONS_DIR = './migrations';
9
9
 
10
+ /**
11
+ * Split SQL into statements on semicolons, respecting quoted strings.
12
+ */
13
+ function splitStatements(sql) {
14
+ const statements = [];
15
+ let current = '';
16
+ let inSingle = false;
17
+ let inDouble = false;
18
+
19
+ for (let i = 0; i < sql.length; i++) {
20
+ const ch = sql[i];
21
+ const prev = i > 0 ? sql[i - 1] : '';
22
+
23
+ if (ch === "'" && !inDouble && prev !== '\\') {
24
+ inSingle = !inSingle;
25
+ } else if (ch === '"' && !inSingle && prev !== '\\') {
26
+ inDouble = !inDouble;
27
+ }
28
+
29
+ if (ch === ';' && !inSingle && !inDouble) {
30
+ const trimmed = current.trim();
31
+ if (trimmed.length > 0) {
32
+ statements.push(trimmed);
33
+ }
34
+ current = '';
35
+ } else {
36
+ current += ch;
37
+ }
38
+ }
39
+
40
+ const trimmed = current.trim();
41
+ if (trimmed.length > 0) {
42
+ statements.push(trimmed);
43
+ }
44
+
45
+ return statements;
46
+ }
47
+
10
48
  /**
11
49
  * Initialize migrations directory
12
50
  */
@@ -287,17 +325,14 @@ export async function runMigrations(target, options = {}) {
287
325
  console.log(chalk.gray(`→ ${name}`));
288
326
  }
289
327
 
290
- // Execute migration (split by semicolon for multiple statements)
328
+ // Execute migration (split by semicolon, respecting quoted strings)
291
329
  // Remove comment lines first, then split
292
330
  const cleanedSQL = migration.up
293
331
  .split('\n')
294
332
  .filter(line => !line.trim().startsWith('--'))
295
333
  .join('\n');
296
334
 
297
- const statements = cleanedSQL
298
- .split(';')
299
- .map(s => s.trim())
300
- .filter(s => s.length > 0);
335
+ const statements = splitStatements(cleanedSQL);
301
336
 
302
337
  for (const statement of statements) {
303
338
  await db.execute(statement);
@@ -385,17 +420,14 @@ export async function rollbackMigration(target, options = {}) {
385
420
  console.log(chalk.cyan(`Rolling back: ${last.name}`));
386
421
  }
387
422
 
388
- // Execute rollback (split by semicolon for multiple statements)
423
+ // Execute rollback (split by semicolon, respecting quoted strings)
389
424
  // Remove comment lines first, then split
390
425
  const cleanedSQL = migration.down
391
426
  .split('\n')
392
427
  .filter(line => !line.trim().startsWith('--'))
393
428
  .join('\n');
394
429
 
395
- const statements = cleanedSQL
396
- .split(';')
397
- .map(s => s.trim())
398
- .filter(s => s.length > 0);
430
+ const statements = splitStatements(cleanedSQL);
399
431
 
400
432
  for (const statement of statements) {
401
433
  await db.execute(statement);
@@ -2,6 +2,7 @@ import chalk from 'chalk';
2
2
  import Table from 'cli-table3';
3
3
  import { getConnection } from '../utils/config.js';
4
4
  import { createConnection } from '../db/connection.js';
5
+ import { generateQueryPDF } from '../reporters/pdf.js';
5
6
 
6
7
  /**
7
8
  * Execute a query or statement
@@ -27,8 +28,15 @@ export async function handleQuery(target, sql, options = {}) {
27
28
  const rows = await db.query(sql);
28
29
  const duration = Date.now() - startTime;
29
30
 
31
+ // PDF output
32
+ if (options.pdf) {
33
+ await generateQueryPDF(rows, { sql, duration }, options.pdf);
34
+ if (!options.quiet) {
35
+ console.log(chalk.green(`✓ Query report saved to ${options.pdf} (${rows.length} rows)`));
36
+ }
37
+ }
30
38
  // JSON output
31
- if (options.json) {
39
+ else if (options.json) {
32
40
  console.log(JSON.stringify({
33
41
  success: true,
34
42
  type: 'query',
@@ -36,15 +44,12 @@ export async function handleQuery(target, sql, options = {}) {
36
44
  rowCount: rows.length,
37
45
  duration
38
46
  }, null, 2));
39
- } else {
47
+ } else if (!options.quiet) {
40
48
  // Display results
41
49
  if (rows.length === 0) {
42
50
  console.log(chalk.yellow('No rows returned'));
43
51
  } else {
44
52
  displayTable(rows);
45
- }
46
-
47
- if (!options.quiet) {
48
53
  console.log(chalk.gray(`\n${rows.length} row(s) in ${duration}ms`));
49
54
  }
50
55
  }
@@ -2,6 +2,7 @@ import chalk from 'chalk';
2
2
  import Table from 'cli-table3';
3
3
  import { createConnection } from '../db/connection.js';
4
4
  import { resolveConnection } from './query.js';
5
+ import { generateSchemaPDF } from '../reporters/pdf.js';
5
6
 
6
7
  /**
7
8
  * Show database info
@@ -135,9 +136,9 @@ export async function describeTable(target, tableName, options = {}) {
135
136
  if (options.json) {
136
137
  console.log(JSON.stringify({ error: `Table "${tableName}" not found` }, null, 2));
137
138
  } else {
138
- console.log(chalk.yellow(`Table "${tableName}" not found or has no columns`));
139
+ console.error(chalk.yellow(`Table "${tableName}" not found or has no columns`));
139
140
  }
140
- return;
141
+ process.exit(1);
141
142
  }
142
143
 
143
144
  // JSON output
@@ -216,19 +217,38 @@ export async function dumpSchema(target, options = {}) {
216
217
  sqlOutput += `-- Table: ${table.name}\n`;
217
218
  sqlOutput += `-- Rows: ${table.rows}\n`;
218
219
 
219
- // This is a simplified dump - real implementations would generate proper DDL
220
+ const pkCols = schema.filter(c => c.primaryKey).map(c => c.name);
220
221
  const cols = schema.map(c => {
221
222
  let def = ` ${c.name} ${c.type}`;
222
- if (!c.nullable) def += ' NOT NULL';
223
+ if (c.primaryKey && pkCols.length === 1) def += ' PRIMARY KEY';
224
+ if (!c.nullable && !c.primaryKey) def += ' NOT NULL';
223
225
  if (c.default) def += ` DEFAULT ${c.default}`;
224
226
  return def;
225
227
  });
226
228
 
227
- sqlOutput += `CREATE TABLE ${table.name} (\n`;
229
+ sqlOutput += `CREATE TABLE "${table.name}" (\n`;
228
230
  sqlOutput += cols.join(',\n');
231
+ if (pkCols.length > 1) {
232
+ sqlOutput += `,\n PRIMARY KEY (${pkCols.join(', ')})`;
233
+ }
229
234
  sqlOutput += '\n);\n\n';
230
235
  }
231
236
 
237
+ // PDF output
238
+ if (options.pdf) {
239
+ const tableSchemas = [];
240
+ for (const table of tables) {
241
+ if (table.type !== 'table') continue;
242
+ const cols = await db.getTableSchema(table.name);
243
+ tableSchemas.push({ name: table.name, columns: cols });
244
+ }
245
+ await generateSchemaPDF(info, tables, tableSchemas, options.pdf);
246
+ if (!options.quiet) {
247
+ console.log(chalk.green(`✓ Schema report saved to ${options.pdf}`));
248
+ }
249
+ return;
250
+ }
251
+
232
252
  // JSON output
233
253
  if (options.json) {
234
254
  console.log(JSON.stringify({ sql: sqlOutput }, null, 2));
@@ -8,6 +8,22 @@ export class BaseAdapter {
8
8
  this.connection = null;
9
9
  }
10
10
 
11
+ /**
12
+ * Validate and quote a table/identifier name to prevent SQL injection.
13
+ * Rejects names with dangerous characters. Override quoteChar in subclasses.
14
+ */
15
+ quoteIdentifier(name) {
16
+ if (!name || typeof name !== 'string') {
17
+ throw new Error('Invalid identifier: must be a non-empty string');
18
+ }
19
+ // Allow alphanumeric, underscores, dots (schema.table), hyphens
20
+ if (!/^[a-zA-Z_][a-zA-Z0-9_.\\-]*$/.test(name)) {
21
+ throw new Error(`Invalid identifier: "${name}" contains disallowed characters`);
22
+ }
23
+ const q = this._identifierQuote || '"';
24
+ return `${q}${name.replace(new RegExp(`\\${q}`, 'g'), q + q)}${q}`;
25
+ }
26
+
11
27
  /**
12
28
  * Connect to database
13
29
  */
@@ -63,7 +79,7 @@ export class BaseAdapter {
63
79
  * Get table row count
64
80
  */
65
81
  async getRowCount(tableName) {
66
- const result = await this.query(`SELECT COUNT(*) as count FROM ${tableName}`);
82
+ const result = await this.query(`SELECT COUNT(*) as count FROM ${this.quoteIdentifier(tableName)}`);
67
83
  return result[0].count;
68
84
  }
69
85
 
@@ -4,6 +4,11 @@ import { BaseAdapter } from './base-adapter.js';
4
4
  * MySQL adapter using mysql2
5
5
  */
6
6
  export class MySQLAdapter extends BaseAdapter {
7
+ constructor(connectionString) {
8
+ super(connectionString);
9
+ this._identifierQuote = '`';
10
+ }
11
+
7
12
  async connect() {
8
13
  try {
9
14
  const mysql = await import('mysql2/promise');
@@ -62,7 +67,7 @@ export class MySQLAdapter extends BaseAdapter {
62
67
  const tables = [];
63
68
  for (const row of rows) {
64
69
  const countResult = await this.query(
65
- `SELECT COUNT(*) as count FROM \`${row.name}\``
70
+ `SELECT COUNT(*) as count FROM ${this.quoteIdentifier(row.name)}`
66
71
  );
67
72
  tables.push({
68
73
  name: row.name,
@@ -64,7 +64,7 @@ export class PostgresAdapter extends BaseAdapter {
64
64
  const tables = [];
65
65
  for (const row of rows) {
66
66
  const countResult = await this.query(
67
- `SELECT COUNT(*) as count FROM ${row.name}`
67
+ `SELECT COUNT(*) as count FROM ${this.quoteIdentifier(row.name)}`
68
68
  );
69
69
  tables.push({
70
70
  name: row.name,
@@ -127,6 +127,17 @@ export class PostgresAdapter extends BaseAdapter {
127
127
  };
128
128
  }
129
129
 
130
+ async recordMigration(name) {
131
+ await this.execute(
132
+ 'INSERT INTO mpx_migrations (name, applied_at) VALUES ($1, $2)',
133
+ [name, new Date().toISOString()]
134
+ );
135
+ }
136
+
137
+ async removeMigration(name) {
138
+ await this.execute('DELETE FROM mpx_migrations WHERE name = $1', [name]);
139
+ }
140
+
130
141
  async ensureMigrationsTable() {
131
142
  await this.execute(`
132
143
  CREATE TABLE IF NOT EXISTS mpx_migrations (
@@ -10,9 +10,9 @@ export class SQLiteAdapter extends BaseAdapter {
10
10
  super(connectionString);
11
11
 
12
12
  // Extract file path from sqlite:// or sqlite3://
13
- this.dbPath = connectionString
14
- .replace(/^sqlite3?:\/\//, '')
15
- .replace(/^\//, ''); // Remove leading slash if absolute path
13
+ // sqlite:///tmp/foo.db /tmp/foo.db (absolute)
14
+ // sqlite://mydb.db → mydb.db (relative)
15
+ this.dbPath = connectionString.replace(/^sqlite3?:\/\//, '');
16
16
  }
17
17
 
18
18
  async connect() {
@@ -76,7 +76,7 @@ export class SQLiteAdapter extends BaseAdapter {
76
76
 
77
77
  const tables = [];
78
78
  for (const row of rows) {
79
- const countResult = await this.query(`SELECT COUNT(*) as count FROM ${row.name}`);
79
+ const countResult = await this.query(`SELECT COUNT(*) as count FROM ${this.quoteIdentifier(row.name)}`);
80
80
  tables.push({
81
81
  name: row.name,
82
82
  type: row.type,
@@ -88,7 +88,7 @@ export class SQLiteAdapter extends BaseAdapter {
88
88
  }
89
89
 
90
90
  async getTableSchema(tableName) {
91
- const rows = await this.query(`PRAGMA table_info(${tableName})`);
91
+ const rows = await this.query(`PRAGMA table_info(${this.quoteIdentifier(tableName)})`);
92
92
 
93
93
  return rows.map(row => ({
94
94
  name: row.name,
package/src/mcp.js CHANGED
@@ -261,7 +261,7 @@ export async function startMCPServer() {
261
261
  const db = await createConnection(connectionString);
262
262
 
263
263
  try {
264
- const rows = await db.query(`SELECT * FROM ${args.table}`);
264
+ const rows = await db.query(`SELECT * FROM ${db.quoteIdentifier(args.table)}`);
265
265
  await db.disconnect();
266
266
 
267
267
  return {