diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 439f991a6..b0eb2d669 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -62,7 +62,7 @@ export function registerDashboard(program: Command): void { console.log(chalk.bold(`Starting dashboard on http://localhost:${port}\n`)); - const env = buildDashboardEnv( + const env = await buildDashboardEnv( port, config.configPath, config.terminalPort, diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 8081f6d57..3e7edc1c9 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -65,14 +65,14 @@ function resolveProject( * Start dashboard server in the background. * Returns the child process handle for cleanup. */ -function startDashboard( +async function startDashboard( port: number, webDir: string, configPath: string | null, terminalPort?: number, directTerminalPort?: number, -): ChildProcess { - const env = buildDashboardEnv(port, configPath, terminalPort, directTerminalPort); +): Promise { + const env = await buildDashboardEnv(port, configPath, terminalPort, directTerminalPort); const child = spawn("pnpm", ["run", "dev"], { cwd: webDir, @@ -156,7 +156,7 @@ export function registerStart(program: Command): void { } spinner.start("Starting dashboard"); - dashboardProcess = startDashboard( + dashboardProcess = await startDashboard( port, webDir, config.configPath, diff --git a/packages/cli/src/lib/web-dir.ts b/packages/cli/src/lib/web-dir.ts index 0084ba22c..7ee48c311 100644 --- a/packages/cli/src/lib/web-dir.ts +++ b/packages/cli/src/lib/web-dir.ts @@ -3,6 +3,7 @@ * Shared utility to avoid duplication between dashboard.ts and start.ts. */ +import { createServer } from "node:net"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { resolve, dirname } from "node:path"; @@ -12,20 +13,59 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const require = createRequire(import.meta.url); +/** Default terminal server base port (14800 range: zero IANA registrations, no dev tool conflicts) */ +const DEFAULT_TERMINAL_PORT = 14800; + +/** + * Check if a TCP port is available by attempting to bind to it. + * Returns true if the port is free, false if in use. + */ +function isPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const server = createServer(); + server.once("error", () => { + resolve(false); + }); + server.once("listening", () => { + server.close(() => resolve(true)); + }); + server.listen(port, "127.0.0.1"); + }); +} + +/** + * Find a pair of consecutive available ports starting from `base`. + * Scans upward in steps of 2 (keeping ports paired) until both are free. + * Returns [terminalPort, directTerminalPort]. + */ +async function findAvailablePortPair(base: number): Promise<[number, number]> { + const MAX_ATTEMPTS = 50; + for (let i = 0; i < MAX_ATTEMPTS; i++) { + const p1 = base + i * 2; + const p2 = p1 + 1; + const [free1, free2] = await Promise.all([isPortAvailable(p1), isPortAvailable(p2)]); + if (free1 && free2) { + return [p1, p2]; + } + } + // If all 50 pairs exhausted, fall back to the base (will fail at bind time with clear error) + return [base, base + 1]; +} + /** * Build environment variables for spawning the dashboard process. * Shared between `ao start` and `ao dashboard` to avoid duplication. * - * Terminal server ports default to 3001/3003 but can be overridden via config - * to allow multiple dashboard instances to run simultaneously (e.g., different - * projects on different ports without EADDRINUSE conflicts). + * Terminal server ports default to 14800/14801 but can be overridden via config. + * When no explicit port is set, auto-detects available ports to allow multiple + * dashboard instances to run simultaneously without EADDRINUSE conflicts. */ -export function buildDashboardEnv( +export async function buildDashboardEnv( port: number, configPath: string | null, terminalPort?: number, directTerminalPort?: number, -): Record { +): Promise> { const env: Record = { ...process.env } as Record; // Pass config path so dashboard uses the same config as the CLI @@ -33,19 +73,37 @@ export function buildDashboardEnv( env["AO_CONFIG_PATH"] = configPath; } - // Set ports for client-side access (Next.js requires NEXT_PUBLIC_ prefix) - // Priority: config value > env var > default env["PORT"] = String(port); - const resolvedTerminalPort = String(terminalPort ?? env["TERMINAL_PORT"] ?? "14800"); - const resolvedDirectTerminalPort = String( - directTerminalPort ?? env["DIRECT_TERMINAL_PORT"] ?? "14801", - ); + // If explicit ports provided (config or env var), use them directly. + // Otherwise, auto-detect an available pair starting from the default. + const explicitTerminal = terminalPort ?? (env["TERMINAL_PORT"] ? parseInt(env["TERMINAL_PORT"], 10) : undefined); + const explicitDirect = directTerminalPort ?? (env["DIRECT_TERMINAL_PORT"] ? parseInt(env["DIRECT_TERMINAL_PORT"], 10) : undefined); - env["TERMINAL_PORT"] = resolvedTerminalPort; - env["DIRECT_TERMINAL_PORT"] = resolvedDirectTerminalPort; - env["NEXT_PUBLIC_TERMINAL_PORT"] = resolvedTerminalPort; - env["NEXT_PUBLIC_DIRECT_TERMINAL_PORT"] = resolvedDirectTerminalPort; + let resolvedTerminal: number; + let resolvedDirect: number; + + if (explicitTerminal !== undefined && explicitDirect !== undefined) { + // Both explicitly set — use as-is + resolvedTerminal = explicitTerminal; + resolvedDirect = explicitDirect; + } else if (explicitTerminal !== undefined) { + // Terminal port set, derive direct from it + resolvedTerminal = explicitTerminal; + resolvedDirect = explicitTerminal + 1; + } else if (explicitDirect !== undefined) { + // Direct port set, derive terminal from it + resolvedTerminal = explicitDirect - 1; + resolvedDirect = explicitDirect; + } else { + // Neither set — auto-detect available pair + [resolvedTerminal, resolvedDirect] = await findAvailablePortPair(DEFAULT_TERMINAL_PORT); + } + + env["TERMINAL_PORT"] = String(resolvedTerminal); + env["DIRECT_TERMINAL_PORT"] = String(resolvedDirect); + env["NEXT_PUBLIC_TERMINAL_PORT"] = String(resolvedTerminal); + env["NEXT_PUBLIC_DIRECT_TERMINAL_PORT"] = String(resolvedDirect); return env; }