Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
90ee21c
fix: repair npm global install — walk-up dep resolution, spawn-helper…
suraj-markup Mar 23, 2026
7611734
fix: true two-step setup — tmux auto-install, process fallback, resta…
suraj-markup Mar 23, 2026
f546300
fix: block with clear tmux install instructions instead of process fa…
suraj-markup Mar 23, 2026
9d15ce5
fix(start): enforce tmux preflight across all start paths
suraj-markup Mar 24, 2026
eee9d77
fix: interactive tmux install with user consent, log before auto-install
suraj-markup Mar 24, 2026
fafe76c
docs: upgrade flow diagrams to visual CSS flowcharts
suraj-markup Mar 24, 2026
fea320e
docs: add prerequisite matrix and install behavior details
suraj-markup Mar 24, 2026
f0d6349
fix: derive skip option from AGENT_INSTALL_OPTIONS.length
suraj-markup Mar 25, 2026
32591c2
fix(cli): use pipx for aider install option
suraj-markup Mar 25, 2026
c5cc062
fix(cli): disable tmux auto-install in spawn preflight
suraj-markup Mar 26, 2026
c19aaf6
fix(cli): disable non-interactive auto-install for required tools
suraj-markup Mar 26, 2026
b976c36
fix(cli): remove required git/tmux auto-install attempts
suraj-markup Mar 26, 2026
d4ff10b
fix(cli): restore interactive installs for ao start prerequisites
suraj-markup Mar 26, 2026
07028ba
perf(cli): avoid redundant agent detection scan in start
suraj-markup Mar 26, 2026
aa7e360
refactor(cli): centralize agent runtime selection logic
suraj-markup Mar 26, 2026
cbdf2d2
docs: sync install-flow design doc with current start/preflight behavior
suraj-markup Mar 26, 2026
ac625c3
chore: add changeset for onboarding/install fixes
suraj-markup Mar 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,064 changes: 1,064 additions & 0 deletions docs/design-npm-global-install-fixes.html

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,18 @@ export default tseslint.config(
"no-console": "off", // Scripts use console for output
},
},

// ao bin scripts - Node.js environment (postinstall, etc.)
{
files: ["packages/ao/bin/**/*.js"],
languageOptions: {
globals: {
console: "readonly",
process: "readonly",
},
},
rules: {
"no-console": "off", // Bin scripts use console for install output
},
},
);
50 changes: 50 additions & 0 deletions packages/ao/bin/postinstall.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/usr/bin/env node
/**
* Postinstall script for @composio/ao (npm/yarn global installs).
*
* Fixes node-pty's spawn-helper binary missing the execute bit.
* node-pty@1.1.0 ships spawn-helper without +x; the monorepo works around
* this via scripts/rebuild-node-pty.js, but that never runs for global installs.
*
* Upstream fix: microsoft/node-pty#866 (only in 1.2.0-beta, not stable yet).
*/

import { chmodSync, existsSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";

// No-op on Windows — different PTY mechanism
if (process.platform === "win32") process.exit(0);

const __dirname = dirname(fileURLToPath(import.meta.url));

function findPackageUp(startDir, ...segments) {
let dir = resolve(startDir);
while (true) {
const candidate = resolve(dir, "node_modules", ...segments);
if (existsSync(candidate)) return candidate;
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}

const nodePtyDir = findPackageUp(__dirname, "node-pty");
if (!nodePtyDir) process.exit(0);

const spawnHelper = resolve(
nodePtyDir,
"prebuilds",
`${process.platform}-${process.arch}`,
"spawn-helper",
);

if (!existsSync(spawnHelper)) process.exit(0);

try {
chmodSync(spawnHelper, 0o755);
console.log("\u2713 node-pty spawn-helper permissions set");
} catch {
console.warn("\u26a0\ufe0f Could not set spawn-helper permissions (non-critical)");
}
3 changes: 3 additions & 0 deletions packages/ao/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
"bin": {
"ao": "bin/ao.js"
},
"scripts": {
"postinstall": "node bin/postinstall.js"
},
"files": [
"bin"
],
Expand Down
20 changes: 17 additions & 3 deletions packages/cli/__tests__/commands/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ vi.mock("../../src/lib/preflight.js", () => ({
preflight: {
checkPort: vi.fn(),
checkBuilt: vi.fn(),
checkTmux: vi.fn().mockResolvedValue(undefined),
},
}));

Expand Down Expand Up @@ -192,8 +193,16 @@ beforeEach(() => {
mockSessionManager.kill.mockReset();
mockExec.mockReset();
mockExecSilent.mockReset();
// Default: execSilent returns null (gh not available), so clone falls through to git SSH/HTTPS
mockExecSilent.mockResolvedValue(null);
// Default command availability:
// - git and tmux are installed
// - gh auth is unavailable (clone falls through to git SSH/HTTPS)
mockExecSilent.mockImplementation(async (cmd: string, args: string[] = []) => {
if (cmd === "git" && args[0] === "--version") return "git version 2.43.0";
if (cmd === "tmux" && args[0] === "-V") return "tmux 3.4";
if (cmd === "gh" && args[0] === "--version") return null;
if (cmd === "gh" && args[0] === "auth" && args[1] === "status") return null;
return null;
});
mockWaitForPortAndOpen.mockReset();
mockWaitForPortAndOpen.mockResolvedValue(undefined);
mockEnsureLifecycleWorker.mockReset();
Expand Down Expand Up @@ -435,7 +444,12 @@ describe("start command — URL argument", () => {
mockCwd(tmpDir);

// gh auth status fails (not installed or not logged in)
mockExecSilent.mockResolvedValue(null);
mockExecSilent.mockImplementation(async (cmd: string, args: string[] = []) => {
if (cmd === "git" && args[0] === "--version") return "git version 2.43.0";
if (cmd === "tmux" && args[0] === "-V") return "tmux 3.4";
if (cmd === "gh" && args[0] === "auth" && args[1] === "status") return null;
return null;
});

mockExec.mockImplementation(async (cmd: string, args: string[]) => {
// SSH attempt fails
Expand Down
61 changes: 43 additions & 18 deletions packages/cli/__tests__/lib/preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,49 +47,74 @@ describe("preflight.checkPort", () => {
});

describe("preflight.checkBuilt", () => {
it("passes when node_modules and core dist exist", async () => {
it("passes when ao-core and dist exist at webDir level (pnpm layout)", async () => {
// findPackageUp finds ao-core on first check (pnpm symlink in webDir/node_modules)
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
it("finds ao-core when hoisted one level up (npm global install layout)", async () => {
// /web/node_modules/@composio/ao-core — miss
// /node_modules/@composio/ao-core — hit
// /node_modules/@composio/ao-core/dist/index.js — exists
mockExistsSync
.mockReturnValueOnce(false)
.mockReturnValueOnce(true)
.mockReturnValueOnce(true);
await expect(preflight.checkBuilt("/web")).resolves.toBeUndefined();
});

it("throws npm hint when ao-core not found in global install", async () => {
mockExistsSync.mockReturnValue(false);
await expect(preflight.checkBuilt("/web")).rejects.toThrow(
"pnpm install",
);
await expect(
preflight.checkBuilt("/usr/local/lib/node_modules/@composio/ao-web"),
).rejects.toThrow("npm install -g @composio/ao@latest");
});

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
it("throws pnpm hint when ao-core not found in monorepo", async () => {
mockExistsSync.mockReturnValue(false);
await expect(
preflight.checkBuilt("/home/user/agent-orchestrator/packages/web"),
).rejects.toThrow("pnpm install && pnpm build");
});

it("throws 'pnpm build' when ao-core exists but dist is missing", async () => {
// findPackageUp finds ao-core, but dist/index.js is missing
mockExistsSync
.mockReturnValueOnce(true)
.mockReturnValueOnce(false);
await expect(preflight.checkBuilt("/web")).rejects.toThrow(
"Packages not built. Run: pnpm build",
"Packages not built",
);
});
});

describe("preflight.checkTmux", () => {
it("passes when tmux is installed", async () => {
it("passes when tmux is already 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("attempts auto-install when tmux is missing", async () => {
// First call: tmux -V fails (not installed)
// Second call: auto-install attempt (brew/apt/dnf)
// Third call: tmux -V succeeds (installed now)
mockExec
.mockRejectedValueOnce(new Error("ENOENT")) // tmux -V
.mockResolvedValueOnce({ stdout: "", stderr: "" }) // install command
.mockResolvedValueOnce({ stdout: "tmux 3.3a", stderr: "" }); // verify
await expect(preflight.checkTmux()).resolves.toBeUndefined();
});

it("includes install instruction in error", async () => {
it("throws with install instructions when auto-install fails", async () => {
// All attempts fail
mockExec.mockRejectedValue(new Error("ENOENT"));
await expect(preflight.checkTmux()).rejects.toThrow("brew install tmux");
const err = await preflight.checkTmux().catch((e: Error) => e);
expect(err).toBeInstanceOf(Error);
expect(err.message).toContain("tmux is not installed");
expect(err.message).toContain("Install it:");
});
});

Expand Down
Loading
Loading