UNPKG

dev3000

Version:

AI-powered development tools with browser monitoring and MCP server integration

1,071 lines 97.8 kB
import chalk from "chalk"; import { spawn } from "child_process"; import { appendFileSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync } from "fs"; import ora from "ora"; import { homedir, tmpdir } from "os"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; import { CDPMonitor } from "./cdp-monitor.js"; import { ScreencastManager } from "./screencast-manager.js"; import { NextJsErrorDetector, OutputProcessor, StandardLogParser } from "./services/parsers/index.js"; import { DevTUI } from "./tui-interface.js"; import { getProjectDisplayName, getProjectName } from "./utils/project-name.js"; import { formatTimestamp } from "./utils/timestamp.js"; // MCP names const MCP_NAMES = { DEV3000: "dev3000", CHROME_DEVTOOLS: "dev3000-chrome-devtools", NEXTJS_DEV: "dev3000-nextjs-dev" }; class Logger { logFile; tail; dateTimeFormat; constructor(logFile, tail = false, dateTimeFormat = "local") { this.logFile = logFile; this.tail = tail; this.dateTimeFormat = dateTimeFormat; // Ensure directory exists const logDir = dirname(logFile); if (!existsSync(logDir)) { mkdirSync(logDir, { recursive: true }); } // Clear log file writeFileSync(this.logFile, ""); } log(source, message) { const timestamp = formatTimestamp(new Date(), this.dateTimeFormat); const logEntry = `[${timestamp}] [${source.toUpperCase()}] ${message}\n`; appendFileSync(this.logFile, logEntry); // If tail is enabled, also output to console if (this.tail) { process.stdout.write(logEntry); } } } function detectPackageManagerForRun() { if (existsSync("pnpm-lock.yaml")) return "pnpm"; if (existsSync("yarn.lock")) return "yarn"; if (existsSync("package-lock.json")) return "npm"; return "npm"; // fallback } async function isPortAvailable(port) { try { const result = await new Promise((resolve) => { const proc = spawn("lsof", ["-ti", `:${port}`], { stdio: "pipe" }); let output = ""; proc.stdout?.on("data", (data) => { output += data.toString(); }); proc.on("exit", () => resolve(output.trim())); }); return !result; // If no output, port is available } catch { return true; // Assume port is available if check fails } } async function findAvailablePort(startPort) { let port = startPort; while (port < 65535) { if (await isPortAvailable(port.toString())) { return port.toString(); } port++; } throw new Error(`No available ports found starting from ${startPort}`); } /** * Check if Next.js MCP server is enabled in the project configuration */ async function isNextjsMcpEnabled() { try { const configFiles = ["next.config.js", "next.config.ts", "next.config.mjs", "next.config.cjs"]; for (const configFile of configFiles) { if (existsSync(configFile)) { try { // Read the config file content const configContent = readFileSync(configFile, "utf8"); // Look for experimental.mcpServer: true or experimental.mcpServer = true // This is a simple string-based check that should work for most cases const hasMcpServerConfig = /experimental\s*:\s*{[^}]*mcpServer\s*:\s*true|experimental\.mcpServer\s*[=:]\s*true/s.test(configContent); if (hasMcpServerConfig) { return true; } } catch { // If we can't read the file, continue checking other config files } } } return false; } catch { return false; } } /** * Check if Chrome version supports chrome-devtools MCP (>= 140.0.7339.214) */ async function isChromeDevtoolsMcpSupported() { try { // Try different Chrome binary paths const chromePaths = [ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", // macOS "/opt/google/chrome/chrome", // Linux "chrome", // PATH "google-chrome", // Linux PATH "google-chrome-stable" // Linux PATH ]; for (const chromePath of chromePaths) { try { const versionOutput = await new Promise((resolve, reject) => { const chromeProcess = spawn(chromePath, ["--version"], { stdio: ["ignore", "pipe", "ignore"] }); let output = ""; chromeProcess.stdout?.on("data", (data) => { output += data.toString(); }); chromeProcess.on("close", (code) => { if (code === 0) { resolve(output.trim()); } else { reject(new Error(`Chrome version check failed with code ${code}`)); } }); chromeProcess.on("error", reject); // Timeout after 3 seconds setTimeout(() => { chromeProcess.kill(); reject(new Error("Chrome version check timeout")); }, 3000); }); // Parse version from output like "Google Chrome 140.0.7339.214" const versionMatch = versionOutput.match(/(\d+)\.(\d+)\.(\d+)\.(\d+)/); if (versionMatch) { const [, major, minor, build, patch] = versionMatch.map(Number); const currentVersion = [major, minor, build, patch]; const requiredVersion = [140, 0, 7339, 214]; // Compare version numbers for (let i = 0; i < 4; i++) { if (currentVersion[i] > requiredVersion[i]) return true; if (currentVersion[i] < requiredVersion[i]) return false; } return true; // Versions are equal } break; // Found Chrome but couldn't parse version - continue with other paths } catch { // Try next Chrome path } } return false; // Chrome not found or version not supported } catch { return false; // Any error means not supported } } /** * Ensure MCP server configurations are added to project's .mcp.json (Claude Code) */ async function ensureMcpServers(mcpPort, _appPort, _enableChromeDevtools, _enableNextjsMcp) { try { const settingsPath = join(process.cwd(), ".mcp.json"); // Read or create settings let settings; if (existsSync(settingsPath)) { const settingsContent = readFileSync(settingsPath, "utf-8"); settings = JSON.parse(settingsContent); } else { settings = {}; } // Ensure mcpServers structure exists if (!settings.mcpServers) { settings.mcpServers = {}; } let added = false; // Add dev3000 MCP server (HTTP type) // NOTE: dev3000 now acts as an MCP orchestrator/gateway that internally // connects to chrome-devtools and nextjs-dev MCPs, so users only need to // configure dev3000 once! if (!settings.mcpServers[MCP_NAMES.DEV3000]) { settings.mcpServers[MCP_NAMES.DEV3000] = { type: "http", url: `http://localhost:${mcpPort}/mcp` }; added = true; } // REMOVED: No longer auto-configure chrome-devtools and nextjs-dev // dev3000 MCP server now orchestrates these internally via the gateway pattern // Write if we added anything if (added) { writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8"); } } catch (_error) { // Ignore errors - settings file manipulation is optional } } /** * Ensure MCP server configurations are added to project's .cursor/mcp.json */ async function ensureCursorMcpServers(mcpPort, _appPort, _enableChromeDevtools, _enableNextjsMcp) { try { const cursorDir = join(process.cwd(), ".cursor"); const settingsPath = join(cursorDir, "mcp.json"); // Ensure .cursor directory exists if (!existsSync(cursorDir)) { mkdirSync(cursorDir, { recursive: true }); } // Read or create settings let settings; if (existsSync(settingsPath)) { const settingsContent = readFileSync(settingsPath, "utf-8"); settings = JSON.parse(settingsContent); } else { settings = {}; } // Ensure mcpServers structure exists if (!settings.mcpServers) { settings.mcpServers = {}; } let added = false; // Add dev3000 MCP server // NOTE: dev3000 now acts as an MCP orchestrator/gateway that internally // connects to chrome-devtools and nextjs-dev MCPs if (!settings.mcpServers[MCP_NAMES.DEV3000]) { settings.mcpServers[MCP_NAMES.DEV3000] = { type: "http", url: `http://localhost:${mcpPort}/mcp` }; added = true; } // REMOVED: No longer auto-configure chrome-devtools and nextjs-dev // dev3000 MCP server now orchestrates these internally via the gateway pattern // Write if we added anything if (added) { writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8"); } } catch (_error) { // Ignore errors - settings file manipulation is optional } } /** * Ensure MCP server configurations are added to project's .opencode.json * OpenCode uses a different structure: "mcp" instead of "mcpServers" and "type": "local" for stdio servers */ async function ensureOpenCodeMcpServers(mcpPort, _appPort, _enableChromeDevtools, _enableNextjsMcp) { try { const settingsPath = join(process.cwd(), ".opencode.json"); // Read or create settings - OpenCode uses "mcp" not "mcpServers" let settings; if (existsSync(settingsPath)) { const settingsContent = readFileSync(settingsPath, "utf-8"); settings = JSON.parse(settingsContent); } else { settings = {}; } // Ensure mcp structure exists if (!settings.mcp) { settings.mcp = {}; } let added = false; // Add dev3000 MCP server - use npx with mcp-client to connect to HTTP server // NOTE: dev3000 now acts as an MCP orchestrator/gateway that internally // connects to chrome-devtools and nextjs-dev MCPs if (!settings.mcp[MCP_NAMES.DEV3000]) { settings.mcp[MCP_NAMES.DEV3000] = { type: "local", command: ["npx", "@modelcontextprotocol/inspector", `http://localhost:${mcpPort}/mcp`], enabled: true }; added = true; } // REMOVED: No longer auto-configure chrome-devtools and nextjs-dev // dev3000 MCP server now orchestrates these internally via the gateway pattern // Write if we added anything if (added) { writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8"); } } catch (_error) { // Ignore errors - settings file manipulation is optional } } // REMOVED: cleanup functions are no longer needed // MCP config files are now kept persistent across dev3000 restarts export function createPersistentLogFile() { // Get unique project name const projectName = getProjectName(); // Use ~/.d3k/logs directory for persistent, accessible logs const logBaseDir = join(homedir(), ".d3k", "logs"); try { if (!existsSync(logBaseDir)) { mkdirSync(logBaseDir, { recursive: true }); } return createLogFileInDir(logBaseDir, projectName); } catch (_error) { // Fallback to user's temp directory if ~/.d3k is not writable const fallbackDir = join(tmpdir(), "dev3000-logs"); if (!existsSync(fallbackDir)) { mkdirSync(fallbackDir, { recursive: true }); } return createLogFileInDir(fallbackDir, projectName); } } // Write session info for MCP server to discover function writeSessionInfo(projectName, logFilePath, appPort, mcpPort, cdpUrl, chromePids, serverCommand) { const sessionDir = join(homedir(), ".d3k"); try { // Create ~/.d3k directory if it doesn't exist if (!existsSync(sessionDir)) { mkdirSync(sessionDir, { recursive: true }); } // Session file contains project info const sessionInfo = { projectName, logFilePath, appPort, mcpPort: mcpPort || null, cdpUrl: cdpUrl || null, startTime: new Date().toISOString(), pid: process.pid, cwd: process.cwd(), chromePids: chromePids || [], serverCommand: serverCommand || null }; // Write session file - use project name as filename for easy lookup const sessionFile = join(sessionDir, `${projectName}.json`); writeFileSync(sessionFile, JSON.stringify(sessionInfo, null, 2)); } catch (error) { // Non-fatal - just log a warning console.warn(chalk.yellow(`⚠️ Could not write session info: ${error}`)); } } // Get Chrome PIDs for this instance function getSessionChromePids(projectName) { const sessionDir = join(homedir(), ".d3k"); const sessionFile = join(sessionDir, `${projectName}.json`); try { if (existsSync(sessionFile)) { const sessionInfo = JSON.parse(readFileSync(sessionFile, "utf8")); return sessionInfo.chromePids || []; } } catch (_error) { // Non-fatal - return empty array } return []; } function createLogFileInDir(baseDir, projectName) { // Create timestamp const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); // Create log file path const logFileName = `${projectName}-${timestamp}.log`; const logFilePath = join(baseDir, logFileName); // Prune old logs for this project (keep only 10 most recent) pruneOldLogs(baseDir, projectName); // Create the log file writeFileSync(logFilePath, ""); return logFilePath; } function pruneOldLogs(baseDir, projectName) { try { // Find all log files for this project const files = readdirSync(baseDir) .filter((file) => file.startsWith(`${projectName}-`) && file.endsWith(".log")) .map((file) => ({ name: file, path: join(baseDir, file), mtime: statSync(join(baseDir, file)).mtime })) .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); // Most recent first // Keep only the 10 most recent, delete the rest if (files.length >= 10) { const filesToDelete = files.slice(9); // Keep first 9, delete the rest for (const file of filesToDelete) { try { unlinkSync(file.path); } catch (_error) { // Silently ignore deletion errors } } } } catch (error) { console.warn(chalk.yellow(`⚠️ Could not prune logs: ${error}`)); } } export class DevEnvironment { serverProcess = null; mcpServerProcess = null; cdpMonitor = null; screencastManager = null; logger; outputProcessor; options; screenshotDir; mcpPublicDir; pidFile; lockFile; spinner; version; isShuttingDown = false; serverStartTime = null; healthCheckTimer = null; tui = null; portChangeMessage = null; firstSigintTime = null; enableNextjsMcp = false; chromeDevtoolsSupported = false; constructor(options) { // Handle portMcp vs mcpPort naming this.options = { ...options, mcpPort: options.portMcp || options.mcpPort || "3684" }; this.logger = new Logger(options.logFile, options.tail || false, options.dateTimeFormat || "local"); this.outputProcessor = new OutputProcessor(new StandardLogParser(), new NextJsErrorDetector()); // Set up MCP server public directory for web-accessible screenshots const currentFile = fileURLToPath(import.meta.url); const packageRoot = dirname(dirname(currentFile)); // Always use MCP server's public directory for screenshots to ensure they're web-accessible // and avoid permission issues with /var/log paths this.screenshotDir = join(packageRoot, "mcp-server", "public", "screenshots"); // Use project-specific PID and lock files to allow multiple projects to run simultaneously const projectName = getProjectName(); this.pidFile = join(tmpdir(), `dev3000-${projectName}.pid`); this.lockFile = join(tmpdir(), `dev3000-${projectName}.lock`); this.mcpPublicDir = join(packageRoot, "mcp-server", "public", "screenshots"); // Read version from package.json for startup message this.version = "0.0.0"; try { const packageJsonPath = join(packageRoot, "package.json"); const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); this.version = packageJson.version; // Use git to detect if we're in the dev3000 source repository try { const { execSync } = require("child_process"); const gitRemote = execSync("git remote get-url origin 2>/dev/null", { cwd: packageRoot, encoding: "utf8" }).trim(); if (gitRemote.includes("vercel-labs/dev3000") && !this.version.includes("canary")) { this.version += "-local"; } } catch { // Not in git repo or no git - use version as-is } } catch (_error) { // Use fallback version } // Initialize spinner for clean output management (only if not in TUI mode) this.spinner = ora({ text: "Initializing...", spinner: "dots", isEnabled: !options.tui // Disable spinner in TUI mode }); // Ensure directories exist if (!existsSync(this.screenshotDir)) { mkdirSync(this.screenshotDir, { recursive: true }); } if (!existsSync(this.mcpPublicDir)) { mkdirSync(this.mcpPublicDir, { recursive: true }); } // Initialize project-specific D3K log file (clear for new session) this.initializeD3KLog(); } async checkPortsAvailable(silent = false) { // Always kill any existing MCP server to ensure clean state if (this.options.mcpPort) { const isPortInUse = !(await isPortAvailable(this.options.mcpPort.toString())); if (isPortInUse) { this.debugLog(`Killing existing process on port ${this.options.mcpPort}`); await this.killMcpServer(); } } // Check if user explicitly set ports via CLI flags const userSetAppPort = this.options.userSetPort || false; // If user didn't set ports, find available ones first (before checking) if (!userSetAppPort) { const startPort = parseInt(this.options.port, 10); const availablePort = await findAvailablePort(startPort); if (availablePort !== this.options.port) { if (!silent) { console.log(chalk.yellow(`Port ${this.options.port} is in use, using port ${availablePort} for app server`)); } // Store message for TUI display this.portChangeMessage = `Port ${this.options.port} is in use, using port ${availablePort} for app server`; this.options.port = availablePort; } } // If user set explicit app port, fail if it's not available if (userSetAppPort) { const available = await isPortAvailable(this.options.port); if (!available) { if (this.spinner?.isSpinning) { this.spinner.fail(`Port ${this.options.port} is already in use`); } if (!silent) { console.log(chalk.yellow(`💡 To free up port ${this.options.port}, run: lsof -ti:${this.options.port} | xargs kill -9`)); } if (this.tui) { await this.tui.shutdown(); } throw new Error(`Port ${this.options.port} is already in use. Please free the port and try again.`); } } // Now check MCP port availability (it should be free after killing) if (this.options.mcpPort) { const available = await isPortAvailable(this.options.mcpPort); if (!available) { if (this.spinner?.isSpinning) { this.spinner.fail(`Port ${this.options.mcpPort} is still in use after cleanup`); } if (!silent) { console.log(chalk.yellow(`💡 To force kill port ${this.options.mcpPort}, run: lsof -ti:${this.options.mcpPort} | xargs kill -9`)); } if (this.tui) { await this.tui.shutdown(); } throw new Error(`Port ${this.options.mcpPort} is still in use. Please free the port and try again.`); } } } async killMcpServer() { try { // First, get the PIDs const getPidsProcess = spawn("lsof", ["-ti", `:${this.options.mcpPort}`], { stdio: "pipe" }); const pids = await new Promise((resolve) => { let output = ""; getPidsProcess.stdout?.on("data", (data) => { output += data.toString(); }); getPidsProcess.on("exit", () => resolve(output.trim())); }); if (pids) { this.debugLog(`Found MCP server processes: ${pids}`); // Kill each PID individually with kill -9 const pidList = pids.split("\n").filter(Boolean); for (const pid of pidList) { await new Promise((resolve) => { const killCmd = spawn("kill", ["-9", pid.trim()], { stdio: "ignore" }); killCmd.on("exit", (code) => { this.debugLog(`Kill command for PID ${pid} exited with code ${code}`); resolve(); }); }); } // Give it time to fully release the port this.debugLog(`Waiting for port ${this.options.mcpPort} to be released...`); await new Promise((resolve) => setTimeout(resolve, 1000)); } } catch (error) { this.debugLog(`Error killing MCP server: ${error}`); } } async checkProcessHealth() { if (this.isShuttingDown) return true; // Skip health check if already shutting down try { const ports = [this.options.port, this.options.mcpPort]; for (const port of ports) { const result = await new Promise((resolve) => { const proc = spawn("lsof", ["-ti", `:${port}`], { stdio: "pipe" }); let output = ""; proc.stdout?.on("data", (data) => { output += data.toString(); }); proc.on("exit", () => resolve(output.trim())); }); if (!result) { this.debugLog(`Health check failed: Port ${port} is no longer in use`); this.logger.log("server", `Health check failed: Critical process on port ${port} is no longer running`); return false; } } this.debugLog("Health check passed: All critical processes are running"); return true; } catch (error) { this.debugLog(`Health check error: ${error}`); // Treat errors as non-fatal - network issues shouldn't kill the process return true; } } startHealthCheck() { // Start health checks every 10 seconds this.healthCheckTimer = setInterval(async () => { const isHealthy = await this.checkProcessHealth(); if (!isHealthy) { console.log(chalk.yellow("⚠️ Critical processes no longer detected. Shutting down gracefully...")); this.gracefulShutdown(); } }, 10000); // 10 seconds this.debugLog("Health check timer started (10 second intervals)"); } stopHealthCheck() { if (this.healthCheckTimer) { clearInterval(this.healthCheckTimer); this.healthCheckTimer = null; this.debugLog("Health check timer stopped"); } } async start() { // Check if another instance is already running for this project if (!this.acquireLock()) { console.error(chalk.red(`\n❌ Another dev3000 instance is already running for this project.`)); console.error(chalk.yellow(` If you're sure no other instance is running, remove: ${this.lockFile}`)); process.exit(1); } // Check if TUI mode is enabled (default) and stdin supports it const canUseTUI = this.options.tui && process.stdin.isTTY; if (!canUseTUI && this.options.tui) { this.debugLog("TTY not available, falling back to non-TUI mode"); } if (canUseTUI) { // Clear console and start TUI immediately for fast render console.clear(); // Get unique project name const projectName = getProjectName(); const projectDisplayName = getProjectDisplayName(); // Start TUI interface with initial status and updated port this.tui = new DevTUI({ appPort: this.options.port, // This may have been updated by checkPortsAvailable mcpPort: this.options.mcpPort || "3684", logFile: this.options.logFile, commandName: this.options.commandName, serversOnly: this.options.serversOnly, version: this.version, projectName: projectDisplayName }); await this.tui.start(); // Give TUI a moment to fully initialize await new Promise((resolve) => setTimeout(resolve, 50)); // Check ports in background after TUI is visible await this.tui.updateStatus("Checking ports..."); await this.checkPortsAvailable(true); // silent mode for TUI // Update the app port in TUI (may have changed during port check) this.tui.updateAppPort(this.options.port); // Show port change message if needed if (this.portChangeMessage) { await this.tui.updateStatus(this.portChangeMessage); // Clear the message after a moment setTimeout(async () => { if (this.tui) { await this.tui.updateStatus("Setting up environment..."); } }, 2000); } else { await this.tui.updateStatus("Setting up environment..."); } // Write our process group ID to PID file for cleanup writeFileSync(this.pidFile, process.pid.toString()); // Setup cleanup handlers BEFORE starting TUI to ensure they work this.setupCleanupHandlers(); // Start user's dev server await this.tui.updateStatus("Starting your dev server..."); await this.startServer(); // Start MCP server await this.tui.updateStatus(`Starting ${this.options.commandName} MCP server...`); await this.startMcpServer(); // Wait for servers to be ready await this.tui.updateStatus("Waiting for your app server..."); const serverStarted = await this.waitForServer(); if (!serverStarted) { await this.tui.updateStatus("❌ Server failed to start"); console.error(chalk.red("\n❌ Your app server failed to start after 30 seconds.")); console.error(chalk.yellow(`Check the logs at ~/.d3k/logs/${getProjectName()}-d3k.log for errors.`)); console.error(chalk.yellow("Exiting without launching browser.")); process.exit(1); } await this.tui.updateStatus(`Waiting for ${this.options.commandName} MCP server...`); await this.waitForMcpServer(); // Progressive MCP discovery - after dev server is ready await this.tui.updateStatus("Discovering available MCPs..."); await this.discoverMcpsAfterServerStart(); // Configure AI CLI integrations (both dev3000 and chrome-devtools MCPs) if (!this.options.serversOnly) { await this.tui.updateStatus("Configuring AI CLI integrations..."); // Check if NextJS MCP is enabled and store the result this.enableNextjsMcp = await isNextjsMcpEnabled(); // Check if Chrome version supports chrome-devtools MCP if (this.options.chromeDevtoolsMcp !== false) { this.chromeDevtoolsSupported = await isChromeDevtoolsMcpSupported(); if (!this.chromeDevtoolsSupported) { this.logD3K("Chrome version < 140.0.7339.214 detected - chrome-devtools MCP will be skipped"); } } // Ensure MCP server configurations in project settings files (instant, local) await ensureMcpServers(this.options.mcpPort || "3684", this.options.port, this.chromeDevtoolsSupported, this.enableNextjsMcp); await ensureCursorMcpServers(this.options.mcpPort || "3684", this.options.port, this.chromeDevtoolsSupported, this.enableNextjsMcp); await ensureOpenCodeMcpServers(this.options.mcpPort || "3684", this.options.port, this.chromeDevtoolsSupported, this.enableNextjsMcp); this.logD3K(`AI CLI Integration: Configured MCP servers in .mcp.json, .cursor/mcp.json, and .opencode.json`); } // Start CDP monitoring only if server started successfully and not in servers-only mode if (!this.options.serversOnly && serverStarted) { await this.tui.updateStatus(`Starting ${this.options.commandName} browser...`); await this.startCDPMonitoringSync(); // Progressive MCP discovery - after browser is ready await this.tui.updateStatus("Final MCP discovery scan..."); await this.discoverMcpsAfterBrowserStart(); } else if (!this.options.serversOnly) { this.debugLog("Browser monitoring skipped - server failed to start"); } else { this.debugLog("Browser monitoring disabled via --servers-only flag"); } // Write session info for MCP server discovery (include CDP URL if browser monitoring was started) const cdpUrl = this.cdpMonitor?.getCdpUrl() || null; const chromePids = this.cdpMonitor?.getChromePids() || []; writeSessionInfo(projectName, this.options.logFile, this.options.port, this.options.mcpPort, cdpUrl, chromePids, this.options.serverCommand); // Clear status - ready! await this.tui.updateStatus(null); } else { // Non-TUI mode - original flow console.log(chalk.hex("#A18CE5")(`Starting ${this.options.commandName} (v${this.version})`)); // Start spinner this.spinner.start("Checking ports..."); // Check if ports are available first await this.checkPortsAvailable(false); // normal mode with console output this.spinner.text = "Setting up environment..."; // Write our process group ID to PID file for cleanup writeFileSync(this.pidFile, process.pid.toString()); // Setup cleanup handlers this.setupCleanupHandlers(); // Start user's dev server this.spinner.text = "Starting your dev server..."; await this.startServer(); // Start MCP server this.spinner.text = `Starting ${this.options.commandName} MCP server...`; await this.startMcpServer(); // Wait for servers to be ready this.spinner.text = "Waiting for your app server..."; const serverStarted = await this.waitForServer(); if (!serverStarted) { this.spinner.fail("Server failed to start"); console.error(chalk.red("\n❌ Your app server failed to start after 30 seconds.")); console.error(chalk.yellow(`Check the logs at ~/.d3k/logs/${getProjectName()}-d3k.log for errors.`)); console.error(chalk.yellow("Exiting without launching browser.")); process.exit(1); } this.spinner.text = `Waiting for ${this.options.commandName} MCP server...`; await this.waitForMcpServer(); // Progressive MCP discovery - after dev server is ready this.spinner.text = "Discovering available MCPs..."; await this.discoverMcpsAfterServerStart(); // Configure AI CLI integrations (both dev3000 and chrome-devtools MCPs) if (!this.options.serversOnly) { this.spinner.text = "Configuring AI CLI integrations..."; // Check if NextJS MCP is enabled and store the result this.enableNextjsMcp = await isNextjsMcpEnabled(); // Check if Chrome version supports chrome-devtools MCP if (this.options.chromeDevtoolsMcp !== false) { this.chromeDevtoolsSupported = await isChromeDevtoolsMcpSupported(); if (!this.chromeDevtoolsSupported) { this.logD3K("Chrome version < 140.0.7339.214 detected - chrome-devtools MCP will be skipped"); } } // Ensure MCP server configurations in project settings files (instant, local) await ensureMcpServers(this.options.mcpPort || "3684", this.options.port, this.chromeDevtoolsSupported, this.enableNextjsMcp); await ensureCursorMcpServers(this.options.mcpPort || "3684", this.options.port, this.chromeDevtoolsSupported, this.enableNextjsMcp); await ensureOpenCodeMcpServers(this.options.mcpPort || "3684", this.options.port, this.chromeDevtoolsSupported, this.enableNextjsMcp); this.logD3K(`AI CLI Integration: Configured MCP servers in .mcp.json, .cursor/mcp.json, and .opencode.json`); } // Start CDP monitoring only if server started successfully and not in servers-only mode if (!this.options.serversOnly && serverStarted) { this.spinner.text = `Starting ${this.options.commandName} browser...`; await this.startCDPMonitoringSync(); // Progressive MCP discovery - after browser is ready this.spinner.text = "Final MCP discovery scan..."; await this.discoverMcpsAfterBrowserStart(); } else if (!this.options.serversOnly) { this.debugLog("Browser monitoring skipped - server failed to start"); } else { this.debugLog("Browser monitoring disabled via --servers-only flag"); } // Get project name for session info and Visual Timeline URL const projectName = getProjectName(); // Include CDP URL if browser monitoring was started const cdpUrl = this.cdpMonitor?.getCdpUrl() || null; const chromePids = this.cdpMonitor?.getChromePids() || []; writeSessionInfo(projectName, this.options.logFile, this.options.port, this.options.mcpPort, cdpUrl, chromePids, this.options.serverCommand); // Complete startup with success message only in non-TUI mode this.spinner.succeed("Development environment ready!"); // Regular console output (when TUI is disabled with --no-tui) console.log(chalk.cyan(`Logs: ${this.options.logFile}`)); console.log(chalk.cyan("☝️ Give this to an AI to auto debug and fix your app\n")); console.log(chalk.cyan(`🌐 Your App: http://localhost:${this.options.port}`)); console.log(chalk.cyan(`🤖 MCP Server: http://localhost:${this.options.mcpPort}`)); console.log(chalk.cyan(`📸 Visual Timeline: http://localhost:${this.options.mcpPort}/logs?project=${encodeURIComponent(projectName)}`)); if (this.options.serversOnly) { console.log(chalk.cyan("🖥️ Servers-only mode - use Chrome extension for browser monitoring")); } console.log(chalk.cyan("\nUse Ctrl-C to stop.\n")); } // Start health monitoring after everything is ready this.startHealthCheck(); } async startServer() { this.debugLog(`Starting server process: ${this.options.serverCommand}`); this.serverStartTime = Date.now(); // Use the full command string with shell: true to properly handle complex commands this.serverProcess = spawn(this.options.serverCommand, { stdio: ["ignore", "pipe", "pipe"], shell: true, detached: true // Run independently }); this.debugLog(`Server process spawned with PID: ${this.serverProcess.pid}`); // Log server output (to file only, reduce stdout noise) this.serverProcess.stdout?.on("data", (data) => { const text = data.toString(); const entries = this.outputProcessor.process(text, false); entries.forEach((entry) => { this.logger.log("server", entry.formatted); }); }); this.serverProcess.stderr?.on("data", (data) => { const text = data.toString(); const entries = this.outputProcessor.process(text, true); entries.forEach((entry) => { this.logger.log("server", entry.formatted); // Show critical errors to console (parser determines what's critical) if (entry.isCritical && entry.rawMessage) { console.error(chalk.red("[ERROR]"), entry.rawMessage); } }); }); this.serverProcess.on("exit", (code) => { if (this.isShuttingDown) return; // Don't handle exits during shutdown if (code !== 0 && code !== null) { this.debugLog(`Server process exited with code ${code}`); this.logger.log("server", `Server process exited with code ${code}`); const timeSinceStart = this.serverStartTime ? Date.now() - this.serverStartTime : 0; const isEarlyExit = timeSinceStart < 5000; // Less than 5 seconds // Check if node_modules exists const nodeModulesExists = existsSync(join(process.cwd(), "node_modules")); // If it's an early exit and node_modules doesn't exist, show helpful message if (isEarlyExit && !nodeModulesExists) { if (this.spinner?.isSpinning) { this.spinner.fail("Server script failed to start - missing dependencies"); } else { console.log(chalk.red("\n❌ Server script failed to start")); } console.log(chalk.yellow("💡 It looks like dependencies are not installed.")); console.log(chalk.yellow(" Run 'pnpm install' (or npm/yarn install) and try again.")); this.showRecentLogs(); this.gracefulShutdown(); return; } // If it's an early exit but node_modules exists, it's still likely a configuration issue if (isEarlyExit) { if (this.spinner?.isSpinning) { this.spinner.fail(`Server script failed to start (exited with code ${code})`); } else { console.log(chalk.red(`\n❌ Server script failed to start (exited with code ${code})`)); } console.log(chalk.yellow("💡 Check your server command configuration and project setup")); console.log(chalk.yellow(` Command: ${this.options.serverCommand}`)); this.showRecentLogs(); this.gracefulShutdown(); return; } // For later exits, any non-zero exit code should be treated as fatal // Only ignore successful exit and specific signal-based exit codes: // - Code 0: Success (not fatal) // - Code 130: Ctrl+C (SIGINT) // - Code 143: SIGTERM const isFatalExit = code !== 0 && code !== 130 && code !== 143; if (isFatalExit) { // Stop spinner and show error for fatal exits if (this.spinner?.isSpinning) { this.spinner.fail(`Server process exited with code ${code}`); } else { console.log(chalk.red(`\n❌ Server process exited with code ${code}`)); } // Show recent log entries to help with debugging this.showRecentLogs(); this.gracefulShutdown(); } else { // For non-fatal exits (like build failures), just log and continue if (this.spinner?.isSpinning) { this.spinner.text = "Server process restarted, waiting..."; } } } }); } acquireLock() { try { // Check if lock file exists if (existsSync(this.lockFile)) { const lockContent = readFileSync(this.lockFile, "utf8"); const oldPID = parseInt(lockContent, 10); // Check if the process is still running try { process.kill(oldPID, 0); // Signal 0 just checks if process exists // Process is running, lock is valid return false; } catch { // Process doesn't exist, remove stale lock this.debugLog(`Removing stale lock file for PID ${oldPID}`); unlinkSync(this.lockFile); } } // Create lock file with our PID writeFileSync(this.lockFile, process.pid.toString()); this.debugLog(`Acquired lock file: ${this.lockFile}`); return true; } catch (error) { this.debugLog(`Failed to acquire lock: ${error}`); return false; } } releaseLock() { try { if (existsSync(this.lockFile)) { unlinkSync(this.lockFile); this.debugLog(`Released lock file: ${this.lockFile}`); } } catch (error) { this.debugLog(`Failed to release lock: ${error}`); } } debugLog(message) { const timestamp = formatTimestamp(new Date(), this.options.dateTimeFormat || "local"); if (this.options.debug) { if (this.spinner?.isSpinning) { // Temporarily stop the spinner, show debug message, then restart const currentText = this.spinner.text; this.spinner.stop(); console.log(chalk.gray(`[DEBUG] ${message}`)); this.spinner.start(currentText); } else { console.log(chalk.gray(`[DEBUG] ${message}`)); } } // Always write to d3k debug log file (even when not in debug mode) try { const debugLogDir = join(homedir(), ".d3k"); if (!existsSync(debugLogDir)) { mkdirSync(debugLogDir, { recursive: true }); } const debugLogFile = join(debugLogDir, "d3k.log"); const logEntry = `[${timestamp}] [DEBUG] ${message}\n`; appendFileSync(debugLogFile, logEntry); } catch { // Ignore debug log write errors } } showRecentLogs() { try { if (existsSync(this.options.logFile)) { const logContent = readFileSync(this.options.logFile, "utf8"); const lines = logContent .trim() .split("\n") .filter((line) => line.trim()); if (lines.length > 0) { // Show last 20 lines, or fewer if log is shorter const recentLines = lines.slice(-20); console.log(chalk.yellow("\n📋 Recent log entries:")); for (const line of recentLines) { console.log(chalk.gray(` ${line}`)); } } } console.log(chalk.cyan(`\n📄 Full logs: ${this.options.logFile}`)); console.log(chalk.cyan(` Quick access: tail -f ${this.options.logFile}`)); } catch (_error) { // Fallback if we can't read the log file console.log(chalk.yellow(`💡 Check logs for details: ${this.options.logFile}`)); } } async startMcpServer() { this.debugLog("Starting MCP server setup"); // Note: MCP server cleanup now happens earlier in checkPortsAvailable() // to ensure the port is free before we check availability // Get the path to our bundled MCP server const currentFile = fileURLToPath(import.meta.url); const packageRoot = dirname(dirname(currentFile)); // Go up from dist/ to package root let mcpServerPath = join(packageRoot, "mcp-server"); this.debugLog(`Initial MCP server path: ${mcpServerPath}`); // For pnpm global installs, resolve symlinks to get the real path if (existsSync(mcpServerPath)) { try { const realPath = realpathSync(mcpServerPath); if (realPath !== mcpServerPath) { this.debugLog(`MCP server path resolved from symlink: ${mcpServerPath} -> ${realPath}`); mcpServerPath = realPath; } } catch (e) { // Error resolving path, continue with original this.debugLog(`Error resolving real path: ${e}`); } } this.debugLog(`Final MCP server path: ${mcpServerPath}`); if (!existsSync(mcpServerPath)) { throw new Error(`MCP server directory not found at ${mcpServerPath}`); } this.debugLog("MCP server directory found"); // Check if MCP server dependencies are installed, install if missing const isGlobalInstall = mcpServerPath.includes(".pnpm"); this.debugLog(`Is global install: ${isGlobalInstall}`); let nodeModulesPath = join(mcpServerPath, "node_modules"); let actualWorkingDir = mcpServerPath; this.debugLog(`Node modules path: ${nodeModulesPath}`); if (isGlobalInstall) { const tmpDirPath = join(tmpdir(), "dev3000-mcp-deps"); nodeModulesPath = join(tmpDirPath, "node_modules"); actualWorkingDir = tmpDirPath; // Update screenshot and MCP public directory to use the temp directory for global installs this.screenshotDir = join(actualWorkingDir, "public", "screenshots"); this.mcpPublicDir = join(actualWorkingDir, "public", "screenshots"); if (!existsSync(this.mcpPublicDir)) { mkdirSync(this.mcpPublicDir, { recursive: true }); } } // Check if .next build directory exists - if so, skip dependency installation const nextBuildPath = join(mcpServerPath, ".next"); this.debugLog(`Checking for pre-built MCP server at: ${nextBuildPath}`); let isPreBuilt = false; if (existsSync(nextBuildPath)) { this.debugLog("MCP server is pre-built (.next directory exists), skipping dependency installation"); isPreBuilt = true; // For global installs with pre-built servers, we'll run from the or