Merge pull request #246 from suraj-markup/feat/issue-245

fix(setup): improve setup.sh prereq validation and add start/spawn pre-flight checks
This commit is contained in:
suraj_markup 2026-03-02 03:24:14 +05:30 committed by GitHub
commit 01a6c56e1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 513 additions and 16 deletions

View File

@ -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<string, unknown>).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<string, unknown>).projects as Record<string, Record<string, unknown>>;
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<string, unknown>).projects as Record<string, Record<string, unknown>>;
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<string, unknown>).projects as Record<string, Record<string, unknown>>;
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");
});
});

View File

@ -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");
});
});

View File

@ -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<void> {
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 }> = [];

View File

@ -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);

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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,
};

View File

@ -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 ""