bmad-federated-knowledge
Version:
Git-Based Federated Knowledge System extension for BMAD-METHOD
1,076 lines (951 loc) • 40.6 kB
JavaScript
const chalk = require('chalk');
const ora = require('ora');
const inquirer = require('inquirer');
const path = require('path');
const fs = require('fs-extra');
// Check if pdfkit is installed
let PDFDocument;
try {
PDFDocument = require('pdfkit');
} catch (error) {
// PDFKit will be handled as optional
PDFDocument = null;
}
/**
* Register the sync-db command to the CLI
* @param {Command} program - Commander program instance
* @param {BmadFederatedKnowledge} bmadFed - BMAD FKS instance
*/
function registerSyncDbCommand(program, bmadFed) {
program
.command('sync-db [knowledgeSourceName]')
.description('Sync data from database knowledge sources and save as PDF in cache')
.option('-a, --all', 'Sync all database knowledge sources')
.option('-f, --force', 'Force sync even if already synced recently')
.option('-j, --json', 'Save as JSON instead of PDF')
.option('-m, --mock', 'Use mock data for testing (no actual database connection)')
.on('--help', () => {
console.log('');
console.log('Database Query Formats:');
console.log(' - SQL Databases: Use standard SQL query syntax');
console.log(' Example: "SELECT * FROM employees WHERE department = \'Engineering\'"');
console.log('');
console.log(' - MongoDB: Can use SQL-like syntax OR JSON format:');
console.log(' SQL-like: "SELECT * FROM employees WHERE department = \'Engineering\'"');
console.log(' JSON format: {"collection": "employees", "filter": {"department": "Engineering"}}');
})
.action(async (knowledgeSourceName, options) => {
try {
await bmadFed.initialize();
// Get knowledge sources from config
const config = bmadFed.dependencyResolver.config;
const knowledgeSources = config.bmad_config.knowledge_sources || {};
const connections = config.bmad_config.connections || {};
// Find database knowledge sources
const dbSources = Object.entries(knowledgeSources)
.filter(([name, source]) => source.type === 'database')
.reduce((acc, [name, source]) => {
acc[name] = source;
return acc;
}, {});
if (Object.keys(dbSources).length === 0) {
console.log(chalk.yellow('No database knowledge sources found.'));
console.log(chalk.blue('Run "bmad-fed add-knowledge" to add a database knowledge source.'));
return;
}
let sourcesToSync = [];
if (knowledgeSourceName) {
// Sync specific source
if (!dbSources[knowledgeSourceName]) {
console.error(chalk.red(`Database knowledge source "${knowledgeSourceName}" not found or not a database source.`));
console.log(chalk.yellow('Available database knowledge sources:'));
Object.keys(dbSources).forEach(name => console.log(` - ${name}`));
process.exit(1);
}
sourcesToSync.push([knowledgeSourceName, dbSources[knowledgeSourceName]]);
} else if (options.all) {
// Sync all sources
sourcesToSync = Object.entries(dbSources);
} else {
// Interactive selection
const { selectedSources } = await inquirer.prompt([
{
type: 'checkbox',
name: 'selectedSources',
message: 'Select database knowledge sources to sync:',
choices: Object.keys(dbSources).map(name => ({
name: `${name} (${dbSources[name].connection_ref})`,
value: name
})),
validate: input => input.length > 0 || 'You must select at least one source.'
}
]);
sourcesToSync = selectedSources.map(name => [name, dbSources[name]]);
}
// Process each source
for (const [name, source] of sourcesToSync) {
const spinner = ora(`Syncing database knowledge source: ${name}`).start();
try {
// Get connection details
const connectionRef = source.connection_ref;
if (!connections[connectionRef]) {
spinner.fail(chalk.red(`Connection "${connectionRef}" not found.`));
continue;
}
const connection = connections[connectionRef];
// Execute query and get data
const data = await executeQuery(connection, source.query, options.mock);
// Create cache directory if it doesn't exist
const cacheRoot = config.bmad_config.federated_settings?.cache_root || './.bmad-fks-cache';
const cachePath = path.join(cacheRoot, 'db-knowledge');
await fs.ensureDir(cachePath);
// Check format preference and PDFKit availability
let outputPath;
const useJson = options.json || !PDFDocument;
if (!useJson && PDFDocument) {
// Generate PDF
outputPath = path.join(cachePath, `${name}.pdf`);
await generatePdf(data, outputPath, name, source);
spinner.succeed(chalk.green(`Database knowledge source "${name}" synced successfully!`));
console.log(chalk.blue(` PDF saved to: ${outputPath}`));
} else {
// Generate JSON output
outputPath = path.join(cachePath, `${name}.json`);
await fs.writeJson(outputPath, {
metadata: {
name,
source: source,
query: source.query,
timestamp: new Date().toISOString()
},
data
}, { spaces: 2 });
spinner.succeed(chalk.green(`Database knowledge source "${name}" synced successfully!`));
console.log(chalk.blue(` JSON saved to: ${outputPath}`));
if (!PDFDocument && !options.json) {
console.log(chalk.yellow(` Note: PDFKit module not found.`));
console.log(chalk.yellow(` To install PDFKit, run: npm install pdfkit`));
console.log(chalk.yellow(` Or use --json flag to always output in JSON format`));
const { installPdfKit } = await inquirer.prompt([{
type: 'confirm',
name: 'installPdfKit',
message: 'Would you like to install PDFKit now?',
default: false
}]);
if (installPdfKit) {
await installPdfKitModule();
}
}
}
} catch (error) {
spinner.fail(chalk.red(`Failed to sync database knowledge source "${name}"`));
console.error(chalk.red(` Error: ${error.message}`));
}
}
} catch (error) {
console.error(chalk.red(`Failed to sync database: ${error.message}`));
process.exit(1);
}
});
}
/**
* Execute database query based on connection type and return data
* @param {Object} connection - Connection configuration
* @param {string} query - SQL query or MongoDB query string
* @returns {Promise<Array>} - Query results
*/
async function executeQuery(connection, query, useMock = false) {
console.log(chalk.blue(`Executing query on ${connection.type} database...`));
console.log(chalk.gray(`Connection: ${maskConnectionString(connection.connection_string)}`));
console.log(chalk.gray(`Query: ${query}`));
// Check for required database drivers
let mongodbModule, mysqlModule, pgModule;
try {
// Dynamically import database drivers only when needed
switch (connection.type) {
case 'mongodb':
try {
mongodbModule = require('mongodb');
} catch (err) {
if (useMock || process.env.BMAD_USE_MOCK_DATA === 'true') {
console.log(chalk.yellow('MongoDB driver not found. Using mock data for testing.'));
return getMockData('mongodb');
}
throw new Error('MongoDB driver not found. Install it with: npm install mongodb');
}
break;
case 'mysql':
try {
mysqlModule = require('mysql2/promise');
} catch (err) {
if (useMock || process.env.BMAD_USE_MOCK_DATA === 'true') {
console.log(chalk.yellow('MySQL driver not found. Using mock data for testing.'));
return getMockData('mysql');
}
throw new Error('MySQL driver not found. Install it with: npm install mysql2');
}
break;
case 'postgresql':
try {
pgModule = require('pg');
} catch (err) {
if (useMock || process.env.BMAD_USE_MOCK_DATA === 'true') {
console.log(chalk.yellow('PostgreSQL driver not found. Using mock data for testing.'));
return getMockData('postgresql');
}
throw new Error('PostgreSQL driver not found. Install it with: npm install pg');
}
break;
}
} catch (error) {
console.error(chalk.red(`Database driver error: ${error.message}`));
if (useMock || process.env.BMAD_USE_MOCK_DATA === 'true') {
console.log(chalk.yellow(`Using mock data for testing purposes.`));
return getMockData(connection.type);
}
throw error;
}
try {
// Execute query based on database type
switch (connection.type) {
case 'mongodb':
return await queryMongodb(mongodbModule, connection.connection_string, query);
case 'mysql':
return await queryMysql(mysqlModule, connection.connection_string, query);
case 'postgresql':
return await queryPostgres(pgModule, connection.connection_string, query);
default:
throw new Error(`Unsupported database type: ${connection.type}`);
}
} catch (error) {
console.error(chalk.red(`${connection.type} query execution error: ${error.message}`));
if (useMock || process.env.BMAD_USE_MOCK_DATA === 'true') {
console.log(chalk.yellow(`Connection failed. Using mock data for testing purposes.`));
return getMockData(connection.type);
}
throw error;
}
}
/**
* Execute MongoDB query
* @param {Object} mongodb - MongoDB module
* @param {string} connectionString - MongoDB connection string
* @param {string} queryString - Query string (can be SQL-like or MongoDB syntax)
* @returns {Promise<Object>} - Standardized query results
*/
async function queryMongodb(mongodb, connectionString, queryString) {
const { MongoClient } = mongodb;
// Add options to ensure connection is established properly
const client = new MongoClient(connectionString);
try {
await client.connect();
console.log(chalk.green('Connected to MongoDB'));
// Extract database name from connection string using multiple methods
let dbName = 'admin'; // Default database
try {
// Method 1: Standard URI format extraction (mongodb://host:port/dbname)
const standardUriMatch = connectionString.match(/\/([^/?]+)(?:\?|$)/);
if (standardUriMatch && standardUriMatch[1] && standardUriMatch[1] !== '') {
dbName = standardUriMatch[1];
}
// Method 2: Check for appName parameter which might indicate database name
else if (connectionString.includes('appName=')) {
const appNameMatch = connectionString.match(/appName=([^&]+)/);
if (appNameMatch && appNameMatch[1]) {
dbName = appNameMatch[1];
console.log(chalk.blue(`Using database name from appName parameter: ${dbName}`));
}
}
// Method 3: Parse MongoDB+SRV format (mongodb+srv://user:pass@cluster.mongodb.net/dbname)
else if (connectionString.includes('mongodb+srv://')) {
const srvParts = connectionString.split('@');
if (srvParts.length > 1) {
const hostAndParams = srvParts[1].split('?');
const hostAndDb = hostAndParams[0].split('/');
if (hostAndDb.length > 1 && hostAndDb[1] !== '') {
dbName = hostAndDb[1];
}
}
}
} catch (err) {
console.log(chalk.yellow(`Error extracting database name: ${err.message}`));
console.log(chalk.yellow(`Using default database name: ${dbName}`));
}
console.log(chalk.blue(`Using database: ${dbName}`));
const db = client.db(dbName);
// Parse the query - handle both SQL-like and MongoDB syntax
let collection = 'knowledge'; // Default collection
let filter = {}; // Default filter (get all documents)
let projection = null;
let sort = {};
let limit = 0;
let skip = 0;
// Enhanced query parsing
if (queryString.trim().startsWith('{')) {
// JSON format
try {
const queryObject = JSON.parse(queryString);
if (queryObject.collection) {
collection = queryObject.collection;
}
if (queryObject.filter) {
filter = queryObject.filter;
}
if (queryObject.projection) {
projection = queryObject.projection;
}
if (queryObject.sort) {
sort = queryObject.sort;
}
if (queryObject.limit) {
limit = parseInt(queryObject.limit, 10);
}
if (queryObject.skip) {
skip = parseInt(queryObject.skip, 10);
}
console.log(chalk.blue('Using MongoDB JSON query format'));
} catch (error) {
console.log(chalk.red(`Invalid JSON query format: ${error.message}`));
console.log(chalk.yellow('Using default query parameters'));
}
} else if (queryString.toLowerCase().includes('select') && queryString.toLowerCase().includes('from')) {
// SQL-like parsing with improved functionality
console.log(chalk.blue('Parsing SQL-like query for MongoDB'));
// Extract collection name (FROM clause)
const fromMatch = queryString.match(/from\s+([^\s;]+)/i);
if (fromMatch && fromMatch[1]) {
collection = fromMatch[1];
}
// Extract field selection (SELECT clause)
const selectMatch = queryString.match(/select\s+(.+?)\s+from/i);
if (selectMatch && selectMatch[1] && selectMatch[1] !== '*') {
projection = {};
const fields = selectMatch[1].split(',').map(f => f.trim());
fields.forEach(field => {
projection[field] = 1;
});
}
// Handle WHERE clause for conditions
const whereMatch = queryString.match(/where\s+(.+?)(?:order by|limit|$)/i);
if (whereMatch && whereMatch[1]) {
const whereClause = whereMatch[1].trim();
// Parse conditions (supporting =, >, <, >=, <=, !=)
const conditions = whereClause.split(/\s+and\s+/i);
conditions.forEach(condition => {
// Match field, operator and value
const parts = condition.match(/([^\s=<>!]+)\s*(=|>|<|>=|<=|!=)\s*['"]?([^'"\s]+)['"]?/i);
if (parts && parts.length === 4) {
const [_, field, operator, value] = parts;
let parsedValue = value;
// Try to convert to number if appropriate
if (!isNaN(value)) {
parsedValue = Number(value);
} else if (value.toLowerCase() === 'true') {
parsedValue = true;
} else if (value.toLowerCase() === 'false') {
parsedValue = false;
}
// Map SQL operators to MongoDB operators
switch (operator) {
case '=': filter[field] = parsedValue; break;
case '>': filter[field] = { $gt: parsedValue }; break;
case '>=': filter[field] = { $gte: parsedValue }; break;
case '<': filter[field] = { $lt: parsedValue }; break;
case '<=': filter[field] = { $lte: parsedValue }; break;
case '!=': filter[field] = { $ne: parsedValue }; break;
}
}
});
}
// Handle ORDER BY clause
const orderMatch = queryString.match(/order by\s+(.+?)(?:limit|$)/i);
if (orderMatch && orderMatch[1]) {
const orderClause = orderMatch[1].trim();
const orderParts = orderClause.split(',');
orderParts.forEach(part => {
const [field, direction] = part.trim().split(/\s+/);
sort[field] = direction && direction.toLowerCase() === 'desc' ? -1 : 1;
});
}
// Handle LIMIT clause
const limitMatch = queryString.match(/limit\s+(\d+)/i);
if (limitMatch && limitMatch[1]) {
limit = parseInt(limitMatch[1], 10);
}
} else {
// Simple collection name
collection = queryString;
}
console.log(chalk.blue(`Executing MongoDB query on collection: ${collection}`));
console.log(chalk.gray(`Filter: ${JSON.stringify(filter)}`));
if (projection) console.log(chalk.gray(`Projection: ${JSON.stringify(projection)}`));
if (Object.keys(sort).length) console.log(chalk.gray(`Sort: ${JSON.stringify(sort)}`));
if (limit > 0) console.log(chalk.gray(`Limit: ${limit}`));
if (skip > 0) console.log(chalk.gray(`Skip: ${skip}`));
// Build the query
let cursor = db.collection(collection).find(filter);
// Apply projection if specified
if (projection) {
cursor = cursor.project(projection);
}
// Apply sort if specified
if (Object.keys(sort).length > 0) {
cursor = cursor.sort(sort);
}
// Apply limit and skip if specified
if (skip > 0) {
cursor = cursor.skip(skip);
}
if (limit > 0) {
cursor = cursor.limit(limit);
}
// Execute the query and get results
const documents = await cursor.toArray();
console.log(chalk.green(`Retrieved ${documents.length} documents`));
// Format the results in a standardized structure
const columns = new Set(['_id']); // Always include _id by default
// Extract all unique field names from all documents
documents.forEach(doc => {
Object.keys(doc).forEach(key => columns.add(key));
});
// Remove _id from columns if no documents contain it
if (documents.length > 0 && !documents[0].hasOwnProperty('_id')) {
columns.delete('_id');
}
// Normalize the documents - ensure all have the same fields
const normalizedRows = documents.map(doc => {
const normalizedDoc = {};
Array.from(columns).forEach(col => {
normalizedDoc[col] = doc[col] !== undefined ? doc[col] : null;
// Format various MongoDB data types for display
if (normalizedDoc[col] !== null && typeof normalizedDoc[col] === 'object') {
// Handle ObjectId
if (normalizedDoc[col].constructor && normalizedDoc[col].constructor.name === 'ObjectId') {
normalizedDoc[col] = normalizedDoc[col].toString();
}
// Handle Date objects
else if (normalizedDoc[col] instanceof Date) {
normalizedDoc[col] = normalizedDoc[col].toISOString();
}
// Handle nested objects and arrays
else {
try {
normalizedDoc[col] = JSON.stringify(normalizedDoc[col]);
} catch (e) {
normalizedDoc[col] = '[Complex Object]';
}
}
}
});
return normalizedDoc;
});
return {
columns: Array.from(columns),
rows: normalizedRows
};
} catch (error) {
console.error(chalk.red(`MongoDB query error: ${error.message}`));
throw error;
} finally {
await client.close();
}
}
/**
* Execute MySQL query
* @param {Object} mysql - MySQL module
* @param {string} connectionString - MySQL connection string
* @param {string} query - SQL query
* @returns {Promise<Object>} - Standardized query results
*/
async function queryMysql(mysql, connectionString, query) {
// Parse connection string to connection config
const config = parseMySqlConnectionString(connectionString);
let connection;
try {
connection = await mysql.createConnection(config);
console.log(chalk.green('Connected to MySQL'));
const [rows] = await connection.query(query);
// Get columns from the first row
const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
return {
columns,
rows
};
} catch (error) {
console.error(chalk.red(`MySQL query error: ${error.message}`));
throw error;
} finally {
if (connection) {
await connection.end();
}
}
}
/**
* Execute PostgreSQL query
* @param {Object} pg - PostgreSQL module
* @param {string} connectionString - PostgreSQL connection string
* @param {string} query - SQL query
* @returns {Promise<Object>} - Standardized query results
*/
async function queryPostgres(pg, connectionString, query) {
const client = new pg.Client({
connectionString
});
try {
await client.connect();
console.log(chalk.green('Connected to PostgreSQL'));
const result = await client.query(query);
const rows = result.rows;
// Get columns from field definitions
const columns = result.fields.map(field => field.name);
return {
columns,
rows
};
} catch (error) {
console.error(chalk.red(`PostgreSQL query error: ${error.message}`));
throw error;
} finally {
await client.end();
}
}
/**
* Parse MySQL connection string to connection config object
* @param {string} connectionString - MySQL connection string
* @returns {Object} - Connection config for mysql2
*/
function parseMySqlConnectionString(connectionString) {
// Handle different connection string formats
// Format: mysql://username:password@hostname:port/database
if (connectionString.startsWith('mysql://')) {
try {
const url = new URL(connectionString);
return {
host: url.hostname,
port: url.port || 3306,
user: url.username,
password: url.password,
database: url.pathname.substring(1) // Remove leading slash
};
} catch (e) {
// Fall through to other formats
}
}
// Format: host=hostname;port=port;user=username;password=password;database=database
const config = {};
const parts = connectionString.split(';');
for (const part of parts) {
const [key, value] = part.split('=');
if (key && value) {
const trimmedKey = key.trim().toLowerCase();
if (trimmedKey === 'host') config.host = value.trim();
else if (trimmedKey === 'port') config.port = parseInt(value.trim(), 10);
else if (trimmedKey === 'user') config.user = value.trim();
else if (trimmedKey === 'password') config.password = value.trim();
else if (trimmedKey === 'database') config.database = value.trim();
}
}
return config;
}
/**
* Generate PDF from query results
* @param {Object} data - Query results with columns and rows
* @param {string} filePath - Output file path
* @param {string} sourceName - Knowledge source name
* @param {Object} sourceConfig - Knowledge source configuration
* @returns {Promise<void>}
*/
async function generatePdf(data, filePath, sourceName, sourceConfig) {
// Check if PDFKit is available
if (!PDFDocument) {
throw new Error('PDFKit module is not installed. Install it with "npm install pdfkit"');
}
// Check if there's actual data to render
if (!data || !data.rows || data.rows.length === 0) {
console.log(chalk.yellow('No data found to generate PDF. Creating empty document with message.'));
}
return new Promise((resolve, reject) => {
try {
// Create a new PDF document
const doc = new PDFDocument({
margin: 50,
size: 'A4',
bufferPages: true // Enable page buffering for custom page numbering
});
// Pipe the PDF to a file
const stream = fs.createWriteStream(filePath);
doc.pipe(stream);
// Add title
doc.fontSize(24).fillColor('#333333')
.text(`Database Knowledge Source: ${sourceName}`, {
align: 'center'
});
doc.moveDown();
// Add metadata
doc.fontSize(10).fillColor('#666666');
doc.text(`Query: ${truncateString(sourceConfig.query, 100)}`);
doc.text(`Connection: ${sourceConfig.connection_ref}`);
if (sourceConfig.metadata?.description) {
doc.text(`Description: ${sourceConfig.metadata.description}`);
}
doc.text(`Generated: ${new Date().toLocaleString()}`);
doc.moveDown();
if (!data || !data.rows || data.rows.length === 0) {
// No data case
doc.fontSize(14).fillColor('#FF0000')
.text('No data found for this query.', { align: 'center' });
doc.moveDown();
doc.fontSize(12).fillColor('#000000')
.text('Possible reasons:', { align: 'left' });
doc.fontSize(10)
.text('• The collection may be empty')
.text('• The query filter may be too restrictive')
.text('• The collection name may be incorrect')
.text('• Database permissions may prevent access');
} else {
// We have data - render the table
// Get columns and limit their display width
const columns = data.columns;
// Calculate optimal column widths based on data
const columnWidths = calculateColumnWidths(data, columns, 500); // 500 is max table width
// Table style options
const tableOptions = {
padding: 5,
headerHeight: 20,
rowHeight: 18,
headerBg: '#EEEEEE',
rowEvenBg: '#FFFFFF',
rowOddBg: '#F9F9F9',
borderColor: '#CCCCCC',
textColor: '#333333',
headerColor: '#000000'
};
// Start at current position
const startY = doc.y;
let currentY = startY;
// Draw table header
doc.fontSize(9).fillColor(tableOptions.headerColor);
doc.rect(50, currentY, 500, tableOptions.headerHeight)
.fill(tableOptions.headerBg);
// Draw header text
let xPos = 50;
columns.forEach((column, i) => {
doc.fillColor(tableOptions.headerColor)
.text(truncateString(column.toString(), 30),
xPos + tableOptions.padding,
currentY + (tableOptions.headerHeight - 9) / 2, // Vertically center
{
width: columnWidths[i] - (tableOptions.padding * 2),
align: 'left'
});
xPos += columnWidths[i];
});
currentY += tableOptions.headerHeight;
// Track if we need to add a page break
let pageBreakNeeded = false;
// Add table rows
data.rows.forEach((row, rowIndex) => {
// Check if we need a page break (leave room for footer)
if (currentY > doc.page.height - 50) {
doc.addPage();
// Redraw the header on the new page
currentY = 50; // Reset Y position on new page
// Draw table header
doc.fontSize(9).fillColor(tableOptions.headerColor);
doc.rect(50, currentY, 500, tableOptions.headerHeight)
.fill(tableOptions.headerBg);
// Draw header text
let headerX = 50;
columns.forEach((column, i) => {
doc.fillColor(tableOptions.headerColor)
.text(truncateString(column.toString(), 30),
headerX + tableOptions.padding,
currentY + (tableOptions.headerHeight - 9) / 2,
{
width: columnWidths[i] - (tableOptions.padding * 2),
align: 'left'
});
headerX += columnWidths[i];
});
currentY += tableOptions.headerHeight;
}
// Draw row background
const rowBg = rowIndex % 2 === 0 ? tableOptions.rowEvenBg : tableOptions.rowOddBg;
doc.rect(50, currentY, 500, tableOptions.rowHeight)
.fill(rowBg)
.strokeColor(tableOptions.borderColor)
.lineWidth(0.5)
.stroke();
// Draw row data
xPos = 50;
columns.forEach((column, i) => {
const cellValue = formatCellValue(row[column]);
doc.fontSize(8).fillColor(tableOptions.textColor)
.text(cellValue,
xPos + tableOptions.padding,
currentY + (tableOptions.rowHeight - 8) / 2, // Vertically center
{
width: columnWidths[i] - (tableOptions.padding * 2),
align: 'left'
});
xPos += columnWidths[i];
});
currentY += tableOptions.rowHeight;
});
// Add summary footer
doc.moveDown(2);
doc.fontSize(10).fillColor('#333333')
.text(`Total records: ${data.rows.length}`, { align: 'right' });
}
// Add page numbers
const pageCount = doc.bufferedPageCount;
for (let i = 0; i < pageCount; i++) {
doc.switchToPage(i);
doc.fontSize(8).fillColor('#666666')
.text(`Page ${i + 1} of ${pageCount}`,
50, doc.page.height - 50,
{ align: 'center', width: doc.page.width - 100 });
}
// Finalize the PDF
doc.end();
// Wait for the stream to finish
stream.on('finish', () => {
resolve();
});
stream.on('error', (error) => {
reject(error);
});
} catch (error) {
reject(error);
}
});
}
/**
* Format cell value for PDF display
* @param {any} value - Cell value from data row
* @returns {string} Formatted value
*/
function formatCellValue(value) {
if (value === undefined || value === null) {
return '';
}
if (typeof value === 'object') {
// Handle special cases (Date, ObjectId, etc.)
if (value instanceof Date) {
return value.toLocaleString();
}
// For regular objects or arrays, stringify with limited depth
try {
return JSON.stringify(value, null, 0).substring(0, 50);
} catch (e) {
return '[Object]';
}
}
return String(value).substring(0, 50); // Limit length
}
/**
* Calculate optimal column widths based on content
* @param {Object} data - The data object with rows and columns
* @param {Array} columns - Column names
* @param {number} maxWidth - Maximum table width
* @returns {Array} Array of column widths
*/
function calculateColumnWidths(data, columns, maxWidth) {
// Default minimum width for each column
const minWidth = 50;
// Get content width approximation for each column
const contentWidths = columns.map(col => {
let maxContentWidth = col.toString().length * 6; // Approximate char width
// Check sample of rows for content width (limit to first 20 rows)
const sampleSize = Math.min(20, data.rows.length);
for (let i = 0; i < sampleSize; i++) {
const row = data.rows[i];
if (row[col] !== undefined && row[col] !== null) {
const cellStr = formatCellValue(row[col]);
maxContentWidth = Math.max(maxContentWidth, cellStr.length * 5);
}
}
return Math.min(maxContentWidth, 150); // Cap at 150px
});
// Get total content width
const totalContentWidth = contentWidths.reduce((sum, width) => sum + width, 0);
// If total content width is less than max width, use content widths
if (totalContentWidth <= maxWidth) {
return contentWidths;
}
// Otherwise, scale all columns proportionally
const scaleFactor = maxWidth / totalContentWidth;
return contentWidths.map(width => {
return Math.max(minWidth, Math.floor(width * scaleFactor));
});
}
/**
* Truncate a string if it exceeds the maximum length
* @param {string} str - String to truncate
* @param {number} maxLength - Maximum length
* @returns {string} Truncated string
*/
function truncateString(str, maxLength) {
if (!str) return '';
str = String(str);
return str.length > maxLength ? str.substring(0, maxLength) + '...' : str;
}
/**
* Helper to mask connection string for display
* @param {string} connectionString - The connection string to mask
* @returns {string} - Masked connection string
*/
function maskConnectionString(connectionString) {
if (!connectionString) return '';
// Try to mask password in common connection string formats
try {
// For connection strings like: protocol://user:pass@host/db
const masked = connectionString.replace(/(\/\/[^:]+:)([^@]+)(@.+)/, '$1*****$3');
// For connection strings with password=X or pwd=X
return masked.replace(/(password|pwd)=([^;& ]+)/gi, '$1=*****');
} catch (error) {
// If parsing fails, return partially masked string
return connectionString.substring(0, 10) + '...' + connectionString.substring(connectionString.length - 10);
}
}
/**
* Get mock data for testing
* @param {string} dbType - Database type
* @returns {Object} Mock data
*/
function getMockData(dbType) {
console.log(chalk.yellow('Using mock data for testing purposes.'));
// Different mock data based on database type
switch (dbType) {
case 'mongodb':
return {
columns: ['_id', 'name', 'email', 'active', 'role', 'last_login', 'profile', 'tags', 'settings'],
rows: [
{
_id: '507f1f77bcf86cd799439011',
name: 'John Doe',
email: 'john.doe@example.com',
active: true,
role: 'admin',
last_login: new Date('2023-01-15T14:22:10Z'),
profile: { age: 35, department: 'IT' },
tags: ['developer', 'backend'],
settings: { theme: 'dark', notifications: true }
},
{
_id: '507f1f77bcf86cd799439012',
name: 'Jane Smith',
email: 'jane.smith@example.com',
active: true,
role: 'user',
last_login: new Date('2023-01-10T09:15:32Z'),
profile: { age: 28, department: 'Marketing' },
tags: ['designer', 'frontend'],
settings: { theme: 'light', notifications: false }
},
{
_id: '507f1f77bcf86cd799439013',
name: 'Robert Johnson',
email: 'robert.johnson@example.com',
active: true,
role: 'editor',
last_login: new Date('2023-01-12T16:44:22Z'),
profile: { age: 42, department: 'Editorial' },
tags: ['content', 'editor'],
settings: { theme: 'system', notifications: true }
},
{
_id: '507f1f77bcf86cd799439014',
name: 'Emily Davis',
email: 'emily.davis@example.com',
active: true,
role: 'user',
last_login: new Date('2023-01-08T11:32:45Z'),
profile: { age: 31, department: 'Sales' },
tags: ['sales', 'accounts'],
settings: { theme: 'light', notifications: true }
},
{
_id: '507f1f77bcf86cd799439015',
name: 'Michael Brown',
email: 'michael.brown@example.com',
active: true,
role: 'admin',
last_login: new Date('2023-01-14T08:19:37Z'),
profile: { age: 39, department: 'IT' },
tags: ['developer', 'devops', 'infrastructure'],
settings: { theme: 'dark', notifications: false }
}
]
};
case 'mysql':
return {
columns: ['id', 'product_name', 'category', 'price', 'stock', 'created_at'],
rows: [
{ id: 1, product_name: 'Laptop', category: 'Electronics', price: 999.99, stock: 50, created_at: '2023-01-05 10:00:00' },
{ id: 2, product_name: 'Smartphone', category: 'Electronics', price: 699.99, stock: 100, created_at: '2023-01-06 11:30:00' },
{ id: 3, product_name: 'Headphones', category: 'Audio', price: 149.99, stock: 200, created_at: '2023-01-07 09:15:00' },
{ id: 4, product_name: 'Monitor', category: 'Electronics', price: 349.99, stock: 30, created_at: '2023-01-08 14:45:00' },
{ id: 5, product_name: 'Keyboard', category: 'Accessories', price: 89.99, stock: 150, created_at: '2023-01-09 16:20:00' }
]
};
case 'postgresql':
return {
columns: ['order_id', 'customer_id', 'order_date', 'total_amount', 'status', 'shipping_address'],
rows: [
{ order_id: 10001, customer_id: 5001, order_date: '2023-01-15', total_amount: 245.50, status: 'Shipped', shipping_address: '123 Main St, Anytown, USA' },
{ order_id: 10002, customer_id: 5002, order_date: '2023-01-16', total_amount: 124.99, status: 'Processing', shipping_address: '456 Oak Ave, Somewhere, USA' },
{ order_id: 10003, customer_id: 5003, order_date: '2023-01-16', total_amount: 89.75, status: 'Delivered', shipping_address: '789 Pine Rd, Nowhere, USA' },
{ order_id: 10004, customer_id: 5001, order_date: '2023-01-17', total_amount: 352.25, status: 'Shipped', shipping_address: '123 Main St, Anytown, USA' },
{ order_id: 10005, customer_id: 5004, order_date: '2023-01-18', total_amount: 78.50, status: 'Processing', shipping_address: '321 Elm Dr, Anyplace, USA' }
]
};
default:
return {
columns: ['id', 'name', 'value'],
rows: [
{ id: 1, name: 'Sample 1', value: 'Value 1' },
{ id: 2, name: 'Sample 2', value: 'Value 2' },
{ id: 3, name: 'Sample 3', value: 'Value 3' }
]
};
}
}
/**
* Helper function to install PDFKit module
* @returns {Promise<boolean>} True if installation successful
*/
async function installPdfKitModule() {
const { spawn } = require('child_process');
console.log(chalk.blue('Installing PDFKit...'));
return new Promise((resolve) => {
// Determine which package manager to use
let command = 'npm';
let args = ['install', 'pdfkit', '--save'];
// Check if this is running in a yarn project
if (fs.existsSync('yarn.lock')) {
command = 'yarn';
args = ['add', 'pdfkit'];
}
const installProcess = spawn(command, args, {
stdio: 'inherit',
shell: true
});
installProcess.on('close', (code) => {
if (code === 0) {
console.log(chalk.green('✓ PDFKit installed successfully!'));
console.log(chalk.blue('Please run the command again to use PDF generation.'));
resolve(true);
} else {
console.log(chalk.red(`✗ Failed to install PDFKit (exit code: ${code}).`));
console.log(chalk.yellow('You can install it manually by running:'));
console.log(chalk.yellow(' npm install pdfkit'));
console.log(chalk.yellow(' or'));
console.log(chalk.yellow(' yarn add pdfkit'));
console.log(chalk.yellow('See docs/pdfkit-installation.md for more details.'));
resolve(false);
}
});
});
}
module.exports = { registerSyncDbCommand };