fix: auto-detect free port in `ao init` instead of hardcoding 3000 (#120)

When port 3000 is occupied, `ao init` now scans upward (3001, 3002, ...)
until a free port is found. Applies to both interactive and --auto modes.
Uses the existing isPortAvailable() from web-dir.ts (now exported).

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
prateek 2026-02-19 07:27:39 +05:30 committed by GitHub
parent 0e533840ba
commit 9822aba4e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 68 additions and 4 deletions

View File

@ -1,7 +1,8 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, rmSync, existsSync } from "node:fs";
import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createServer } from "node:net";
import { Command } from "commander";
import { registerInit } from "../../src/commands/init.js";
@ -35,4 +36,53 @@ describe("init command", () => {
// Original file should be untouched
expect(existsSync(outputPath)).toBe(true);
});
it("auto mode uses port 3000 when it is available", async () => {
tmpDir = mkdtempSync(join(tmpdir(), "ao-init-test-"));
const outputPath = join(tmpDir, "agent-orchestrator.yaml");
const program = new Command();
program.exitOverride();
registerInit(program);
vi.spyOn(console, "log").mockImplementation(() => {});
await program.parseAsync(["node", "test", "init", "--auto", "--output", outputPath]);
const content = readFileSync(outputPath, "utf-8");
expect(content).toContain("port: 3000");
});
it("auto mode picks next free port when 3000 is occupied", async () => {
tmpDir = mkdtempSync(join(tmpdir(), "ao-init-test-"));
const outputPath = join(tmpDir, "agent-orchestrator.yaml");
// Occupy port 3000
const blocker = createServer();
await new Promise<void>((resolve) => {
blocker.listen(3000, "127.0.0.1", () => resolve());
});
try {
const program = new Command();
program.exitOverride();
registerInit(program);
vi.spyOn(console, "log").mockImplementation(() => {});
await program.parseAsync(["node", "test", "init", "--auto", "--output", outputPath]);
const content = readFileSync(outputPath, "utf-8");
// Should NOT be 3000 since we're occupying it
expect(content).not.toContain("port: 3000");
// Should pick 3001 (or higher if 3001 is also taken)
const portMatch = content.match(/port: (\d+)/);
expect(portMatch).toBeTruthy();
const port = parseInt(portMatch![1], 10);
expect(port).toBeGreaterThan(3000);
expect(port).toBeLessThan(3100);
} finally {
await new Promise<void>((resolve) => blocker.close(() => resolve()));
}
});
});

View File

@ -6,12 +6,24 @@ import { stringify as yamlStringify } from "yaml";
import chalk from "chalk";
import type { Command } from "commander";
import { git, gh, execSilent } from "../lib/shell.js";
import { isPortAvailable } from "../lib/web-dir.js";
import {
detectProjectType,
generateRulesFromTemplates,
formatProjectTypeForDisplay,
} from "../lib/project-detection.js";
const DEFAULT_PORT = 3000;
const MAX_PORT_SCAN = 100;
/** Find the first available port starting from `start`, scanning upward. */
async function findFreePort(start: number): Promise<number> {
for (let port = start; port < start + MAX_PORT_SCAN; port++) {
if (await isPortAvailable(port)) return port;
}
return start; // fallback — will fail at bind time with a clear error
}
async function prompt(
rl: ReturnType<typeof createInterface>,
question: string,
@ -231,7 +243,8 @@ export function registerInit(program: Command): void {
"~/.agent-orchestrator",
);
const worktreeDir = await prompt(rl, "Worktree directory", "~/.worktrees");
const portStr = await prompt(rl, "Dashboard port", "3000");
const freePort = await findFreePort(DEFAULT_PORT);
const portStr = await prompt(rl, "Dashboard port", String(freePort));
const port = parseInt(portStr, 10);
if (isNaN(port) || port < 1 || port > 65535) {
console.error(chalk.red("\nInvalid port number. Must be 1-65535."));
@ -430,10 +443,11 @@ async function handleAutoMode(outputPath: string, smart: boolean): Promise<void>
const path = env.isGitRepo ? workingDir : `~/${projectId}`;
const defaultBranch = env.defaultBranch || "main";
const port = await findFreePort(DEFAULT_PORT);
const config: Record<string, unknown> = {
dataDir: "~/.agent-orchestrator",
worktreeDir: "~/.worktrees",
port: 3000,
port,
defaults: {
runtime: "tmux",
agent: "claude-code",

View File

@ -20,7 +20,7 @@ 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> {
export function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = createServer();
server.once("error", () => {