From 3d518aed2fcadb09ecb2a0787186d1e1bf9ce215 Mon Sep 17 00:00:00 2001 From: Harsh Batheja <40922251+harsh-batheja@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:59:22 +0530 Subject: [PATCH] feat: add doctor and update maintenance tooling (#437) * feat: add ao doctor maintenance checks * feat: add ao update refresh script * feat: add shared CLI script runner * feat: add doctor and update CLI commands * docs: document doctor and update commands * fix: harden maintenance safety checks * fix: harden ao update behavior * fix: strip inline comments from doctor config values --- README.md | 51 ++- SETUP.md | 22 ++ .../cli/__tests__/commands/doctor.test.ts | 40 ++ .../cli/__tests__/commands/update.test.ts | 55 +++ .../__tests__/scripts/doctor-script.test.ts | 179 +++++++++ .../__tests__/scripts/update-script.test.ts | 226 ++++++++++++ packages/cli/src/commands/doctor.ts | 17 + packages/cli/src/commands/update.ts | 28 ++ packages/cli/src/index.ts | 4 + packages/cli/src/lib/script-runner.ts | 54 +++ scripts/ao-doctor.sh | 342 ++++++++++++++++++ scripts/ao-update.sh | 130 +++++++ 12 files changed, 1131 insertions(+), 17 deletions(-) create mode 100644 packages/cli/__tests__/commands/doctor.test.ts create mode 100644 packages/cli/__tests__/commands/update.test.ts create mode 100644 packages/cli/__tests__/scripts/doctor-script.test.ts create mode 100644 packages/cli/__tests__/scripts/update-script.test.ts create mode 100644 packages/cli/src/commands/doctor.ts create mode 100644 packages/cli/src/commands/update.ts create mode 100644 packages/cli/src/lib/script-runner.ts create mode 100755 scripts/ao-doctor.sh create mode 100755 scripts/ao-update.sh diff --git a/README.md b/README.md index 12978ef0a..2620c6716 100644 --- a/README.md +++ b/README.md @@ -89,16 +89,16 @@ ao spawn my-project 123 Eight slots. Every abstraction is swappable. -| Slot | Default | Alternatives | -|------|---------|-------------| -| Runtime | tmux | docker, k8s, process | -| Agent | claude-code | codex, aider, opencode | -| Workspace | worktree | clone | -| Tracker | github | linear | -| SCM | github | — | -| Notifier | desktop | slack, composio, webhook | -| Terminal | iterm2 | web | -| Lifecycle | core | — | +| Slot | Default | Alternatives | +| --------- | ----------- | ------------------------ | +| Runtime | tmux | docker, k8s, process | +| Agent | claude-code | codex, aider, opencode | +| Workspace | worktree | clone | +| Tracker | github | linear | +| SCM | github | — | +| Notifier | desktop | slack, composio, webhook | +| Terminal | iterm2 | web | +| Lifecycle | core | — | All interfaces defined in [`packages/core/src/types.ts`](packages/core/src/types.ts). A plugin implements one interface and exports a `PluginModule`. That's it. @@ -131,7 +131,7 @@ reactions: action: send-to-agent escalateAfter: 30m approved-and-green: - auto: false # flip to true for auto-merge + auto: false # flip to true for auto-merge action: notify ``` @@ -149,8 +149,25 @@ ao session ls # List sessions ao session kill # Kill a session ao session restore # Revive a crashed agent ao dashboard # Open web dashboard +ao doctor [--fix] # Check install, runtime, and stale temp issues +ao update # Update local AO install and run smoke tests ``` +## Maintenance + +```bash +# Run deterministic install and runtime checks +ao doctor + +# Apply safe cleanup and launcher fixes +ao doctor --fix + +# Update this local AO checkout, rebuild critical packages, and verify the launcher +ao update +``` + +`ao doctor` checks PATH and launcher resolution, required binaries, tmux and GitHub CLI health, config support directories, stale AO temp files, and core build/runtime sanity. `ao update` fast-forwards the local install repo on `main`, runs `pnpm install`, clean-rebuilds `@composio/ao-core`, `@composio/ao-cli`, and `@composio/ao-web`, refreshes the global `ao` launcher with `npm link`, and finishes with CLI smoke tests. + ## Why Agent Orchestrator? Running one AI agent in a terminal is easy. Running 30 across different issues, branches, and PRs is a coordination problem. @@ -178,12 +195,12 @@ See [CLAUDE.md](CLAUDE.md) for code conventions and architecture details. ## Documentation -| Doc | What it covers | -|-----|---------------| -| [Setup Guide](SETUP.md) | Detailed installation and configuration | -| [Examples](examples/) | Config templates (GitHub, Linear, multi-project, auto-merge) | -| [CLAUDE.md](CLAUDE.md) | Architecture, conventions, plugin pattern | -| [Troubleshooting](TROUBLESHOOTING.md) | Common issues and fixes | +| Doc | What it covers | +| ------------------------------------- | ------------------------------------------------------------ | +| [Setup Guide](SETUP.md) | Detailed installation and configuration | +| [Examples](examples/) | Config templates (GitHub, Linear, multi-project, auto-merge) | +| [CLAUDE.md](CLAUDE.md) | Architecture, conventions, plugin pattern | +| [Troubleshooting](TROUBLESHOOTING.md) | Common issues and fixes | ## Contributing diff --git a/SETUP.md b/SETUP.md index 0397c9f2d..4e82f5f79 100644 --- a/SETUP.md +++ b/SETUP.md @@ -392,6 +392,28 @@ See [CLAUDE.md](./CLAUDE.md) for plugin development guidelines. ## Troubleshooting +### Run `ao doctor` + +Use the built-in doctor before debugging a broken install by hand: + +```bash +ao doctor +ao doctor --fix +``` + +`ao doctor` reports deterministic PASS/WARN/FAIL checks for PATH and launcher resolution, required binaries, tmux and GitHub CLI health, stale AO temp files, config support directories, and core build/runtime sanity. `--fix` only applies safe fixes such as creating missing AO support directories, refreshing the local launcher link, and removing stale AO temp files. + +### Run `ao update` + +When you installed AO from this repository and want to refresh that local install: + +```bash +git switch main +ao update +``` + +`ao update` is intentionally conservative: it requires a clean working tree on `main`, fast-forwards from `origin/main`, reinstalls dependencies, clean-rebuilds the critical core/CLI/web packages, refreshes the launcher with `npm link`, and runs CLI smoke tests. Use `ao update --skip-smoke` to stop after rebuild, or `ao update --smoke-only` to rerun just the smoke checks. + ### "No agent-orchestrator.yaml found" **Problem:** The orchestrator can't find your config file. diff --git a/packages/cli/__tests__/commands/doctor.test.ts b/packages/cli/__tests__/commands/doctor.test.ts new file mode 100644 index 000000000..823c56cf2 --- /dev/null +++ b/packages/cli/__tests__/commands/doctor.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Command } from "commander"; + +const { mockExecuteScriptCommand } = vi.hoisted(() => ({ + mockExecuteScriptCommand: vi.fn(), +})); + +vi.mock("../../src/lib/script-runner.js", () => ({ + executeScriptCommand: (...args: unknown[]) => mockExecuteScriptCommand(...args), +})); + +import { registerDoctor } from "../../src/commands/doctor.js"; + +describe("doctor command", () => { + let program: Command; + + beforeEach(() => { + program = new Command(); + program.exitOverride(); + registerDoctor(program); + mockExecuteScriptCommand.mockReset(); + mockExecuteScriptCommand.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("runs the doctor script with no extra args by default", async () => { + await program.parseAsync(["node", "test", "doctor"]); + + expect(mockExecuteScriptCommand).toHaveBeenCalledWith("ao-doctor.sh", []); + }); + + it("passes through --fix", async () => { + await program.parseAsync(["node", "test", "doctor", "--fix"]); + + expect(mockExecuteScriptCommand).toHaveBeenCalledWith("ao-doctor.sh", ["--fix"]); + }); +}); diff --git a/packages/cli/__tests__/commands/update.test.ts b/packages/cli/__tests__/commands/update.test.ts new file mode 100644 index 000000000..809de3ded --- /dev/null +++ b/packages/cli/__tests__/commands/update.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Command } from "commander"; + +const { mockExecuteScriptCommand } = vi.hoisted(() => ({ + mockExecuteScriptCommand: vi.fn(), +})); + +vi.mock("../../src/lib/script-runner.js", () => ({ + executeScriptCommand: (...args: unknown[]) => mockExecuteScriptCommand(...args), +})); + +import { registerUpdate } from "../../src/commands/update.js"; + +describe("update command", () => { + let program: Command; + + beforeEach(() => { + program = new Command(); + program.exitOverride(); + registerUpdate(program); + mockExecuteScriptCommand.mockReset(); + mockExecuteScriptCommand.mockResolvedValue(undefined); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("runs the update script with default args", async () => { + await program.parseAsync(["node", "test", "update"]); + + expect(mockExecuteScriptCommand).toHaveBeenCalledWith("ao-update.sh", []); + }); + + it("passes through --skip-smoke", async () => { + await program.parseAsync(["node", "test", "update", "--skip-smoke"]); + + expect(mockExecuteScriptCommand).toHaveBeenCalledWith("ao-update.sh", ["--skip-smoke"]); + }); + + it("rejects conflicting smoke flags", async () => { + await expect( + program.parseAsync(["node", "test", "update", "--skip-smoke", "--smoke-only"]), + ).rejects.toThrow("process.exit(1)"); + + expect(mockExecuteScriptCommand).not.toHaveBeenCalled(); + expect(vi.mocked(console.error)).toHaveBeenCalledWith( + "`ao update` does not allow `--skip-smoke` together with `--smoke-only`.", + ); + }); +}); diff --git a/packages/cli/__tests__/scripts/doctor-script.test.ts b/packages/cli/__tests__/scripts/doctor-script.test.ts new file mode 100644 index 000000000..af99048ef --- /dev/null +++ b/packages/cli/__tests__/scripts/doctor-script.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect } from "vitest"; +import { + chmodSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../.."); +const scriptPath = join(repoRoot, "scripts", "ao-doctor.sh"); + +function writeExecutable(path: string, content: string): void { + writeFileSync(path, content); + chmodSync(path, 0o755); +} + +function createFakeBinary(binDir: string, name: string, body: string): void { + writeExecutable(join(binDir, name), `#!/bin/bash\nset -e\n${body}\n`); +} + +function createHealthyRepo(tempRoot: string): string { + const fakeRepo = join(tempRoot, "repo"); + mkdirSync(join(fakeRepo, "node_modules"), { recursive: true }); + mkdirSync(join(fakeRepo, "packages", "core", "dist"), { recursive: true }); + mkdirSync(join(fakeRepo, "packages", "cli", "dist"), { recursive: true }); + mkdirSync(join(fakeRepo, "packages", "agent-orchestrator", "bin"), { recursive: true }); + mkdirSync(join(fakeRepo, "packages", "web"), { recursive: true }); + writeFileSync(join(fakeRepo, "packages", "core", "dist", "index.js"), "export {};\n"); + writeFileSync(join(fakeRepo, "packages", "cli", "dist", "index.js"), "export {};\n"); + writeFileSync( + join(fakeRepo, "packages", "agent-orchestrator", "bin", "ao.js"), + '#!/usr/bin/env node\nconsole.log("0.1.0");\n', + ); + chmodSync(join(fakeRepo, "packages", "agent-orchestrator", "bin", "ao.js"), 0o755); + return fakeRepo; +} + +function createHealthyPath(binDir: string): void { + createFakeBinary( + binDir, + "node", + 'if [ "$1" = "--version" ]; then\n printf "v20.11.1\\n"\n exit 0\nfi\nexit 0', + ); + createFakeBinary( + binDir, + "git", + 'if [ "$1" = "--version" ]; then\n printf "git version 2.43.0\\n"\n exit 0\nfi\nexit 0', + ); + createFakeBinary( + binDir, + "pnpm", + 'if [ "$1" = "--version" ]; then\n printf "9.15.4\\n"\n exit 0\nfi\nexit 0', + ); + createFakeBinary( + binDir, + "npm", + 'if [ "$1" = "bin" ]; then\n printf "/tmp/npm-bin\\n"\n exit 0\nfi\nexit 0', + ); + createFakeBinary( + binDir, + "tmux", + 'if [ "$1" = "-V" ]; then\n printf "tmux 3.4\\n"\n exit 0\nfi\nif [ "$1" = "list-sessions" ]; then\n exit 1\nfi\nexit 0', + ); + createFakeBinary( + binDir, + "gh", + 'if [ "$1" = "--version" ]; then\n printf "gh version 2.50.0\\n"\n exit 0\nfi\nif [ "$1" = "auth" ] && [ "$2" = "status" ]; then\n exit 0\nfi\nexit 0', + ); + createFakeBinary(binDir, "ao", 'printf "/fake/ao\\n" >/dev/null\nexit 0'); +} + +describe("scripts/ao-doctor.sh", () => { + it("reports a healthy install as PASS", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "ao-doctor-script-")); + const fakeRepo = createHealthyRepo(tempRoot); + const binDir = join(tempRoot, "bin"); + mkdirSync(binDir, { recursive: true }); + createHealthyPath(binDir); + + const configPath = join(tempRoot, "agent-orchestrator.yaml"); + const dataDir = join(tempRoot, "data"); + const worktreeDir = join(tempRoot, "worktrees"); + mkdirSync(dataDir, { recursive: true }); + mkdirSync(worktreeDir, { recursive: true }); + writeFileSync( + configPath, + [`dataDir: ${dataDir}`, `worktreeDir: ${worktreeDir}`, "projects: {}"].join("\n"), + ); + + const result = spawnSync("bash", [scriptPath], { + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH || ""}`, + AO_REPO_ROOT: fakeRepo, + AO_CONFIG_PATH: configPath, + }, + encoding: "utf8", + }); + + rmSync(tempRoot, { recursive: true, force: true }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("PASS"); + expect(result.stdout).toContain("Environment looks healthy"); + }); + + it("applies safe fixes for missing launcher, missing dirs, and stale temp files", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "ao-doctor-fix-")); + const fakeRepo = createHealthyRepo(tempRoot); + const binDir = join(tempRoot, "bin"); + mkdirSync(binDir, { recursive: true }); + createHealthyPath(binDir); + rmSync(join(binDir, "ao"), { force: true }); + + const npmLog = join(tempRoot, "npm.log"); + createFakeBinary( + binDir, + "npm", + `printf '%s\\n' "$*" >> ${JSON.stringify(npmLog)}\nif [ "$1" = "bin" ]; then\n printf "/tmp/npm-bin\\n"\nfi\nexit 0`, + ); + + const configPath = join(tempRoot, "agent-orchestrator.yaml"); + const dataDir = join(tempRoot, "data"); + const worktreeDir = join(tempRoot, "worktrees"); + const commentedDataDir = `${dataDir} # session metadata`; + const commentedWorktreeDir = `${worktreeDir} # ephemeral worktrees`; + writeFileSync( + configPath, + [`dataDir: ${commentedDataDir}`, `worktreeDir: ${commentedWorktreeDir}`, "projects: {}"].join( + "\n", + ), + ); + + const tmpRoot = join(tempRoot, "tmp-root"); + mkdirSync(tmpRoot, { recursive: true }); + const staleFile = join(tmpRoot, "ao-stale.tmp"); + writeFileSync(staleFile, "stale\n"); + const oldTimestamp = new Date(Date.now() - 2 * 60 * 60 * 1000); + utimesSync(staleFile, oldTimestamp, oldTimestamp); + + const result = spawnSync("bash", [scriptPath, "--fix"], { + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH || ""}`, + AO_REPO_ROOT: fakeRepo, + AO_CONFIG_PATH: configPath, + AO_DOCTOR_TMP_ROOT: tmpRoot, + }, + encoding: "utf8", + }); + + const npmCommands = readFileSync(npmLog, "utf8"); + const staleStillExists = existsSync(staleFile); + const dataDirExists = existsSync(dataDir); + const worktreeDirExists = existsSync(worktreeDir); + const commentedDataDirExists = existsSync(commentedDataDir); + const commentedWorktreeDirExists = existsSync(commentedWorktreeDir); + rmSync(tempRoot, { recursive: true, force: true }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("FIXED"); + expect(npmCommands).toContain("link"); + expect(result.stdout).toContain("launcher"); + expect(result.stdout).toContain("stale temp files"); + expect(staleStillExists).toBe(false); + expect(dataDirExists).toBe(true); + expect(worktreeDirExists).toBe(true); + expect(commentedDataDirExists).toBe(false); + expect(commentedWorktreeDirExists).toBe(false); + }); +}); diff --git a/packages/cli/__tests__/scripts/update-script.test.ts b/packages/cli/__tests__/scripts/update-script.test.ts new file mode 100644 index 000000000..694f0cc5e --- /dev/null +++ b/packages/cli/__tests__/scripts/update-script.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect } from "vitest"; +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../.."); +const scriptPath = join(repoRoot, "scripts", "ao-update.sh"); + +function writeExecutable(path: string, content: string): void { + writeFileSync(path, content); + chmodSync(path, 0o755); +} + +function createFakeBinary(binDir: string, name: string, body: string): void { + writeExecutable(join(binDir, name), `#!/bin/bash\nset -e\n${body}\n`); +} + +describe("scripts/ao-update.sh", () => { + it("runs the expected fetch, rebuild, and launcher refresh flow", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-script-")); + const fakeRepo = join(tempRoot, "repo"); + mkdirSync(join(fakeRepo, "packages", "cli"), { recursive: true }); + + const binDir = join(tempRoot, "bin"); + mkdirSync(binDir, { recursive: true }); + const commandLog = join(tempRoot, "commands.log"); + + createFakeBinary( + binDir, + "git", + `printf 'git %s\\n' "$*" >> ${JSON.stringify(commandLog)}\ncase "$*" in\n "rev-parse --is-inside-work-tree") printf 'true\\n' ;; + "status --porcelain") ;; + "branch --show-current") printf 'main\\n' ;; + "fetch origin main") ;; + "pull --ff-only origin main") ;; + *) ;; +esac\nexit 0`, + ); + createFakeBinary( + binDir, + "pnpm", + `printf 'pnpm %s\\n' "$*" >> ${JSON.stringify(commandLog)}\nif [ "$1" = "--version" ]; then\n printf '9.15.4\\n'\nfi\nexit 0`, + ); + createFakeBinary( + binDir, + "npm", + `printf 'npm %s\\n' "$*" >> ${JSON.stringify(commandLog)}\nexit 0`, + ); + createFakeBinary( + binDir, + "node", + `printf 'node %s\\n' "$*" >> ${JSON.stringify(commandLog)}\nif [ "$1" = "--version" ]; then\n printf 'v20.11.1\\n'\nfi\nexit 0`, + ); + + const result = spawnSync("bash", [scriptPath, "--skip-smoke"], { + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH || ""}`, + AO_REPO_ROOT: fakeRepo, + }, + encoding: "utf8", + }); + + const commands = readFileSync(commandLog, "utf8"); + rmSync(tempRoot, { recursive: true, force: true }); + + expect(result.status).toBe(0); + expect(commands).toContain("git fetch origin main"); + expect(commands).toContain("git pull --ff-only origin main"); + expect(commands).toContain("pnpm install"); + expect(commands).toContain("pnpm --filter @composio/ao-core clean"); + expect(commands).toContain("pnpm --filter @composio/ao-cli build"); + expect(commands).toContain("npm link"); + }); + + it("runs the built-in smoke commands in smoke-only mode", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-smoke-")); + const fakeRepo = join(tempRoot, "repo"); + mkdirSync(join(fakeRepo, "packages", "agent-orchestrator", "bin"), { recursive: true }); + writeFileSync( + join(fakeRepo, "packages", "agent-orchestrator", "bin", "ao.js"), + "#!/usr/bin/env node\n", + ); + + const binDir = join(tempRoot, "bin"); + mkdirSync(binDir, { recursive: true }); + const commandLog = join(tempRoot, "commands.log"); + createFakeBinary( + binDir, + "node", + `if [ "$1" = "--version" ]; then printf 'v20.11.1\\n'; fi +printf 'node %s\\n' "$*" >> ${JSON.stringify(commandLog)} +exit 0`, + ); + + const result = spawnSync("bash", [scriptPath, "--smoke-only"], { + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH || ""}`, + AO_REPO_ROOT: fakeRepo, + }, + encoding: "utf8", + }); + + const commands = readFileSync(commandLog, "utf8"); + rmSync(tempRoot, { recursive: true, force: true }); + + expect(result.status).toBe(0); + expect(commands).toContain( + `node ${join(fakeRepo, "packages", "agent-orchestrator", "bin", "ao.js")} --version`, + ); + expect(commands).toContain( + `node ${join(fakeRepo, "packages", "agent-orchestrator", "bin", "ao.js")} doctor --help`, + ); + expect(commands).toContain( + `node ${join(fakeRepo, "packages", "agent-orchestrator", "bin", "ao.js")} update --help`, + ); + }); + + it("fails fast on a dirty install repo with an actionable message", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-dirty-")); + const fakeRepo = join(tempRoot, "repo"); + mkdirSync(fakeRepo, { recursive: true }); + + const binDir = join(tempRoot, "bin"); + mkdirSync(binDir, { recursive: true }); + + createFakeBinary( + binDir, + "git", + `case "$*" in + "rev-parse --is-inside-work-tree") printf "true\\n" ;; + "status --porcelain") printf " M README.md\\n" ;; + "branch --show-current") printf "main\\n" ;; +esac +exit 0`, + ); + createFakeBinary( + binDir, + "pnpm", + 'if [ "$1" = "--version" ]; then printf "9.15.4\\n"; fi\nexit 0', + ); + createFakeBinary(binDir, "npm", "exit 0"); + createFakeBinary( + binDir, + "node", + 'if [ "$1" = "--version" ]; then printf "v20.11.1\\n"; fi\nexit 0', + ); + + const result = spawnSync("bash", [scriptPath], { + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH || ""}`, + AO_REPO_ROOT: fakeRepo, + }, + encoding: "utf8", + }); + + rmSync(tempRoot, { recursive: true, force: true }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Working tree is dirty"); + expect(result.stderr).toContain("commit or stash"); + }); + + it("rejects conflicting smoke flags in the script", () => { + const result = spawnSync("bash", [scriptPath, "--skip-smoke", "--smoke-only"], { + encoding: "utf8", + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Conflicting options"); + }); + + it("reports when the update itself dirties the checkout", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-post-dirty-")); + const fakeRepo = join(tempRoot, "repo"); + mkdirSync(join(fakeRepo, "packages", "cli"), { recursive: true }); + + const binDir = join(tempRoot, "bin"); + mkdirSync(binDir, { recursive: true }); + + createFakeBinary( + binDir, + "git", + `case "$*" in + "rev-parse --is-inside-work-tree") printf "true\\n" ;; + "status --porcelain") + if [ -f ${JSON.stringify(join(tempRoot, "post-dirty"))} ]; then + printf " M pnpm-lock.yaml\\n" + fi + ;; + "branch --show-current") printf "main\\n" ;; + "pull --ff-only origin main") touch ${JSON.stringify(join(tempRoot, "post-dirty"))} ;; +esac +exit 0`, + ); + createFakeBinary( + binDir, + "pnpm", + 'if [ "$1" = "--version" ]; then printf "9.15.4\\n"; fi\nexit 0', + ); + createFakeBinary(binDir, "npm", "exit 0"); + createFakeBinary( + binDir, + "node", + 'if [ "$1" = "--version" ]; then printf "v20.11.1\\n"; fi\nexit 0', + ); + + const result = spawnSync("bash", [scriptPath, "--skip-smoke"], { + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH || ""}`, + AO_REPO_ROOT: fakeRepo, + }, + encoding: "utf8", + }); + + rmSync(tempRoot, { recursive: true, force: true }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Update modified tracked files"); + }); +}); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts new file mode 100644 index 000000000..cb4cf1611 --- /dev/null +++ b/packages/cli/src/commands/doctor.ts @@ -0,0 +1,17 @@ +import type { Command } from "commander"; +import { executeScriptCommand } from "../lib/script-runner.js"; + +export function registerDoctor(program: Command): void { + program + .command("doctor") + .description("Run install, environment, and runtime health checks") + .option("--fix", "Apply safe fixes for launcher and stale temp issues") + .action(async (opts: { fix?: boolean }) => { + const args: string[] = []; + if (opts.fix) { + args.push("--fix"); + } + + await executeScriptCommand("ao-doctor.sh", args); + }); +} diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts new file mode 100644 index 000000000..bb8eecf5c --- /dev/null +++ b/packages/cli/src/commands/update.ts @@ -0,0 +1,28 @@ +import type { Command } from "commander"; +import { executeScriptCommand } from "../lib/script-runner.js"; + +export function registerUpdate(program: Command): void { + program + .command("update") + .description( + "Fast-forward the local install repo, rebuild critical packages, and run smoke tests", + ) + .option("--skip-smoke", "Skip smoke tests after rebuilding") + .option("--smoke-only", "Run smoke tests without fetching or rebuilding") + .action(async (opts: { skipSmoke?: boolean; smokeOnly?: boolean }) => { + if (opts.skipSmoke && opts.smokeOnly) { + console.error("`ao update` does not allow `--skip-smoke` together with `--smoke-only`."); + process.exit(1); + } + + const args: string[] = []; + if (opts.skipSmoke) { + args.push("--skip-smoke"); + } + if (opts.smokeOnly) { + args.push("--smoke-only"); + } + + await executeScriptCommand("ao-update.sh", args); + }); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 6ee253ccd..a70146fb7 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -12,6 +12,8 @@ import { registerOpen } from "./commands/open.js"; import { registerStart, registerStop } from "./commands/start.js"; import { registerLifecycleWorker } from "./commands/lifecycle-worker.js"; import { registerVerify } from "./commands/verify.js"; +import { registerDoctor } from "./commands/doctor.js"; +import { registerUpdate } from "./commands/update.js"; const program = new Command(); @@ -33,5 +35,7 @@ registerDashboard(program); registerOpen(program); registerLifecycleWorker(program); registerVerify(program); +registerDoctor(program); +registerUpdate(program); program.parse(); diff --git a/packages/cli/src/lib/script-runner.ts b/packages/cli/src/lib/script-runner.ts new file mode 100644 index 000000000..372091e3f --- /dev/null +++ b/packages/cli/src/lib/script-runner.ts @@ -0,0 +1,54 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const DEFAULT_REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../../../"); + +export function resolveRepoRoot(): string { + const override = process.env["AO_REPO_ROOT"]; + return override ? resolve(override) : DEFAULT_REPO_ROOT; +} + +export function resolveScriptPath(scriptName: string): string { + const scriptPath = resolve(resolveRepoRoot(), "scripts", scriptName); + if (!existsSync(scriptPath)) { + throw new Error(`Script not found: ${scriptPath}`); + } + return scriptPath; +} + +export async function runRepoScript(scriptName: string, args: string[]): Promise { + const shell = process.env["AO_BASH_PATH"] || "bash"; + const scriptPath = resolveScriptPath(scriptName); + + return await new Promise((resolveExit, reject) => { + const child = spawn(shell, [scriptPath, ...args], { + cwd: resolveRepoRoot(), + env: process.env, + stdio: "inherit", + }); + + child.on("error", reject); + child.on("exit", (code, signal) => { + if (signal) { + resolveExit(1); + return; + } + + resolveExit(code ?? 1); + }); + }); +} + +export async function executeScriptCommand(scriptName: string, args: string[]): Promise { + try { + const exitCode = await runRepoScript(scriptName, args); + if (exitCode !== 0) { + process.exit(exitCode); + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/ao-doctor.sh b/scripts/ao-doctor.sh new file mode 100755 index 000000000..e2942f3f5 --- /dev/null +++ b/scripts/ao-doctor.sh @@ -0,0 +1,342 @@ +#!/bin/bash + +set -uo pipefail + +FIX_MODE=false + +while [ $# -gt 0 ]; do + case "$1" in + --fix) + FIX_MODE=true + ;; + -h|--help) + cat <<'EOF' +Usage: ao doctor [--fix] + +Checks install, PATH, binaries, service health, stale temp files, and runtime sanity. + +Options: + --fix Apply safe fixes for missing launcher links, missing support dirs, and stale temp files +EOF + exit 0 + ;; + *) + printf 'Unknown option: %s\n' "$1" >&2 + exit 1 + ;; + esac + shift +done + +REPO_ROOT="${AO_REPO_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +DEFAULT_CONFIG_HOME="${HOME:-$REPO_ROOT}" +PASS_COUNT=0 +WARN_COUNT=0 +FAIL_COUNT=0 +FIX_COUNT=0 + +pass() { + PASS_COUNT=$((PASS_COUNT + 1)) + printf 'PASS %s\n' "$1" +} + +warn() { + WARN_COUNT=$((WARN_COUNT + 1)) + printf 'WARN %s\n' "$1" +} + +fail() { + FAIL_COUNT=$((FAIL_COUNT + 1)) + printf 'FAIL %s\n' "$1" +} + +fixed() { + FIX_COUNT=$((FIX_COUNT + 1)) + printf 'FIXED %s\n' "$1" +} + +expand_home() { + case "$1" in + ~/*) + printf '%s/%s' "$DEFAULT_CONFIG_HOME" "${1#~/}" + ;; + *) + printf '%s' "$1" + ;; + esac +} + +find_config() { + if [ -n "${AO_CONFIG_PATH:-}" ] && [ -f "$AO_CONFIG_PATH" ]; then + printf '%s\n' "$AO_CONFIG_PATH" + return 0 + fi + + local current_dir="$PWD" + while [ "$current_dir" != "/" ]; do + if [ -f "$current_dir/agent-orchestrator.yaml" ]; then + printf '%s\n' "$current_dir/agent-orchestrator.yaml" + return 0 + fi + if [ -f "$current_dir/agent-orchestrator.yml" ]; then + printf '%s\n' "$current_dir/agent-orchestrator.yml" + return 0 + fi + current_dir="$(dirname "$current_dir")" + done + + if [ -f "$REPO_ROOT/agent-orchestrator.yaml" ]; then + printf '%s\n' "$REPO_ROOT/agent-orchestrator.yaml" + return 0 + fi + + if [ -f "$DEFAULT_CONFIG_HOME/.agent-orchestrator.yaml" ]; then + printf '%s\n' "$DEFAULT_CONFIG_HOME/.agent-orchestrator.yaml" + return 0 + fi + + return 1 +} + +read_config_value() { + local key="$1" + local file="$2" + local raw + local value + raw="$(grep -E "^[[:space:]]*${key}:" "$file" | head -n 1 | cut -d: -f2- || true)" + raw="${raw%%[[:space:]]#*}" + value="$(printf '%s' "$raw" | tr -d '"' | xargs 2>/dev/null || true)" + printf '%s' "$value" +} + +ensure_dir() { + local dir_path="$1" + local label="$2" + local fix_hint="$3" + if [ -d "$dir_path" ]; then + pass "$label exists at $dir_path" + return 0 + fi + + if [ "$FIX_MODE" = true ]; then + if mkdir -p "$dir_path"; then + fixed "$label created at $dir_path" + return 0 + fi + fail "$label could not be created at $dir_path. Fix: $fix_hint" + return 1 + fi + + warn "$label is missing at $dir_path. Fix: $fix_hint" +} + +check_command() { + local name="$1" + local required="$2" + local fix_hint="$3" + local command_path + command_path="$(command -v "$name" 2>/dev/null || true)" + if [ -z "$command_path" ]; then + if [ "$required" = "required" ]; then + fail "$name is not in PATH. Fix: $fix_hint" + else + warn "$name is not in PATH. Fix: $fix_hint" + fi + return 1 + fi + + pass "$name resolves to $command_path" + return 0 +} + +check_node() { + if ! check_command "node" "required" "install Node.js 20+ and reopen your shell"; then + return + fi + local version major + version="$(node --version 2>/dev/null || true)" + major="${version#v}" + major="${major%%.*}" + if [ -z "$major" ] || [ "$major" -lt 20 ]; then + fail "Node.js 20+ is required, found ${version:-unknown}. Fix: install Node.js 20+" + return + fi + pass "Node.js version ${version} is supported" +} + +check_git() { + if ! check_command "git" "required" "install git 2.25+ and reopen your shell"; then + return + fi + local version major minor + version="$(git --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -n 1)" + major="${version%%.*}" + minor="${version#*.}" + minor="${minor%%.*}" + if [ -z "$version" ] || [ "$major" -lt 2 ] || { [ "$major" -eq 2 ] && [ "$minor" -lt 25 ]; }; then + fail "git 2.25+ is required, found ${version:-unknown}. Fix: upgrade git" + return + fi + pass "git version ${version} supports worktrees" +} + +check_pnpm() { + if ! check_command "pnpm" "required" "enable corepack or run npm install -g pnpm"; then + return + fi + local version + version="$(pnpm --version 2>/dev/null || true)" + pass "pnpm version ${version:-unknown} is available" +} + +check_launcher() { + local ao_path + ao_path="$(command -v ao 2>/dev/null || true)" + if [ -n "$ao_path" ]; then + pass "ao launcher resolves to $ao_path" + return + fi + + if [ "$FIX_MODE" = true ] && command -v npm >/dev/null 2>&1 && [ -d "$REPO_ROOT/packages/cli" ]; then + if (cd "$REPO_ROOT/packages/cli" && npm link >/dev/null 2>&1) && command -v ao >/dev/null 2>&1; then + fixed "ao launcher refreshed with npm link" + return + fi + warn "ao launcher refresh failed. Fix: cd $REPO_ROOT/packages/cli && npm link" + return + fi + + warn "ao launcher is not in PATH. Fix: cd $REPO_ROOT && bash scripts/setup.sh" +} + +check_tmux() { + if ! command -v tmux >/dev/null 2>&1; then + warn "tmux is not installed. Fix: install tmux for the default runtime" + return + fi + if tmux -V >/dev/null 2>&1 && tmux start-server >/dev/null 2>&1; then + pass "tmux is installed and the server can start" + return + fi + warn "tmux is installed but failed a basic server health check. Fix: restart tmux or reinstall it" +} + +check_gh() { + if ! command -v gh >/dev/null 2>&1; then + warn "GitHub CLI is not installed. Fix: install gh from https://cli.github.com/" + return + fi + if gh auth status >/dev/null 2>&1; then + pass "gh is installed and authenticated" + return + fi + warn "gh is installed but not authenticated. Fix: run gh auth login" +} + +check_install_layout() { + if [ -d "$REPO_ROOT/node_modules" ]; then + pass "dependencies are installed at $REPO_ROOT/node_modules" + else + fail "dependencies are missing at $REPO_ROOT/node_modules. Fix: run pnpm install" + fi + + if [ -f "$REPO_ROOT/packages/core/dist/index.js" ]; then + pass "core package is built" + else + fail "core package is not built. Fix: run pnpm --filter @composio/ao-core build" + fi + + if [ -f "$REPO_ROOT/packages/cli/dist/index.js" ]; then + pass "CLI package is built" + else + fail "CLI package is not built. Fix: run pnpm --filter @composio/ao-cli build" + fi +} + +check_runtime_sanity() { + if [ ! -f "$REPO_ROOT/packages/agent-orchestrator/bin/ao.js" ]; then + fail "launcher entrypoint is missing. Fix: reinstall from a clean checkout" + return + fi + + if node "$REPO_ROOT/packages/agent-orchestrator/bin/ao.js" --version >/dev/null 2>&1; then + pass "launcher runtime sanity check passed (ao --version)" + else + fail "launcher runtime sanity check failed. Fix: run pnpm build and refresh the launcher" + fi +} + +check_config_dirs() { + local config_path data_dir worktree_dir + config_path="$(find_config || true)" + if [ -z "$config_path" ]; then + warn "No agent-orchestrator config was found. Fix: run ao init --auto in a target repo" + return + fi + + pass "config found at $config_path" + data_dir="$(read_config_value dataDir "$config_path")" + worktree_dir="$(read_config_value worktreeDir "$config_path")" + + if [ -z "$data_dir" ]; then + data_dir="$DEFAULT_CONFIG_HOME/.agent-orchestrator" + fi + if [ -z "$worktree_dir" ]; then + worktree_dir="$DEFAULT_CONFIG_HOME/.worktrees" + fi + + data_dir="$(expand_home "$data_dir")" + worktree_dir="$(expand_home "$worktree_dir")" + + ensure_dir "$data_dir" "metadata directory" "mkdir -p $data_dir" + ensure_dir "$worktree_dir" "worktree directory" "mkdir -p $worktree_dir" +} + +check_stale_temp_files() { + local temp_root stale_count deleted_count + temp_root="${AO_DOCTOR_TMP_ROOT:-${TMPDIR:-/tmp}/agent-orchestrator}" + if [ ! -d "$temp_root" ]; then + pass "temp root exists check skipped because $temp_root does not exist" + return + fi + + stale_count="$(find "$temp_root" -maxdepth 1 -type f -mmin +60 \( -name 'ao-*.tmp' -o -name 'ao-*.pid' -o -name 'ao-*.lock' \) | wc -l | tr -d ' ')" + if [ "$stale_count" = "0" ]; then + pass "no stale temp files were detected under $temp_root" + return + fi + + if [ "$FIX_MODE" = true ]; then + deleted_count="$(find "$temp_root" -maxdepth 1 -type f -mmin +60 \( -name 'ao-*.tmp' -o -name 'ao-*.pid' -o -name 'ao-*.lock' \) -delete -print | wc -l | tr -d ' ')" + if [ "$deleted_count" = "$stale_count" ]; then + fixed "$deleted_count stale temp files removed from $temp_root" + return + fi + warn "Only removed $deleted_count of $stale_count stale temp files from $temp_root. Fix: inspect that directory manually" + return + fi + + warn "$stale_count stale temp files older than 60 minutes found under $temp_root. Fix: rerun ao doctor --fix" +} + +printf 'Agent Orchestrator Doctor\n\n' + +check_node +check_git +check_pnpm +check_launcher +check_tmux +check_gh +check_config_dirs +check_stale_temp_files +check_install_layout +check_runtime_sanity + +printf '\nResults: %s PASS, %s WARN, %s FAIL, %s FIXED\n' "$PASS_COUNT" "$WARN_COUNT" "$FAIL_COUNT" "$FIX_COUNT" + +if [ "$FAIL_COUNT" -gt 0 ]; then + printf 'Environment needs attention before AO is safe to update or run.\n' >&2 + exit 1 +fi + +printf 'Environment looks healthy enough to run Agent Orchestrator.\n' diff --git a/scripts/ao-update.sh b/scripts/ao-update.sh new file mode 100755 index 000000000..0168359f9 --- /dev/null +++ b/scripts/ao-update.sh @@ -0,0 +1,130 @@ +#!/bin/bash + +set -euo pipefail + +SKIP_SMOKE=false +SMOKE_ONLY=false +TARGET_BRANCH="${AO_UPDATE_BRANCH:-main}" + +while [ $# -gt 0 ]; do + case "$1" in + --skip-smoke) + SKIP_SMOKE=true + ;; + --smoke-only) + SMOKE_ONLY=true + ;; + -h|--help) + cat <<'EOF' +Usage: ao update [--skip-smoke] [--smoke-only] + +Fast-forwards the local Agent Orchestrator install repo to main, installs deps, +clean-rebuilds critical packages, refreshes the ao launcher, and runs smoke tests. + +Options: + --skip-smoke Skip smoke tests after rebuild + --smoke-only Run smoke tests without fetching or rebuilding +EOF + exit 0 + ;; + *) + printf 'Unknown option: %s\n' "$1" >&2 + exit 1 + ;; + esac + shift +done + +if [ "$SKIP_SMOKE" = true ] && [ "$SMOKE_ONLY" = true ]; then + printf 'Conflicting options: use either --skip-smoke or --smoke-only, not both.\n' >&2 + exit 1 +fi + +REPO_ROOT="${AO_REPO_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" + +require_command() { + local name="$1" + local fix_hint="$2" + if ! command -v "$name" >/dev/null 2>&1; then + printf 'Missing required command: %s. Fix: %s\n' "$name" "$fix_hint" >&2 + exit 1 + fi +} + +run_cmd() { + printf -- '-> %s\n' "$*" + "$@" +} + +run_smoke_tests() { + printf '\nRunning smoke tests...\n' + run_cmd node "$REPO_ROOT/packages/agent-orchestrator/bin/ao.js" --version + run_cmd node "$REPO_ROOT/packages/agent-orchestrator/bin/ao.js" doctor --help + run_cmd node "$REPO_ROOT/packages/agent-orchestrator/bin/ao.js" update --help +} + +ensure_repo_clean() { + local reason="$1" + local status_output + status_output="$(git status --porcelain)" + if [ -n "$status_output" ]; then + printf '%s\n' "$reason" >&2 + exit 1 + fi +} + +ensure_on_target_branch() { + local current_branch + current_branch="$(git branch --show-current)" + if [ "$current_branch" != "$TARGET_BRANCH" ]; then + printf 'Current branch is %s, expected %s. Fix: git switch %s && rerun ao update.\n' \ + "$current_branch" "$TARGET_BRANCH" "$TARGET_BRANCH" >&2 + exit 1 + fi +} + +printf 'Agent Orchestrator Update\n\n' + +require_command node "install Node.js 20+" + +cd "$REPO_ROOT" + +if [ "$SMOKE_ONLY" = false ]; then + require_command git "install git 2.25+" + require_command pnpm "enable corepack or run npm install -g pnpm" + require_command npm "install npm with Node.js" + + if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + printf 'The update command must run inside the Agent Orchestrator git checkout.\n' >&2 + exit 1 + fi + + ensure_repo_clean "Working tree is dirty. Fix: commit or stash local changes before running ao update." + ensure_on_target_branch + + run_cmd git fetch origin "$TARGET_BRANCH" + run_cmd git pull --ff-only origin "$TARGET_BRANCH" + run_cmd pnpm install + + run_cmd pnpm --filter @composio/ao-core clean + run_cmd pnpm --filter @composio/ao-cli clean + run_cmd pnpm --filter @composio/ao-web clean + + run_cmd pnpm --filter @composio/ao-core build + run_cmd pnpm --filter @composio/ao-cli build + run_cmd pnpm --filter @composio/ao-web build + + printf '\nRefreshing ao launcher...\n' + ( + cd "$REPO_ROOT/packages/cli" + run_cmd npm link + ) + + ensure_repo_clean "Update modified tracked files. Inspect git status, review the changes, and rerun after restoring a clean checkout if needed." +fi + +if [ "$SKIP_SMOKE" = false ]; then + run_smoke_tests +fi + +printf '\nUpdate complete.\n'