From e40275b53cdb973cc30c4c7387c72e58849a03f8 Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Sun, 1 Mar 2026 19:39:29 +0530 Subject: [PATCH 01/10] fix(setup): improve setup.sh with prereq validation and add ao start/spawn pre-flight checks - Rewrite setup.sh with hard stops for Node<20 and git<2.25, soft warns with interactive fix for tmux/gh-auth/claude, corepack-based pnpm install, and PATH verification after npm link - Add pre-flight checks to `ao start`: verify dashboard port is free and packages are built before proceeding - Add pre-flight checks to `ao spawn`: verify tmux is installed and gh is authenticated (when using github tracker) before session creation - Extract shared preflight utilities into packages/cli/src/lib/preflight.ts Closes #245 Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/spawn.ts | 10 ++ packages/cli/src/commands/start.ts | 5 + packages/cli/src/lib/preflight.ts | 73 +++++++++++++++ scripts/setup.sh | 141 ++++++++++++++++++++++++++--- 4 files changed, 214 insertions(+), 15 deletions(-) create mode 100644 packages/cli/src/lib/preflight.ts diff --git a/packages/cli/src/commands/spawn.ts b/packages/cli/src/commands/spawn.ts index f912450b5..749480af4 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -5,6 +5,7 @@ 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"; async function spawnSession( config: OrchestratorConfig, @@ -13,6 +14,15 @@ async function spawnSession( openTab?: boolean, agent?: string, ): Promise { + // Pre-flight: ensure tmux is available (default runtime) + await preflight.checkTmux(); + + // Pre-flight: ensure gh is authenticated if using github tracker + const project = config.projects[projectId]; + if (project?.tracker?.plugin === "github") { + await preflight.checkGhAuth(); + } + const spinner = ora("Creating session").start(); try { diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 3e7edc1c9..b37b6ec41 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. @@ -138,6 +139,10 @@ export function registerStart(program: Command): void { const sessionId = `${project.sessionPrefix}-orchestrator`; const port = config.port ?? 3000; + // Pre-flight checks + await preflight.checkPort(port); + await preflight.checkBuilt(); + console.log(chalk.bold(`\nStarting orchestrator for ${chalk.cyan(project.name)}\n`)); // Start dashboard (unless --no-dashboard) diff --git a/packages/cli/src/lib/preflight.ts b/packages/cli/src/lib/preflight.ts new file mode 100644 index 000000000..8a808a4ba --- /dev/null +++ b/packages/cli/src/lib/preflight.ts @@ -0,0 +1,73 @@ +/** + * 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. + */ + +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import chalk from "chalk"; +import { isPortAvailable, findWebDir } from "./web-dir.js"; +import { exec } from "./shell.js"; + +/** + * Check that the dashboard port is free. + * Exits with a clear message if it's already in use. + */ +async function checkPort(port: number): Promise { + const free = await isPortAvailable(port); + if (!free) { + console.error( + chalk.red( + `Port ${port} is already in use. Free it or change 'port' in agent-orchestrator.yaml.`, + ), + ); + process.exit(1); + } +} + +/** + * Check that packages have been built (the web .next directory exists). + * Exits with a clear message if not. + */ +async function checkBuilt(): Promise { + const webDir = findWebDir(); + const buildId = resolve(webDir, ".next", "BUILD_ID"); + if (!existsSync(buildId)) { + console.error(chalk.red("Packages not built. Run: pnpm build")); + process.exit(1); + } +} + +/** + * Check that tmux is installed (required for the default runtime). + * Throws with a clear message if not. + */ +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 authenticated. + * Only relevant when the project uses the github tracker plugin. + * Throws with a clear message if not authenticated. + */ +async function checkGhAuth(): Promise { + 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..e23e961ee 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -1,38 +1,149 @@ #!/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) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +# tmux +if ! command -v tmux &> /dev/null; then + echo "" + echo "WARNING: tmux is not installed (default runtime requires it)." + if 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." + 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 "[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 via corepack โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +echo "Enabling corepack and preparing pnpm..." +corepack enable +corepack prepare --activate +echo "[ok] pnpm $(pnpm --version)" + +# โ”€โ”€โ”€ 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 "" From c0b998a445e3aea5f5b9971284d891ef03a3e399 Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Sun, 1 Mar 2026 20:47:02 +0530 Subject: [PATCH 02/10] fix: scope pre-flight checks to when they are relevant - Move port/build checks inside the `--no-dashboard` guard so they only run when the dashboard is actually being started - Only check for tmux when the resolved runtime is "tmux", respecting per-project runtime overrides (e.g. "process") Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/spawn.ts | 9 +++++---- packages/cli/src/commands/start.ts | 7 +++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/spawn.ts b/packages/cli/src/commands/spawn.ts index 749480af4..2a741b248 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -14,11 +14,12 @@ async function spawnSession( openTab?: boolean, agent?: string, ): Promise { - // Pre-flight: ensure tmux is available (default runtime) - await preflight.checkTmux(); - - // Pre-flight: ensure gh is authenticated if using github tracker + // Pre-flight: ensure tmux is available when using tmux runtime 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(); } diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index b37b6ec41..e4fff6d5b 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -139,10 +139,6 @@ export function registerStart(program: Command): void { const sessionId = `${project.sessionPrefix}-orchestrator`; const port = config.port ?? 3000; - // Pre-flight checks - await preflight.checkPort(port); - await preflight.checkBuilt(); - console.log(chalk.bold(`\nStarting orchestrator for ${chalk.cyan(project.name)}\n`)); // Start dashboard (unless --no-dashboard) @@ -151,6 +147,9 @@ 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); + await preflight.checkBuilt(); const webDir = findWebDir(); if (!existsSync(resolve(webDir, "package.json"))) { throw new Error("Could not find @composio/ao-web package. Run: pnpm install"); From 34cf42616f631aacd52b4e04b8fd33e57b38f2f2 Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Sun, 1 Mar 2026 20:52:36 +0530 Subject: [PATCH 03/10] fix: skip interactive prompts in non-interactive environments Guard `read` prompts behind `[ -t 0 ]` check so setup.sh works in CI/Docker without hanging or failing on stdin. Soft warnings still print but skip the interactive offer to fix. Co-Authored-By: Claude Opus 4.6 --- scripts/setup.sh | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/scripts/setup.sh b/scripts/setup.sh index e23e961ee..5d94a58b2 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -45,11 +45,17 @@ 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 command -v brew &> /dev/null; then + 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 @@ -74,12 +80,16 @@ else if ! gh auth status &> /dev/null; then echo "" echo "WARNING: GitHub CLI is not authenticated." - read -r -p " Run 'gh auth login' now? [Y/n] " response - response=${response:-Y} - if [[ "$response" =~ ^[Yy]$ ]]; then - gh auth login + 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 " Skipping. Authenticate later: gh auth login" + echo " Authenticate later: gh auth login" fi else echo "[ok] gh authenticated" From 7128a9d469a96a40def8ea65a54c7fa721d40305 Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Sun, 1 Mar 2026 20:56:15 +0530 Subject: [PATCH 04/10] fix: handle corepack permission errors in setup.sh Skip pnpm install if already available. When it's not, try corepack first and fall back to npm install -g pnpm if corepack fails (e.g. non-root user in Docker can't write to /usr/local/bin). Co-Authored-By: Claude Opus 4.6 --- scripts/setup.sh | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/scripts/setup.sh b/scripts/setup.sh index 5d94a58b2..5ae0679ca 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -105,12 +105,20 @@ fi echo "" -# โ”€โ”€โ”€ Install pnpm via corepack โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# โ”€โ”€โ”€ Install pnpm โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -echo "Enabling corepack and preparing pnpm..." -corepack enable -corepack prepare --activate -echo "[ok] pnpm $(pnpm --version)" +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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ From f14465335d01760bf1dba9a15d634fca6934abee Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Mon, 2 Mar 2026 01:45:39 +0530 Subject: [PATCH 05/10] fix: address review feedback on preflight checks - Consistent error handling: all preflight functions now throw instead of mixing process.exit and throw, so callers can catch and clean up - checkGhAuth distinguishes "not installed" (ENOENT) from "not authenticated" by checking gh --version first - Move checkBuilt() after package.json existence check in start.ts so missing web package shows "Run: pnpm install" not "Run: pnpm build" Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/start.ts | 2 +- packages/cli/src/lib/preflight.ts | 31 ++++++++++++++++-------------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index e4fff6d5b..1a4947e5a 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -149,11 +149,11 @@ export function registerStart(program: Command): void { if (opts?.dashboard !== false) { // Pre-flight: only check port/build when actually starting the dashboard await preflight.checkPort(port); - await preflight.checkBuilt(); 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(); if (opts?.rebuild) { await cleanNextCache(webDir); diff --git a/packages/cli/src/lib/preflight.ts b/packages/cli/src/lib/preflight.ts index 8a808a4ba..faaa5e451 100644 --- a/packages/cli/src/lib/preflight.ts +++ b/packages/cli/src/lib/preflight.ts @@ -3,46 +3,43 @@ * * 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 chalk from "chalk"; import { isPortAvailable, findWebDir } from "./web-dir.js"; import { exec } from "./shell.js"; /** * Check that the dashboard port is free. - * Exits with a clear message if it's already in use. + * Throws if the port is already in use. */ async function checkPort(port: number): Promise { const free = await isPortAvailable(port); if (!free) { - console.error( - chalk.red( - `Port ${port} is already in use. Free it or change 'port' in agent-orchestrator.yaml.`, - ), + throw new Error( + `Port ${port} is already in use. Free it or change 'port' in agent-orchestrator.yaml.`, ); - process.exit(1); } } /** * Check that packages have been built (the web .next directory exists). - * Exits with a clear message if not. + * Throws if not built. */ async function checkBuilt(): Promise { const webDir = findWebDir(); const buildId = resolve(webDir, ".next", "BUILD_ID"); if (!existsSync(buildId)) { - console.error(chalk.red("Packages not built. Run: pnpm build")); - process.exit(1); + throw new Error("Packages not built. Run: pnpm build"); } } /** * Check that tmux is installed (required for the default runtime). - * Throws with a clear message if not. + * Throws if not installed. */ async function checkTmux(): Promise { try { @@ -53,11 +50,17 @@ async function checkTmux(): Promise { } /** - * Check that the GitHub CLI is authenticated. - * Only relevant when the project uses the github tracker plugin. - * Throws with a clear message if not authenticated. + * 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 { From 96c7ae534a8f0474523244a78f80a6997a062ffd Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Mon, 2 Mar 2026 02:01:15 +0530 Subject: [PATCH 06/10] fix: run spawn preflight checks once before batch loop Extract preflight checks from spawnSession into runSpawnPreflight and call it once upfront in both registerSpawn and registerBatchSpawn. A missing prerequisite now fails fast with one clear error instead of repeating N times across the batch. Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/spawn.ts | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/spawn.ts b/packages/cli/src/commands/spawn.ts index 2a741b248..f7b662de4 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -7,14 +7,12 @@ import { banner } from "../lib/format.js"; import { getSessionManager } from "../lib/create-session-manager.js"; import { preflight } from "../lib/preflight.js"; -async function spawnSession( - config: OrchestratorConfig, - projectId: string, - issueId?: string, - openTab?: boolean, - agent?: string, -): Promise { - // Pre-flight: ensure tmux is available when using tmux runtime +/** + * 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") { @@ -23,7 +21,15 @@ async function spawnSession( if (project?.tracker?.plugin === "github") { await preflight.checkGhAuth(); } +} +async function spawnSession( + config: OrchestratorConfig, + projectId: string, + issueId?: string, + openTab?: boolean, + agent?: string, +): Promise { const spinner = ora("Creating session").start(); try { @@ -84,6 +90,7 @@ 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}`)); @@ -116,6 +123,9 @@ 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 + await runSpawnPreflight(config, projectId); + const sm = await getSessionManager(config); const created: Array<{ session: string; issue: string }> = []; const skipped: Array<{ issue: string; existing: string }> = []; From cb071e122df91e3315c8235ba1a5bde4d4d9da5a Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Mon, 2 Mar 2026 02:40:12 +0530 Subject: [PATCH 07/10] fix: wrap batch-spawn preflight in try-catch Preflight errors in batch-spawn were unhandled, causing unformatted rejection output. Now caught with chalk.red formatting and process.exit(1), matching the single spawn handler. Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/spawn.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/spawn.ts b/packages/cli/src/commands/spawn.ts index f7b662de4..b12c07d6f 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -124,7 +124,12 @@ export function registerBatchSpawn(program: Command): void { console.log(); // Pre-flight once before the loop so a missing prerequisite fails fast - await runSpawnPreflight(config, projectId); + 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 }> = []; From b43b1556c0e519b938dd3111e73085c995222074 Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Mon, 2 Mar 2026 02:44:19 +0530 Subject: [PATCH 08/10] fix: check core dist output instead of .next/BUILD_ID in checkBuilt .next/BUILD_ID only exists after next build (production) but the dashboard runs with next dev. Check @composio/ao-core resolve instead, which is the actual cause of module resolution errors when packages are not compiled. Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/lib/preflight.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/lib/preflight.ts b/packages/cli/src/lib/preflight.ts index faaa5e451..c424759e3 100644 --- a/packages/cli/src/lib/preflight.ts +++ b/packages/cli/src/lib/preflight.ts @@ -9,7 +9,8 @@ import { existsSync } from "node:fs"; import { resolve } from "node:path"; -import { isPortAvailable, findWebDir } from "./web-dir.js"; +import { createRequire } from "node:module"; +import { isPortAvailable } from "./web-dir.js"; import { exec } from "./shell.js"; /** @@ -26,13 +27,17 @@ async function checkPort(port: number): Promise { } /** - * Check that packages have been built (the web .next directory exists). - * Throws if not built. + * Check that workspace packages have been compiled (TypeScript โ†’ JavaScript). + * Verifies @composio/ao-core dist output exists, since a missing dist/ is the + * actual cause of module resolution errors when starting the dashboard. + * Works with both `next dev` and `next build` (unlike .next/BUILD_ID which + * is production-only). */ async function checkBuilt(): Promise { - const webDir = findWebDir(); - const buildId = resolve(webDir, ".next", "BUILD_ID"); - if (!existsSync(buildId)) { + const require = createRequire(import.meta.url); + try { + require.resolve("@composio/ao-core"); + } catch { throw new Error("Packages not built. Run: pnpm build"); } } From 62cd42b30f68bc065d3e14dbdd39e792b549116a Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Mon, 2 Mar 2026 02:50:56 +0530 Subject: [PATCH 09/10] fix: resolve checkBuilt from web node_modules and remove unused imports Check ao-core/dist/index.js relative to webDir's node_modules instead of using require.resolve which fails under npm link. Also removes unused existsSync/resolve/createRequire imports that caused lint errors. Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/start.ts | 2 +- packages/cli/src/lib/preflight.ts | 16 ++++++---------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 1a4947e5a..a0889246a 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -153,7 +153,7 @@ export function registerStart(program: Command): void { if (!existsSync(resolve(webDir, "package.json"))) { throw new Error("Could not find @composio/ao-web package. Run: pnpm install"); } - await preflight.checkBuilt(); + 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 index c424759e3..d3879a28b 100644 --- a/packages/cli/src/lib/preflight.ts +++ b/packages/cli/src/lib/preflight.ts @@ -9,7 +9,6 @@ import { existsSync } from "node:fs"; import { resolve } from "node:path"; -import { createRequire } from "node:module"; import { isPortAvailable } from "./web-dir.js"; import { exec } from "./shell.js"; @@ -28,16 +27,13 @@ async function checkPort(port: number): Promise { /** * Check that workspace packages have been compiled (TypeScript โ†’ JavaScript). - * Verifies @composio/ao-core dist output exists, since a missing dist/ is the - * actual cause of module resolution errors when starting the dashboard. - * Works with both `next dev` and `next build` (unlike .next/BUILD_ID which - * is production-only). + * 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(): Promise { - const require = createRequire(import.meta.url); - try { - require.resolve("@composio/ao-core"); - } catch { +async function checkBuilt(webDir: string): Promise { + const coreEntry = resolve(webDir, "node_modules", "@composio", "ao-core", "dist", "index.js"); + if (!existsSync(coreEntry)) { throw new Error("Packages not built. Run: pnpm build"); } } From 18dea011e7f8197a4a698ead63d20774f0c34c4b Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Mon, 2 Mar 2026 03:05:00 +0530 Subject: [PATCH 10/10] fix: address review feedback and add preflight tests - Consistent error formatting: single spawn now uses err.message like batch-spawn, avoiding redundant "Error:" prefix - checkBuilt distinguishes missing node_modules ("pnpm install") from missing dist ("pnpm build") so users get the right guidance - Add 13 preflight unit tests covering port, build, tmux, and gh checks - Add 5 spawn integration tests covering runtime-aware tmux check, tracker-aware gh check, and error message formatting Co-Authored-By: Claude Opus 4.6 --- packages/cli/__tests__/commands/spawn.test.ts | 118 +++++++++++++++ packages/cli/__tests__/lib/preflight.test.ts | 139 ++++++++++++++++++ packages/cli/src/commands/spawn.ts | 2 +- packages/cli/src/lib/preflight.ts | 6 +- 4 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 packages/cli/__tests__/lib/preflight.test.ts 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 b12c07d6f..dd813483c 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -93,7 +93,7 @@ export function registerSpawn(program: Command): void { 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); } }); diff --git a/packages/cli/src/lib/preflight.ts b/packages/cli/src/lib/preflight.ts index d3879a28b..921833184 100644 --- a/packages/cli/src/lib/preflight.ts +++ b/packages/cli/src/lib/preflight.ts @@ -32,7 +32,11 @@ async function checkPort(port: number): Promise { * starting the dashboard. Works with both `next dev` and `next build`. */ async function checkBuilt(webDir: string): Promise { - const coreEntry = resolve(webDir, "node_modules", "@composio", "ao-core", "dist", "index.js"); + 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"); }