dev3000
Version:
AI-powered development tools with browser monitoring and MCP server integration
1,071 lines • 97.8 kB
JavaScript
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