feat: auto-detect available terminal ports for zero-config multi-dashboard

When no terminal ports are configured (no config, no env vars),
buildDashboardEnv now probes for an available port pair starting at
14800. The second `ao start` automatically gets 14802/14803 (or the
next free pair), eliminating EADDRINUSE without any user configuration.

Port detection scans in steps of 2 to keep the pair consecutive.
Explicit config/env values bypass auto-detection entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Prateek 2026-02-19 03:38:21 +05:30
parent 4dc131180e
commit 2bf48cec5e
3 changed files with 78 additions and 20 deletions

View File

@ -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,

View File

@ -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<ChildProcess> {
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,

View File

@ -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<boolean> {
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<string, string> {
): Promise<Record<string, string>> {
const env: Record<string, string> = { ...process.env } as Record<string, string>;
// 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;
}