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:
parent
4dc131180e
commit
2bf48cec5e
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue