UNPKG

mcp-product-manager

Version:

MCP Orchestrator for task and project management with web interface

479 lines (413 loc) 14.7 kB
#!/usr/bin/env node /** * Product Manager CLI * Main entry point for the npm package */ import { spawn, execSync } from 'child_process'; import { homedir } from 'os'; import { join, dirname } from 'path'; import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, statSync, copyFileSync, createWriteStream } from 'fs'; import { fileURLToPath } from 'url'; import { createInterface } from 'readline'; // Database compatibility is now handled by the database utilities on-demand const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Configuration const APP_NAME = 'product-manager'; const CONFIG_DIR = join(homedir(), `.${APP_NAME}`); const DB_PATH = join(CONFIG_DIR, `${APP_NAME}.db`); const CONFIG_FILE = join(CONFIG_DIR, 'config.json'); const PID_FILE = join(CONFIG_DIR, 'server.pid'); const LOG_FILE = join(CONFIG_DIR, 'server.log'); // Default configuration const DEFAULT_CONFIG = { port: 1234, database: DB_PATH, autoPrefix: true, skipPrefixForAPI: true, mcp: { enabled: true, stdio: true } }; // Colors for console output const colors = { reset: '\x1b[0m', bright: '\x1b[1m', green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m', blue: '\x1b[34m', cyan: '\x1b[36m' }; function log(message, color = colors.reset) { console.log(`${color}${message}${colors.reset}`); } function error(message) { console.error(`${colors.red}${message}${colors.reset}`); } function success(message) { console.log(`${colors.green}${message}${colors.reset}`); } function info(message) { console.log(`${colors.cyan}ℹ️ ${message}${colors.reset}`); } // Initialize configuration directory async function initConfig(silent = false) { if (!existsSync(CONFIG_DIR)) { mkdirSync(CONFIG_DIR, { recursive: true }); if (!silent) info(`Created configuration directory: ${CONFIG_DIR}`); } if (!existsSync(CONFIG_FILE)) { writeFileSync(CONFIG_FILE, JSON.stringify(DEFAULT_CONFIG, null, 2)); if (!silent) info(`Created configuration file: ${CONFIG_FILE}`); } const config = JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); // Check if database exists (only show messages if not in silent mode) if (!silent) { if (!existsSync(config.database)) { info(`Database will be created at: ${config.database}`); } else { const stats = statSync(config.database); const sizeMB = (stats.size / 1024 / 1024).toFixed(2); success(`Using existing database: ${config.database} (${sizeMB} MB)`); } } return config; } // Check if server is running function isRunning() { if (!existsSync(PID_FILE)) return false; try { const pid = readFileSync(PID_FILE, 'utf8').trim(); process.kill(parseInt(pid), 0); return true; } catch { // Process doesn't exist, clean up PID file try { unlinkSync(PID_FILE); } catch {} return false; } } // Start the server async function startServer(config, overrides = {}) { // Apply runtime overrides (do not persist) const effectiveConfig = { ...config }; if (overrides.database) effectiveConfig.database = overrides.database; if (overrides.port) effectiveConfig.port = overrides.port; if (isRunning()) { error('Server is already running!'); info('Use "product-manager stop" to stop it first'); process.exit(1); } log('\n🚀 Starting Product Manager...', colors.bright); // Optional: preflight port check and kill if requested const desiredPort = overrides.port || config.port; const forcePortKill = overrides.forcePortKill || process.env.PM_FORCE_KILL_PORT === 'true'; try { // Quick probe to see if something is listening const probeUrl = `http://localhost:${desiredPort}/api`; let occupied = false; try { const res = await fetch(probeUrl); if (res.ok) occupied = true; } catch { // not responding; might still be occupied by non-HTTP; fall through to lsof check below } // If not clearly HTTP, try lsof to detect listeners (best-effort, mac/linux) if (!occupied) { try { const out = execSync(`lsof -ti:${desiredPort} || true`, { stdio: ['ignore','pipe','ignore'] }).toString().trim(); if (out) occupied = true; } catch {} } if (occupied) { if (forcePortKill) { info(`Port ${desiredPort} is in use; attempting to free it...`); try { execSync(`lsof -ti:${desiredPort} | xargs kill -9 2>/dev/null || true`, { stdio: 'ignore' }); } catch {} } else { error(`Port ${desiredPort} is already in use.`); info('Use --port <num> to choose a different port, or --force-port to kill whatever is on the port.'); process.exit(1); } } } catch {} // Set environment variables process.env.DATABASE_PATH = effectiveConfig.database; process.env.PORT = effectiveConfig.port; process.env.AUTO_PREFIX_TASKS = effectiveConfig.autoPrefix ? 'true' : 'false'; process.env.SKIP_PREFIX_FOR_API = effectiveConfig.skipPrefixForAPI ? 'true' : 'false'; // Auto-disable usage tracking on Node < 20 to avoid ccusage engine errors try { const ver = process.versions.node || process.version.replace(/^v/, ''); const major = parseInt(ver.split('.')[0], 10); if (Number.isFinite(major) && major < 20 && process.env.USAGE_TRACKING_DISABLED == null) { process.env.USAGE_TRACKING_DISABLED = 'true'; } } catch {} // Start the REST server const serverPath = join(__dirname, '..', 'rest-server.js'); // Use shell to redirect output to log file const command = process.platform === 'win32' ? 'cmd' : 'sh'; const args = process.platform === 'win32' ? ['/c', `node "${serverPath}" >> "${LOG_FILE}" 2>&1`] : ['-c', `node "${serverPath}" >> "${LOG_FILE}" 2>&1`]; const server = spawn(command, args, { detached: true, stdio: 'ignore', env: { ...process.env } }); // Save PID writeFileSync(PID_FILE, server.pid.toString()); // Unref the server immediately so parent can exit server.unref(); setTimeout(() => { (async () => { if (isRunning()) { // Try a quick readiness check const base = `http://localhost:${process.env.PORT}`; let ready = false; try { const res = await fetch(`${base}/api`); ready = res.ok; } catch {} success('Server started successfully!'); info(`URL: ${base}`); info(`Database: ${process.env.DATABASE_PATH}`); info(`Logs: ${LOG_FILE}`); if (!ready) { console.log('Note: API endpoint not reachable yet; it should be ready shortly.'); } } else { error('Server failed to start. Check logs: ' + LOG_FILE); } process.exit(0); })(); }, 5000); } // Stop the server function stopServer() { if (!isRunning()) { error('Server is not running'); process.exit(1); } try { const pid = readFileSync(PID_FILE, 'utf8').trim(); process.kill(parseInt(pid), 'SIGTERM'); unlinkSync(PID_FILE); success('Server stopped successfully'); } catch (err) { error(`Failed to stop server: ${err.message}`); process.exit(1); } } // Show server status async function showStatus() { const config = await initConfig(); log('\n📊 Product Manager Status', colors.bright); console.log('─'.repeat(40)); if (isRunning()) { const pid = readFileSync(PID_FILE, 'utf8').trim(); success(`Server: Running (PID: ${pid})`); info(`URL: http://localhost:${config.port}`); info(`Database: ${config.database}`); // Check database size if (existsSync(config.database)) { const stats = statSync(config.database); const size = (stats.size / 1024 / 1024).toFixed(2); info(`Database size: ${size} MB`); } } else { error('Server: Not running'); } console.log('\n📁 Configuration:'); console.log(` Config: ${CONFIG_FILE}`); console.log(` Logs: ${LOG_FILE}`); console.log(` Database: ${DB_PATH}`); } // Start MCP server mode async function startMCP(overrides = {}) { const config = await initConfig(true); // Silent mode for MCP const effectiveDb = overrides.database || config.database; const effectivePort = overrides.port || config.port; process.env.DATABASE_PATH = effectiveDb; // Helper: wait for REST readiness async function waitForRestReady(baseUrl, attempts = 10, delayMs = 500) { for (let i = 0; i < attempts; i++) { try { const res = await fetch(`${baseUrl}/api`, { method: 'GET' }); if (res.ok) return true; } catch {} await new Promise(r => setTimeout(r, delayMs)); } return false; } const restUrl = `http://localhost:${effectivePort}`; // Auto-disable usage tracking on Node < 20 when launching MCP try { const ver = process.versions.node || process.version.replace(/^v/, ''); const major = parseInt(ver.split('.')[0], 10); if (Number.isFinite(major) && major < 20 && process.env.USAGE_TRACKING_DISABLED == null) { process.env.USAGE_TRACKING_DISABLED = 'true'; } } catch {} let restReady = false; try { restReady = await waitForRestReady(restUrl, 3, 300); } catch {} // If REST not ready, start it in the background if (!restReady) { info(`REST not detected on ${restUrl}. Starting REST server...`); const serverPath = join(__dirname, '..', 'rest-server.js'); const child = spawn('node', [serverPath], { detached: true, stdio: 'ignore', env: { ...process.env, PORT: String(effectivePort), DATABASE_PATH: effectiveDb } }); child.unref(); // Wait for readiness restReady = await waitForRestReady(restUrl, 20, 500); if (!restReady) { error(`Failed to start REST server on ${restUrl}.`); console.log('Tip: run "product-manager start" to boot the REST API, then retry "product-manager mcp".'); process.exit(1); } } success(`REST API detected at ${restUrl}. Launching MCP server...`); // Start the MCP server in foreground const mcpPath = join(__dirname, '..', 'mcp-server.js'); spawn('node', [mcpPath], { stdio: 'inherit', env: { ...process.env } }); } // Reset database async function resetDatabase() { const config = await initConfig(); if (isRunning()) { error('Cannot reset database while server is running'); info('Stop the server first: product-manager stop'); process.exit(1); } if (!existsSync(config.database)) { error('Database does not exist'); process.exit(1); } // SAFETY CHECK for production database const stats = statSync(config.database); const sizeMB = (stats.size / 1024 / 1024).toFixed(2); console.log(`\n⚠️ WARNING: About to reset database`); console.log(` Database: ${config.database}`); console.log(` Size: ${sizeMB} MB`); console.log(`\nThis action will:`); console.log(` 1. Create a backup at: ${config.database}.backup.${Date.now()}`); console.log(` 2. DELETE the current database`); console.log(`\nAre you ABSOLUTELY sure? Type 'yes' to confirm: `); const readline = createInterface({ input: process.stdin, output: process.stdout }); readline.question('', (answer) => { readline.close(); if (answer.toLowerCase() !== 'yes') { info('Database reset cancelled'); process.exit(0); } // Create backup const backupPath = `${config.database}.backup.${Date.now()}`; copyFileSync(config.database, backupPath); success(`Backup created: ${backupPath}`); // Remove database unlinkSync(config.database); success('Database reset successfully'); info('Start the server to create a new database'); }); } // Main CLI async function main() { // Database compatibility will be handled on-demand by database utilities const args = process.argv.slice(2); const command = args[0]; const rest = args.slice(1); // Simple options parser const opts = { }; for (let i = 0; i < rest.length; i++) { const a = rest[i]; if (a === '--db' || a === '--database' || a === '--db-path') { opts.database = rest[i + 1]; i++; } else if (a === '--cwd-db') { opts.database = join(process.cwd(), 'product-manager.db'); } else if (a === '--port' || a === '-p') { opts.port = rest[i + 1]; i++; } else if (a === '--force-port' || a === '--kill-port' || a === '--force') { opts.forcePortKill = true; } } switch (command) { case 'start': const config = await initConfig(); if (opts.database) info(`Using database override: ${opts.database}`); if (opts.port) info(`Using port override: ${opts.port}`); await startServer(config, opts); break; case 'stop': stopServer(); break; case 'restart': if (isRunning()) { stopServer(); setTimeout(async () => { const cfg = await initConfig(); await startServer(cfg); }, 1000); } else { const cfg = await initConfig(); await startServer(cfg); } break; case 'status': await showStatus(); break; case 'mcp': if (opts.database) info(`Using database override: ${opts.database}`); if (opts.port) info(`Using port override: ${opts.port}`); await startMCP(opts); break; case 'reset': await resetDatabase(); break; case 'config': const conf = await initConfig(); console.log(JSON.stringify(conf, null, 2)); break; default: log('\n🔧 Product Manager CLI', colors.bright); console.log('─'.repeat(40)); console.log('\nUsage: product-manager <command>\n'); console.log('Commands:'); console.log(' start - Start the server'); console.log(' Options: --db <path> | --cwd-db | --port <num> | --force-port'); console.log(' stop - Stop the server'); console.log(' restart - Restart the server'); console.log(' status - Show server status'); console.log(' mcp - Start in MCP mode (for Claude Desktop)'); console.log(' Options: --db <path> | --cwd-db | --port <num>'); console.log(' reset - Reset the database'); console.log(' config - Show configuration'); console.log('\nQuick start:'); console.log(' npx mcp-product-manager start'); console.log('\nConfiguration file:'); console.log(` ${CONFIG_FILE}`); } } // Handle errors process.on('uncaughtException', (err) => { error(`Unexpected error: ${err.message}`); process.exit(1); }); main().catch(err => { error(err.message); process.exit(1); });