diff --git a/.changeset/config.json b/.changeset/config.json index 085b2dc31..07ccb4343 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -14,20 +14,30 @@ "@aoagents/ao-plugin-agent-codex", "@aoagents/ao-plugin-agent-aider", "@aoagents/ao-plugin-agent-opencode", + "@aoagents/ao-plugin-agent-cursor", + "@aoagents/ao-plugin-agent-kimicode", "@aoagents/ao-plugin-workspace-worktree", "@aoagents/ao-plugin-workspace-clone", "@aoagents/ao-plugin-tracker-github", "@aoagents/ao-plugin-tracker-linear", + "@aoagents/ao-plugin-tracker-gitlab", "@aoagents/ao-plugin-scm-github", + "@aoagents/ao-plugin-scm-gitlab", "@aoagents/ao-plugin-notifier-desktop", "@aoagents/ao-plugin-notifier-slack", "@aoagents/ao-plugin-notifier-webhook", "@aoagents/ao-plugin-notifier-composio", + "@aoagents/ao-plugin-notifier-discord", + "@aoagents/ao-plugin-notifier-openclaw", "@aoagents/ao-plugin-terminal-iterm2", "@aoagents/ao-plugin-terminal-web", "@aoagents/ao-web" ] ], + "snapshot": { + "useCalculatedVersionForSnapshots": true, + "prereleaseTemplate": "{tag}-{commit}" + }, "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", diff --git a/.changeset/release-train.md b/.changeset/release-train.md new file mode 100644 index 000000000..527832eb6 --- /dev/null +++ b/.changeset/release-train.md @@ -0,0 +1,52 @@ +--- +"@aoagents/ao-core": minor +"@aoagents/ao-cli": minor +"@aoagents/ao": minor +"@aoagents/ao-web": minor +--- + +feat(release): weekly release train — channels, onboarding, dashboard banner, cron + +Ships the full release pipeline described in `release-process.html`: + +- **Cron-driven nightly canary.** `.github/workflows/canary.yml` triggers via + `schedule: '0 18 * * 5,6,0,1,2'` (23:30 IST Fri–Tue) plus `workflow_dispatch`. + Bake window (Wed–Thu) pauses scheduled nightlies; the captain re-cuts via + workflow_dispatch when a fix lands. Stable `release.yml` publishes via + `changesets/action`. `.changeset/config.json` adds the snapshot template + (`{tag}-{commit}`). `@aoagents/ao-web` stays in the linked group and ships + alongside `@aoagents/ao-cli` (it's a workspace:* runtime dep, so marking it + private would 404 every `npm install -g @aoagents/ao` after publish). + `scripts/check-publishable-deps.mjs` runs in both release.yml and canary.yml + before the publish step and fails CI if a publishable package depends on a + `private: true` package via workspace:*. + +- **Update channels.** New `updateChannel` field in the global config schema + (`stable | nightly | manual`, default `manual` so existing users see no + surprise installs). `update-check.ts` reads `dist-tags[channel]` from the + npm registry, compares prerelease versions segment-by-segment so SHA-suffixed + nightlies sort correctly, and skips notices entirely on `manual`. + +- **Soft auto-install + active-session guard.** On stable/nightly, `ao update` + skips the confirm prompt and just installs. Before installing it lists + sessions and refuses with `N session(s) active. Run \`ao stop\` first.` if + any are in `working`/`idle`/`needs_input`/`stuck`. Same guard duplicated + in `POST /api/update` so the dashboard returns a structured 409. + +- **Onboarding question.** `ao start` prompts once for the channel if unset; + dismissal persists `manual`. `ao config set updateChannel ` (and + `installMethod`) lets users change it later. + +- **Dashboard banner.** `GET /api/version` reads the same cache file as the + CLI. `UpdateBanner` (Tailwind only, `var(--color-*)` tokens) appears at the + top of the dashboard when `isOutdated`. Click POSTs to `/api/update`; + dismissal persists per-version in `localStorage`. + +- **Bun + Homebrew detection.** New install-method classifiers for + `~/.bun/install/global/` (auto-installs `bun add -g @aoagents/ao@`) + and `/Cellar/ao/` (notice only — `brew upgrade ao` to avoid clobbering + brew's symlinks). `installMethod` config field overrides path detection. + +Supersedes #1525 (incorporates the canary + release infrastructure with the +cron / no-stale-SHA-guard / no-merged-PR-comment modifications called out in +the design doc). diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml new file mode 100644 index 000000000..fb42f6996 --- /dev/null +++ b/.github/workflows/canary.yml @@ -0,0 +1,65 @@ +name: Canary + +# Nightly canary publishes the current tip of `main` to npm under @nightly. +# Cron schedule: 23:30 IST = 18:00 UTC, on Fri,Sat,Sun,Mon,Tue +# (DOW 5,6,0,1,2). Wed/Thu are the bake window — no scheduled publishes. +# `workflow_dispatch` lets the release captain re-cut a nightly during bake +# when a fix lands and the Discord cohort should test the patched candidate. +on: + schedule: + - cron: "0 18 * * 5,6,0,1,2" + workflow_dispatch: + +concurrency: + group: canary + cancel-in-progress: false + +permissions: + contents: read + id-token: write + +jobs: + canary: + name: Publish canary + runs-on: ubuntu-latest + environment: release + steps: + - uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + registry-url: https://registry.npmjs.org + + - run: echo "HUSKY=0" >> $GITHUB_ENV + + - run: pnpm install --frozen-lockfile + + - run: pnpm -r build + + # Pre-publish guard: same as release.yml — catches workspace:* deps on + # private packages before they'd silently break a published install. + - run: node scripts/check-publishable-deps.mjs + + - name: Create snapshot versions + run: | + # If no changesets exist (e.g. right after a Version Packages merge), + # create a minimal one. `changeset version --snapshot` consumes and + # deletes it, so no cleanup is needed. + if [ -z "$(ls .changeset/*.md 2>/dev/null | grep -vi 'README')" ]; then + printf -- '---\n"@aoagents/ao": patch\n---\n\nchore: canary build\n' > .changeset/canary-temp.md + fi + pnpm changeset version --snapshot nightly + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish canary + run: pnpm changeset publish --tag nightly --no-git-tag + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_PROVENANCE: "true" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..cee557905 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,64 @@ +name: Release + +# Stable @latest publishes when CI passes on `main` and a Version Packages PR +# is merged. The changesets/action below opens/updates that PR automatically; +# merging it runs `changeset publish` and pushes to npm. +on: + workflow_run: + # Depends on the workflow named "CI" in .github/workflows/ci.yml — if + # you rename that file or change its `name:` field, update this string + # too. GitHub matches by name (not filename) and silently no-ops on + # mismatch — so a rename here will mean releases never trigger again. + workflows: [CI] + types: [completed] + branches: [main] + +concurrency: + group: release + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + id-token: write + +jobs: + release: + name: Release + runs-on: ubuntu-latest + environment: release + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + fetch-depth: 0 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + registry-url: https://registry.npmjs.org + - run: echo "HUSKY=0" >> $GITHUB_ENV + - run: pnpm install --frozen-lockfile + - run: pnpm -r build + # Pre-publish guard: catches the case where a publishable package has a + # workspace:* runtime dep on a `private: true` package — pnpm would + # rewrite the dep on publish to a version that doesn't exist on npm, + # breaking `npm install -g @aoagents/ao`. + - run: node scripts/check-publishable-deps.mjs + - uses: changesets/action@v1 + with: + publish: pnpm changeset publish + version: pnpm changeset version + # Creates/updates a PR titled "chore: version packages" when changesets + # are pending. Merging that PR publishes to npm under @latest. + title: "chore: version packages" + commit: "chore: version packages" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_PROVENANCE: "true" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e4fb28ee3..a1df81037 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,6 +70,24 @@ ao update `ao update` fast-forwards the local install repo, reinstalls dependencies, clean-rebuilds `@aoagents/ao-core`, `@aoagents/ao-cli`, and `@aoagents/ao-web`, refreshes the global launcher with `npm link`, and finishes with CLI smoke tests. Use `ao update --skip-smoke` when you only need the rebuild step, or `ao update --smoke-only` when validating an existing install. +## Release Setup (maintainers only) + +The canary and stable release workflows require one secret configured in GitHub repo settings: + +- `NPM_TOKEN` — an npm automation token with publish access to the `@aoagents` org. Add it at **Settings → Secrets and variables → Actions → New repository secret**. + +Without this secret, both `release.yml` and `canary.yml` will fail at the publish step. + +## Testing your changes + +### Latest main at any time + +```bash +npm install -g @aoagents/ao@nightly +``` + +The nightly cron publishes from `main` daily at 23:30 IST (Fri–Tue). The bake window (Wed–Thu) pauses scheduled nightlies; release captains can re-cut a nightly via `workflow_dispatch` if a fix lands during bake. + --- ## Building a Plugin diff --git a/README.md b/README.md index db4c11d26..966e02837 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,9 @@ Agent Orchestrator manages fleets of AI coding agents working in parallel on you npm install -g @aoagents/ao ``` +> **Nightly builds** (latest `main`, daily Fri–Tue): `npm install -g @aoagents/ao@nightly` +> Back to stable: `npm install -g @aoagents/ao@latest` +
Permission denied? Install from source? diff --git a/packages/ao/package.json b/packages/ao/package.json index 7279505f4..7f137042a 100644 --- a/packages/ao/package.json +++ b/packages/ao/package.json @@ -27,5 +27,9 @@ }, "dependencies": { "@aoagents/ao-cli": "workspace:*" + }, + "publishConfig": { + "access": "public", + "provenance": true } } diff --git a/packages/cli/__tests__/commands/start.test.ts b/packages/cli/__tests__/commands/start.test.ts index 06041c2f3..841830617 100644 --- a/packages/cli/__tests__/commands/start.test.ts +++ b/packages/cli/__tests__/commands/start.test.ts @@ -237,6 +237,17 @@ vi.mock("../../src/lib/prompts.js", () => ({ promptConfirm: (...args: unknown[]) => mockPromptConfirm(...args), })); +// Stub the update-channel onboarding so `runStartup` doesn't touch the real +// global config file under ~/.agent-orchestrator. Without this, a test that +// reaches runStartup writes `updateChannel` to disk, which makes subsequent +// tests load that config and report wrong errors (e.g. "No projects +// configured" instead of the expected "project not found"). +vi.mock("../../src/lib/update-channel-onboarding.js", () => ({ + maybePromptForUpdateChannel: vi.fn(async () => {}), + hasChosenUpdateChannel: vi.fn(() => true), + persistUpdateChannel: vi.fn(), +})); + // Mock node:child_process — start.ts imports spawn for dashboard + browser open vi.mock("node:child_process", async (importOriginal) => { // eslint-disable-next-line @typescript-eslint/consistent-type-imports diff --git a/packages/cli/__tests__/commands/update.test.ts b/packages/cli/__tests__/commands/update.test.ts index a0797e226..72cb31047 100644 --- a/packages/cli/__tests__/commands/update.test.ts +++ b/packages/cli/__tests__/commands/update.test.ts @@ -39,12 +39,75 @@ const { }), })); +const { mockResolveUpdateChannel, mockReadCachedUpdateInfo } = vi.hoisted(() => ({ + mockResolveUpdateChannel: vi.fn(() => "manual" as "stable" | "nightly" | "manual"), + mockReadCachedUpdateInfo: vi.fn<() => { channel?: string } | null>(() => null), +})); + vi.mock("../../src/lib/update-check.js", () => ({ detectInstallMethod: () => mockDetectInstallMethod(), checkForUpdate: (...args: unknown[]) => mockCheckForUpdate(...args), invalidateCache: () => mockInvalidateCache(), getCurrentVersion: () => mockGetCurrentVersion(), getUpdateCommand: (...args: unknown[]) => mockGetUpdateCommand(...args), + resolveUpdateChannel: () => mockResolveUpdateChannel(), + readCachedUpdateInfo: (...args: unknown[]) => mockReadCachedUpdateInfo(...args), + isManualOnlyInstall: (m: string) => m === "homebrew", +})); + +// Stub the active-session guard's dependencies so handlers don't try to load +// real config / spawn plugins. Default: no sessions, so the guard passes. +const { mockSessions } = vi.hoisted(() => ({ + mockSessions: { value: [] as Array<{ id: string; status: string }> }, +})); + +vi.mock("../../src/lib/create-session-manager.js", () => ({ + getSessionManager: vi.fn(async () => ({ + list: async () => mockSessions.value, + })), +})); + +import type * as AoCoreType from "@aoagents/ao-core"; +import type * as FsType from "node:fs"; + +const { mockIsWindows, mockLoadConfig, mockLoadGlobalConfig, mockExistsSync } = vi.hoisted(() => ({ + mockIsWindows: vi.fn(() => false), + mockLoadConfig: vi.fn(), + mockLoadGlobalConfig: vi.fn(), + mockExistsSync: vi.fn(() => false), +})); + +vi.mock("@aoagents/ao-core", async () => { + const actual = (await vi.importActual("@aoagents/ao-core")) as typeof AoCoreType; + return { + ...actual, + loadConfig: (...args: unknown[]) => mockLoadConfig(...args), + loadGlobalConfig: (...args: unknown[]) => mockLoadGlobalConfig(...args), + getGlobalConfigPath: () => "/tmp/test-global-config.yaml", + isCanonicalGlobalConfigPath: (p: string | undefined) => + p === "/tmp/test-global-config.yaml", + isWindows: () => mockIsWindows(), + }; +}); + +vi.mock("node:fs", async () => { + const actual = (await vi.importActual("node:fs")) as typeof FsType; + return { + ...actual, + existsSync: (path: string) => mockExistsSync(path), + }; +}); + +// running.json is the live signal: ensureNoActiveSessions now consults +// `getRunning()` before falling back to the global registry. Default to +// "no daemon running" so the existing global-config-driven tests keep +// exercising the fallback path. Per-test overrides simulate a live daemon. +const { mockGetRunning } = vi.hoisted(() => ({ + mockGetRunning: vi.fn<() => Promise>(async () => null), +})); + +vi.mock("../../src/lib/running-state.js", () => ({ + getRunning: () => mockGetRunning(), })); const { mockPromptConfirm } = vi.hoisted(() => ({ @@ -69,6 +132,7 @@ vi.mock("node:child_process", async () => { }); import { registerUpdate } from "../../src/commands/update.js"; +import type { InstallMethod } from "../../src/lib/update-check.js"; import { EventEmitter } from "node:events"; function makeNpmUpdateInfo(overrides = {}) { @@ -107,6 +171,24 @@ describe("update command", () => { mockPromptConfirm.mockReset(); mockPromptConfirm.mockResolvedValue(false); mockSpawn.mockReset(); + mockResolveUpdateChannel.mockReset(); + mockResolveUpdateChannel.mockReturnValue("manual"); + mockReadCachedUpdateInfo.mockReset(); + mockReadCachedUpdateInfo.mockReturnValue(null); + mockIsWindows.mockReset(); + mockIsWindows.mockReturnValue(false); + // Default: project-local loadConfig succeeds with no projects, and no + // global-config file exists. Tests opt into the global-config code path + // by making mockLoadConfig throw and mockExistsSync return true. + mockLoadConfig.mockReset(); + mockLoadConfig.mockReturnValue({ projects: {}, configPath: "/tmp/test-config.yaml" }); + mockLoadGlobalConfig.mockReset(); + mockLoadGlobalConfig.mockReturnValue(null); + mockExistsSync.mockReset(); + mockExistsSync.mockReturnValue(false); + mockGetRunning.mockReset(); + mockGetRunning.mockResolvedValue(null); // default: no live daemon + mockSessions.value = []; origStdinTTY = process.stdin.isTTY; origStdoutTTY = process.stdout.isTTY; vi.spyOn(console, "error").mockImplementation(() => {}); @@ -133,6 +215,43 @@ describe("update command", () => { expect(mockRunRepoScript).not.toHaveBeenCalled(); }); + describe("git-only flags rejected on non-git installs", () => { + it.each(["npm-global", "pnpm-global", "bun-global", "homebrew", "unknown"])( + "rejects --skip-smoke on %s installs with an actionable message", + async (method) => { + mockDetectInstallMethod.mockReturnValue(method as InstallMethod); + const errSpy = vi.mocked(console.error); + await expect( + program.parseAsync(["node", "test", "update", "--skip-smoke"]), + ).rejects.toThrow("process.exit(1)"); + const messages = errSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(messages).toMatch(/--skip-smoke only applies to git installs/); + expect(mockRunRepoScript).not.toHaveBeenCalled(); + expect(mockSpawn).not.toHaveBeenCalled(); + }, + ); + + it("rejects --smoke-only on npm installs with an actionable message", async () => { + mockDetectInstallMethod.mockReturnValue("npm-global"); + const errSpy = vi.mocked(console.error); + await expect( + program.parseAsync(["node", "test", "update", "--smoke-only"]), + ).rejects.toThrow("process.exit(1)"); + const messages = errSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(messages).toMatch(/--smoke-only only applies to git installs/); + }); + + it("still accepts --skip-smoke on git installs", async () => { + mockDetectInstallMethod.mockReturnValue("git"); + mockRunRepoScript.mockResolvedValue(0); + await program.parseAsync(["node", "test", "update", "--skip-smoke"]); + expect(mockRunRepoScript).toHaveBeenCalledWith( + "ao-update.sh", + expect.arrayContaining(["--skip-smoke"]), + ); + }); + }); + // ----------------------------------------------------------------------- // --check // ----------------------------------------------------------------------- @@ -254,19 +373,11 @@ describe("update command", () => { ); }); - it("warns when --skip-smoke is used", async () => { - mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ isOutdated: false })); - const logSpy = vi.mocked(console.log); - - await program.parseAsync(["node", "test", "update", "--skip-smoke"]); - expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining("only apply to git source installs"), - ); - }); - it("forces a fresh registry fetch", async () => { await program.parseAsync(["node", "test", "update"]); - expect(mockCheckForUpdate).toHaveBeenCalledWith({ force: true }); + expect(mockCheckForUpdate).toHaveBeenCalledWith( + expect.objectContaining({ force: true }), + ); }); it("prints command and exits cleanly in non-TTY mode without prompting", async () => { @@ -404,7 +515,649 @@ describe("update command", () => { it("suggests npm install command", async () => { mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ installMethod: "unknown" })); await program.parseAsync(["node", "test", "update"]); - expect(mockGetUpdateCommand).toHaveBeenCalledWith("npm-global"); + // Channel passed alongside method (manual is the default in this test). + expect(mockGetUpdateCommand).toHaveBeenCalledWith("npm-global", "manual"); + }); + }); + + // ----------------------------------------------------------------------- + // Active-session guard (Section C) + // ----------------------------------------------------------------------- + + describe("active-session guard", () => { + beforeEach(() => { + mockDetectInstallMethod.mockReturnValue("npm-global"); + mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ installMethod: "npm-global" })); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + // The guard now ALWAYS loads from global config. Stage a registered + // project so the early-return ("no registry → allow") doesn't fire. + mockExistsSync.mockReturnValue(true); + mockLoadGlobalConfig.mockReturnValue({ + projects: { "my-app": { path: "/tmp/foo" } }, + }); + mockLoadConfig.mockImplementation((path?: string) => + path + ? { projects: { "my-app": { path: "/tmp/foo" } }, configPath: path } + : { projects: { "my-app": { path: "/tmp/foo" } }, configPath: "/cwd/agent-orchestrator.yaml" }, + ); + }); + + it("refuses to install when a session is in 'working'", async () => { + mockSessions.value = [{ id: "feat-1", status: "working" }]; + const errSpy = vi.mocked(console.error); + await expect( + program.parseAsync(["node", "test", "update"]), + ).rejects.toThrow("process.exit(1)"); + const messages = errSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(messages).toMatch(/1 session active/); + expect(messages).toMatch(/ao stop/); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it.each(["working", "idle", "needs_input", "stuck"])( + "refuses for status %s", + async (status) => { + mockSessions.value = [{ id: "feat-1", status }]; + await expect( + program.parseAsync(["node", "test", "update"]), + ).rejects.toThrow("process.exit(1)"); + }, + ); + + it("does NOT refuse for terminal statuses (done, terminated, killed)", async () => { + mockSessions.value = [ + { id: "old-1", status: "done" }, + { id: "old-2", status: "terminated" }, + ]; + mockPromptConfirm.mockResolvedValue(false); // decline, no install + await program.parseAsync(["node", "test", "update"]); + // Reaches the prompt step since the guard passed. + expect(mockPromptConfirm).toHaveBeenCalled(); + }); + + // --------------------------------------------------------------------- + // Global-config layout (review #3 / scope-gap follow-up) + // --------------------------------------------------------------------- + + it("the refusal message lists active sessions from EVERY registered project, not just one (Dhruv proof)", async () => { + // Reviewer challenge: prove loadConfig(globalPath) actually enumerates + // sessions across all registered projects, not just the cwd's project. + // We register proj-a + proj-b in the global config, seed one active + // session in each, and assert BOTH ids appear in the stderr output. + mockLoadConfig.mockImplementation((path?: string) => { + // Mimic buildEffectiveConfigFromGlobalConfigPath: the global path + // returns BOTH projects; project-local would only return one. + if (!path) { + return { projects: { "proj-a": {} }, configPath: "/cwd/agent-orchestrator.yaml" }; + } + return { + projects: { + "proj-a": { path: "/repos/a" }, + "proj-b": { path: "/repos/b" }, + }, + configPath: path, + }; + }); + mockLoadGlobalConfig.mockReturnValue({ + projects: { + "proj-a": { path: "/repos/a" }, + "proj-b": { path: "/repos/b" }, + }, + }); + mockExistsSync.mockReturnValue(true); + // One active session per project. sm.list() is single-call (the SM + // implementation enumerates across all projectIds), so we return both + // sessions in one shot — matching real behavior. `projectId` is + // included so it's visible to anyone reading the refusal output. + mockSessions.value = [ + { id: "proj-a-feat-1", status: "working", projectId: "proj-a" }, + { id: "proj-b-feat-2", status: "needs_input", projectId: "proj-b" }, + ]; + + const errSpy = vi.mocked(console.error); + await expect( + program.parseAsync(["node", "test", "update"]), + ).rejects.toThrow("process.exit(1)"); + + const stderr = errSpy.mock.calls.map((c) => String(c[0])).join("\n"); + // Refusal message reports the correct total count (2, not 1). + expect(stderr).toMatch(/2 sessions active/); + // Both project's session ids appear in the listing. + expect(stderr).toMatch(/proj-a-feat-1/); + expect(stderr).toMatch(/proj-b-feat-2/); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it("always loads global config (never project-local), so sessions in OTHER projects fire the guard", async () => { + // Simulate running inside a project: project-local loadConfig() would + // succeed and return only THIS project's sessions. The guard must + // ignore it and still consult the global registry, otherwise active + // sessions in other projects get missed and the install would proceed. + mockLoadConfig.mockImplementation((path?: string) => { + if (!path) { + // Project-local: would return only "this-project"'s sessions. + return { projects: { "this-project": {} }, configPath: "/cwd/agent-orchestrator.yaml" }; + } + return { + projects: { + "this-project": { path: "/cwd" }, + "other-project": { path: "/other" }, + }, + configPath: path, + }; + }); + mockLoadGlobalConfig.mockReturnValue({ + projects: { + "this-project": { path: "/cwd" }, + "other-project": { path: "/other" }, + }, + }); + mockExistsSync.mockReturnValue(true); + // Active session lives in the OTHER project — only visible via global. + mockSessions.value = [ + { id: "other-1", status: "working" }, + ]; + + await expect( + program.parseAsync(["node", "test", "update"]), + ).rejects.toThrow("process.exit(1)"); + + expect(mockLoadGlobalConfig).toHaveBeenCalled(); + // Critical: we did NOT call the project-local (no-arg) loadConfig path. + const noArgCalls = mockLoadConfig.mock.calls.filter((c) => c.length === 0); + expect(noArgCalls).toHaveLength(0); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it("uses the global registry when running outside any project", async () => { + mockLoadConfig.mockImplementation((path?: string) => { + if (!path) throw new Error("no config found"); + return { projects: { "my-app": { path: "/tmp/foo" } }, configPath: path }; + }); + mockLoadGlobalConfig.mockReturnValue({ + projects: { "my-app": { path: "/tmp/foo" } }, + }); + mockExistsSync.mockReturnValue(true); + mockSessions.value = [{ id: "feat-1", status: "working" }]; + + await expect( + program.parseAsync(["node", "test", "update"]), + ).rejects.toThrow("process.exit(1)"); + + expect(mockLoadGlobalConfig).toHaveBeenCalled(); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it("returns early without building SessionManager when global registry is empty", async () => { + mockLoadConfig.mockImplementation((path?: string) => { + if (!path) throw new Error("no config found"); + return { projects: {}, configPath: path }; + }); + mockLoadGlobalConfig.mockReturnValue({ projects: {} }); + mockExistsSync.mockReturnValue(true); + + // Guard returns true (allow update) without ever calling sm.list(). + // No mockSessions configured, no spawn → confirms we never reached + // SessionManager construction. + mockPromptConfirm.mockResolvedValue(false); // decline soft-install + await program.parseAsync(["node", "test", "update"]); + expect(mockLoadGlobalConfig).toHaveBeenCalled(); + // The decline-prompt path means the guard let us through. + expect(mockPromptConfirm).toHaveBeenCalled(); + }); + + it("returns early without building SessionManager when global config file is missing", async () => { + mockLoadConfig.mockImplementation((path?: string) => { + if (!path) throw new Error("no config found"); + return { projects: {}, configPath: path }; + }); + mockExistsSync.mockReturnValue(false); // no ~/.agent-orchestrator/config.yaml + + mockPromptConfirm.mockResolvedValue(false); + await program.parseAsync(["node", "test", "update"]); + // We didn't even consult loadGlobalConfig — existsSync(globalPath) was false. + expect(mockLoadGlobalConfig).not.toHaveBeenCalled(); + expect(mockPromptConfirm).toHaveBeenCalled(); + }); + + it("refuses when sessions exist in a locally-registered project not in global config (Dhruv edge-case)", async () => { + // The bypass: user ran `ao start` from a repo with a local + // agent-orchestrator.yaml and no global registration. running.json + // says that project is being polled, sessions live on disk, but the + // global registry is empty. Before this fix, the guard hit the + // "global has no projects → allow" branch and let `ao update` + // clobber the running daemon. + // + // Fix: consult running.json BEFORE falling back to global. When + // running.json has projects, build the SessionManager from + // running.configPath (which is the local project's yaml in this case) + // and enumerate from there. + mockGetRunning.mockResolvedValue({ + pid: 12345, + configPath: "/repos/local-only/agent-orchestrator.yaml", + port: 3000, + startedAt: new Date().toISOString(), + projects: ["local-only"], + }); + // Global registry has no record of `local-only` — this is the bypass + // condition. With the old code, we'd return true here. + mockLoadGlobalConfig.mockReturnValue({ projects: {} }); + // loadConfig with the local configPath returns the local project's + // OrchestratorConfig (project-local schema is auto-wrapped). + mockLoadConfig.mockImplementation((path?: string) => { + if (path === "/repos/local-only/agent-orchestrator.yaml") { + return { + projects: { "local-only": { path: "/repos/local-only" } }, + configPath: path, + }; + } + return { projects: {}, configPath: path ?? "/cwd/agent-orchestrator.yaml" }; + }); + mockSessions.value = [ + { id: "local-feat-1", status: "working", projectId: "local-only" }, + ]; + + const errSpy = vi.mocked(console.error); + await expect( + program.parseAsync(["node", "test", "update"]), + ).rejects.toThrow("process.exit(1)"); + + const stderr = errSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(stderr).toMatch(/1 session active/); + expect(stderr).toMatch(/local-feat-1/); + // We must have routed through running.configPath, NOT the global path. + expect(mockLoadConfig).toHaveBeenCalledWith("/repos/local-only/agent-orchestrator.yaml"); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it("returns true (allows update) when running.json is gone and global is empty", async () => { + // No daemon running, no global projects. Genuinely safe to update. + mockGetRunning.mockResolvedValue(null); + mockExistsSync.mockReturnValue(false); + mockPromptConfirm.mockResolvedValue(false); + await program.parseAsync(["node", "test", "update"]); + expect(mockPromptConfirm).toHaveBeenCalled(); // guard passed → reached prompt + }); + + it("trusts running.json over an inconsistent global config", async () => { + // running.json says project P is being polled. Global config also + // lists P. We should use running.configPath (the live signal), and + // any active session in P fires the guard. + mockGetRunning.mockResolvedValue({ + pid: 12345, + configPath: "/tmp/test-global-config.yaml", + port: 3000, + startedAt: new Date().toISOString(), + projects: ["my-app"], + }); + mockLoadConfig.mockImplementation((path?: string) => ({ + projects: { "my-app": { path: "/tmp/foo" } }, + configPath: path ?? "/cwd/agent-orchestrator.yaml", + })); + mockLoadGlobalConfig.mockReturnValue({ + projects: { "my-app": { path: "/tmp/foo" } }, + }); + mockSessions.value = [{ id: "feat-1", status: "working" }]; + + await expect( + program.parseAsync(["node", "test", "update"]), + ).rejects.toThrow("process.exit(1)"); + + // Because getRunning() returned a daemon, we went straight to its + // configPath — we should NOT have fallen back to loadGlobalConfig. + expect(mockLoadGlobalConfig).not.toHaveBeenCalled(); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + }); + + // ----------------------------------------------------------------------- + // Soft auto-install (Section B) + // ----------------------------------------------------------------------- + + describe("soft auto-install", () => { + beforeEach(() => { + mockDetectInstallMethod.mockReturnValue("npm-global"); + mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ installMethod: "npm-global" })); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + }); + + it("skips the confirm prompt on stable channel", async () => { + mockResolveUpdateChannel.mockReturnValue("stable"); + mockSpawn.mockReturnValue(createMockChild(0)); + await program.parseAsync(["node", "test", "update"]); + expect(mockPromptConfirm).not.toHaveBeenCalled(); + expect(mockSpawn).toHaveBeenCalled(); + }); + + it("skips the confirm prompt on nightly channel", async () => { + mockResolveUpdateChannel.mockReturnValue("nightly"); + mockSpawn.mockReturnValue(createMockChild(0)); + await program.parseAsync(["node", "test", "update"]); + expect(mockPromptConfirm).not.toHaveBeenCalled(); + expect(mockSpawn).toHaveBeenCalled(); + }); + + it("still prompts on manual channel", async () => { + mockResolveUpdateChannel.mockReturnValue("manual"); + mockPromptConfirm.mockResolvedValue(false); + await program.parseAsync(["node", "test", "update"]); + expect(mockPromptConfirm).toHaveBeenCalled(); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + }); + + // ----------------------------------------------------------------------- + // Channel-switch detection (review #2) + // ----------------------------------------------------------------------- + + describe("channel-switch detection", () => { + beforeEach(() => { + mockDetectInstallMethod.mockReturnValue("npm-global"); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + }); + + it("forces an explicit prompt when active channel differs from cached.channel and !isOutdated", async () => { + // Stable→nightly transition: numeric base equal so isOutdated=false, + // but the user clearly wants the nightly build. + mockResolveUpdateChannel.mockReturnValue("nightly"); + mockReadCachedUpdateInfo.mockReturnValue({ channel: "stable" }); + mockCheckForUpdate.mockResolvedValue( + makeNpmUpdateInfo({ + installMethod: "npm-global", + currentVersion: "0.5.0", + latestVersion: "0.5.0-nightly-abc", + isOutdated: false, + recommendedCommand: "npm install -g @aoagents/ao@nightly", + }), + ); + mockPromptConfirm.mockResolvedValue(true); + mockSpawn.mockReturnValue(createMockChild(0)); + + await program.parseAsync(["node", "test", "update"]); + + // Prompt was forced (default=false for safety) and user confirmed → install ran. + expect(mockPromptConfirm).toHaveBeenCalledWith( + expect.stringMatching(/Switch to nightly/), + false, + ); + expect(mockSpawn).toHaveBeenCalled(); + }); + + it("declines the channel-switch prompt → no install", async () => { + mockResolveUpdateChannel.mockReturnValue("nightly"); + mockReadCachedUpdateInfo.mockReturnValue({ channel: "stable" }); + mockCheckForUpdate.mockResolvedValue( + makeNpmUpdateInfo({ + installMethod: "npm-global", + currentVersion: "0.5.0", + latestVersion: "0.5.0-nightly-abc", + isOutdated: false, + }), + ); + mockPromptConfirm.mockResolvedValue(false); + + await program.parseAsync(["node", "test", "update"]); + expect(mockPromptConfirm).toHaveBeenCalled(); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it("does NOT force a prompt when channel matches cached.channel (no switch)", async () => { + mockResolveUpdateChannel.mockReturnValue("nightly"); + mockReadCachedUpdateInfo.mockReturnValue({ channel: "nightly" }); + mockCheckForUpdate.mockResolvedValue( + makeNpmUpdateInfo({ + installMethod: "npm-global", + currentVersion: "0.5.0-nightly-abc", + latestVersion: "0.5.0-nightly-abc", + isOutdated: false, + }), + ); + const logSpy = vi.mocked(console.log); + + await program.parseAsync(["node", "test", "update"]); + + const all = logSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(all).toMatch(/Already on latest nightly/); + expect(mockPromptConfirm).not.toHaveBeenCalled(); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it("does NOT force a channel-switch prompt when versions match (no prior cache, same channel build)", async () => { + // Prior behavior was "no previous cache → no prompt regardless of + // version mismatch", which silently dropped the first-opt-in install + // (Ashish P2). The first-opt-in branch is now covered by a dedicated + // describe block above; this test guards the OTHER case — no prior + // cache but versions actually match — which should still say + // "Already on latest" and not prompt. + mockResolveUpdateChannel.mockReturnValue("nightly"); + mockReadCachedUpdateInfo.mockReturnValue(null); + mockCheckForUpdate.mockResolvedValue( + makeNpmUpdateInfo({ + installMethod: "npm-global", + currentVersion: "0.5.0-nightly-abc", + latestVersion: "0.5.0-nightly-abc", + isOutdated: false, + }), + ); + const logSpy = vi.mocked(console.log); + + await program.parseAsync(["node", "test", "update"]); + const all = logSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(all).toMatch(/Already on latest nightly/); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + }); + + // ----------------------------------------------------------------------- + // First-channel opt-in (Ashish P2 — `ao config set updateChannel nightly` + // followed by `ao update` with no prior auto-update cache) + // ----------------------------------------------------------------------- + + describe("first-channel opt-in", () => { + beforeEach(() => { + mockDetectInstallMethod.mockReturnValue("npm-global"); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + // Active-session guard happy path. + mockExistsSync.mockReturnValue(true); + mockLoadGlobalConfig.mockReturnValue({ + projects: { "my-app": { path: "/tmp/foo" } }, + }); + mockLoadConfig.mockImplementation((path?: string) => ({ + projects: { "my-app": { path: "/tmp/foo" } }, + configPath: path ?? "/cwd/agent-orchestrator.yaml", + })); + }); + + it("triggers install when stable user opts into nightly and there's no prior cache (Ashish proof)", async () => { + // Repro of Ashish P2: stable user on 0.5.0, runs `ao config set + // updateChannel nightly`, runs `ao update`. Previously got + // "Already on latest nightly" because semver says prerelease < stable. + // With the first-opt-in branch, we recognise the version mismatch and + // prompt; on confirm, install runs. + mockResolveUpdateChannel.mockReturnValue("nightly"); + mockReadCachedUpdateInfo.mockReturnValue(null); // no prior cache + mockCheckForUpdate.mockResolvedValue( + makeNpmUpdateInfo({ + installMethod: "npm-global", + currentVersion: "0.5.0", + latestVersion: "0.5.0-nightly-abc", + isOutdated: false, + recommendedCommand: "npm install -g @aoagents/ao@nightly", + }), + ); + mockPromptConfirm.mockResolvedValue(true); + mockSpawn.mockReturnValue(createMockChild(0)); + + await program.parseAsync(["node", "test", "update"]); + + // Prompt should be forced (default=false) because this is a first-time + // opt-in into a different channel, even with no prior cache. + expect(mockPromptConfirm).toHaveBeenCalledWith( + expect.stringMatching(/Switch to nightly/), + false, + ); + expect(mockSpawn).toHaveBeenCalled(); + }); + + it("still says 'already on latest' when versions actually match", async () => { + // Sanity check: don't false-positive for users who genuinely are up to date. + mockResolveUpdateChannel.mockReturnValue("nightly"); + mockReadCachedUpdateInfo.mockReturnValue(null); + mockCheckForUpdate.mockResolvedValue( + makeNpmUpdateInfo({ + installMethod: "npm-global", + currentVersion: "0.5.0-nightly-abc", + latestVersion: "0.5.0-nightly-abc", + isOutdated: false, + }), + ); + const logSpy = vi.mocked(console.log); + + await program.parseAsync(["node", "test", "update"]); + + const all = logSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(all).toMatch(/Already on latest nightly/); + expect(mockPromptConfirm).not.toHaveBeenCalled(); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + }); + + // ----------------------------------------------------------------------- + // API-invoked (non-interactive) install — Ashish P1 merge blocker + // ----------------------------------------------------------------------- + + describe("API-invoked install (AO_NON_INTERACTIVE_INSTALL=1)", () => { + let origNonInteractive: string | undefined; + beforeEach(() => { + mockDetectInstallMethod.mockReturnValue("npm-global"); + mockResolveUpdateChannel.mockReturnValue("stable"); + mockCheckForUpdate.mockResolvedValue( + makeNpmUpdateInfo({ installMethod: "npm-global" }), + ); + mockExistsSync.mockReturnValue(true); + mockLoadGlobalConfig.mockReturnValue({ + projects: { "my-app": { path: "/tmp/foo" } }, + }); + mockLoadConfig.mockImplementation((path?: string) => ({ + projects: { "my-app": { path: "/tmp/foo" } }, + configPath: path ?? "/cwd/agent-orchestrator.yaml", + })); + // stdio: "ignore" makes isTTY() return false, simulating the spawn + // context POST /api/update creates. + Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true }); + origNonInteractive = process.env["AO_NON_INTERACTIVE_INSTALL"]; + process.env["AO_NON_INTERACTIVE_INSTALL"] = "1"; + mockSpawn.mockReturnValue(createMockChild(0)); + }); + + afterEach(() => { + if (origNonInteractive === undefined) { + delete process.env["AO_NON_INTERACTIVE_INSTALL"]; + } else { + process.env["AO_NON_INTERACTIVE_INSTALL"] = origNonInteractive; + } + }); + + it("actually invokes runNpmInstall when AO_NON_INTERACTIVE_INSTALL=1 even though isTTY is false", async () => { + // The P1 bug: before this fix, the !isTTY() branch printed "Run: ..." + // and returned. The dashboard's banner click would 202 but no install + // would run. Asserting spawn was called proves the install actually + // happens in the API-invoked path. + await program.parseAsync(["node", "test", "update"]); + expect(mockSpawn).toHaveBeenCalledTimes(1); + // And without a TTY, we MUST NOT have prompted — that would hang the + // detached child forever. + expect(mockPromptConfirm).not.toHaveBeenCalled(); + }); + + it("preserves the old 'print Run:' behavior for non-API non-TTY (piped output)", async () => { + delete process.env["AO_NON_INTERACTIVE_INSTALL"]; + const logSpy = vi.mocked(console.log); + await program.parseAsync(["node", "test", "update"]); + const all = logSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(all).toMatch(/Run: npm install -g @aoagents\/ao@latest/); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it("still refuses on active sessions even when API-invoked (the API's own guard isn't a single point of trust)", async () => { + mockSessions.value = [{ id: "feat-1", status: "working" }]; + await expect( + program.parseAsync(["node", "test", "update"]), + ).rejects.toThrow("process.exit(1)"); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + }); + + // ----------------------------------------------------------------------- + // Homebrew (Section F) + // ----------------------------------------------------------------------- + + describe("homebrew install", () => { + it("does not auto-install — surfaces the brew upgrade notice", async () => { + mockDetectInstallMethod.mockReturnValue("homebrew"); + mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ installMethod: "homebrew" })); + const logSpy = vi.mocked(console.log); + + await program.parseAsync(["node", "test", "update"]); + + const all = logSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(all).toMatch(/brew upgrade ao/); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + }); + + // ----------------------------------------------------------------------- + // runNpmInstall — Windows PATHEXT / shell handling + // ----------------------------------------------------------------------- + + describe("runNpmInstall — cross-platform spawn options", () => { + beforeEach(() => { + mockDetectInstallMethod.mockReturnValue("npm-global"); + mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ installMethod: "npm-global" })); + mockResolveUpdateChannel.mockReturnValue("stable"); // soft-install path skips prompt + mockSpawn.mockReturnValue(createMockChild(0)); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + }); + + it("passes shell:true and windowsHide:true on Windows so PATHEXT resolves npm.cmd", async () => { + mockIsWindows.mockReturnValue(true); + await program.parseAsync(["node", "test", "update"]); + expect(mockSpawn).toHaveBeenCalledTimes(1); + const opts = mockSpawn.mock.calls[0][2] as Record; + expect(opts.shell).toBe(true); + expect(opts.windowsHide).toBe(true); + expect(opts.stdio).toBe("inherit"); + }); + + it("passes shell:false on macOS / Linux (no shell wrap needed)", async () => { + mockIsWindows.mockReturnValue(false); + await program.parseAsync(["node", "test", "update"]); + expect(mockSpawn).toHaveBeenCalledTimes(1); + const opts = mockSpawn.mock.calls[0][2] as Record; + expect(opts.shell).toBe(false); + }); + + it.each([ + ["pnpm-global" as const, "pnpm add -g @aoagents/ao@latest"], + ["bun-global" as const, "bun add -g @aoagents/ao@latest"], + ])("applies the same shell:true on Windows for %s installs", async (method, command) => { + mockIsWindows.mockReturnValue(true); + mockDetectInstallMethod.mockReturnValue(method); + mockCheckForUpdate.mockResolvedValue( + makeNpmUpdateInfo({ + installMethod: method, + recommendedCommand: command, + }), + ); + await program.parseAsync(["node", "test", "update"]); + const opts = mockSpawn.mock.calls[0][2] as Record; + expect(opts.shell).toBe(true); }); }); }); diff --git a/packages/cli/__tests__/lib/update-channel-onboarding.test.ts b/packages/cli/__tests__/lib/update-channel-onboarding.test.ts new file mode 100644 index 000000000..f06726d28 --- /dev/null +++ b/packages/cli/__tests__/lib/update-channel-onboarding.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const { mockExistsSync } = vi.hoisted(() => ({ + mockExistsSync: vi.fn<(path: string) => boolean>(), +})); + +import type * as FsType from "node:fs"; + +vi.mock("node:fs", async () => { + const actual = (await vi.importActual("node:fs")) as typeof FsType; + return { + ...actual, + existsSync: (...args: unknown[]) => mockExistsSync(args[0] as string), + }; +}); + +const { mockGlobalConfig, mockSaveGlobalConfig } = vi.hoisted(() => ({ + mockGlobalConfig: { value: null as null | { updateChannel?: string } }, + mockSaveGlobalConfig: vi.fn(), +})); + +import type * as AoCoreType from "@aoagents/ao-core"; + +vi.mock("@aoagents/ao-core", async () => { + const actual = (await vi.importActual("@aoagents/ao-core")) as typeof AoCoreType; + return { + ...actual, + loadGlobalConfig: () => mockGlobalConfig.value, + saveGlobalConfig: (...args: unknown[]) => mockSaveGlobalConfig(...args), + getGlobalConfigPath: () => "/tmp/test-global.yaml", + }; +}); + +vi.mock("../../src/lib/caller-context.js", () => ({ + isHumanCaller: vi.fn(() => true), +})); + +vi.mock("../../src/lib/prompts.js", () => ({ + promptSelect: vi.fn(), +})); + +import { + hasChosenUpdateChannel, + maybePromptForUpdateChannel, + persistUpdateChannel, +} from "../../src/lib/update-channel-onboarding.js"; + +describe("update-channel-onboarding", () => { + beforeEach(() => { + vi.resetAllMocks(); + mockGlobalConfig.value = null; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("hasChosenUpdateChannel — the ask-once gate", () => { + it("returns false when global config does not exist (first run)", () => { + mockExistsSync.mockReturnValue(false); + expect(hasChosenUpdateChannel()).toBe(false); + }); + + it("returns false when global config exists but updateChannel is unset", () => { + mockExistsSync.mockReturnValue(true); + mockGlobalConfig.value = {}; + expect(hasChosenUpdateChannel()).toBe(false); + }); + + it("returns true when updateChannel is set to any value", () => { + mockExistsSync.mockReturnValue(true); + mockGlobalConfig.value = { updateChannel: "manual" }; + expect(hasChosenUpdateChannel()).toBe(true); + }); + + it("returns true on load failure (don't pester the user)", () => { + mockExistsSync.mockReturnValue(true); + mockGlobalConfig.value = null; // simulates failed load + // hasChosenUpdateChannel sees loadGlobalConfig() returning null and treats + // the field as unset → false. The "true on error" branch only kicks in + // for thrown exceptions (file corruption, etc.). + expect(hasChosenUpdateChannel()).toBe(false); + }); + }); + + describe("maybePromptForUpdateChannel — ask-once integration", () => { + it("does not prompt when channel is already set", async () => { + mockExistsSync.mockReturnValue(true); + mockGlobalConfig.value = { updateChannel: "stable" }; + const prompt = vi.fn().mockResolvedValue("nightly" as const); + + await maybePromptForUpdateChannel({ prompt, isInteractive: () => true }); + + expect(prompt).not.toHaveBeenCalled(); + expect(mockSaveGlobalConfig).not.toHaveBeenCalled(); + }); + + it("does not prompt when caller is non-interactive", async () => { + mockExistsSync.mockReturnValue(false); + const prompt = vi.fn().mockResolvedValue("nightly" as const); + + await maybePromptForUpdateChannel({ prompt, isInteractive: () => false }); + + expect(prompt).not.toHaveBeenCalled(); + expect(mockSaveGlobalConfig).not.toHaveBeenCalled(); + }); + + it("prompts and persists the chosen channel on first run", async () => { + mockExistsSync.mockReturnValue(false); + const prompt = vi.fn().mockResolvedValue("nightly" as const); + + await maybePromptForUpdateChannel({ prompt, isInteractive: () => true }); + + expect(prompt).toHaveBeenCalledTimes(1); + expect(mockSaveGlobalConfig).toHaveBeenCalledTimes(1); + const [config] = mockSaveGlobalConfig.mock.calls[0]!; + expect((config as { updateChannel: string }).updateChannel).toBe("nightly"); + }); + + it("falls back to 'manual' when the user dismisses the prompt", async () => { + mockExistsSync.mockReturnValue(false); + const prompt = vi.fn().mockRejectedValue(new Error("dismissed")); + + await maybePromptForUpdateChannel({ prompt, isInteractive: () => true }); + + expect(mockSaveGlobalConfig).toHaveBeenCalledTimes(1); + const [config] = mockSaveGlobalConfig.mock.calls[0]!; + expect((config as { updateChannel: string }).updateChannel).toBe("manual"); + }); + + it("does not re-prompt the second time it runs after persisting", async () => { + mockExistsSync.mockReturnValue(false); + const prompt = vi.fn().mockResolvedValue("stable" as const); + + // First call: persists. + await maybePromptForUpdateChannel({ prompt, isInteractive: () => true }); + expect(prompt).toHaveBeenCalledTimes(1); + + // Simulate the persisted state. + mockExistsSync.mockReturnValue(true); + mockGlobalConfig.value = { updateChannel: "stable" }; + + await maybePromptForUpdateChannel({ prompt, isInteractive: () => true }); + expect(prompt).toHaveBeenCalledTimes(1); // still 1 — not re-prompted + }); + }); + + describe("persistUpdateChannel", () => { + it("writes the channel into existing config", () => { + mockExistsSync.mockReturnValue(true); + mockGlobalConfig.value = { updateChannel: "manual" } as unknown as { updateChannel: string }; + persistUpdateChannel("nightly"); + const [config] = mockSaveGlobalConfig.mock.calls[0]!; + expect((config as { updateChannel: string }).updateChannel).toBe("nightly"); + }); + + it("creates a fresh config when none exists", () => { + mockExistsSync.mockReturnValue(false); + persistUpdateChannel("stable"); + const [config] = mockSaveGlobalConfig.mock.calls[0]!; + expect((config as { updateChannel: string }).updateChannel).toBe("stable"); + }); + }); +}); diff --git a/packages/cli/__tests__/lib/update-check.test.ts b/packages/cli/__tests__/lib/update-check.test.ts index 957b51a6a..4b160d426 100644 --- a/packages/cli/__tests__/lib/update-check.test.ts +++ b/packages/cli/__tests__/lib/update-check.test.ts @@ -49,6 +49,22 @@ vi.mock("../../src/options/version.js", () => ({ getCliVersion: () => mockGetCliVersion(), })); +// Stub global config loader so channel resolution is deterministic. Tests that +// need a specific channel set `mockChannel` before the import below runs. +const { mockGlobalConfig } = vi.hoisted(() => ({ + mockGlobalConfig: { value: null as null | { updateChannel?: string; installMethod?: string } }, +})); + +import type * as AoCoreType from "@aoagents/ao-core"; + +vi.mock("@aoagents/ao-core", async () => { + const actual = (await vi.importActual("@aoagents/ao-core")) as typeof AoCoreType; + return { + ...actual, + loadGlobalConfig: () => mockGlobalConfig.value, + }; +}); + // Mock global fetch const mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); @@ -68,6 +84,9 @@ import { maybeShowUpdateNotice, scheduleBackgroundRefresh, isVersionOutdated, + resolveUpdateChannel, + resolveInstallMethodOverride, + isManualOnlyInstall, } from "../../src/lib/update-check.js"; // --------------------------------------------------------------------------- @@ -86,10 +105,14 @@ describe("update-check", () => { } return null; }); + // Default to nightly so checkForUpdate exercises the registry path. + // Individual tests reset this when they need different channel behavior. + mockGlobalConfig.value = { updateChannel: "nightly" }; }); afterEach(() => { vi.restoreAllMocks(); + mockGlobalConfig.value = null; }); // ----------------------------------------------------------------------- @@ -140,8 +163,26 @@ describe("update-check", () => { expect(isVersionOutdated("beta", "1.0.0")).toBe(false); }); - it("returns false when both are the same with pre-release", () => { - expect(isVersionOutdated("0.2.2-rc.1", "0.2.2-rc.2")).toBe(false); + it("compares prerelease segments numerically (rc.1 < rc.2)", () => { + expect(isVersionOutdated("0.2.2-rc.1", "0.2.2-rc.2")).toBe(true); + expect(isVersionOutdated("0.2.2-rc.2", "0.2.2-rc.1")).toBe(false); + expect(isVersionOutdated("0.2.2-rc.2", "0.2.2-rc.2")).toBe(false); + }); + + it("treats any nightly SHA-suffix difference as outdated (lexical order would misfire)", () => { + // Real-world canary tag: 0.5.0-nightly-. SHAs are random hex, so + // lexical compare gives the wrong answer ~50% of the time. The cache + // always carries the registry's current dist-tag, so any SHA mismatch + // on the same base means the installed copy is behind. + expect(isVersionOutdated("0.5.0-nightly-abc", "0.5.0-nightly-def")).toBe(true); + expect(isVersionOutdated("0.5.0-nightly-def", "0.5.0-nightly-abc")).toBe(true); + expect(isVersionOutdated("0.5.0-nightly-f00d123", "0.5.0-nightly-0dead01")).toBe(true); + expect(isVersionOutdated("0.5.0-nightly-abc", "0.5.0-nightly-abc")).toBe(false); + }); + + it("treats missing prerelease segments as older than longer prereleases", () => { + // Per semver: a longer prerelease is greater when shared segments are equal. + expect(isVersionOutdated("0.5.0-nightly", "0.5.0-nightly.1")).toBe(true); }); }); @@ -554,73 +595,74 @@ describe("update-check", () => { // ----------------------------------------------------------------------- describe("fetchLatestVersion", () => { - it("returns version string from registry", async () => { + it("returns latest dist-tag from registry by default (stable channel)", async () => { mockFetch.mockResolvedValue({ ok: true, - json: async () => ({ version: "0.3.0" }), + json: async () => ({ "dist-tags": { latest: "0.3.0", nightly: "0.4.0-nightly-abc" } }), }); - const version = await fetchLatestVersion(); + const version = await fetchLatestVersion("stable"); expect(version).toBe("0.3.0"); expect(mockFetch).toHaveBeenCalledWith( - "https://registry.npmjs.org/@aoagents%2Fao/latest", + "https://registry.npmjs.org/@aoagents%2Fao", expect.objectContaining({ headers: { Accept: "application/json" } }), ); }); + it("returns nightly dist-tag when nightly channel requested", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ "dist-tags": { latest: "0.3.0", nightly: "0.4.0-nightly-abc" } }), + }); + expect(await fetchLatestVersion("nightly")).toBe("0.4.0-nightly-abc"); + }); + + it("falls back to latest when nightly tag is missing", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ "dist-tags": { latest: "0.3.0" } }), + }); + expect(await fetchLatestVersion("nightly")).toBe("0.3.0"); + }); + it("passes an AbortSignal for timeout", async () => { mockFetch.mockResolvedValue({ ok: true, - json: async () => ({ version: "1.0.0" }), + json: async () => ({ "dist-tags": { latest: "1.0.0" } }), }); - await fetchLatestVersion(); + await fetchLatestVersion("stable"); expect(mockFetch.mock.calls[0][1]).toHaveProperty("signal"); }); it("returns null on non-ok response", async () => { mockFetch.mockResolvedValue({ ok: false, status: 404 }); - expect(await fetchLatestVersion()).toBeNull(); - }); - - it("returns null on 500 server error", async () => { - mockFetch.mockResolvedValue({ ok: false, status: 500 }); - expect(await fetchLatestVersion()).toBeNull(); + expect(await fetchLatestVersion("stable")).toBeNull(); }); it("returns null on network error", async () => { mockFetch.mockRejectedValue(new Error("fetch failed")); - expect(await fetchLatestVersion()).toBeNull(); + expect(await fetchLatestVersion("stable")).toBeNull(); }); it("returns null on timeout (AbortError)", async () => { mockFetch.mockRejectedValue(new DOMException("signal timed out", "TimeoutError")); - expect(await fetchLatestVersion()).toBeNull(); + expect(await fetchLatestVersion("stable")).toBeNull(); }); - it("returns null on non-JSON response", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => { - throw new Error("invalid json"); - }, - }); - expect(await fetchLatestVersion()).toBeNull(); - }); - - it("returns null when version field is missing", async () => { + it("returns null when dist-tags missing", async () => { mockFetch.mockResolvedValue({ ok: true, json: async () => ({ name: "@aoagents/ao" }), }); - expect(await fetchLatestVersion()).toBeNull(); + expect(await fetchLatestVersion("stable")).toBeNull(); }); - it("returns null when version field is not a string", async () => { + it("returns null when chosen tag is not a string", async () => { mockFetch.mockResolvedValue({ ok: true, - json: async () => ({ version: 123 }), + json: async () => ({ "dist-tags": { latest: 123 } }), }); - expect(await fetchLatestVersion()).toBeNull(); + expect(await fetchLatestVersion("stable")).toBeNull(); }); }); @@ -657,6 +699,10 @@ describe("update-check", () => { checkedAt: now, currentVersionAtCheck: currentVersion, installMethod: "npm-global", + // Must match the active channel (nightly via the suite-level + // beforeEach) or the new "treat missing channel as miss when + // channel is provided" guard would reject the cache entry. + channel: "nightly", }), ); mockExistsSync.mockReturnValue(false); @@ -680,7 +726,7 @@ describe("update-check", () => { mockExistsSync.mockReturnValue(false); mockFetch.mockResolvedValue({ ok: true, - json: async () => ({ version: "0.4.0" }), + json: async () => ({ "dist-tags": { latest: "0.4.0", nightly: "0.4.0" } }), }); const info = await checkForUpdate({ force: true }); @@ -695,7 +741,7 @@ describe("update-check", () => { mockExistsSync.mockReturnValue(false); mockFetch.mockResolvedValue({ ok: true, - json: async () => ({ version: "0.3.0" }), + json: async () => ({ "dist-tags": { latest: "0.3.0", nightly: "0.3.0" } }), }); const info = await checkForUpdate(); @@ -710,7 +756,7 @@ describe("update-check", () => { mockExistsSync.mockReturnValue(false); mockFetch.mockResolvedValue({ ok: true, - json: async () => ({ version: "0.3.0" }), + json: async () => ({ "dist-tags": { latest: "0.3.0", nightly: "0.3.0" } }), }); await checkForUpdate(); @@ -741,7 +787,7 @@ describe("update-check", () => { mockExistsSync.mockReturnValue(false); mockFetch.mockResolvedValue({ ok: true, - json: async () => ({ version: currentVersion }), + json: async () => ({ "dist-tags": { latest: currentVersion, nightly: currentVersion } }), }); const info = await checkForUpdate({ force: true }); @@ -768,7 +814,7 @@ describe("update-check", () => { mockExistsSync.mockReturnValue(false); mockFetch.mockResolvedValue({ ok: true, - json: async () => ({ version: "0.3.0" }), + json: async () => ({ "dist-tags": { latest: "0.3.0", nightly: "0.3.0" } }), }); const info = await checkForUpdate(); @@ -786,6 +832,7 @@ describe("update-check", () => { checkedAt: new Date().toISOString(), currentVersionAtCheck: currentVersion, installMethod: "npm-global", + channel: "nightly", // matches the suite-level beforeEach }); } return JSON.stringify({ name: "not-ao" }); @@ -798,6 +845,9 @@ describe("update-check", () => { }); it("uses npm registry and npm-global command for npm-global installs", async () => { + // Stable channel so the install command picks @latest. The default + // beforeEach sets nightly which would resolve to @nightly. + mockGlobalConfig.value = { updateChannel: "stable" }; mockReadFileSync.mockImplementation((path: string) => { if (path.endsWith("update-check.json")) throw new Error("ENOENT"); return JSON.stringify({ name: "not-ao" }); @@ -805,7 +855,7 @@ describe("update-check", () => { mockExistsSync.mockReturnValue(false); mockFetch.mockResolvedValue({ ok: true, - json: async () => ({ version: "0.3.0" }), + json: async () => ({ "dist-tags": { latest: "0.3.0" } }), }); const info = await checkForUpdate({ force: true, installMethod: "npm-global" }); @@ -815,13 +865,14 @@ describe("update-check", () => { }); it("uses npm registry and pnpm-global command for pnpm-global installs", async () => { + mockGlobalConfig.value = { updateChannel: "stable" }; mockReadFileSync.mockImplementation((path: string) => { if (path.endsWith("update-check.json")) throw new Error("ENOENT"); return JSON.stringify({ name: "not-ao" }); }); mockFetch.mockResolvedValue({ ok: true, - json: async () => ({ version: "0.3.0" }), + json: async () => ({ "dist-tags": { latest: "0.3.0" } }), }); const info = await checkForUpdate({ force: true, installMethod: "pnpm-global" }); @@ -844,6 +895,7 @@ describe("update-check", () => { checkedAt: new Date().toISOString(), currentVersionAtCheck: currentVersion, installMethod: "git", + channel: "nightly", // matches the suite-level beforeEach isOutdated: false, currentRevisionAtCheck: "abc", latestRevisionAtCheck: "abc", @@ -923,6 +975,10 @@ describe("update-check", () => { }); it("prints update notice when cache shows outdated version", () => { + // Stable channel so the install command picks @latest. (Default is + // nightly which would prepend "(nightly)" to the message and use + // @nightly in the install command.) + mockGlobalConfig.value = { updateChannel: "stable" }; const currentVersion = getCurrentVersion(); mockReadFileSync.mockReturnValue( JSON.stringify({ @@ -930,6 +986,7 @@ describe("update-check", () => { checkedAt: new Date().toISOString(), currentVersionAtCheck: currentVersion, installMethod: "unknown", + channel: "stable", }), ); @@ -943,6 +1000,7 @@ describe("update-check", () => { }); it("prints git update notice from cached git state", () => { + mockGlobalConfig.value = { updateChannel: "stable" }; const currentVersion = getCurrentVersion(); mockExistsSync.mockImplementation((path: string) => path.endsWith(".git")); mockExecFileSync.mockImplementation((_cmd: string, args: string[]) => { @@ -956,6 +1014,7 @@ describe("update-check", () => { checkedAt: new Date().toISOString(), currentVersionAtCheck: currentVersion, installMethod: "git", + channel: "stable", isOutdated: true, currentRevisionAtCheck: "local", }); @@ -1038,7 +1097,7 @@ describe("update-check", () => { mockExistsSync.mockReturnValue(false); mockFetch.mockResolvedValue({ ok: true, - json: async () => ({ version: "0.3.0" }), + json: async () => ({ "dist-tags": { latest: "0.3.0", nightly: "0.3.0" } }), }); expect(() => scheduleBackgroundRefresh()).not.toThrow(); @@ -1053,5 +1112,193 @@ describe("update-check", () => { expect(() => scheduleBackgroundRefresh()).not.toThrow(); }); + + it("does NOT schedule a refresh when channel is manual", () => { + mockGlobalConfig.value = { updateChannel: "manual" }; + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + scheduleBackgroundRefresh(); + expect(setTimeoutSpy).not.toHaveBeenCalled(); + setTimeoutSpy.mockRestore(); + }); + }); + + // ----------------------------------------------------------------------- + // Channel + install-method overrides (Section B / F) + // ----------------------------------------------------------------------- + + describe("resolveUpdateChannel", () => { + it("returns the channel from global config when set", () => { + mockGlobalConfig.value = { updateChannel: "stable" }; + expect(resolveUpdateChannel()).toBe("stable"); + }); + + it("defaults to 'manual' when global config is missing", () => { + mockGlobalConfig.value = null; + expect(resolveUpdateChannel()).toBe("manual"); + }); + + it("defaults to 'manual' when updateChannel is unset", () => { + mockGlobalConfig.value = {}; + expect(resolveUpdateChannel()).toBe("manual"); + }); + }); + + describe("resolveInstallMethodOverride", () => { + it("returns the override when set", () => { + mockGlobalConfig.value = { installMethod: "bun-global" }; + expect(resolveInstallMethodOverride()).toBe("bun-global"); + }); + + it("returns undefined when not set", () => { + mockGlobalConfig.value = { updateChannel: "stable" }; + expect(resolveInstallMethodOverride()).toBeUndefined(); + }); + }); + + describe("classifyInstallPath — bun + homebrew (Section F)", () => { + it("returns 'bun-global' for ~/.bun/install/global/ paths", () => { + expect( + classifyInstallPath( + "/home/user/.bun/install/global/node_modules/@aoagents/ao-cli/dist/lib/update-check.js", + ), + ).toBe("bun-global"); + }); + + it("returns 'bun-global' for Windows .bun paths", () => { + expect( + classifyInstallPath( + "C:\\Users\\test\\.bun\\install\\global\\node_modules\\@aoagents\\ao-cli\\dist\\lib\\update-check.js", + ), + ).toBe("bun-global"); + }); + + it("returns 'homebrew' for /Cellar/ao/ paths even when nested under lib/node_modules", () => { + // Brew installs nest the npm tree inside the formula's libexec dir, so + // the path also matches /lib/node_modules/. The Cellar check must run + // FIRST or we'd misclassify brew installs as npm-global. + expect( + classifyInstallPath( + "/usr/local/Cellar/ao/0.5.0/libexec/lib/node_modules/@aoagents/ao-cli/dist/lib/update-check.js", + ), + ).toBe("homebrew"); + }); + }); + + describe("getUpdateCommand — channel-aware (Section B)", () => { + it("uses @nightly tag for nightly channel", () => { + expect(getUpdateCommand("npm-global", "nightly")).toBe( + "npm install -g @aoagents/ao@nightly", + ); + expect(getUpdateCommand("pnpm-global", "nightly")).toBe( + "pnpm add -g @aoagents/ao@nightly", + ); + expect(getUpdateCommand("bun-global", "nightly")).toBe( + "bun add -g @aoagents/ao@nightly", + ); + }); + + it("uses @latest tag for stable + manual channels", () => { + expect(getUpdateCommand("npm-global", "stable")).toBe( + "npm install -g @aoagents/ao@latest", + ); + expect(getUpdateCommand("npm-global", "manual")).toBe( + "npm install -g @aoagents/ao@latest", + ); + }); + + it("returns the brew upgrade notice for homebrew installs", () => { + expect(getUpdateCommand("homebrew", "stable")).toBe("brew upgrade ao"); + expect(getUpdateCommand("homebrew", "nightly")).toBe("brew upgrade ao"); + }); + }); + + describe("isManualOnlyInstall", () => { + it("returns true only for homebrew", () => { + expect(isManualOnlyInstall("homebrew")).toBe(true); + expect(isManualOnlyInstall("npm-global")).toBe(false); + expect(isManualOnlyInstall("bun-global")).toBe(false); + expect(isManualOnlyInstall("git")).toBe(false); + }); + }); + + describe("detectInstallMethod with override", () => { + it("uses the configured installMethod when set", () => { + mockGlobalConfig.value = { installMethod: "bun-global" }; + expect(detectInstallMethod()).toBe("bun-global"); + }); + }); + + describe("checkForUpdate — channel + cache discrimination", () => { + it("ignores cached entries when their channel does not match the active channel", async () => { + mockGlobalConfig.value = { updateChannel: "nightly" }; + const now = new Date().toISOString(); + // Cache was written by the @latest channel; nightly request must skip it. + mockReadFileSync.mockReturnValue( + JSON.stringify({ + latestVersion: "0.3.0", + checkedAt: now, + currentVersionAtCheck: getCurrentVersion(), + installMethod: "npm-global", + channel: "stable", + }), + ); + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ "dist-tags": { latest: "0.3.0", nightly: "0.5.0-nightly-x" } }), + }); + + const info = await checkForUpdate({ installMethod: "npm-global" }); + expect(info.latestVersion).toBe("0.5.0-nightly-x"); + expect(mockFetch).toHaveBeenCalled(); + }); + + it("ignores legacy cached entries that pre-date channel scoping (no `channel` field)", async () => { + // A user who installed before channel scoping has a cache entry without + // a `channel` field. Without the explicit-channel guard, that entry + // would pass the mismatch check (`channel && data.channel && ...` + // short-circuits on `!data.channel`) and we'd return stale stable + // version info after the user switched to nightly. Force a refresh. + mockGlobalConfig.value = { updateChannel: "nightly" }; + mockReadFileSync.mockReturnValue( + JSON.stringify({ + latestVersion: "0.3.0", + checkedAt: new Date().toISOString(), + currentVersionAtCheck: getCurrentVersion(), + installMethod: "npm-global", + // no `channel` field — legacy + }), + ); + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ "dist-tags": { latest: "0.3.0", nightly: "0.5.0-nightly-x" } }), + }); + + const info = await checkForUpdate({ installMethod: "npm-global" }); + expect(info.latestVersion).toBe("0.5.0-nightly-x"); + expect(mockFetch).toHaveBeenCalled(); + }); + + it("skips notice when channel is manual", () => { + mockGlobalConfig.value = { updateChannel: "manual" }; + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); + delete process.env["CI"]; + delete process.env["AGENT_ORCHESTRATOR_CI"]; + delete process.env["AO_NO_UPDATE_NOTIFIER"]; + const origArgv = process.argv; + process.argv = ["node", "ao", "start"]; + mockReadFileSync.mockReturnValue( + JSON.stringify({ + latestVersion: "99.0.0", + checkedAt: new Date().toISOString(), + currentVersionAtCheck: getCurrentVersion(), + }), + ); + + maybeShowUpdateNotice(); + expect(stderrSpy).not.toHaveBeenCalled(); + stderrSpy.mockRestore(); + process.argv = origArgv; + }); }); }); diff --git a/packages/cli/package.json b/packages/cli/package.json index ccdf24425..b86ceb410 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -68,5 +68,9 @@ "tsx": "^4.19.0", "typescript": "^5.7.0", "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } } diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts new file mode 100644 index 000000000..c7d895671 --- /dev/null +++ b/packages/cli/src/commands/config.ts @@ -0,0 +1,119 @@ +/** + * `ao config` — read/write fields in ~/.agent-orchestrator/config.yaml. + * + * Today this only manages `updateChannel` and `installMethod`, which are the + * two settings the release pipeline depends on. We deliberately resist adding + * a generic key/value writer — the global config has Zod validation and most + * fields are not safe to set blindly from a flag. + */ + +import type { Command } from "commander"; +import chalk from "chalk"; +import { + createDefaultGlobalConfig, + getGlobalConfigPath, + loadGlobalConfig, + saveGlobalConfig, + UpdateChannelSchema, + InstallMethodOverrideSchema, + type GlobalConfig, + type UpdateChannel, + type InstallMethodOverride, +} from "@aoagents/ao-core"; +import { existsSync } from "node:fs"; + +const SUPPORTED_KEYS = ["updateChannel", "installMethod"] as const; +type SupportedKey = (typeof SUPPORTED_KEYS)[number]; + +function isSupportedKey(value: string): value is SupportedKey { + return (SUPPORTED_KEYS as readonly string[]).includes(value); +} + +function loadOrInit(): GlobalConfig { + const path = getGlobalConfigPath(); + if (existsSync(path)) { + const config = loadGlobalConfig(path); + if (config) return config; + } + return createDefaultGlobalConfig(); +} + +function setUpdateChannel(value: string): void { + const parsed = UpdateChannelSchema.safeParse(value); + if (!parsed.success) { + console.error( + chalk.red(`Invalid value for updateChannel: "${value}". Expected: stable | nightly | manual`), + ); + process.exit(1); + } + const channel: UpdateChannel = parsed.data; + const config = loadOrInit(); + saveGlobalConfig({ ...config, updateChannel: channel }, getGlobalConfigPath()); + console.log(chalk.green(`✓ updateChannel set to ${chalk.bold(channel)}`)); +} + +function setInstallMethod(value: string): void { + const parsed = InstallMethodOverrideSchema.safeParse(value); + if (!parsed.success) { + console.error( + chalk.red( + `Invalid value for installMethod: "${value}". Expected: git | npm-global | pnpm-global | bun-global | homebrew | unknown`, + ), + ); + process.exit(1); + } + const method: InstallMethodOverride = parsed.data; + const config = loadOrInit(); + saveGlobalConfig({ ...config, installMethod: method }, getGlobalConfigPath()); + console.log(chalk.green(`✓ installMethod set to ${chalk.bold(method)}`)); +} + +function showGet(key: SupportedKey): void { + const path = getGlobalConfigPath(); + if (!existsSync(path)) { + console.log(chalk.dim("(unset)")); + return; + } + const config = loadGlobalConfig(path); + const value = config?.[key]; + console.log(value === undefined ? chalk.dim("(unset)") : String(value)); +} + +export function registerConfig(program: Command): void { + const config = program + .command("config") + .description("Read or write global AO config (~/.agent-orchestrator/config.yaml)"); + + config + .command("set ") + .description(`Set a config value. Keys: ${SUPPORTED_KEYS.join(", ")}`) + .action((key: string, value: string) => { + if (!isSupportedKey(key)) { + console.error( + chalk.red(`Unsupported config key: "${key}". Supported: ${SUPPORTED_KEYS.join(", ")}`), + ); + process.exit(1); + } + switch (key) { + case "updateChannel": + setUpdateChannel(value); + break; + case "installMethod": + setInstallMethod(value); + break; + } + }); + + config + .command("get ") + .description(`Read a config value. Keys: ${SUPPORTED_KEYS.join(", ")}`) + .action((key: string) => { + if (!isSupportedKey(key)) { + console.error( + chalk.red(`Unsupported config key: "${key}". Supported: ${SUPPORTED_KEYS.join(", ")}`), + ); + process.exit(1); + } + showGet(key); + }); +} diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 42a6ab502..16eb933fc 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -99,6 +99,7 @@ import { ensureGit, runtimePreflight } from "../lib/startup-preflight.js"; import { installShutdownHandlers } from "../lib/shutdown.js"; import { resolveOrCreateProject } from "../lib/resolve-project.js"; import { pathsEqual } from "../lib/path-equality.js"; +import { maybePromptForUpdateChannel } from "../lib/update-channel-onboarding.js"; import { DEFAULT_PORT } from "../lib/constants.js"; import { projectSessionUrl } from "../lib/routes.js"; @@ -838,6 +839,11 @@ async function runStartup( ): Promise { await runtimePreflight(config); + // Ask about the auto-update channel once on first `ao start` after this + // feature ships. No-op on subsequent runs (idempotent — guarded by the + // presence of `updateChannel` in the global config). + await maybePromptForUpdateChannel(); + const shouldStartLifecycle = opts?.dashboard !== false || opts?.orchestrator !== false; let port = config.port ?? DEFAULT_PORT; console.log(chalk.bold(`\nStarting orchestrator for ${chalk.cyan(project.name)}\n`)); diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 7b900931e..8364d613d 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -1,6 +1,15 @@ import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; import type { Command } from "commander"; import chalk from "chalk"; +import { + getGlobalConfigPath, + isCanonicalGlobalConfigPath, + isWindows, + loadConfig, + loadGlobalConfig, + type Session, +} from "@aoagents/ao-core"; import { runRepoScript } from "../lib/script-runner.js"; import { checkForUpdate, @@ -8,14 +17,49 @@ import { getCurrentVersion, getUpdateCommand, invalidateCache, + readCachedUpdateInfo, + resolveUpdateChannel, + type InstallMethod, } from "../lib/update-check.js"; import { promptConfirm } from "../lib/prompts.js"; +import { getSessionManager } from "../lib/create-session-manager.js"; +import { getRunning } from "../lib/running-state.js"; /** Inline check instead of module-level constant so tests can control TTY state. */ function isTTY(): boolean { return Boolean(process.stdin.isTTY && process.stdout.isTTY); } +/** + * The dashboard's POST /api/update spawns `ao update` with `stdio: "ignore"`, + * which makes `isTTY()` return false. That used to be fatal: handleNpmUpdate + * fell into the "non-interactive — print and return" branch and never invoked + * the install. The route would respond 202 "started" and absolutely nothing + * would happen. + * + * /api/update sets `AO_NON_INTERACTIVE_INSTALL=1` on the spawn env so we can + * distinguish "API kicked this off, please install without prompting" from + * "user piped output and we shouldn't surprise-install." + */ +export const NON_INTERACTIVE_INSTALL_ENV = "AO_NON_INTERACTIVE_INSTALL"; + +function isApiInvoked(): boolean { + return process.env[NON_INTERACTIVE_INSTALL_ENV] === "1"; +} + +/** + * Statuses that mean "the agent is doing real work right now and updating + * `ao` would yank the rug out from under it." + * + * Mirrors the design doc (release-process.html §07): refuse, never auto-stop. + */ +const ACTIVE_SESSION_STATUSES = new Set([ + "working", + "idle", + "needs_input", + "stuck", +]); + export function registerUpdate(program: Command): void { program .command("update") @@ -32,7 +76,6 @@ export function registerUpdate(program: Command): void { process.exit(1); } - // --check: print JSON and exit if (opts.check) { await handleCheck(); return; @@ -40,13 +83,28 @@ export function registerUpdate(program: Command): void { const method = detectInstallMethod(); + // Reject git-only flags up front when the install isn't a git source. + // Without this, users copy/pasting `ao update --skip-smoke` from older + // docs would silently no-op on npm/pnpm/bun installs (the flag would be + // accepted, ignored, and the user would never know why smoke tests + // didn't run — because they never ran on these install methods anyway). + if ((opts.skipSmoke || opts.smokeOnly) && method !== "git") { + const flag = opts.skipSmoke ? "--skip-smoke" : "--smoke-only"; + console.error(`${flag} only applies to git installs (current install: ${method}).`); + process.exit(1); + } + switch (method) { case "git": await handleGitUpdate(opts); break; + case "homebrew": + await handleHomebrewUpdate(); + break; case "npm-global": case "pnpm-global": - await handleNpmUpdate(opts); + case "bun-global": + await handleNpmUpdate(method); break; case "unknown": await handleUnknownUpdate(); @@ -65,6 +123,89 @@ async function handleCheck(): Promise { console.log(JSON.stringify(info, null, 2)); } +// --------------------------------------------------------------------------- +// Active-session guard +// --------------------------------------------------------------------------- + +/** + * Refuse to update when the user has live sessions. + * + * Auto-stopping would lose the agent's in-flight context (and potentially + * uncommitted work). The release doc is explicit: refuse, surface the + * `ao stop` command, let the user decide. + * + * Best-effort — when no config is reachable (fresh install, broken yaml) + * we skip the guard rather than blocking the update on a missing dependency. + */ +async function ensureNoActiveSessions(): Promise { + let sessions: Session[]; + try { + // Live signal first: running.json lists whichever projects the active + // `ao start` daemon is currently polling. That can include local-only + // projects whose `agent-orchestrator.yaml` is NOT in the global registry + // (Dhruv edge case: user runs `ao start` from a repo with a local config + // and no global registration — sessions live on disk, would be clobbered + // if `ao update` proceeded). + // + // If a daemon is running, trust its configPath — it's the source of + // truth for "which sessions does the running ao instance own?" + const running = await getRunning(); + if (running && running.projects.length > 0) { + // running.configPath could be local-wrapped (a project's + // agent-orchestrator.yaml) OR the canonical global path. loadConfig + // dispatches based on the path shape — both cases produce a full + // OrchestratorConfig the SessionManager can enumerate. + const config = loadConfig(running.configPath); + const sm = await getSessionManager(config); + sessions = await sm.list(); + } else { + // No live daemon. Fall back to the global registry — covers the case + // where the user ran `ao stop` (running.json gone) but stale sessions + // sit on disk under ~/.agent-orchestrator/{hash}-{projectId}/. The + // SessionManager's enrichment will reconcile any stale-runtime + // sessions to `killed`, so terminal statuses don't block the update. + const globalPath = getGlobalConfigPath(); + if (!existsSync(globalPath)) return true; + const globalConfig = loadGlobalConfig(globalPath); + if (!globalConfig || Object.keys(globalConfig.projects).length === 0) { + return true; + } + if (!isCanonicalGlobalConfigPath(globalPath)) { + return true; + } + const config = loadConfig(globalPath); + const sm = await getSessionManager(config); + sessions = await sm.list(); + } + } catch { + // If we can't enumerate sessions, don't pretend there are zero — but + // also don't block the upgrade indefinitely. Surface a soft warning. + console.error( + chalk.yellow( + "⚠ Could not check for active sessions before updating. Proceeding anyway.", + ), + ); + return true; + } + + const active = sessions.filter((s) => ACTIVE_SESSION_STATUSES.has(s.status)); + if (active.length === 0) return true; + + const noun = active.length === 1 ? "session" : "sessions"; + console.error( + chalk.red( + `\n✗ ${active.length} ${noun} active. Run \`ao stop\` first, then \`ao update\`.\n`, + ), + ); + for (const s of active.slice(0, 5)) { + console.error(chalk.dim(` • ${s.id} (${s.status})`)); + } + if (active.length > 5) { + console.error(chalk.dim(` … and ${active.length - 5} more`)); + } + return false; +} + // --------------------------------------------------------------------------- // git install // --------------------------------------------------------------------------- @@ -73,6 +214,10 @@ async function handleGitUpdate(opts: { skipSmoke?: boolean; smokeOnly?: boolean; }): Promise { + if (!(await ensureNoActiveSessions())) { + process.exit(1); + } + const args: string[] = []; if (opts.skipSmoke) args.push("--skip-smoke"); if (opts.smokeOnly) args.push("--smoke-only"); @@ -104,47 +249,121 @@ async function handleGitUpdate(opts: { } // --------------------------------------------------------------------------- -// npm-global install +// npm / pnpm / bun global install // --------------------------------------------------------------------------- -async function handleNpmUpdate(opts: { - skipSmoke?: boolean; - smokeOnly?: boolean; -}): Promise { - if (opts.skipSmoke || opts.smokeOnly) { - console.log( - chalk.yellow("--skip-smoke and --smoke-only only apply to git source installs. Ignoring."), - ); - } +async function handleNpmUpdate(method: InstallMethod): Promise { + const channel = resolveUpdateChannel(); - const info = await checkForUpdate({ force: true }); + // Snapshot the previously cached channel BEFORE we force a refresh, so we + // can detect a channel switch (stable→nightly or vice versa). force:true + // would overwrite cache.channel before we can read it. + const previousChannel = readCachedUpdateInfo(method)?.channel; + + const info = await checkForUpdate({ force: true, channel }); if (!info.latestVersion) { console.error(chalk.red("Could not reach npm registry. Check your network and try again.")); process.exit(1); } - if (!info.isOutdated) { - console.log(chalk.green(`Already on latest version (${info.currentVersion}).`)); + // Detect a channel switch. When stable=0.5.0 and nightly=0.5.0-nightly-abc, + // isVersionOutdated returns false (per semver, prerelease < stable on equal + // base), so a stable→nightly user would see "Already on latest nightly" + // until the next numeric bump. Force the prompt instead — explicit consent + // is the right UX for a channel transition, and the install command we'd + // run is genuinely different even if the version-compare says "no". + const isChannelSwitch = + !info.isOutdated && + previousChannel !== undefined && + previousChannel !== channel; + + // First-channel opt-in. previousChannel === undefined means we've never + // installed via the auto-updater. A user who just ran `ao config set + // updateChannel nightly` (after a manual install) would otherwise see + // "Already on latest nightly" because semver says prerelease < stable. + // Treat any version mismatch as install-worthy in that case. + const isFirstChannelOptIn = + !info.isOutdated && + !isChannelSwitch && + previousChannel === undefined && + info.currentVersion !== info.latestVersion; + + const needsInstall = info.isOutdated || isChannelSwitch || isFirstChannelOptIn; + + if (!needsInstall) { + console.log( + chalk.green( + `Already on latest ${channel === "nightly" ? "nightly" : "version"} (${info.currentVersion}).`, + ), + ); return; } console.log(`Current version: ${chalk.dim(info.currentVersion)}`); console.log(`Latest version: ${chalk.green(info.latestVersion)}`); + console.log(`Channel: ${chalk.cyan(channel)}`); + if (isChannelSwitch) { + console.log( + chalk.yellow( + `\nChannel switch detected: was on ${previousChannel}, now ${channel}.`, + ), + ); + console.log( + chalk.dim( + " The version compare says you're current, but the install command picks a different dist-tag.", + ), + ); + } else if (isFirstChannelOptIn) { + console.log( + chalk.yellow( + `\nFirst install via the ${channel} channel — installing the channel's current build.`, + ), + ); + } console.log(); - const command = info.recommendedCommand; + const command = getUpdateCommand(method, channel); + const apiInvoked = isApiInvoked(); + const interactive = isTTY() && !apiInvoked; - if (!isTTY()) { - // Non-interactive: print the command. Exit 0 because this isn't an error, - // the user just needs to run the command manually. + // Non-interactive path: API-invoked OR piped output. We still gate on the + // active-session guard (refusing returns true/false), but we never bail + // out just because there's no terminal — the dashboard's "Update" click + // must actually install. The only thing we skip is the confirm prompt. + if (!(await ensureNoActiveSessions())) { + process.exit(1); + } + + if (interactive) { + // Soft auto-install: when the user has opted into stable or nightly we + // skip the confirm prompt — they've already said "keep me on this channel." + // Manual users (and explicit channel switches / first opt-ins) still see + // the confirm so an unintended `ao update` doesn't wipe the version they + // pinned to. + if (channel === "manual" || isChannelSwitch || isFirstChannelOptIn) { + const promptText = + isChannelSwitch || isFirstChannelOptIn + ? `Switch to ${channel} via ${chalk.cyan(command)}?` + : `Run ${chalk.cyan(command)}?`; + const confirmed = await promptConfirm( + promptText, + !(isChannelSwitch || isFirstChannelOptIn), + ); + if (!confirmed) return; + } else { + console.log(chalk.dim(`Updating: ${command}`)); + } + } else if (apiInvoked) { + console.log(chalk.dim(`Updating (api-invoked): ${command}`)); + } else { + // Non-TTY but also not API-invoked (piped output). Keep the old + // "print the command and let the user run it" behavior so a script + // running `ao update | tee` doesn't get a surprise install. console.log(`Run: ${chalk.cyan(command)}`); return; } - const confirmed = await promptConfirm(`Run ${chalk.cyan(command)}?`); - if (!confirmed) return; - const exitCode = await runNpmInstall(command); if (exitCode === 0) { invalidateCache(); @@ -157,7 +376,17 @@ async function handleNpmUpdate(opts: { function runNpmInstall(command: string): Promise { const [cmd, ...args] = command.split(" "); return new Promise((resolveExit, reject) => { - const child = spawn(cmd!, args, { stdio: "inherit" }); + // `shell: isWindows()` is required so PATHEXT gets consulted on Windows — + // npm/pnpm/bun install as `*.cmd` shims, and Node.js does not look at + // PATHEXT for non-shell spawns, so a bare `npm` / `pnpm` / `bun` lookup + // would silently ENOENT on every Windows install. `windowsHide: true` + // keeps the shell window from flashing. Same fix that landed for the + // dashboard's /api/update spawn in commit 9f29131d. + const child = spawn(cmd!, args, { + stdio: "inherit", + shell: isWindows(), + windowsHide: true, + }); child.on("error", reject); child.on("exit", (code, signal) => { if (signal) { @@ -173,21 +402,51 @@ function runNpmInstall(command: string): Promise { }); } +// --------------------------------------------------------------------------- +// homebrew install (notice only) +// --------------------------------------------------------------------------- + +async function handleHomebrewUpdate(): Promise { + const channel = resolveUpdateChannel(); + const info = await checkForUpdate({ force: true, channel }); + console.log(`Installed via: ${chalk.yellow("Homebrew")}`); + console.log(`Current version: ${chalk.dim(info.currentVersion)}`); + if (info.latestVersion) { + console.log(`Latest version: ${chalk.green(info.latestVersion)}`); + } + console.log(); + console.log( + `Homebrew installs are managed by brew. Run:\n ${chalk.cyan("brew upgrade ao")}`, + ); + console.log( + chalk.dim( + " (AO does not auto-install for brew installs because it would clobber brew's symlinks.)", + ), + ); +} + // --------------------------------------------------------------------------- // unknown install // --------------------------------------------------------------------------- async function handleUnknownUpdate(): Promise { const version = getCurrentVersion(); - const info = await checkForUpdate({ force: true }); + const channel = resolveUpdateChannel(); + const info = await checkForUpdate({ force: true, channel }); console.log(`Installed version: ${chalk.dim(version)}`); if (info.latestVersion) { console.log(`Latest version: ${chalk.green(info.latestVersion)}`); } console.log(`Install method: ${chalk.yellow("unknown")}`); + console.log(`Channel: ${chalk.cyan(channel)}`); console.log(); console.log( - `Could not detect install method. If you installed via npm, run:\n ${chalk.cyan(getUpdateCommand("npm-global"))}`, + `Could not detect install method. If you installed via npm, run:\n ${chalk.cyan(getUpdateCommand("npm-global", channel))}`, + ); + console.log( + chalk.dim( + ` Override detection in ~/.agent-orchestrator/config.yaml:\n installMethod: pnpm-global # or bun-global, npm-global, homebrew, git`, + ), ); } diff --git a/packages/cli/src/lib/update-channel-onboarding.ts b/packages/cli/src/lib/update-channel-onboarding.ts new file mode 100644 index 000000000..4d5d665de --- /dev/null +++ b/packages/cli/src/lib/update-channel-onboarding.ts @@ -0,0 +1,128 @@ +/** + * Update-channel onboarding helpers. + * + * On first `ao start`, prompt the user once for an `updateChannel` and persist + * it to ~/.agent-orchestrator/config.yaml. Never re-prompt — if they dismiss + * (Ctrl+C / Esc), default to `manual` so we don't surprise-install anything. + * + * Spec: release-process.html §08 (Onboarding integration). + */ + +import { existsSync } from "node:fs"; +import chalk from "chalk"; +import { + createDefaultGlobalConfig, + getGlobalConfigPath, + loadGlobalConfig, + saveGlobalConfig, + UpdateChannelSchema, + type GlobalConfig, + type UpdateChannel, +} from "@aoagents/ao-core"; +import { promptSelect } from "./prompts.js"; +import { isHumanCaller } from "./caller-context.js"; + +interface PromptDeps { + /** Test seam — defaults to the real prompt. */ + prompt?: ( + message: string, + options: { value: UpdateChannel | "skip"; label: string; hint?: string }[], + ) => Promise; + /** Test seam — defaults to `isHumanCaller()`. */ + isInteractive?: () => boolean; +} + +/** + * Has the user already been asked about update channels? + * + * True when: + * - The global config exists AND has `updateChannel` set. + * + * False when: + * - The global config does not exist yet (first run — prompt fires immediately). + * - The global config exists but `updateChannel` is unset (existing user, pre-onboarding). + */ +export function hasChosenUpdateChannel(): boolean { + if (!existsSync(getGlobalConfigPath())) return false; + try { + const config = loadGlobalConfig(); + return Boolean(config?.updateChannel); + } catch { + return true; // Don't pester users when config can't be read. + } +} + +/** + * Persist the chosen channel to the global config. + * Loads existing config (or creates a new one via `createDefaultGlobalConfig`) + * and writes the field. + */ +export function persistUpdateChannel(channel: UpdateChannel): void { + const path = getGlobalConfigPath(); + const existing = existsSync(path) ? loadGlobalConfig(path) : null; + const next: GlobalConfig = existing + ? { ...existing, updateChannel: channel } + : { ...createDefaultGlobalConfig(), updateChannel: channel }; + saveGlobalConfig(next, path); +} + +/** + * Prompt the user once and persist the chosen channel. + * + * Skipped silently when: + * - The choice was already made (idempotent — never re-prompts). + * - The caller is not interactive (CI, scripted ao start). + * + * On dismissal, persists `manual` so we don't ask again — surprise auto-installs + * are worse than a quiet manual default. + */ +export async function maybePromptForUpdateChannel(deps: PromptDeps = {}): Promise { + const isInteractive = deps.isInteractive ?? isHumanCaller; + if (!isInteractive()) return; + if (hasChosenUpdateChannel()) return; + + console.log(chalk.bold("\nHow do you want to receive updates?")); + console.log( + chalk.dim( + " Stable ships every Thursday. Nightly ships daily Fri–Tue. Manual stays put.", + ), + ); + console.log( + chalk.dim( + " You can switch later with `ao config set updateChannel ` —\n the next `ao update` will prompt before installing the other channel's build.\n", + ), + ); + + const promptFn = deps.prompt ?? defaultPrompt; + let raw: UpdateChannel | "skip" | undefined; + try { + raw = await promptFn("Update channel:", [ + { value: "stable", label: "Stable — weekly releases. Recommended for most users.", hint: "@latest" }, + { value: "nightly", label: "Nightly — daily builds. Bleeding edge.", hint: "@nightly" }, + { value: "manual", label: "Manual — no checks. Run `ao update` yourself.", hint: "default if dismissed" }, + ]); + } catch { + raw = "manual"; + } + + // Never re-prompt: the absence of `updateChannel` is the signal we use to + // know we haven't asked yet, so even on dismissal we must persist *something*. + // Validate against the schema before writing — never persist `undefined` or + // an unrecognized string (which would corrupt the config and re-trigger + // every Zod parse path with `.catch(undefined)` fallbacks). + const candidate = raw === "skip" || raw === undefined ? "manual" : raw; + const parsed = UpdateChannelSchema.safeParse(candidate); + const channel: UpdateChannel = parsed.success ? parsed.data : "manual"; + persistUpdateChannel(channel); + console.log(chalk.green(` ✓ Update channel set to ${chalk.bold(channel)}`)); + console.log( + chalk.dim(` Change it later with: ao config set updateChannel \n`), + ); +} + +async function defaultPrompt( + message: string, + options: { value: UpdateChannel | "skip"; label: string; hint?: string }[], +): Promise { + return promptSelect(message, options); +} diff --git a/packages/cli/src/lib/update-check.ts b/packages/cli/src/lib/update-check.ts index f2d8b3773..80ec8f586 100644 --- a/packages/cli/src/lib/update-check.ts +++ b/packages/cli/src/lib/update-check.ts @@ -5,22 +5,35 @@ * - `ao update` (install-aware routing) * - Startup notifier (synchronous cache read) * - `ao doctor` (version freshness check) + * - Dashboard (`/api/version` route) */ import { execFile, execFileSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; -import { createRequire } from "node:module"; -import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; +import { + getInstalledAoVersion, + getUpdateCheckCachePath, + isVersionOutdated as coreIsVersionOutdated, + loadGlobalConfig, + type UpdateChannel, + type InstallMethodOverride, +} from "@aoagents/ao-core"; import { getCliVersion } from "../options/version.js"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- -export type InstallMethod = "git" | "npm-global" | "pnpm-global" | "unknown"; +export type InstallMethod = + | "git" + | "npm-global" + | "pnpm-global" + | "bun-global" + | "homebrew" + | "unknown"; export interface UpdateInfo { currentVersion: string; @@ -29,14 +42,23 @@ export interface UpdateInfo { installMethod: InstallMethod; recommendedCommand: string; checkedAt: string | null; + channel: UpdateChannel; } export interface CacheData { latestVersion: string; checkedAt: string; currentVersionAtCheck: string; + /** + * Cache scoping. The cache stores exactly one entry — entries for a + * different install method or channel are treated as misses, forcing a + * refresh against the relevant source (git fetch / npm registry). + */ installMethod?: InstallMethod; + channel?: UpdateChannel; + /** For non-git installs, derived from `isVersionOutdated(current, latest)`. */ isOutdated?: boolean; + /** For git installs, lets us cheaply detect a manual `git pull` since the last check. */ currentRevisionAtCheck?: string; latestRevisionAtCheck?: string; } @@ -45,7 +67,8 @@ export interface CacheData { // Constants // --------------------------------------------------------------------------- -const REGISTRY_URL = "https://registry.npmjs.org/@aoagents%2Fao/latest"; +/** Full package document — includes `dist-tags` for channel resolution. */ +const REGISTRY_PACKAGE_URL = "https://registry.npmjs.org/@aoagents%2Fao"; const FETCH_TIMEOUT_MS = 3000; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours const DEFAULT_GIT_REMOTE = "origin"; @@ -53,19 +76,42 @@ const DEFAULT_GIT_BRANCH = "main"; const execFileAsync = promisify(execFile); // --------------------------------------------------------------------------- -// Install detection +// Channel resolution // --------------------------------------------------------------------------- /** - * Classify a resolved file path as npm-global, git source, or unknown. - * Extracted for testability — `detectInstallMethod` calls this with - * the resolved `import.meta.url` path. + * Resolve the user's chosen update channel from global config. * - * Distinguishes global npm installs (e.g. /usr/local/lib/node_modules, - * ~/.nvm/.../lib/node_modules, pnpm global store) from local project - * node_modules by checking for `lib/node_modules` (global) vs a bare - * `node_modules` that sits inside a project directory (local/npx). + * Defaults to "manual" when: + * - The global config does not exist yet (first run). + * - The user has not set `updateChannel` (existing user, pre-onboarding). + * + * "manual" is intentionally conservative: surprise auto-installs are bad, + * and the onboarding flow promotes users to "stable" or "nightly" explicitly. */ +export function resolveUpdateChannel(): UpdateChannel { + try { + const config = loadGlobalConfig(); + return config?.updateChannel ?? "manual"; + } catch { + return "manual"; + } +} + +/** Read the install-method override from global config (if any). */ +export function resolveInstallMethodOverride(): InstallMethodOverride | undefined { + try { + const config = loadGlobalConfig(); + return config?.installMethod; + } catch { + return undefined; + } +} + +// --------------------------------------------------------------------------- +// Install detection +// --------------------------------------------------------------------------- + export function hasNodeModulesAncestor(resolvedPath: string): boolean { return resolvedPath.includes("/node_modules/") || resolvedPath.includes("\\node_modules\\"); } @@ -104,28 +150,41 @@ export function isAoCliPackageRoot(root: string): boolean { return readPackageName(resolve(root, "package.json")) === "@aoagents/ao-cli"; } +/** + * Classify a resolved file path as one of the known install methods. + * + * Order matters — Homebrew installs typically nest the npm tree under + * `/Cellar/ao/.../libexec/lib/node_modules/`, so we check for `/Cellar/ao/` + * BEFORE classifying as `npm-global`. Bun's global store sits under + * `~/.bun/install/global/` and is detected the same way. + */ export function classifyInstallPath(resolvedPath: string): InstallMethod { + // Homebrew installs of the `ao` formula land under /Cellar/ao//. + // Detect this BEFORE the generic node_modules walk, because brew installs + // also live under .../lib/node_modules/. We don't auto-install for brew — + // that would clobber the symlinks brew owns. + if (resolvedPath.includes("/Cellar/ao/") || resolvedPath.includes("\\Cellar\\ao\\")) { + return "homebrew"; + } + + // Bun's global install layout: ~/.bun/install/global/node_modules/... + if ( + resolvedPath.includes("/.bun/install/global/") || + resolvedPath.includes("\\.bun\\install\\global\\") + ) { + return "bun-global"; + } + if (hasNodeModulesAncestor(resolvedPath)) { - // Global installs live under .../lib/node_modules/... (npm/nvm/fnm/volta) - // or pnpm's global store (.../pnpm/global/.../node_modules/...). - // Local project installs have node_modules directly inside a project dir. - // Note: /.pnpm/ alone is NOT a global signal — pnpm creates node_modules/.pnpm/ - // for local installs too. Only pnpm/global paths indicate a global install. const isPnpmGlobal = resolvedPath.includes("/pnpm/global/") || resolvedPath.includes("\\pnpm\\global\\"); - - if (isPnpmGlobal) { - return "pnpm-global"; - } + if (isPnpmGlobal) return "pnpm-global"; const isNpmGlobal = - resolvedPath.includes("/lib/node_modules/") || resolvedPath.includes("\\lib\\node_modules\\"); + resolvedPath.includes("/lib/node_modules/") || + resolvedPath.includes("\\lib\\node_modules\\"); + if (isNpmGlobal) return "npm-global"; - if (isNpmGlobal) { - return "npm-global"; - } - // Local node_modules (e.g. npx, project-local install) — treat as unknown - // so we don't suggest "npm install -g" to someone using npx return "unknown"; } @@ -139,8 +198,10 @@ export function classifyInstallPath(resolvedPath: string): InstallMethod { return "unknown"; } -/** Detect how the running `ao` binary was installed based on its file location. */ +/** Detect how the running `ao` binary was installed. Honors `installMethod` override. */ export function detectInstallMethod(): InstallMethod { + const override = resolveInstallMethodOverride(); + if (override) return override; return classifyInstallPath(fileURLToPath(import.meta.url)); } @@ -149,22 +210,20 @@ export function detectInstallMethod(): InstallMethod { // --------------------------------------------------------------------------- /** - * Get the installed version of the `@aoagents/ao` wrapper package. - * Falls back to the CLI package version if the wrapper is not resolvable - * (e.g. running from source where both are the same version anyway). + * Resolve the currently-installed `@aoagents/ao` version. + * + * Delegates to core's `getInstalledAoVersion` (single source of truth shared + * with the dashboard) and falls back to the CLI's own embedded version when + * neither package is in `node_modules` (test/dev edge case). */ export function getCurrentVersion(): string { - try { - const require = createRequire(import.meta.url); - const aoPkg = require("@aoagents/ao/package.json") as { version: string }; - return aoPkg.version; - } catch { - return getCliVersion(); - } + const fromCore = getInstalledAoVersion(); + if (fromCore !== "0.0.0") return fromCore; + return getCliVersion(); } // --------------------------------------------------------------------------- -// Update command mapping +// Git update target (#1595) // --------------------------------------------------------------------------- export interface GitUpdateTarget { @@ -183,46 +242,95 @@ export function getGitUpdateRef(): string { return getGitUpdateTarget().ref; } -export function getUpdateCommand(method: InstallMethod): string { +// --------------------------------------------------------------------------- +// Update command mapping +// --------------------------------------------------------------------------- + +/** + * Map an install method + channel to the command the user should run. + * + * Git installs always run `ao update` (which delegates to `ao-update.sh`) + * regardless of channel — the channel only affects npm-published builds. + * + * Homebrew is special: we never auto-install. We surface the brew command as + * a notice so the user runs it themselves — auto-running `npm install -g` + * inside a brew prefix overwrites brew's symlinks. + */ +export function getUpdateCommand( + method: InstallMethod, + channel: UpdateChannel = "stable", +): string { + // "manual" channel maps to "stable" for the install command — the channel + // affects when we check, not which tag manual installers should pick. + const tag = channel === "nightly" ? "nightly" : "latest"; switch (method) { case "git": return "ao update"; case "npm-global": - return "npm install -g @aoagents/ao@latest"; + return `npm install -g @aoagents/ao@${tag}`; case "pnpm-global": - return "pnpm add -g @aoagents/ao@latest"; + return `pnpm add -g @aoagents/ao@${tag}`; + case "bun-global": + return `bun add -g @aoagents/ao@${tag}`; + case "homebrew": + return "brew upgrade ao"; case "unknown": - return "npm install -g @aoagents/ao@latest"; + return `npm install -g @aoagents/ao@${tag}`; } } +/** True when the install method requires a manual user action (no auto-install). */ +export function isManualOnlyInstall(method: InstallMethod): boolean { + return method === "homebrew"; +} + // --------------------------------------------------------------------------- // Cache // --------------------------------------------------------------------------- +/** Directory holding the update cache. Re-exported for ao doctor / CLI smoke tests. */ export function getCacheDir(): string { - const xdg = process.env["XDG_CACHE_HOME"]; - const base = xdg || join(homedir(), ".cache"); - return join(base, "ao"); + // dirname(getUpdateCheckCachePath()) keeps this in lock-step with core's + // canonical path resolver. + return dirname(getUpdateCheckCachePath()); } function getCachePath(): string { - return join(getCacheDir(), "update-check.json"); + return getUpdateCheckCachePath(); } -/** Read cached update info. Returns null if missing, expired, corrupt, version-mismatched, or install-method-mismatched. */ -export function readCachedUpdateInfo(installMethod = detectInstallMethod()): CacheData | null { +/** + * Read cached update info. Returns null if missing, expired, corrupt, + * version-mismatched, install-method-mismatched, or channel-mismatched. + * + * The cache is keyed by both `installMethod` and `channel` because the + * `latestVersion` stored at each tuple is meaningfully different (stable + * 0.5.0 vs nightly 0.5.0-nightly-abc; git's `origin/main` ref vs npm tag). + */ +export function readCachedUpdateInfo( + installMethod: InstallMethod = detectInstallMethod(), + channel?: UpdateChannel, +): CacheData | null { try { const raw = readFileSync(getCachePath(), "utf-8"); const data = JSON.parse(raw) as CacheData; if (!data.latestVersion || !data.checkedAt) return null; - // Legacy cache entries predate install-method scoping, so treat them as unsafe - // for every install method rather than guessing which update channel produced them. + // Legacy cache entries predate install-method scoping — treat as unsafe. if (!data.installMethod) return null; if (data.installMethod !== installMethod) return null; + // Channel scoping. When the caller passes an explicit channel, the cache + // entry MUST advertise its own channel and that channel MUST match. A + // legacy entry without a `channel` field (written before channel scoping + // landed) is treated as a miss — otherwise a stable→nightly switch would + // keep returning the pre-switch latestVersion until the TTL expired. + if (channel) { + if (!data.channel) return null; + if (data.channel !== channel) return null; + } + // Cache is stale if user upgraded since the check const currentVersion = getCurrentVersion(); if (data.currentVersionAtCheck && data.currentVersionAtCheck !== currentVersion) { @@ -268,7 +376,7 @@ export function invalidateCache(): void { } // --------------------------------------------------------------------------- -// Git fetch +// Git fetch (#1595) // --------------------------------------------------------------------------- export interface GitLatestState { @@ -314,16 +422,37 @@ export async function fetchGitLatestState( // Registry fetch // --------------------------------------------------------------------------- -/** Fetch the latest version of @aoagents/ao from the npm registry. */ -export async function fetchLatestVersion(): Promise { +/** + * Fetch the latest version of @aoagents/ao for the given dist-tag. + * + * Hits the full package document (not the per-tag URL) so we get all dist-tags + * in one round trip. Channels: + * stable / manual → dist-tags.latest + * nightly → dist-tags.nightly (falls back to latest if no nightly tag) + */ +export async function fetchLatestVersion( + channel: UpdateChannel = "stable", +): Promise { try { - const response = await fetch(REGISTRY_URL, { + const response = await fetch(REGISTRY_PACKAGE_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { Accept: "application/json" }, }); if (!response.ok) return null; - const data = (await response.json()) as { version?: string }; - return typeof data.version === "string" ? data.version : null; + const data = (await response.json()) as { "dist-tags"?: Record }; + const tags = data["dist-tags"]; + if (!tags || typeof tags !== "object") return null; + + const tag = channel === "nightly" ? "nightly" : "latest"; + const value = tags[tag]; + if (typeof value === "string") return value; + + // Nightly tag missing? Fall back to latest so the dashboard isn't broken + // before the first nightly publishes. + if (tag === "nightly" && typeof tags["latest"] === "string") { + return tags["latest"]; + } + return null; } catch { return null; } @@ -333,19 +462,30 @@ export async function fetchLatestVersion(): Promise { // Orchestrator // --------------------------------------------------------------------------- -/** Check for updates, using cache when fresh and fetching when stale. */ +/** + * Check for updates, using cache when fresh and fetching when stale. + * + * Source of truth depends on install method: + * - git installs → `git fetch ` + `merge-base` + * - npm/pnpm/bun/... → npm registry, dist-tags[channel] + * + * When the channel is "manual" the function still runs (so `--check` works + * and the dashboard can show current state) but the startup notice and the + * background refresh respect the channel and stay quiet. + */ export async function checkForUpdate(opts?: { force?: boolean; + channel?: UpdateChannel; installMethod?: InstallMethod; repoRoot?: string; }): Promise { + const channel = opts?.channel ?? resolveUpdateChannel(); const currentVersion = getCurrentVersion(); const installMethod = opts?.installMethod ?? detectInstallMethod(); - const recommendedCommand = getUpdateCommand(installMethod); + const recommendedCommand = getUpdateCommand(installMethod, channel); - // Try cache first (unless forced) if (!opts?.force) { - const cached = readCachedUpdateInfo(installMethod); + const cached = readCachedUpdateInfo(installMethod, channel); if (cached) { return { currentVersion, @@ -357,6 +497,7 @@ export async function checkForUpdate(opts?: { installMethod, recommendedCommand, checkedAt: cached.checkedAt, + channel, }; } } @@ -371,6 +512,7 @@ export async function checkForUpdate(opts?: { checkedAt: now, currentVersionAtCheck: currentVersion, installMethod, + channel, isOutdated: gitState.isBehind, currentRevisionAtCheck: gitState.headRevision, latestRevisionAtCheck: gitState.latestRevision, @@ -384,11 +526,12 @@ export async function checkForUpdate(opts?: { installMethod, recommendedCommand, checkedAt: gitState ? now : null, + channel, }; } - // npm/pnpm/unknown installs use the npm registry as their update channel. - const latestVersion = await fetchLatestVersion(); + // npm/pnpm/bun/unknown installs use the npm registry as their update channel. + const latestVersion = await fetchLatestVersion(channel); if (latestVersion) { writeCache({ @@ -396,6 +539,7 @@ export async function checkForUpdate(opts?: { checkedAt: now, currentVersionAtCheck: currentVersion, installMethod, + channel, isOutdated: isVersionOutdated(currentVersion, latestVersion), }); } @@ -407,6 +551,7 @@ export async function checkForUpdate(opts?: { installMethod, recommendedCommand, checkedAt: latestVersion ? now : null, + channel, }; } @@ -414,18 +559,26 @@ export async function checkForUpdate(opts?: { // Startup notifier (synchronous, cache-only) // --------------------------------------------------------------------------- -/** Print an update notice to stderr if a newer version is cached. No network call. */ +/** + * Print an update notice to stderr if a newer version is cached. + * + * Skipped entirely when channel is "manual" — the user opted out of nudges. + * Stable users see "Run: ao update". Nightly users get the same nudge but + * the suggested install command picks `@nightly` instead of `@latest`. + */ export function maybeShowUpdateNotice(): void { if (!process.stderr.isTTY) return; if (process.env["AO_NO_UPDATE_NOTIFIER"] === "1") return; if (process.env["CI"] || process.env["AGENT_ORCHESTRATOR_CI"]) return; - // Skip for meta commands const skipArgs = ["update", "doctor", "--version", "-V", "--help", "-h"]; if (process.argv.some((arg) => skipArgs.includes(arg))) return; + const channel = resolveUpdateChannel(); + if (channel === "manual") return; + const installMethod = detectInstallMethod(); - const cached = readCachedUpdateInfo(installMethod); + const cached = readCachedUpdateInfo(installMethod, channel); if (!cached) return; const currentVersion = getCurrentVersion(); @@ -435,22 +588,21 @@ export function maybeShowUpdateNotice(): void { : isVersionOutdated(currentVersion, cached.latestVersion); if (!isOutdated) return; + const channelSuffix = channel === "nightly" ? " (nightly)" : ""; + const command = getUpdateCommand(installMethod, channel); const message = installMethod === "git" - ? `\nUpdate available from ${cached.latestVersion} — Run: ${getUpdateCommand(installMethod)}\n\n` - : `\nUpdate available: ${currentVersion} → ${cached.latestVersion} — Run: ${getUpdateCommand(installMethod)}\n\n`; + ? `\nUpdate available${channelSuffix} from ${cached.latestVersion} — Run: ${command}\n\n` + : `\nUpdate available${channelSuffix}: ${currentVersion} → ${cached.latestVersion} — Run: ${command}\n\n`; process.stderr.write(message); } /** - * Kick off a background cache refresh. Call after parse() completes. - * Uses setTimeout with .unref() so the process can exit without waiting. - * Note: for short-lived commands, the timer may not fire before exit. - * The cache gets seeded reliably by `ao update --check` or any `ao update` - * invocation. This is a best-effort bonus for long-running commands like - * `ao start`. + * Kick off a background cache refresh. Skips entirely on `manual` channel + * so users who opted out don't generate any registry traffic. */ export function scheduleBackgroundRefresh(): void { + if (resolveUpdateChannel() === "manual") return; const timer = setTimeout(() => { checkForUpdate().catch(() => {}); }, 0); @@ -462,31 +614,9 @@ export function scheduleBackgroundRefresh(): void { // --------------------------------------------------------------------------- /** - * Simple semver comparison: returns true if current < latest. - * - * The npm registry `latest` tag normally points to a stable release, so we - * only need one prerelease rule beyond numeric comparison: when the numeric - * parts match, a prerelease current version is older than a stable latest - * version (for example `0.2.2-beta.1` < `0.2.2`). + * Re-export the core implementation so CLI consumers (and the existing test + * suite) keep importing from this module while the dashboard imports the same + * function from `@aoagents/ao-core` — single source of truth for the prerelease + * comparison rules. */ -export function isVersionOutdated(current: string, latest: string): boolean { - const parseVersion = (version: string) => { - const [base, prerelease] = version.split("-", 2); - return { - parts: (base ?? "").split(".").map(Number), - hasPrerelease: Boolean(prerelease), - }; - }; - - const currentVersion = parseVersion(current); - const latestVersion = parseVersion(latest); - for (let i = 0; i < 3; i++) { - const c = currentVersion.parts[i] ?? 0; - const l = latestVersion.parts[i] ?? 0; - if (Number.isNaN(c) || Number.isNaN(l)) return false; - if (c < l) return true; - if (c > l) return false; - } - - return currentVersion.hasPrerelease && !latestVersion.hasPrerelease; -} +export const isVersionOutdated = coreIsVersionOutdated; diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index 12ffb0858..71cb76136 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -17,6 +17,7 @@ import { registerProjectCommand } from "./commands/project.js"; import { registerMigrateStorage } from "./commands/migrate-storage.js"; import { registerCompletion } from "./commands/completion.js"; import { registerEvents } from "./commands/events.js"; +import { registerConfig } from "./commands/config.js"; import { getConfigInstruction } from "./lib/config-instruction.js"; import { getCliVersion } from "./options/version.js"; @@ -49,6 +50,7 @@ export function createProgram(): Command { registerMigrateStorage(program); registerCompletion(program); registerEvents(program); + registerConfig(program); program .command("config-help") diff --git a/packages/core/package.json b/packages/core/package.json index c5678cc26..00b11bac7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -103,5 +103,9 @@ "tslib": "^2.8.1", "typescript": "^5.7.0", "vitest": "^4.0.18" + }, + "publishConfig": { + "access": "public", + "provenance": true } } diff --git a/packages/core/src/__tests__/version-compare.test.ts b/packages/core/src/__tests__/version-compare.test.ts new file mode 100644 index 000000000..7eeedd9e4 --- /dev/null +++ b/packages/core/src/__tests__/version-compare.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest"; + +import { isVersionOutdated } from "../version-compare.js"; + +// Both `packages/cli/src/lib/update-check.ts` and +// `packages/web/src/app/api/version/route.ts` import this implementation +// from core. These tests are the single source of truth for the comparison +// rules — if either consumer's behavior diverges, fix the consumer, not this. + +describe("isVersionOutdated (shared core implementation)", () => { + describe("numeric major/minor/patch", () => { + it("treats lower major as older", () => { + expect(isVersionOutdated("0.2.2", "1.0.0")).toBe(true); + }); + + it("treats lower minor as older", () => { + expect(isVersionOutdated("0.2.2", "0.3.0")).toBe(true); + }); + + it("treats lower patch as older", () => { + expect(isVersionOutdated("0.2.2", "0.2.3")).toBe(true); + }); + + it("returns false when versions are equal", () => { + expect(isVersionOutdated("0.2.2", "0.2.2")).toBe(false); + }); + + it("returns false when current is newer", () => { + expect(isVersionOutdated("1.0.0", "0.9.9")).toBe(false); + }); + + it("treats missing patch as 0", () => { + expect(isVersionOutdated("1.0", "1.0.1")).toBe(true); + }); + + it("returns false when prerelease tags produce NaN parts", () => { + expect(isVersionOutdated("beta", "1.0.0")).toBe(false); + }); + }); + + describe("prerelease vs stable", () => { + it("treats prerelease as older than the matching stable", () => { + expect(isVersionOutdated("0.2.2-beta.1", "0.2.2")).toBe(true); + expect(isVersionOutdated("0.2.2-rc.1", "0.2.2")).toBe(true); + expect(isVersionOutdated("0.5.0-nightly-abc", "0.5.0")).toBe(true); + }); + + it("treats stable as newer than its prerelease", () => { + expect(isVersionOutdated("0.3.0", "0.3.0-beta.1")).toBe(false); + }); + + it("compares numeric base first regardless of prerelease tag", () => { + expect(isVersionOutdated("0.2.2-beta.1", "0.3.0")).toBe(true); + }); + }); + + describe("prerelease vs prerelease", () => { + it("compares numeric prerelease segments numerically", () => { + expect(isVersionOutdated("0.2.2-rc.1", "0.2.2-rc.2")).toBe(true); + expect(isVersionOutdated("0.2.2-rc.2", "0.2.2-rc.1")).toBe(false); + expect(isVersionOutdated("0.2.2-rc.2", "0.2.2-rc.2")).toBe(false); + }); + + it("treats any SHA-suffix difference as outdated (lexical compare would misfire ~50%)", () => { + // The release pipeline tags nightlies as 0.x.y-nightly-. SHAs are + // uniformly-random hex, so lexical ordering of `nightly-f00d123` vs + // `nightly-0dead01` would give the wrong answer half the time + // ('f' > '0' but the second SHA is newer). The cache always carries the + // registry's CURRENT dist-tag target, so any SHA mismatch on the same + // base means we're behind by construction. + expect(isVersionOutdated("0.5.0-nightly-abc", "0.5.0-nightly-def")).toBe(true); + // Critical: the inverse must also report "outdated" — that's the bug + // the user would hit when their leading-hex sorts higher than latest's. + expect(isVersionOutdated("0.5.0-nightly-def", "0.5.0-nightly-abc")).toBe(true); + expect(isVersionOutdated("0.5.0-nightly-f00d123", "0.5.0-nightly-0dead01")).toBe(true); + // Same SHA on both sides → still equal, not outdated. + expect(isVersionOutdated("0.5.0-nightly-abc", "0.5.0-nightly-abc")).toBe(false); + }); + + it("still uses numeric ordering when prerelease segments are numbers", () => { + // We didn't break the `rc.1 < rc.2` semver semantics — numeric segments + // continue to compare numerically. Only non-numeric differences fall + // back to "older." + expect(isVersionOutdated("0.2.2-rc.1", "0.2.2-rc.2")).toBe(true); + expect(isVersionOutdated("0.2.2-rc.2", "0.2.2-rc.1")).toBe(false); + }); + + it("longer prerelease wins when shared segments are equal", () => { + expect(isVersionOutdated("0.5.0-nightly", "0.5.0-nightly.1")).toBe(true); + }); + + it("numeric segment is older than non-numeric segment", () => { + expect(isVersionOutdated("0.5.0-1", "0.5.0-alpha")).toBe(true); + expect(isVersionOutdated("0.5.0-alpha", "0.5.0-1")).toBe(false); + }); + }); +}); diff --git a/packages/core/src/global-config.ts b/packages/core/src/global-config.ts index 342715776..cda4ca625 100644 --- a/packages/core/src/global-config.ts +++ b/packages/core/src/global-config.ts @@ -153,6 +153,34 @@ export type GlobalProjectEntry = z.infer; * Global config schema. * Operational settings + project registry with identity fields only. */ +/** + * Update channel — controls which npm dist-tag the auto-updater tracks. + * + * stable — @latest (weekly Thursday releases). Auto-installs when run. + * nightly — @nightly (daily Fri–Tue cron). Auto-installs when run. + * manual — no checks, no notice, no install. User runs `ao update` manually. + */ +export const UpdateChannelSchema = z.enum(["stable", "nightly", "manual"]); +export type UpdateChannel = z.infer; + +/** + * Install-method override. When set, the auto-updater bypasses path-based + * detection and uses this value to pick the upgrade command. Useful for + * non-standard install layouts (custom prefixes, asdf, etc.). + * + * Mirrors `InstallMethod` from the CLI (kept as `string` here so the core + * package doesn't depend on the CLI). + */ +export const InstallMethodOverrideSchema = z.enum([ + "git", + "npm-global", + "pnpm-global", + "bun-global", + "homebrew", + "unknown", +]); +export type InstallMethodOverride = z.infer; + export const GlobalConfigSchema = z .object({ /** Web dashboard port. Default: 3000 */ @@ -161,6 +189,22 @@ export const GlobalConfigSchema = z directTerminalPort: z.number().optional(), /** Time before a "ready" session becomes "idle". Default: 300 000 ms (5 min). */ readyThresholdMs: z.number().nonnegative().default(300_000), + /** + * Auto-update channel preference. + * + * Default `manual` (resolved at read time) so users who upgrade across + * this change keep their existing behavior — no surprise auto-installs. + * The onboarding flow prompts new users on first `ao start` and persists + * the answer here. + * + * `.catch(undefined)` makes the schema tolerant of legacy / typo'd values + * in the on-disk config: a stray `updateChannel: foo` parses as + * "unset" rather than failing the whole config load. The user can fix it + * later via `ao config set updateChannel `. + */ + updateChannel: UpdateChannelSchema.optional().catch(undefined), + /** Override path-based install detection. Optional. */ + installMethod: InstallMethodOverrideSchema.optional().catch(undefined), /** Cross-project defaults — projects inherit when fields are omitted. */ defaults: z .object({ @@ -1019,7 +1063,21 @@ export function migrateToGlobalConfig(oldConfigPath: string, globalConfigPath?: // HELPERS // ============================================================================= -function makeEmptyGlobalConfig(): GlobalConfig { +/** + * Build a fresh GlobalConfig with all platform-aware defaults filled in. + * + * Single source of truth for "what does a brand-new global config look like?" + * — used by: + * - The internal initial-load path here in core (`makeEmptyGlobalConfig`). + * - `ao config set` (CLI) when no config file exists yet. + * - `maybePromptForUpdateChannel` (CLI) when persisting the user's channel + * pick on first run. + * + * Critically, `defaults.runtime` is platform-aware via `getDefaultRuntime()` + * (returns "process" on Windows, "tmux" elsewhere) — hardcoding "tmux" would + * lock Windows users into a non-functional config. + */ +export function createDefaultGlobalConfig(): GlobalConfig { return { port: 3000, readyThresholdMs: 300_000, @@ -1041,6 +1099,11 @@ function makeEmptyGlobalConfig(): GlobalConfig { }; } +/** Internal alias for back-compat with existing callers in this file. */ +function makeEmptyGlobalConfig(): GlobalConfig { + return createDefaultGlobalConfig(); +} + function sanitizeRawGlobalConfig(raw: Record): RawGlobalConfigSanitization { const projects = raw["projects"]; if (!projects || typeof projects !== "object") { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c41ce3021..6adcdaae4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -291,6 +291,7 @@ export { isCanonicalGlobalConfigPath, loadGlobalConfig, saveGlobalConfig, + createDefaultGlobalConfig, loadLocalProjectConfig, LocalProjectConfigSchema, loadLocalProjectConfigDetailed, @@ -310,7 +311,24 @@ export type { LocalProjectConfig, LocalProjectConfigLoadResult, RegisterProjectOptions, + UpdateChannel, + InstallMethodOverride, } from "./global-config.js"; +export { UpdateChannelSchema, InstallMethodOverrideSchema } from "./global-config.js"; + +// Channel-aware semver comparison shared by the CLI's update-check and the +// dashboard's /api/version route. +export { isVersionOutdated } from "./version-compare.js"; + +// Cache-layer primitives for the update pipeline. Both the CLI and the +// dashboard's /api/version route read the same cache file; centralising the +// path + shape here prevents drift. +export { + getUpdateCheckCachePath, + readUpdateCheckCacheRaw, + getInstalledAoVersion, +} from "./update-cache.js"; +export type { UpdateCheckCacheRaw } from "./update-cache.js"; export { loadEffectiveProjectConfig, iterateAllProjects } from "./project-resolver.js"; diff --git a/packages/core/src/update-cache.ts b/packages/core/src/update-cache.ts new file mode 100644 index 000000000..591b7e301 --- /dev/null +++ b/packages/core/src/update-cache.ts @@ -0,0 +1,93 @@ +/** + * Shared cache-layer primitives for the AO update pipeline. + * + * Single source of truth for: + * - the on-disk path of the update-check cache + * - a raw (no-validation) read of that cache file + * - the currently-installed `@aoagents/ao` version + * + * Both the CLI's `update-check.ts` and the dashboard's `/api/version` route + * consume these. Without this module, both sides would reimplement the cache + * path resolution and drift over time — a renamed file or relocated XDG dir + * would silently de-sync the CLI's startup notice from the dashboard banner. + * + * The CLI keeps its richer `readCachedUpdateInfo` (which layers on + * install-method / channel / git-rev validation) on top of the raw read here. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Wire-format of `~/.cache/ao/update-check.json`. + * + * Kept loose (all fields optional) because: + * 1. Legacy entries predate install-method scoping and lack `installMethod`. + * 2. The CLI's stricter `readCachedUpdateInfo` enforces the per-call + * invariants (channel match, install-method match, freshness, git-rev + * currency) — this raw shape is what's on disk, not what's safe to use. + */ +export interface UpdateCheckCacheRaw { + latestVersion?: string; + checkedAt?: string; + currentVersionAtCheck?: string; + /** "stable" | "nightly" | "manual"; kept as string here so core doesn't import the enum. */ + channel?: string; + /** "git" | "npm-global" | "pnpm-global" | "bun-global" | "homebrew" | "unknown". */ + installMethod?: string; + /** Set by `checkForUpdate` when computing `isOutdated` for non-git entries. */ + isOutdated?: boolean; + /** Git installs only — used to invalidate the cache when the user runs `git pull` manually. */ + currentRevisionAtCheck?: string; + latestRevisionAtCheck?: string; +} + +/** + * Resolve the canonical path of `update-check.json`. Honors `$XDG_CACHE_HOME`, + * falling back to `~/.cache/ao/update-check.json`. + */ +export function getUpdateCheckCachePath(): string { + const xdg = process.env["XDG_CACHE_HOME"]; + const base = xdg || join(homedir(), ".cache"); + return join(base, "ao", "update-check.json"); +} + +/** Raw cache read with no semantic validation. Returns null on missing/corrupt. */ +export function readUpdateCheckCacheRaw(): UpdateCheckCacheRaw | null { + const path = getUpdateCheckCachePath(); + if (!existsSync(path)) return null; + try { + const raw = readFileSync(path, "utf-8"); + return JSON.parse(raw) as UpdateCheckCacheRaw; + } catch { + return null; + } +} + +/** + * The currently-installed `@aoagents/ao` version. + * + * Tries the wrapper package first (the canonical version users see). Falls + * back to `@aoagents/ao-web` for dev mode where the wrapper isn't always in + * `node_modules` — the dashboard ships in lockstep with `@aoagents/ao` (the + * changeset linked group), so the web version is a safe proxy. + * + * Final fallback returns `"0.0.0"` so callers always have a string to + * `isVersionOutdated` against. + */ +export function getInstalledAoVersion(): string { + const require = createRequire(fileURLToPath(import.meta.url)); + const candidates = ["@aoagents/ao/package.json", "@aoagents/ao-web/package.json"]; + for (const candidate of candidates) { + try { + const pkg = require(candidate) as { version?: unknown }; + if (typeof pkg.version === "string") return pkg.version; + } catch { + // try next candidate + } + } + return "0.0.0"; +} diff --git a/packages/core/src/version-compare.ts b/packages/core/src/version-compare.ts new file mode 100644 index 000000000..224678656 --- /dev/null +++ b/packages/core/src/version-compare.ts @@ -0,0 +1,96 @@ +/** + * Channel-aware semver comparison for the AO release pipeline. + * + * Lives in core (not the CLI) because both the CLI's update-check and the + * dashboard's `/api/version` route compare versions and must stay in lockstep. + * Keeping the implementation here means there is exactly one tested copy. + * + * Spec: release-process.html §07 (Auto-update mechanics). + */ + +/** + * Returns true if `current` is an older version than `latest`. + * + * Compares numeric major/minor/patch first, then handles prereleases: + * - When base versions are equal: + * prerelease vs stable → prerelease is OLDER (`0.5.0-nightly < 0.5.0`) + * prerelease vs prerelease → compare the prerelease identifiers + * segment-by-segment (numeric or lexical), + * so `0.5.0-nightly-abc < 0.5.0-nightly-def` + * works for SHA-suffixed snapshots. + * + * Channel-aware comparison happens at the *cache layer* (callers only cache + * the tag they're tracking), so this function just answers: is current < latest? + */ +export function isVersionOutdated(current: string, latest: string): boolean { + const c = parseVersion(current); + const l = parseVersion(latest); + + for (let i = 0; i < 3; i++) { + const cp = c.parts[i] ?? 0; + const lp = l.parts[i] ?? 0; + if (Number.isNaN(cp) || Number.isNaN(lp)) return false; + if (cp < lp) return true; + if (cp > lp) return false; + } + + // Numeric base equal — compare prerelease tags. + if (!c.prerelease && !l.prerelease) return false; + if (c.prerelease && !l.prerelease) return true; // prerelease < stable + if (!c.prerelease && l.prerelease) return false; // stable > prerelease + return comparePrereleaseSegments(c.prerelease ?? "", l.prerelease ?? "") < 0; +} + +function parseVersion(version: string): { parts: number[]; prerelease: string | undefined } { + const [base, ...rest] = version.split("-"); + const prerelease = rest.length > 0 ? rest.join("-") : undefined; + return { + parts: (base ?? "").split(".").map(Number), + prerelease, + }; +} + +/** + * Compare two prerelease identifiers segment-by-segment. + * Returns -1 if a < b, 0 if equal, 1 if a > b. + * + * Rules: + * - Numeric segments compare numerically (`rc.1` < `rc.2`). + * - Numeric < non-numeric (`0.5.0-1` < `0.5.0-alpha`). + * - Longer prerelease wins when all shared segments are equal + * (`0.5.0-nightly` < `0.5.0-nightly.1`). + * - **Differing non-numeric segments → treat current as older.** Git SHAs + * are uniformly random hex, so a lexical compare (`'f' < '0'`) gives the + * wrong answer ~50% of the time for snapshot tags like `nightly-`. + * The cache layer always carries the registry's CURRENT dist-tag, so a + * mismatch here means the installed copy is behind by construction — + * return -1 unconditionally for non-numeric differences so the update + * banner surfaces. (Caveat: this would over-fire if a user manually + * installed `0.5.0-beta` when the registry only has `0.5.0-alpha`. AO's + * release pipeline only emits SHA-suffixed nightly prereleases, so the + * scenario doesn't occur in practice.) + */ +function comparePrereleaseSegments(a: string, b: string): number { + const aSeg = a.split("."); + const bSeg = b.split("."); + const max = Math.max(aSeg.length, bSeg.length); + for (let i = 0; i < max; i++) { + const ax = aSeg[i]; + const bx = bSeg[i]; + if (ax === undefined) return -1; + if (bx === undefined) return 1; + const aNum = /^\d+$/.test(ax); + const bNum = /^\d+$/.test(bx); + if (aNum && bNum) { + const an = Number(ax); + const bn = Number(bx); + if (an !== bn) return an < bn ? -1 : 1; + } else if (aNum !== bNum) { + return aNum ? -1 : 1; // numeric < non-numeric + } else if (ax !== bx) { + // Both non-numeric and differ. Cannot reliably order — return "older." + return -1; + } + } + return 0; +} diff --git a/packages/plugins/agent-aider/package.json b/packages/plugins/agent-aider/package.json index ed2b733fb..990c4807f 100644 --- a/packages/plugins/agent-aider/package.json +++ b/packages/plugins/agent-aider/package.json @@ -40,5 +40,9 @@ "@types/node": "^25.2.3", "typescript": "^5.7.0", "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } } diff --git a/packages/plugins/agent-claude-code/package.json b/packages/plugins/agent-claude-code/package.json index a17f9ee5f..3a4734da3 100644 --- a/packages/plugins/agent-claude-code/package.json +++ b/packages/plugins/agent-claude-code/package.json @@ -40,5 +40,9 @@ "@types/node": "^25.2.3", "typescript": "^5.7.0", "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } } diff --git a/packages/plugins/agent-codex/package.json b/packages/plugins/agent-codex/package.json index 4b8eb3589..56853d557 100644 --- a/packages/plugins/agent-codex/package.json +++ b/packages/plugins/agent-codex/package.json @@ -40,5 +40,9 @@ "@types/node": "^25.2.3", "typescript": "^5.7.0", "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } } diff --git a/packages/plugins/agent-cursor/package.json b/packages/plugins/agent-cursor/package.json index 6205bae45..dd383f3e0 100644 --- a/packages/plugins/agent-cursor/package.json +++ b/packages/plugins/agent-cursor/package.json @@ -40,5 +40,9 @@ "@types/node": "^25.2.3", "typescript": "^5.7.0", "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } } diff --git a/packages/plugins/agent-kimicode/package.json b/packages/plugins/agent-kimicode/package.json index 6f2dda0b6..6d9fd38c0 100644 --- a/packages/plugins/agent-kimicode/package.json +++ b/packages/plugins/agent-kimicode/package.json @@ -40,5 +40,9 @@ "@types/node": "^25.2.3", "typescript": "^5.7.0", "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } } diff --git a/packages/plugins/agent-opencode/package.json b/packages/plugins/agent-opencode/package.json index 166b95da9..67e794c55 100644 --- a/packages/plugins/agent-opencode/package.json +++ b/packages/plugins/agent-opencode/package.json @@ -40,5 +40,9 @@ "@types/node": "^25.2.3", "typescript": "^5.7.0", "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } } diff --git a/packages/plugins/notifier-composio/package.json b/packages/plugins/notifier-composio/package.json index 5efe6d46d..c907a66fb 100644 --- a/packages/plugins/notifier-composio/package.json +++ b/packages/plugins/notifier-composio/package.json @@ -48,5 +48,9 @@ "@types/node": "^25.2.3", "typescript": "^5.7.0", "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } } diff --git a/packages/plugins/notifier-desktop/package.json b/packages/plugins/notifier-desktop/package.json index d6ad0024d..cd69871c0 100644 --- a/packages/plugins/notifier-desktop/package.json +++ b/packages/plugins/notifier-desktop/package.json @@ -40,5 +40,9 @@ "@types/node": "^25.2.3", "typescript": "^5.7.0", "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } } diff --git a/packages/plugins/notifier-discord/package.json b/packages/plugins/notifier-discord/package.json index 04c7c1195..3a4ad75ea 100644 --- a/packages/plugins/notifier-discord/package.json +++ b/packages/plugins/notifier-discord/package.json @@ -41,5 +41,9 @@ "rimraf": "^6.0.0", "typescript": "^5.7.0", "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } } diff --git a/packages/plugins/notifier-openclaw/package.json b/packages/plugins/notifier-openclaw/package.json index 239a31aa4..6a50caf20 100644 --- a/packages/plugins/notifier-openclaw/package.json +++ b/packages/plugins/notifier-openclaw/package.json @@ -40,5 +40,9 @@ "@types/node": "^25.2.3", "typescript": "^5.7.0", "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } } diff --git a/packages/plugins/notifier-slack/package.json b/packages/plugins/notifier-slack/package.json index 15b6e23d6..a81db3852 100644 --- a/packages/plugins/notifier-slack/package.json +++ b/packages/plugins/notifier-slack/package.json @@ -40,5 +40,9 @@ "@types/node": "^25.2.3", "typescript": "^5.7.0", "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } } diff --git a/packages/plugins/notifier-webhook/package.json b/packages/plugins/notifier-webhook/package.json index c16c7d00d..9da736c7a 100644 --- a/packages/plugins/notifier-webhook/package.json +++ b/packages/plugins/notifier-webhook/package.json @@ -40,5 +40,9 @@ "@types/node": "^25.2.3", "typescript": "^5.7.0", "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } } diff --git a/packages/plugins/runtime-process/package.json b/packages/plugins/runtime-process/package.json index 829f1c409..9609c0f8d 100644 --- a/packages/plugins/runtime-process/package.json +++ b/packages/plugins/runtime-process/package.json @@ -42,5 +42,9 @@ "@types/node": "^25.2.3", "typescript": "^5.7.0", "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } } diff --git a/packages/plugins/runtime-tmux/package.json b/packages/plugins/runtime-tmux/package.json index bfd28c44a..4392457d9 100644 --- a/packages/plugins/runtime-tmux/package.json +++ b/packages/plugins/runtime-tmux/package.json @@ -41,5 +41,9 @@ "@types/node": "^25.2.3", "typescript": "^5.7.0", "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } } diff --git a/packages/plugins/scm-github/package.json b/packages/plugins/scm-github/package.json index 9ea778c52..e2c9f1de7 100644 --- a/packages/plugins/scm-github/package.json +++ b/packages/plugins/scm-github/package.json @@ -41,5 +41,9 @@ "@types/node": "^25.2.3", "typescript": "^5.7.0", "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } } diff --git a/packages/plugins/scm-gitlab/package.json b/packages/plugins/scm-gitlab/package.json index 823fec6db..754577095 100644 --- a/packages/plugins/scm-gitlab/package.json +++ b/packages/plugins/scm-gitlab/package.json @@ -44,5 +44,9 @@ "@types/node": "^25.2.3", "typescript": "^5.7.0", "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } } diff --git a/packages/plugins/terminal-iterm2/package.json b/packages/plugins/terminal-iterm2/package.json index f1ed9713c..d5ab04077 100644 --- a/packages/plugins/terminal-iterm2/package.json +++ b/packages/plugins/terminal-iterm2/package.json @@ -40,5 +40,9 @@ "@types/node": "^25.2.3", "typescript": "^5.7.0", "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } } diff --git a/packages/plugins/terminal-web/package.json b/packages/plugins/terminal-web/package.json index ca6b5b54d..b6733f82e 100644 --- a/packages/plugins/terminal-web/package.json +++ b/packages/plugins/terminal-web/package.json @@ -40,5 +40,9 @@ "@types/node": "^25.2.3", "typescript": "^5.7.0", "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } } diff --git a/packages/plugins/tracker-github/package.json b/packages/plugins/tracker-github/package.json index f8ff79b9e..7a6d8b466 100644 --- a/packages/plugins/tracker-github/package.json +++ b/packages/plugins/tracker-github/package.json @@ -40,5 +40,9 @@ "@types/node": "^25.2.3", "typescript": "^5.7.0", "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } } diff --git a/packages/plugins/tracker-gitlab/package.json b/packages/plugins/tracker-gitlab/package.json index ad9ddb7a2..5268be3d2 100644 --- a/packages/plugins/tracker-gitlab/package.json +++ b/packages/plugins/tracker-gitlab/package.json @@ -41,5 +41,9 @@ "@types/node": "^25.2.3", "typescript": "^5.7.0", "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } } diff --git a/packages/plugins/tracker-linear/package.json b/packages/plugins/tracker-linear/package.json index 8356c6346..8c8e247d8 100644 --- a/packages/plugins/tracker-linear/package.json +++ b/packages/plugins/tracker-linear/package.json @@ -40,5 +40,9 @@ "@types/node": "^25.2.3", "typescript": "^5.7.0", "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } } diff --git a/packages/plugins/workspace-clone/package.json b/packages/plugins/workspace-clone/package.json index 8d44bdbe8..f5f04461e 100644 --- a/packages/plugins/workspace-clone/package.json +++ b/packages/plugins/workspace-clone/package.json @@ -41,5 +41,9 @@ "@types/node": "^25.2.3", "typescript": "^5.7.0", "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } } diff --git a/packages/plugins/workspace-worktree/package.json b/packages/plugins/workspace-worktree/package.json index 798495179..4167b5ebe 100644 --- a/packages/plugins/workspace-worktree/package.json +++ b/packages/plugins/workspace-worktree/package.json @@ -41,5 +41,9 @@ "@types/node": "^25.2.3", "typescript": "^5.7.0", "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } } diff --git a/packages/web/package.json b/packages/web/package.json index 480e3a0b4..bbf38cf7c 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,21 @@ "name": "@aoagents/ao-web", "version": "0.6.0", "description": "Web dashboard for agent-orchestrator", + "license": "MIT", "type": "module", + "repository": { + "type": "git", + "url": "https://github.com/ComposioHQ/agent-orchestrator.git", + "directory": "packages/web" + }, + "homepage": "https://github.com/ComposioHQ/agent-orchestrator", + "bugs": { + "url": "https://github.com/ComposioHQ/agent-orchestrator/issues" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, "files": [ ".next/server", ".next/static", diff --git a/packages/web/src/__tests__/version-update-api.test.ts b/packages/web/src/__tests__/version-update-api.test.ts new file mode 100644 index 000000000..e7bd8da63 --- /dev/null +++ b/packages/web/src/__tests__/version-update-api.test.ts @@ -0,0 +1,241 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type * as AoCoreType from "@aoagents/ao-core"; + +// Use a real on-disk cache file in a per-test temp dir rather than mocking +// node:fs. Mocking ESM-imported fs functions is unreliable when the route +// captures the binding at module-load time; a real file works in all cases. + +const { mockGlobalConfig } = vi.hoisted(() => ({ + mockGlobalConfig: { + value: null as null | { updateChannel?: "stable" | "nightly" | "manual" }, + }, +})); + +vi.mock("@aoagents/ao-core", async () => { + const actual = (await vi.importActual("@aoagents/ao-core")) as typeof AoCoreType; + return { + ...actual, + loadGlobalConfig: () => mockGlobalConfig.value, + }; +}); + +const { mockSessionList } = vi.hoisted(() => ({ + mockSessionList: vi.fn(async () => [] as Array<{ id: string; status: string }>), +})); + +vi.mock("@/lib/services", () => ({ + getServices: vi.fn(async () => ({ + sessionManager: { list: mockSessionList }, + })), +})); + +import { GET as versionGET } from "@/app/api/version/route"; +import { POST as updatePOST } from "@/app/api/update/route"; + +// ── Tests ───────────────────────────────────────────────────────────── + +describe("GET /api/version", () => { + let tmpCacheDir: string; + let origXdg: string | undefined; + + beforeEach(() => { + vi.clearAllMocks(); + mockGlobalConfig.value = null; + // Per-test cache dir, deterministic. + tmpCacheDir = mkdtempSync(join(tmpdir(), "ao-version-test-")); + mkdirSync(join(tmpCacheDir, "ao"), { recursive: true }); + origXdg = process.env["XDG_CACHE_HOME"]; + process.env["XDG_CACHE_HOME"] = tmpCacheDir; + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (origXdg !== undefined) process.env["XDG_CACHE_HOME"] = origXdg; + else delete process.env["XDG_CACHE_HOME"]; + rmSync(tmpCacheDir, { recursive: true, force: true }); + }); + + function writeCache(data: object) { + writeFileSync( + join(tmpCacheDir, "ao", "update-check.json"), + JSON.stringify(data), + ); + } + + it("returns current version, channel='manual' default, latest=null when cache absent", async () => { + const res = await versionGET(); + const body = (await res.json()) as { + current: string; + latest: string | null; + channel: string; + isOutdated: boolean; + }; + expect(body.channel).toBe("manual"); + expect(body.latest).toBeNull(); + expect(body.isOutdated).toBe(false); + expect(typeof body.current).toBe("string"); + }); + + it("returns latest from cache when present and channel matches", async () => { + mockGlobalConfig.value = { updateChannel: "nightly" }; + writeCache({ + latestVersion: "0.6.0-nightly-abc", + checkedAt: new Date().toISOString(), + currentVersionAtCheck: "0.5.0", + channel: "nightly", + }); + const res = await versionGET(); + const body = (await res.json()) as { latest: string | null; channel: string; isOutdated: boolean }; + expect(body.channel).toBe("nightly"); + expect(body.latest).toBe("0.6.0-nightly-abc"); + }); + + it("ignores cache entries from a different channel", async () => { + mockGlobalConfig.value = { updateChannel: "stable" }; + writeCache({ + latestVersion: "0.6.0-nightly-abc", + checkedAt: new Date().toISOString(), + channel: "nightly", + }); + const res = await versionGET(); + const body = (await res.json()) as { latest: string | null }; + expect(body.latest).toBeNull(); + }); + + it("trusts cached.isOutdated for git installs (latestVersion is a ref, not semver)", async () => { + // Git installs cache `latestVersion: "origin/main"`. Without the + // installMethod=="git" branch, `isVersionOutdated(current, "origin/main")` + // would always return false because parseVersion produces NaN parts — + // git-installed users would never see the banner. + mockGlobalConfig.value = { updateChannel: "stable" }; + writeCache({ + latestVersion: "origin/main", + checkedAt: new Date().toISOString(), + currentVersionAtCheck: "0.6.0", + installMethod: "git", + channel: "stable", + isOutdated: true, + currentRevisionAtCheck: "abc", + latestRevisionAtCheck: "def", + }); + + const res = await versionGET(); + const body = (await res.json()) as { latest: string | null; isOutdated: boolean }; + expect(body.latest).toBe("origin/main"); + expect(body.isOutdated).toBe(true); + }); + + it("returns isOutdated=false for git installs whose cache says they're current", async () => { + mockGlobalConfig.value = { updateChannel: "stable" }; + writeCache({ + latestVersion: "origin/main", + checkedAt: new Date().toISOString(), + currentVersionAtCheck: "0.6.0", + installMethod: "git", + channel: "stable", + isOutdated: false, + currentRevisionAtCheck: "abc", + latestRevisionAtCheck: "abc", + }); + + const res = await versionGET(); + const body = (await res.json()) as { isOutdated: boolean }; + expect(body.isOutdated).toBe(false); + }); + + it("ignores legacy cache entries without a `channel` field (matches CLI behavior)", async () => { + // Pre-channel-scoping cache entry. Even though latestVersion looks newer + // than current, we can't know which channel it was written for, so we + // must reject it — otherwise a stable→nightly switch keeps serving the + // old stable latestVersion via the dashboard until the 24h TTL expires. + mockGlobalConfig.value = { updateChannel: "nightly" }; + writeCache({ + latestVersion: "99.0.0", + checkedAt: new Date().toISOString(), + currentVersionAtCheck: "0.6.0", + installMethod: "npm-global", + // No `channel` field — legacy. + }); + + const res = await versionGET(); + const body = (await res.json()) as { + latest: string | null; + isOutdated: boolean; + checkedAt: string | null; + }; + expect(body.latest).toBeNull(); + expect(body.isOutdated).toBe(false); + expect(body.checkedAt).toBeNull(); + }); +}); + +describe("POST /api/update", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSessionList.mockResolvedValue([]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function makeReq() { + return new NextRequest("http://localhost:3000/api/update", { method: "POST" }); + } + + it("refuses with 409 when sessions are active", async () => { + mockSessionList.mockResolvedValue([ + { id: "s1", status: "working" }, + { id: "s2", status: "needs_input" }, + ]); + const res = await updatePOST(makeReq()); + expect(res.status).toBe(409); + const body = (await res.json()) as { ok: boolean; activeSessions?: number; message: string }; + expect(body.ok).toBe(false); + expect(body.activeSessions).toBe(2); + expect(body.message).toMatch(/ao stop/); + }); + + it.each(["working", "idle", "needs_input", "stuck"])( + "refuses for status %s", + async (status) => { + mockSessionList.mockResolvedValue([{ id: "s1", status }]); + const res = await updatePOST(makeReq()); + expect(res.status).toBe(409); + }, + ); + + it("does not refuse for terminal statuses (kicks off update)", async () => { + mockSessionList.mockResolvedValue([ + { id: "s1", status: "done" }, + { id: "s2", status: "terminated" }, + ]); + const res = await updatePOST(makeReq()); + // 202 because the guard passed and spawn ran (or failed silently — either + // way the route returns 202 since spawn errors are caught). + expect([202, 500]).toContain(res.status); + }); + + it("returns 202 when no sessions are active", async () => { + mockSessionList.mockResolvedValue([]); + const res = await updatePOST(makeReq()); + expect([202, 500]).toContain(res.status); + if (res.status === 202) { + const body = (await res.json()) as { ok: boolean }; + expect(body.ok).toBe(true); + } + }); + + it("returns 500 when session listing throws", async () => { + mockSessionList.mockRejectedValue(new Error("disk full")); + const res = await updatePOST(makeReq()); + expect(res.status).toBe(500); + const body = (await res.json()) as { ok: boolean; message: string }; + expect(body.ok).toBe(false); + expect(body.message).toMatch(/disk full/); + }); +}); diff --git a/packages/web/src/app/api/update/route.ts b/packages/web/src/app/api/update/route.ts new file mode 100644 index 000000000..6fdc7af04 --- /dev/null +++ b/packages/web/src/app/api/update/route.ts @@ -0,0 +1,114 @@ +/** + * POST /api/update — kick off `ao update` from the dashboard banner. + * + * Refuses when any session is in working/idle/needs_input/stuck — the + * release doc is explicit: never auto-stop a user's agent. The banner + * surfaces the refusal as a 409 with a guidance message. + * + * On success, spawns the install detached and returns 202 immediately. + * The dashboard process exits when the install replaces the binary; the + * banner shows progress until the SSE stream drops, at which point the + * user re-runs `ao start` to pick up the new version. + */ + +import { spawn } from "node:child_process"; +import { NextResponse, type NextRequest } from "next/server"; +import { isWindows } from "@aoagents/ao-core"; +import { getServices } from "@/lib/services"; + +export const dynamic = "force-dynamic"; + +const ACTIVE_STATUSES = new Set([ + "working", + "idle", + "needs_input", + "stuck", +]); + +interface UpdateResponse { + ok: boolean; + message: string; + activeSessions?: number; +} + +export async function POST(_req: NextRequest) { + // Active-session guard mirrors the CLI's `ensureNoActiveSessions`. We + // duplicate it here (rather than shelling out to `ao update --check`) + // so the dashboard can give an immediate, structured 409 response. + let activeCount: number; + try { + const { sessionManager } = await getServices(); + const sessions = await sessionManager.list(); + activeCount = sessions.filter((s) => ACTIVE_STATUSES.has(s.status)).length; + } catch (err) { + return NextResponse.json( + { + ok: false, + message: `Failed to check active sessions: ${err instanceof Error ? err.message : String(err)}`, + }, + { status: 500 }, + ); + } + + if (activeCount > 0) { + return NextResponse.json( + { + ok: false, + message: `${activeCount} session${activeCount === 1 ? "" : "s"} active. Run \`ao stop\` first, then click Update again.`, + activeSessions: activeCount, + }, + { status: 409 }, + ); + } + + // Spawn `ao update` detached so this request can return before the install + // tears down the dashboard process. We rely on PATH resolution because the + // user installed `ao` themselves — there's no canonical install location. + // + // `shell: isWindows()` is required so PATHEXT gets consulted on Windows — + // npm's `ao` shim is `ao.cmd`, and Node.js does not look at PATHEXT for + // non-shell spawns, so the bare `ao` lookup would silently ENOENT on every + // Windows install. `windowsHide: true` keeps the shell window from flashing. + // + // ENOENT still fires asynchronously as an `error` event on the child (POSIX + // case where `ao` isn't on PATH), NOT as a sync throw — without an explicit + // handler it would propagate as an unhandled error and crash the test + // runner. The handler is a noop in production; the user will see "no + // version change" on next page load if the install never ran. + try { + const child = spawn("ao", ["update"], { + detached: true, + stdio: "ignore", + shell: isWindows(), + windowsHide: true, + // AO_NON_INTERACTIVE_INSTALL=1 tells the CLI's handleNpmUpdate to skip + // the isTTY gate and run the install. Without this, stdio:"ignore" + // makes isTTY() return false, the CLI falls into the "print the + // command and exit" branch, and the dashboard's "Update" button is + // effectively a no-op (banner returns 202, nothing installs). + env: { ...process.env, AO_NON_INTERACTIVE_INSTALL: "1" }, + }); + child.on("error", () => { + // Swallow async spawn errors (ENOENT etc.) so they don't become + // unhandled errors. The user will see "no version change" if it failed. + }); + child.unref(); + } catch (err) { + return NextResponse.json( + { + ok: false, + message: `Failed to spawn 'ao update': ${err instanceof Error ? err.message : String(err)}`, + }, + { status: 500 }, + ); + } + + return NextResponse.json( + { + ok: true, + message: + "Update started. The dashboard will restart once the new version is installed; re-run `ao start` to resume.", + }, + { status: 202 }, + ); +} diff --git a/packages/web/src/app/api/version/route.ts b/packages/web/src/app/api/version/route.ts new file mode 100644 index 000000000..768820ed8 --- /dev/null +++ b/packages/web/src/app/api/version/route.ts @@ -0,0 +1,77 @@ +/** + * GET /api/version — current AO version, latest available, and channel state. + * + * Backed by the same cache file that the CLI's `update-check.ts` writes to + * (`$XDG_CACHE_HOME/ao/update-check.json` or `~/.cache/ao/update-check.json`), + * so the dashboard banner and the CLI startup notice always agree. + * + * Cache-only by design — never makes a network call inside a request handler. + * The CLI keeps the cache fresh (24 h TTL) via `scheduleBackgroundRefresh()`, + * and `ao update --check` forces a refresh on demand. + */ + +import { NextResponse } from "next/server"; +import { + getInstalledAoVersion, + isVersionOutdated, + loadGlobalConfig, + readUpdateCheckCacheRaw, + type UpdateChannel, +} from "@aoagents/ao-core"; + +export const dynamic = "force-dynamic"; + +interface VersionResponse { + current: string; + latest: string | null; + channel: UpdateChannel; + isOutdated: boolean; + checkedAt: string | null; +} + +function resolveChannel(): UpdateChannel { + try { + const config = loadGlobalConfig(); + return config?.updateChannel ?? "manual"; + } catch { + return "manual"; + } +} + +export async function GET() { + const current = getInstalledAoVersion(); + const channel = resolveChannel(); + const cache = readUpdateCheckCacheRaw(); + + // Cache must match the active channel — otherwise we'd report a stale + // @latest version to a user who recently switched to @nightly. Legacy + // entries (no `channel` field, written before channel scoping landed) are + // treated as misses, matching the CLI's `readCachedUpdateInfo` behavior. + // Without this the dashboard would happily serve a stale 0.6.0 latestVersion + // to a user who just switched to nightly until the 24h TTL expires. + const cacheMatchesChannel = cache?.channel === channel; + const latest = cache?.latestVersion && cacheMatchesChannel ? cache.latestVersion : null; + + // Git installs cache `latestVersion: "origin/main"` (a ref, not a semver), + // so `isVersionOutdated(current, "origin/main")` would always return false. + // The CLI works around this by trusting the precomputed `cached.isOutdated` + // for git installs — mirror that here so the dashboard banner actually + // appears when a git-installed user is behind origin/main. + let isOutdated = false; + if (latest && cacheMatchesChannel) { + isOutdated = + cache?.installMethod === "git" + ? cache.isOutdated === true + : isVersionOutdated(current, latest); + } + + const body: VersionResponse = { + current, + latest, + channel, + isOutdated, + checkedAt: cache?.checkedAt && cacheMatchesChannel ? cache.checkedAt : null, + }; + + return NextResponse.json(body); +} diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index 2e5a3eade..cd55a3e72 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -22,6 +22,7 @@ import type { ProjectInfo } from "@/lib/project-name"; import { EmptyState } from "./Skeleton"; import { ToastProvider, useToast } from "./Toast"; import { ConnectionBar } from "./ConnectionBar"; +import { UpdateBanner } from "./UpdateBanner"; import { CopyDebugBundleButton } from "./CopyDebugBundleButton"; import { SidebarContext } from "./workspace/SidebarContext"; import { projectDashboardPath, projectSessionPath } from "@/lib/routes"; @@ -527,6 +528,7 @@ function DashboardInner({ value={{ onToggleSidebar: handleToggleSidebar, mobileSidebarOpen: mobileMenuOpen }} > <> +
diff --git a/packages/web/src/components/UpdateBanner.tsx b/packages/web/src/components/UpdateBanner.tsx new file mode 100644 index 000000000..92d453e93 --- /dev/null +++ b/packages/web/src/components/UpdateBanner.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +const DISMISS_KEY = "ao.updateBanner.dismissedFor"; + +interface VersionResponse { + current: string; + latest: string | null; + channel: "stable" | "nightly" | "manual"; + isOutdated: boolean; + checkedAt: string | null; +} + +interface UpdateResponse { + ok: boolean; + message: string; + activeSessions?: number; +} + +type Phase = "idle" | "starting" | "started" | "blocked" | "error"; + +export function UpdateBanner() { + const [info, setInfo] = useState(null); + const [phase, setPhase] = useState("idle"); + const [errorMessage, setErrorMessage] = useState(null); + const [dismissedFor, setDismissedFor] = useState(null); + + // Hydrate dismissal flag from localStorage on mount. + useEffect(() => { + try { + setDismissedFor(window.localStorage.getItem(DISMISS_KEY)); + } catch { + // Private mode / quota — treat as not dismissed. + } + }, []); + + // Initial load runs once on mount. + // + // No interval / re-fetch: the cache TTL is 24 h, the CLI keeps the cache + // fresh in the background, and the dashboard re-mounts on navigation — + // a fresh `` mount picks up any new version. Re-evaluate if we + // ever see "user kept tab open for days, missed an update." + useEffect(() => { + if (typeof fetch !== "function") return; + let cancelled = false; + Promise.resolve(fetch("/api/version", { cache: "no-store" })) + .then((r) => (r && r.ok ? (r.json() as Promise) : null)) + .then((data) => { + if (cancelled) return; + setInfo(data); + }) + .catch(() => { + // Silent — banner stays hidden if we can't talk to the route. + }); + return () => { + cancelled = true; + }; + }, []); + + const handleDismiss = useCallback(() => { + if (!info?.latest) return; + try { + window.localStorage.setItem(DISMISS_KEY, info.latest); + } catch { + // ignore + } + setDismissedFor(info.latest); + // Reset to idle so the hide condition (`dismissedFor === info.latest && + // phase === "idle"`) fires even when the user dismisses while we're + // showing a 409 / error message. Without this the banner would stay + // pinned on screen until the user reloads. + setPhase("idle"); + setErrorMessage(null); + }, [info]); + + const handleUpdate = useCallback(async () => { + setPhase("starting"); + setErrorMessage(null); + try { + const res = await fetch("/api/update", { method: "POST" }); + const body = (await res.json()) as UpdateResponse; + if (res.ok) { + setPhase("started"); + return; + } + if (res.status === 409) { + setPhase("blocked"); + setErrorMessage(body.message); + return; + } + setPhase("error"); + setErrorMessage(body.message ?? "Update failed"); + } catch (err) { + setPhase("error"); + setErrorMessage(err instanceof Error ? err.message : String(err)); + } + }, []); + + if (!info || !info.isOutdated || !info.latest) return null; + if (info.channel === "manual") return null; + if (dismissedFor === info.latest && phase === "idle") return null; + if (phase === "started") return null; + + const channelLabel = info.channel === "nightly" ? " (nightly)" : ""; + + return ( +
+
+
+
+ + +
+
+ ); +} diff --git a/packages/web/src/components/__tests__/Dashboard.projectOverview.test.tsx b/packages/web/src/components/__tests__/Dashboard.projectOverview.test.tsx index 33d5800b1..731b9487e 100644 --- a/packages/web/src/components/__tests__/Dashboard.projectOverview.test.tsx +++ b/packages/web/src/components/__tests__/Dashboard.projectOverview.test.tsx @@ -19,7 +19,17 @@ describe("Dashboard project overview cards", () => { close: vi.fn(), }) as unknown as EventSource, ); - global.fetch = vi.fn(); + // The Dashboard mounts UpdateBanner, which fetches /api/version on its + // own. Default that to a no-op response (404) so the banner stays hidden + // and doesn't consume `mockImplementationOnce` queued by individual tests + // for /api/orchestrators or /api/spawn. + global.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/version")) { + return { ok: false, status: 404, json: async () => ({}) } as Response; + } + return { ok: false, status: 500, json: async () => ({}) } as Response; + }); }); it("renders Spawn Orchestrator only for projects without one", () => { @@ -122,13 +132,22 @@ describe("Dashboard project overview cards", () => { }); it("updates the card after spawning an orchestrator", async () => { + // Route by URL: UpdateBanner's /api/version stays on the default 404 + // (banner stays hidden); only /api/orchestrators is held until we resolve. let resolveSpawn: ((value: Response) => void) | null = null; - vi.mocked(fetch).mockImplementationOnce( - () => - new Promise((resolve) => { + vi.mocked(fetch).mockImplementation((input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/orchestrators")) { + return new Promise((resolve) => { resolveSpawn = resolve; - }), - ); + }); + } + return Promise.resolve({ + ok: false, + status: 404, + json: async () => ({}), + } as Response); + }); render( { }); it("shows the API error when spawning fails", async () => { - vi.mocked(fetch).mockResolvedValueOnce({ - ok: false, - json: async () => ({ error: "Project is paused" }), - } as Response); + vi.mocked(fetch).mockImplementation((input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/orchestrators")) { + return Promise.resolve({ + ok: false, + json: async () => ({ error: "Project is paused" }), + } as Response); + } + return Promise.resolve({ + ok: false, + status: 404, + json: async () => ({}), + } as Response); + }); render( { beforeEach(() => { renderCounts.clear(); currentMuxSessions = []; - global.fetch = vi.fn(); + // Stub /api/version so UpdateBanner stays hidden and doesn't trigger + // an extra render that would inflate the cadence counts under test. + global.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/version")) { + return { ok: false, status: 404, json: async () => ({}) } as Response; + } + return { ok: false, status: 500, json: async () => ({}) } as Response; + }); }); it("rerenders only the changed session card for same-membership snapshots", async () => { @@ -94,7 +102,16 @@ describe("Dashboard render cadence", () => { expect(renderCounts.get("session-1")).toBe(2); expect(renderCounts.get("session-2")).toBe(1); - expect(fetch).not.toHaveBeenCalled(); + // The Dashboard mounts UpdateBanner which fetches /api/version once. + // We assert that no OTHER endpoints were touched — that's what the + // cadence test really cares about (no per-card refetch). + const otherCalls = vi + .mocked(fetch) + .mock.calls.filter(([input]) => { + const url = typeof input === "string" ? input : (input as URL).toString(); + return !url.includes("/api/version"); + }); + expect(otherCalls).toHaveLength(0); }); it("does not rerender any card when snapshot data is identical", async () => { diff --git a/packages/web/src/components/__tests__/UpdateBanner.test.tsx b/packages/web/src/components/__tests__/UpdateBanner.test.tsx new file mode 100644 index 000000000..79b97b05c --- /dev/null +++ b/packages/web/src/components/__tests__/UpdateBanner.test.tsx @@ -0,0 +1,188 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +import { UpdateBanner } from "../UpdateBanner"; + +const DISMISS_KEY = "ao.updateBanner.dismissedFor"; + +function mockVersionResponse(body: { + current: string; + latest: string | null; + channel: "stable" | "nightly" | "manual"; + isOutdated: boolean; + checkedAt?: string | null; +}) { + return { + ok: true, + json: async () => ({ checkedAt: null, ...body }), + } as Response; +} + +describe("UpdateBanner", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + window.localStorage.clear(); + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("renders nothing when /api/version reports up-to-date", async () => { + fetchMock.mockResolvedValueOnce( + mockVersionResponse({ + current: "0.5.0", + latest: "0.5.0", + channel: "stable", + isOutdated: false, + }), + ); + const { container } = render(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + expect(container.firstChild).toBeNull(); + }); + + it("renders banner when isOutdated is true", async () => { + fetchMock.mockResolvedValueOnce( + mockVersionResponse({ + current: "0.5.0", + latest: "0.5.1", + channel: "stable", + isOutdated: true, + }), + ); + render(); + await screen.findByText(/Update available: 0.5.0 → 0.5.1/); + }); + + it("hides on manual channel even when outdated (user opted out)", async () => { + fetchMock.mockResolvedValueOnce( + mockVersionResponse({ + current: "0.5.0", + latest: "0.5.1", + channel: "manual", + isOutdated: true, + }), + ); + const { container } = render(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + expect(container.firstChild).toBeNull(); + }); + + it("hides when dismissed via localStorage for the current latest version", async () => { + window.localStorage.setItem(DISMISS_KEY, "0.5.1"); + fetchMock.mockResolvedValueOnce( + mockVersionResponse({ + current: "0.5.0", + latest: "0.5.1", + channel: "stable", + isOutdated: true, + }), + ); + const { container } = render(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + expect(container.firstChild).toBeNull(); + }); + + it("re-shows the banner when a NEW version is available after dismissal", async () => { + // User dismissed 0.5.1; now 0.5.2 is out — banner reappears. + window.localStorage.setItem(DISMISS_KEY, "0.5.1"); + fetchMock.mockResolvedValueOnce( + mockVersionResponse({ + current: "0.5.0", + latest: "0.5.2", + channel: "stable", + isOutdated: true, + }), + ); + render(); + await screen.findByText(/Update available: 0.5.0 → 0.5.2/); + }); + + it("POSTs to /api/update on click and hides on success", async () => { + fetchMock + .mockResolvedValueOnce( + mockVersionResponse({ + current: "0.5.0", + latest: "0.5.1", + channel: "stable", + isOutdated: true, + }), + ) + .mockResolvedValueOnce({ + ok: true, + status: 202, + json: async () => ({ ok: true, message: "started" }), + } as Response); + + const { container } = render(); + const button = await screen.findByRole("button", { name: "Update" }); + fireEvent.click(button); + + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith("/api/update", { method: "POST" }), + ); + await waitFor(() => expect(container.firstChild).toBeNull()); + }); + + it("surfaces 409 active-session refusal as inline error text", async () => { + fetchMock + .mockResolvedValueOnce( + mockVersionResponse({ + current: "0.5.0", + latest: "0.5.1", + channel: "stable", + isOutdated: true, + }), + ) + .mockResolvedValueOnce({ + ok: false, + status: 409, + json: async () => ({ + ok: false, + message: "3 sessions active. Run `ao stop` first.", + activeSessions: 3, + }), + } as Response); + + render(); + const button = await screen.findByRole("button", { name: "Update" }); + fireEvent.click(button); + + await screen.findByText(/3 sessions active/); + }); + + it("dismiss button hides the banner even from the 'blocked' (409) error state", async () => { + fetchMock + .mockResolvedValueOnce( + mockVersionResponse({ + current: "0.5.0", + latest: "0.5.1", + channel: "stable", + isOutdated: true, + }), + ) + .mockResolvedValueOnce({ + ok: false, + status: 409, + json: async () => ({ + ok: false, + message: "1 session active. Run `ao stop` first.", + activeSessions: 1, + }), + } as Response); + + const { container } = render(); + const update = await screen.findByRole("button", { name: "Update" }); + fireEvent.click(update); + // Wait for the 409 to surface so we're definitely in the blocked phase. + await screen.findByText(/1 session active/); + + fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); + await waitFor(() => expect(container.firstChild).toBeNull()); + }); +}); diff --git a/scripts/check-publishable-deps.mjs b/scripts/check-publishable-deps.mjs new file mode 100644 index 000000000..c0336db35 --- /dev/null +++ b/scripts/check-publishable-deps.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * Pre-publish sanity check. + * + * Asserts that every `workspace:*` runtime dependency of a publishable + * package (one without `"private": true`) is itself publishable. Without + * this, pnpm would rewrite the workspace dep on publish to a literal + * version pointing at a package that doesn't exist on npm, and consumers + * doing `npm install -g @aoagents/ao` would fail. + * + * Concretely: this catches the case where `@aoagents/ao-cli` has + * `"@aoagents/ao-web": "workspace:*"` while `@aoagents/ao-web` is + * `"private": true` — the dashboard would never reach consumers. + * + * Run from CI before `changeset publish`. + */ + +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(__dirname, ".."); + +/** Find every package.json under packages/, regardless of nesting depth. */ +function collectPackages(root) { + const found = []; + function walk(dir) { + if (dir.includes("node_modules")) return; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + const full = join(dir, e.name); + if (e.isDirectory()) { + walk(full); + } else if (e.name === "package.json") { + const parsed = JSON.parse(readFileSync(full, "utf-8")); + if (typeof parsed.name === "string") { + found.push({ path: full, pkg: parsed }); + } + } + } + } + walk(join(root, "packages")); + return found; +} + +const packages = collectPackages(repoRoot); +const byName = new Map(packages.map((p) => [p.pkg.name, p])); + +const problems = []; +for (const { pkg, path } of packages) { + if (pkg.private === true) continue; + const deps = { ...pkg.dependencies, ...pkg.peerDependencies }; + for (const [depName, depSpec] of Object.entries(deps)) { + if (typeof depSpec !== "string" || !depSpec.startsWith("workspace:")) continue; + const target = byName.get(depName); + if (!target) continue; + if (target.pkg.private === true) { + problems.push( + ` ${pkg.name} (${path}) depends on ${depName} via workspace:*, ` + + `but ${depName} is private — install would fail on publish.`, + ); + } + } +} + +if (problems.length > 0) { + console.error("✗ Publishable-dependency check failed:\n"); + for (const p of problems) console.error(p); + console.error( + "\nFix by making the dependency publishable (drop `private: true` and add" + + " it to the changeset linked group) OR by removing the runtime dependency.", + ); + process.exit(1); +} + +console.log(`✓ Publishable-dependency check passed (${packages.length} packages scanned).`);