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
This commit is contained in:
parent
3deea32bda
commit
3d518aed2f
51
README.md
51
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 <session> # Kill a session
|
||||
ao session restore <session> # 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
|
||||
|
||||
|
|
|
|||
22
SETUP.md
22
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.
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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`.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<number> {
|
||||
const shell = process.env["AO_BASH_PATH"] || "bash";
|
||||
const scriptPath = resolveScriptPath(scriptName);
|
||||
|
||||
return await new Promise<number>((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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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'
|
||||
Loading…
Reference in New Issue