diff --git a/packages/cli/__tests__/commands/spawn.test.ts b/packages/cli/__tests__/commands/spawn.test.ts index a422bb06c..4b314e20e 100644 --- a/packages/cli/__tests__/commands/spawn.test.ts +++ b/packages/cli/__tests__/commands/spawn.test.ts @@ -304,3 +304,121 @@ describe("spawn command", () => { ).rejects.toThrow("process.exit(1)"); }); }); + +describe("spawn pre-flight checks", () => { + it("fails with clear error when tmux is not installed (default runtime)", async () => { + mockExec.mockRejectedValue(new Error("ENOENT")); + + await expect( + program.parseAsync(["node", "test", "spawn", "my-app"]), + ).rejects.toThrow("process.exit(1)"); + + const errors = vi.mocked(console.error).mock.calls.map((c) => String(c[0])).join("\n"); + expect(errors).toContain("tmux"); + // Should not attempt to spawn + expect(mockSessionManager.spawn).not.toHaveBeenCalled(); + }); + + it("skips tmux check when runtime is not tmux", async () => { + const fakeSession: Session = { + id: "app-1", + projectId: "my-app", + status: "spawning", + activity: null, + branch: null, + issueId: null, + pr: null, + workspacePath: "/tmp/wt", + runtimeHandle: { id: "proc-1", runtimeName: "process", data: {} }, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: {}, + }; + mockSessionManager.spawn.mockResolvedValue(fakeSession); + + // Set runtime to "process" + (mockConfigRef.current as Record).defaults = { + runtime: "process", + agent: "claude-code", + workspace: "worktree", + notifiers: ["desktop"], + }; + + // exec would fail for tmux but should never be called + mockExec.mockRejectedValue(new Error("ENOENT")); + + await program.parseAsync(["node", "test", "spawn", "my-app"]); + + expect(mockSessionManager.spawn).toHaveBeenCalled(); + }); + + it("checks gh auth when tracker is github", async () => { + const projects = (mockConfigRef.current as Record).projects as Record>; + projects["my-app"].tracker = { plugin: "github" }; + + // tmux check passes, gh --version passes, gh auth status fails + mockExec + .mockResolvedValueOnce({ stdout: "tmux 3.3a", stderr: "" }) // tmux -V + .mockResolvedValueOnce({ stdout: "gh version 2.40", stderr: "" }) // gh --version + .mockRejectedValueOnce(new Error("not logged in")); // gh auth status + + await expect( + program.parseAsync(["node", "test", "spawn", "my-app"]), + ).rejects.toThrow("process.exit(1)"); + + const errors = vi.mocked(console.error).mock.calls.map((c) => String(c[0])).join("\n"); + expect(errors).toContain("not authenticated"); + expect(mockSessionManager.spawn).not.toHaveBeenCalled(); + }); + + it("skips gh auth check when tracker is not github", async () => { + const fakeSession: Session = { + id: "app-1", + projectId: "my-app", + status: "spawning", + activity: null, + branch: null, + issueId: null, + pr: null, + workspacePath: "/tmp/wt", + runtimeHandle: { id: "hash-1", runtimeName: "tmux", data: {} }, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: {}, + }; + mockSessionManager.spawn.mockResolvedValue(fakeSession); + + const projects = (mockConfigRef.current as Record).projects as Record>; + projects["my-app"].tracker = { plugin: "linear" }; + + // tmux check passes — gh should never be called + mockExec.mockResolvedValue({ stdout: "tmux 3.3a", stderr: "" }); + + await program.parseAsync(["node", "test", "spawn", "my-app"]); + + // Should only call tmux -V, not gh + expect(mockExec).toHaveBeenCalledWith("tmux", ["-V"]); + expect(mockExec).not.toHaveBeenCalledWith("gh", expect.anything()); + expect(mockSessionManager.spawn).toHaveBeenCalled(); + }); + + it("distinguishes gh not installed from gh not authenticated", async () => { + const projects = (mockConfigRef.current as Record).projects as Record>; + projects["my-app"].tracker = { plugin: "github" }; + + // tmux passes, gh --version fails (not installed) + mockExec + .mockResolvedValueOnce({ stdout: "tmux 3.3a", stderr: "" }) // tmux -V + .mockRejectedValueOnce(new Error("ENOENT")); // gh --version fails + + await expect( + program.parseAsync(["node", "test", "spawn", "my-app"]), + ).rejects.toThrow("process.exit(1)"); + + const errors = vi.mocked(console.error).mock.calls.map((c) => String(c[0])).join("\n"); + expect(errors).toContain("not installed"); + expect(errors).not.toContain("not authenticated"); + }); +}); diff --git a/packages/cli/__tests__/lib/preflight.test.ts b/packages/cli/__tests__/lib/preflight.test.ts new file mode 100644 index 000000000..39b5240d7 --- /dev/null +++ b/packages/cli/__tests__/lib/preflight.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { mockExec, mockIsPortAvailable, mockExistsSync } = vi.hoisted(() => ({ + mockExec: vi.fn(), + mockIsPortAvailable: vi.fn(), + mockExistsSync: vi.fn(), +})); + +vi.mock("../../src/lib/shell.js", () => ({ + exec: mockExec, +})); + +vi.mock("../../src/lib/web-dir.js", () => ({ + isPortAvailable: mockIsPortAvailable, +})); + +vi.mock("node:fs", () => ({ + existsSync: mockExistsSync, +})); + +import { preflight } from "../../src/lib/preflight.js"; + +beforeEach(() => { + mockExec.mockReset(); + mockIsPortAvailable.mockReset(); + mockExistsSync.mockReset(); +}); + +describe("preflight.checkPort", () => { + it("passes when port is free", async () => { + mockIsPortAvailable.mockResolvedValue(true); + await expect(preflight.checkPort(3000)).resolves.toBeUndefined(); + expect(mockIsPortAvailable).toHaveBeenCalledWith(3000); + }); + + it("throws when port is in use", async () => { + mockIsPortAvailable.mockResolvedValue(false); + await expect(preflight.checkPort(3000)).rejects.toThrow( + "Port 3000 is already in use", + ); + }); + + it("includes port number in error message", async () => { + mockIsPortAvailable.mockResolvedValue(false); + await expect(preflight.checkPort(8080)).rejects.toThrow("Port 8080"); + }); +}); + +describe("preflight.checkBuilt", () => { + it("passes when node_modules and core dist exist", async () => { + mockExistsSync.mockReturnValue(true); + await expect(preflight.checkBuilt("/web")).resolves.toBeUndefined(); + expect(mockExistsSync).toHaveBeenCalled(); + }); + + it("throws 'pnpm install' when node_modules is missing", async () => { + // First call checks node_modules/@composio/ao-core — missing + mockExistsSync.mockReturnValue(false); + await expect(preflight.checkBuilt("/web")).rejects.toThrow( + "pnpm install", + ); + }); + + it("throws 'pnpm build' when node_modules exists but dist is missing", async () => { + // First call: node_modules/@composio/ao-core exists + // Second call: dist/index.js does not exist + mockExistsSync + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + await expect(preflight.checkBuilt("/web")).rejects.toThrow( + "Packages not built. Run: pnpm build", + ); + }); +}); + +describe("preflight.checkTmux", () => { + it("passes when tmux is installed", async () => { + mockExec.mockResolvedValue({ stdout: "tmux 3.3a", stderr: "" }); + await expect(preflight.checkTmux()).resolves.toBeUndefined(); + expect(mockExec).toHaveBeenCalledWith("tmux", ["-V"]); + }); + + it("throws when tmux is not installed", async () => { + mockExec.mockRejectedValue(new Error("ENOENT")); + await expect(preflight.checkTmux()).rejects.toThrow( + "tmux is not installed", + ); + }); + + it("includes install instruction in error", async () => { + mockExec.mockRejectedValue(new Error("ENOENT")); + await expect(preflight.checkTmux()).rejects.toThrow("brew install tmux"); + }); +}); + +describe("preflight.checkGhAuth", () => { + it("passes when gh is installed and authenticated", async () => { + mockExec.mockResolvedValue({ stdout: "ok", stderr: "" }); + await expect(preflight.checkGhAuth()).resolves.toBeUndefined(); + expect(mockExec).toHaveBeenCalledWith("gh", ["--version"]); + expect(mockExec).toHaveBeenCalledWith("gh", ["auth", "status"]); + }); + + it("throws 'not installed' when gh is missing (ENOENT)", async () => { + mockExec.mockRejectedValue(new Error("ENOENT")); + await expect(preflight.checkGhAuth()).rejects.toThrow( + "GitHub CLI (gh) is not installed", + ); + // Should only call --version, not auth status + expect(mockExec).toHaveBeenCalledTimes(1); + expect(mockExec).toHaveBeenCalledWith("gh", ["--version"]); + }); + + it("throws 'not authenticated' when gh exists but auth fails", async () => { + mockExec + .mockResolvedValueOnce({ stdout: "gh version 2.40", stderr: "" }) // --version succeeds + .mockRejectedValueOnce(new Error("not logged in")); // auth status fails + await expect(preflight.checkGhAuth()).rejects.toThrow( + "GitHub CLI is not authenticated", + ); + expect(mockExec).toHaveBeenCalledTimes(2); + }); + + it("includes correct fix instructions for each failure", async () => { + // Not installed → install link + mockExec.mockRejectedValue(new Error("ENOENT")); + await expect(preflight.checkGhAuth()).rejects.toThrow( + "https://cli.github.com/", + ); + + mockExec.mockReset(); + + // Not authenticated → auth login + mockExec + .mockResolvedValueOnce({ stdout: "gh version 2.40", stderr: "" }) + .mockRejectedValueOnce(new Error("not logged in")); + await expect(preflight.checkGhAuth()).rejects.toThrow("gh auth login"); + }); +}); diff --git a/packages/cli/src/commands/spawn.ts b/packages/cli/src/commands/spawn.ts index f912450b5..dd813483c 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -5,6 +5,23 @@ import { loadConfig, type OrchestratorConfig } from "@composio/ao-core"; import { exec } from "../lib/shell.js"; import { banner } from "../lib/format.js"; import { getSessionManager } from "../lib/create-session-manager.js"; +import { preflight } from "../lib/preflight.js"; + +/** + * Run pre-flight checks for a project once, before any sessions are spawned. + * Validates runtime and tracker prerequisites so failures surface immediately + * rather than repeating per-session in a batch. + */ +async function runSpawnPreflight(config: OrchestratorConfig, projectId: string): Promise { + const project = config.projects[projectId]; + const runtime = project?.runtime ?? config.defaults.runtime; + if (runtime === "tmux") { + await preflight.checkTmux(); + } + if (project?.tracker?.plugin === "github") { + await preflight.checkGhAuth(); + } +} async function spawnSession( config: OrchestratorConfig, @@ -73,9 +90,10 @@ export function registerSpawn(program: Command): void { } try { + await runSpawnPreflight(config, projectId); await spawnSession(config, projectId, issueId, opts.open, opts.agent); } catch (err) { - console.error(chalk.red(`✗ ${err}`)); + console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`)); process.exit(1); } }); @@ -105,6 +123,14 @@ export function registerBatchSpawn(program: Command): void { console.log(` Issues: ${issues.join(", ")}`); console.log(); + // Pre-flight once before the loop so a missing prerequisite fails fast + try { + await runSpawnPreflight(config, projectId); + } catch (err) { + console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`)); + process.exit(1); + } + const sm = await getSessionManager(config); const created: Array<{ session: string; issue: string }> = []; const skipped: Array<{ issue: string; existing: string }> = []; diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 3e7edc1c9..a0889246a 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -22,6 +22,7 @@ import { exec } from "../lib/shell.js"; import { getSessionManager } from "../lib/create-session-manager.js"; import { findWebDir, buildDashboardEnv } from "../lib/web-dir.js"; import { cleanNextCache } from "../lib/dashboard-rebuild.js"; +import { preflight } from "../lib/preflight.js"; /** * Resolve project from config. @@ -146,10 +147,13 @@ export function registerStart(program: Command): void { let exists = false; // Track whether orchestrator session already exists if (opts?.dashboard !== false) { + // Pre-flight: only check port/build when actually starting the dashboard + await preflight.checkPort(port); const webDir = findWebDir(); if (!existsSync(resolve(webDir, "package.json"))) { throw new Error("Could not find @composio/ao-web package. Run: pnpm install"); } + await preflight.checkBuilt(webDir); if (opts?.rebuild) { await cleanNextCache(webDir); diff --git a/packages/cli/src/lib/preflight.ts b/packages/cli/src/lib/preflight.ts new file mode 100644 index 000000000..921833184 --- /dev/null +++ b/packages/cli/src/lib/preflight.ts @@ -0,0 +1,81 @@ +/** + * Pre-flight checks for `ao start` and `ao spawn`. + * + * Validates runtime prerequisites before entering the main command flow, + * giving clear errors instead of cryptic failures. + * + * All checks throw on failure so callers can catch and handle uniformly. + */ + +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { isPortAvailable } from "./web-dir.js"; +import { exec } from "./shell.js"; + +/** + * Check that the dashboard port is free. + * Throws if the port is already in use. + */ +async function checkPort(port: number): Promise { + const free = await isPortAvailable(port); + if (!free) { + throw new Error( + `Port ${port} is already in use. Free it or change 'port' in agent-orchestrator.yaml.`, + ); + } +} + +/** + * Check that workspace packages have been compiled (TypeScript → JavaScript). + * Verifies @composio/ao-core dist output exists from the web package's + * node_modules, since a missing dist/ causes module resolution errors when + * starting the dashboard. Works with both `next dev` and `next build`. + */ +async function checkBuilt(webDir: string): Promise { + const nodeModules = resolve(webDir, "node_modules", "@composio", "ao-core"); + if (!existsSync(nodeModules)) { + throw new Error("Dependencies not installed. Run: pnpm install && pnpm build"); + } + const coreEntry = resolve(nodeModules, "dist", "index.js"); + if (!existsSync(coreEntry)) { + throw new Error("Packages not built. Run: pnpm build"); + } +} + +/** + * Check that tmux is installed (required for the default runtime). + * Throws if not installed. + */ +async function checkTmux(): Promise { + try { + await exec("tmux", ["-V"]); + } catch { + throw new Error("tmux is not installed. Install it: brew install tmux"); + } +} + +/** + * Check that the GitHub CLI is installed and authenticated. + * Distinguishes between "not installed" and "not authenticated" + * so the user gets the right troubleshooting guidance. + */ +async function checkGhAuth(): Promise { + try { + await exec("gh", ["--version"]); + } catch { + throw new Error("GitHub CLI (gh) is not installed. Install it: https://cli.github.com/"); + } + + try { + await exec("gh", ["auth", "status"]); + } catch { + throw new Error("GitHub CLI is not authenticated. Run: gh auth login"); + } +} + +export const preflight = { + checkPort, + checkBuilt, + checkTmux, + checkGhAuth, +}; diff --git a/scripts/setup.sh b/scripts/setup.sh index 6c5672650..5ae0679ca 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -1,38 +1,167 @@ #!/bin/bash # Agent Orchestrator setup script -# Installs dependencies, builds packages, and links the CLI globally +# Validates prerequisites, installs dependencies, builds packages, and links the CLI globally set -e # Exit on error -echo "🤖 Agent Orchestrator Setup" +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +echo "Agent Orchestrator Setup" echo "" -# Check for pnpm -if ! command -v pnpm &> /dev/null; then - echo "❌ pnpm not found. Installing pnpm..." - npm install -g pnpm +# ─── Hard requirements (exit 1 if missing) ──────────────────────────────────── + +# Node.js >= 20 +if ! command -v node &> /dev/null; then + echo "ERROR: Node.js is not installed." + echo " Install Node.js 20+: https://nodejs.org/en/download" + exit 1 fi -echo "📦 Installing dependencies..." +NODE_MAJOR=$(node -e "process.stdout.write(String(process.versions.node.split('.')[0]))") +if [ "$NODE_MAJOR" -lt 20 ]; then + echo "ERROR: Node.js $NODE_MAJOR.x detected, but 20+ is required." + echo " Install Node.js 20+: https://nodejs.org/en/download" + exit 1 +fi +echo "[ok] Node.js $(node --version)" + +# git >= 2.25 (required for worktree support) +if ! command -v git &> /dev/null; then + echo "ERROR: git is not installed." + echo " Install git 2.25+: https://git-scm.com/downloads" + exit 1 +fi + +GIT_VERSION=$(git --version | grep -oE '[0-9]+\.[0-9]+' | head -1) +GIT_MAJOR=$(echo "$GIT_VERSION" | cut -d. -f1) +GIT_MINOR=$(echo "$GIT_VERSION" | cut -d. -f2) +if [ "$GIT_MAJOR" -lt 2 ] || { [ "$GIT_MAJOR" -eq 2 ] && [ "$GIT_MINOR" -lt 25 ]; }; then + echo "ERROR: git $GIT_VERSION detected, but 2.25+ is required (worktree support)." + echo " Upgrade git: https://git-scm.com/downloads" + exit 1 +fi +echo "[ok] git $GIT_VERSION" + +# ─── Soft requirements (warn + offer interactive fix) ───────────────────────── + +# Detect interactive terminal for optional prompts (skip in CI/Docker) +INTERACTIVE=false +if [ -t 0 ]; then + INTERACTIVE=true +fi + +# tmux +if ! command -v tmux &> /dev/null; then + echo "" + echo "WARNING: tmux is not installed (default runtime requires it)." + if [ "$INTERACTIVE" = true ] && command -v brew &> /dev/null; then + read -r -p " Install tmux via Homebrew? [Y/n] " response + response=${response:-Y} + if [[ "$response" =~ ^[Yy]$ ]]; then + brew install tmux + echo "[ok] tmux installed" + else + echo " Skipping. Install later: brew install tmux" + fi + else + echo " Install tmux: https://github.com/tmux/tmux/wiki/Installing" + fi +else + echo "[ok] tmux $(tmux -V | grep -oE '[0-9]+\.[0-9a-z]+')" +fi + +# gh CLI authentication +if ! command -v gh &> /dev/null; then + echo "" + echo "WARNING: GitHub CLI (gh) is not installed." + echo " Install: https://cli.github.com/" +else + if ! gh auth status &> /dev/null; then + echo "" + echo "WARNING: GitHub CLI is not authenticated." + if [ "$INTERACTIVE" = true ]; then + read -r -p " Run 'gh auth login' now? [Y/n] " response + response=${response:-Y} + if [[ "$response" =~ ^[Yy]$ ]]; then + gh auth login + else + echo " Skipping. Authenticate later: gh auth login" + fi + else + echo " Authenticate later: gh auth login" + fi + else + echo "[ok] gh authenticated" + fi +fi + +# claude CLI +if ! command -v claude &> /dev/null; then + echo "" + echo "WARNING: Claude CLI is not installed (required for claude-code agent)." + echo " Install: npm install -g @anthropic-ai/claude-code" +fi + +echo "" + +# ─── Install pnpm ──────────────────────────────────────────────────────────── + +if command -v pnpm &> /dev/null; then + echo "[ok] pnpm $(pnpm --version) (already installed)" +else + echo "Installing pnpm via corepack..." + if corepack enable && corepack prepare --activate 2>/dev/null; then + echo "[ok] pnpm $(pnpm --version)" + else + echo " corepack failed (likely permissions), falling back to npm install..." + npm install -g pnpm + echo "[ok] pnpm $(pnpm --version)" + fi +fi + +# ─── Install, build, link ──────────────────────────────────────────────────── + +echo "" +echo "Installing dependencies..." pnpm install -echo "🧹 Cleaning stale build artifacts..." +echo "" +echo "Cleaning stale build artifacts..." rm -rf packages/web/.next -echo "🔨 Building all packages..." +echo "" +echo "Building all packages..." pnpm build -echo "🔗 Linking CLI globally..." +echo "" +echo "Linking CLI globally..." cd packages/cli npm link -cd ../.. +cd "$REPO_ROOT" + +# ─── Verify ao is in PATH ──────────────────────────────────────────────────── echo "" -echo "✅ Setup complete! The 'ao' command is now available." +if command -v ao &> /dev/null; then + echo "[ok] 'ao' command is available in PATH" +else + NPM_BIN="$(npm bin -g 2>/dev/null || npm config get prefix)/bin" + echo "WARNING: 'ao' is not in your PATH." + echo " Add this to your shell profile (~/.zshrc or ~/.bashrc):" + echo "" + echo " export PATH=\"$NPM_BIN:\$PATH\"" + echo "" + echo " Then restart your terminal or run: source ~/.zshrc" +fi + +# ─── Done ───────────────────────────────────────────────────────────────────── + +echo "" +echo "Setup complete!" echo "" echo "Next steps:" -echo " 1. cd /path/to/your/project" +echo " 1. cd /path/to/your-project" echo " 2. ao init --auto" -echo " 3. gh auth login" -echo " 4. ao start" +echo " 3. ao start" echo ""