diff --git a/packages/cli/__tests__/commands/init.test.ts b/packages/cli/__tests__/commands/init.test.ts index e6a1a8f43..7f530fd38 100644 --- a/packages/cli/__tests__/commands/init.test.ts +++ b/packages/cli/__tests__/commands/init.test.ts @@ -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((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((resolve) => blocker.close(() => resolve())); + } + }); }); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index b009c9f5f..17c54e256 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -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 { + 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, 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 const path = env.isGitRepo ? workingDir : `~/${projectId}`; const defaultBranch = env.defaultBranch || "main"; + const port = await findFreePort(DEFAULT_PORT); const config: Record = { dataDir: "~/.agent-orchestrator", worktreeDir: "~/.worktrees", - port: 3000, + port, defaults: { runtime: "tmux", agent: "claude-code", diff --git a/packages/cli/src/lib/web-dir.ts b/packages/cli/src/lib/web-dir.ts index 7ee48c311..349f2fa90 100644 --- a/packages/cli/src/lib/web-dir.ts +++ b/packages/cli/src/lib/web-dir.ts @@ -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 { +export function isPortAvailable(port: number): Promise { return new Promise((resolve) => { const server = createServer(); server.once("error", () => {