agent-orchestrator/packages/cli/src/lib/web-dir.ts

208 lines
7.3 KiB
TypeScript

/**
* Web directory locator — finds the @aoagents/ao-web package.
* Shared utility to avoid duplication between dashboard.ts and start.ts.
*/
import { spawn } from "node:child_process";
import { Socket } from "node:net";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { resolve, dirname } from "node:path";
import { existsSync } from "node:fs";
import { formatCommandError } from "./cli-errors.js";
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 connect to it.
* A successful connect means something is already listening (port in use).
* ECONNREFUSED means nothing is listening (port free).
*
* Connect-based detection is more reliable than bind-based because it works
* regardless of whether the occupying process is bound to 127.0.0.1, ::1,
* 0.0.0.0, or :: (IPv6 wildcard).
*/
export function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const s = new Socket();
s.setTimeout(300);
s.once("connect", () => { s.destroy(); resolve(false); }); // something listening → in use
s.once("error", () => { s.destroy(); resolve(true); }); // ECONNREFUSED → free
s.once("timeout", () => { s.destroy(); resolve(true); }); // no response → free
s.connect(port, "127.0.0.1");
});
}
/** How many consecutive ports to scan before giving up. */
export const MAX_PORT_SCAN = 100;
/**
* Find the first available port starting from `start`, scanning upward.
* Returns `null` if no free port is found within `maxScan` attempts.
*/
export async function findFreePort(start: number, maxScan = MAX_PORT_SCAN): Promise<number | null> {
for (let port = start; port < start + maxScan; port++) {
if (await isPortAvailable(port)) return port;
}
return null;
}
/**
* Open a URL in the user's browser without throwing back into the caller.
*/
export function openUrl(url: string): void {
const [cmd, args]: [string, string[]] =
process.platform === "win32"
? ["cmd.exe", ["/c", "start", "", url]]
: [process.platform === "linux" ? "xdg-open" : "open", [url]];
const browser = spawn(cmd, args, { stdio: "ignore" });
browser.on("error", (err) => {
console.warn(
formatCommandError(err, {
cmd,
args,
action: `open ${url} in a browser`,
installHints:
process.platform === "linux"
? ["Install xdg-utils so `xdg-open` is available on PATH."]
: process.platform === "win32"
? []
: [],
}).message,
);
});
}
/**
* Poll until a port is accepting connections, then open a URL in the browser.
* Respects an AbortSignal so the caller can cancel if the dashboard process
* exits early. Gives up silently after timeoutMs (default 30s).
*/
export async function waitForPortAndOpen(
port: number,
url: string,
signal: AbortSignal,
timeoutMs = 30_000,
): Promise<void> {
const start = Date.now();
while (!signal.aborted && Date.now() - start < timeoutMs) {
const free = await isPortAvailable(port);
if (!free) {
openUrl(url);
return;
}
await new Promise((r) => setTimeout(r, 300));
}
}
/**
* 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 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 async function buildDashboardEnv(
port: number,
configPath: string | null,
terminalPort?: number,
directTerminalPort?: number,
): 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
if (configPath) {
env["AO_CONFIG_PATH"] = configPath;
}
env["PORT"] = String(port);
// 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);
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;
}
/**
* Locate the @aoagents/ao-web package directory.
* Uses createRequire for ESM-compatible require.resolve, with fallback
* to sibling package paths that work from both src/ and dist/.
*/
export function findWebDir(): string {
// Try to resolve from node_modules first (installed as workspace dep)
try {
const pkgJson = require.resolve("@aoagents/ao-web/package.json");
return resolve(pkgJson, "..");
} catch {
// Fallback: sibling package in monorepo (works both from src/ and dist/)
// packages/cli/src/lib/ → packages/web
// packages/cli/dist/lib/ → packages/web
const candidates = [
resolve(__dirname, "../../../web"),
resolve(__dirname, "../../../../packages/web"),
];
for (const candidate of candidates) {
if (existsSync(resolve(candidate, "package.json"))) {
return candidate;
}
}
throw new Error(
"Could not find @aoagents/ao-web package.\n" +
" If installed via npm: npm install -g @aoagents/ao\n" +
" If cloned from source: pnpm install && pnpm build",
);
}
}