mcp-product-manager
Version:
MCP Orchestrator for task and project management with web interface
479 lines (413 loc) • 14.7 kB
JavaScript
/**
* 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);
});