diff --git a/packages/cli/__tests__/scripts/doctor-script.test.ts b/packages/cli/__tests__/scripts/doctor-script.test.ts index d31578961..29381c206 100644 --- a/packages/cli/__tests__/scripts/doctor-script.test.ts +++ b/packages/cli/__tests__/scripts/doctor-script.test.ts @@ -6,6 +6,7 @@ import { mkdirSync, readFileSync, rmSync, + symlinkSync, utimesSync, writeFileSync, } from "node:fs"; @@ -181,7 +182,7 @@ describe("ao-doctor.sh", () => { expect(result.status).toBe(0); expect(result.stdout).toContain("FIXED"); - expect(npmCommands).toContain("link"); + expect(npmCommands).toContain("link --force"); expect(result.stdout).toContain("launcher"); expect(result.stdout).toContain("stale temp files"); expect(staleStillExists).toBe(false); @@ -191,6 +192,63 @@ describe("ao-doctor.sh", () => { expect(commentedWorktreeDirExists).toBe(false); }); + it("repairs a dangling ao launcher shim in fix mode", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "ao-doctor-dangling-launcher-")); + const fakeRepo = createHealthyRepo(tempRoot); + const binDir = join(tempRoot, "bin"); + mkdirSync(binDir, { recursive: true }); + createHealthyPath(binDir); + + const aoPath = join(binDir, "ao"); + rmSync(aoPath, { force: true }); + symlinkSync(join(tempRoot, "deleted-checkout", "dist", "index.js"), aoPath); + + const npmLog = join(tempRoot, "npm.log"); + createFakeBinary( + binDir, + "npm", + `printf '%s\n' "$*" >> ${JSON.stringify(npmLog)} +if [ "$1" = "link" ]; then + rm -f ${JSON.stringify(aoPath)} + printf '#!/bin/bash\nexit 0\n' > ${JSON.stringify(aoPath)} + chmod +x ${JSON.stringify(aoPath)} +fi +if [ "$1" = "bin" ]; then + printf "/tmp/npm-bin\n" +fi +exit 0`, + ); + + 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, "--fix"], { + env: { + ...process.env, + PATH: `${binDir}:/bin:/usr/bin`, + AO_REPO_ROOT: fakeRepo, + AO_CONFIG_PATH: configPath, + }, + encoding: "utf8", + }); + + const npmCommands = existsSync(npmLog) ? readFileSync(npmLog, "utf8") : ""; + const repairedLauncherIsExecutable = existsSync(aoPath); + rmSync(tempRoot, { recursive: true, force: true }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("FIXED"); + expect(repairedLauncherIsExecutable).toBe(true); + expect(npmCommands).toContain("link --force"); + }); + it("reports a healthy packaged install without source-checkout failures", () => { const tempRoot = mkdtempSync(join(tmpdir(), "ao-doctor-package-")); const fakeInstall = createHealthyPackageInstall(tempRoot); diff --git a/packages/cli/__tests__/scripts/update-script.test.ts b/packages/cli/__tests__/scripts/update-script.test.ts index 4c1966cf8..c6e20c7b5 100644 --- a/packages/cli/__tests__/scripts/update-script.test.ts +++ b/packages/cli/__tests__/scripts/update-script.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect } from "vitest"; -import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + 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"; @@ -76,7 +84,7 @@ esac\nexit 0`, expect(commands).toContain("pnpm install"); expect(commands).toContain("pnpm --filter @aoagents/ao-core clean"); expect(commands).toContain("pnpm --filter @aoagents/ao-cli build"); - expect(commands).toContain("npm link"); + expect(commands).toContain("npm link --force"); }); it("syncs the fork with upstream via gh and fast-forwards the local checkout from upstream", () => { @@ -146,14 +154,77 @@ esac\nexit 0`, expect(commands).not.toContain("git fetch origin main"); }); + it("uses forced npm link so stale global ao shims are overwritten", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-stale-shim-")); + const fakeRepo = join(tempRoot, "repo"); + mkdirSync(join(fakeRepo, "packages", "cli"), { recursive: true }); + mkdirSync(join(fakeRepo, "packages", "ao"), { recursive: true }); + + const binDir = join(tempRoot, "bin"); + mkdirSync(binDir, { recursive: true }); + const commandLog = join(tempRoot, "commands.log"); + + createFakeBinary( + binDir, + "git", + `case "$*" in + "remote get-url upstream") exit 1 ;; + "rev-parse --is-inside-work-tree") printf 'true\n' ;; + "status --porcelain") ;; + "branch --show-current") printf 'main\n' ;; + "fetch origin main") ;; + "rev-parse HEAD") printf 'oldsha000\n' ;; + "rev-parse origin/main") printf 'newsha111\n' ;; + "pull --ff-only origin main") ;; +esac +exit 0`, + ); + createFakeBinary( + binDir, + "pnpm", + `if [ "$1" = "--version" ]; then printf '9.15.4\n'; fi +exit 0`, + ); + createFakeBinary( + binDir, + "npm", + `printf 'npm %s\n' "$*" >> ${JSON.stringify(commandLog)} +if [ "$*" = "link" ]; then + printf 'npm error code EEXIST\n' >&2 + exit 1 +fi +exit 0`, + ); + createFakeBinary( + binDir, + "node", + `if [ "$1" = "--version" ]; then printf 'v20.11.1\n'; fi +exit 0`, + ); + + const result = spawnSync("bash", [scriptPath, "--skip-smoke"], { + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH || ""}`, + AO_REPO_ROOT: fakeRepo, + }, + encoding: "utf8", + }); + + const commands = existsSync(commandLog) ? readFileSync(commandLog, "utf8") : ""; + rmSync(tempRoot, { recursive: true, force: true }); + + expect(result.status).toBe(0); + expect(commands).toContain("npm link --force"); + expect(commands).not.toContain("npm link\n"); + expect(result.stdout).not.toContain("Permission denied"); + }); + 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", "ao", "bin"), { recursive: true }); - writeFileSync( - join(fakeRepo, "packages", "ao", "bin", "ao.js"), - "#!/usr/bin/env node\n", - ); + writeFileSync(join(fakeRepo, "packages", "ao", "bin", "ao.js"), "#!/usr/bin/env node\n"); const binDir = join(tempRoot, "bin"); mkdirSync(binDir, { recursive: true }); @@ -270,7 +341,11 @@ exit 0`, "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, + "npm", + `printf 'npm %s\\n' "$*" >> ${JSON.stringify(commandLog)}\nexit 0`, + ); createFakeBinary( binDir, "node", diff --git a/packages/cli/src/assets/scripts/ao-doctor.sh b/packages/cli/src/assets/scripts/ao-doctor.sh index 1cb4a0950..825cef424 100755 --- a/packages/cli/src/assets/scripts/ao-doctor.sh +++ b/packages/cli/src/assets/scripts/ao-doctor.sh @@ -215,23 +215,27 @@ 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 + if [ -x "$ao_path" ]; then + pass "ao launcher resolves to $ao_path" + return + fi + warn "ao launcher resolves to $ao_path, but its target is missing or not executable" fi if [ "$SCRIPT_LAYOUT" = "source-checkout" ] && [ "$FIX_MODE" = true ] && command -v npm >/dev/null 2>&1 && [ -d "$REPO_ROOT/packages/ao" ]; then - if (cd "$REPO_ROOT/packages/ao" && npm link >/dev/null 2>&1) && command -v ao >/dev/null 2>&1; then - fixed "ao launcher refreshed with npm link" + if (cd "$REPO_ROOT/packages/ao" && npm link --force >/dev/null 2>&1) && command -v ao >/dev/null 2>&1; then + fixed "ao launcher refreshed with npm link --force" return fi if [ -t 0 ]; then - printf ' Permission denied. Retrying with sudo...\n' - if (cd "$REPO_ROOT/packages/ao" && sudo npm link >/dev/null 2>&1) && command -v ao >/dev/null 2>&1; then - fixed "ao launcher refreshed with sudo npm link" + printf ' Launcher refresh failed. Retrying with sudo...\n' + if (cd "$REPO_ROOT/packages/ao" && sudo npm link --force >/dev/null 2>&1) && command -v ao >/dev/null 2>&1; then + fixed "ao launcher refreshed with sudo npm link --force" return fi + printf 'ERROR: sudo npm link --force failed. Inspect npm output above.\n' >&2 fi - warn "ao launcher refresh failed. Fix: cd $REPO_ROOT/packages/ao && sudo npm link" + warn "ao launcher refresh failed. Fix: cd $REPO_ROOT/packages/ao && sudo npm link --force" return fi diff --git a/packages/cli/src/assets/scripts/ao-update.sh b/packages/cli/src/assets/scripts/ao-update.sh index dc9876baf..095085b75 100755 --- a/packages/cli/src/assets/scripts/ao-update.sh +++ b/packages/cli/src/assets/scripts/ao-update.sh @@ -197,13 +197,20 @@ if [ "$SMOKE_ONLY" = false ]; then printf '\nRefreshing ao launcher...\n' ( cd "$REPO_ROOT/packages/ao" - if npm link 2>/dev/null; then - : + npm_link_error="$(mktemp)" + if npm link --force 2>"$npm_link_error"; then + rm -f "$npm_link_error" elif [ -t 0 ]; then - printf ' Permission denied. Retrying with sudo...\n' - sudo npm link + rm -f "$npm_link_error" + printf ' Launcher refresh failed. Retrying with sudo...\n' + if ! sudo npm link --force; then + printf 'ERROR: sudo npm link --force failed. Inspect npm output above.\n' >&2 + exit 1 + fi else - printf 'ERROR: Permission denied. Run manually: cd %s/packages/ao && sudo npm link\n' "$REPO_ROOT" + cat "$npm_link_error" >&2 + rm -f "$npm_link_error" + printf 'ERROR: Launcher refresh failed. Run manually: cd %s/packages/ao && sudo npm link --force\n' "$REPO_ROOT" exit 1 fi ) diff --git a/scripts/setup.sh b/scripts/setup.sh index ce50fb3c7..7d396dede 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -137,13 +137,20 @@ pnpm build echo "" echo "Linking CLI globally..." cd packages/ao -if npm link 2>/dev/null; then - : +npm_link_error="$(mktemp)" +if npm link --force 2>"$npm_link_error"; then + rm -f "$npm_link_error" elif [ "$INTERACTIVE" = true ]; then - echo " Permission denied. Retrying with sudo..." - sudo npm link + rm -f "$npm_link_error" + echo " Launcher refresh failed. Retrying with sudo..." + if ! sudo npm link --force; then + echo "ERROR: sudo npm link --force failed. Inspect npm output above." >&2 + exit 1 + fi else - echo "ERROR: Permission denied. Run manually: cd packages/ao && sudo npm link" + cat "$npm_link_error" >&2 + rm -f "$npm_link_error" + echo "ERROR: Launcher refresh failed. Run manually: cd packages/ao && sudo npm link --force" exit 1 fi cd "$REPO_ROOT"