feat(release): weekly release train — channels, onboarding, dashboard banner, cron (#1781)

* feat(release): weekly release train — channels, onboarding, dashboard banner, cron

Implements the full release pipeline described in release-process.html
(supersedes #1525, which only had the workflow scaffolding).

A. Release infrastructure — .github/workflows/canary.yml triggers on a cron
   ('30 17 * * 5,6,0,1,2', i.e. 23:00 IST Fri–Tue) plus workflow_dispatch,
   without the stale-SHA guard or the merged-PR-comment step from #1525
   (cron has no merged-PR context). release.yml uses changesets/action.
   .changeset/config.json adds the snapshot template and moves the private
   @aoagents/ao-web to ignore[].

B. Channel awareness (packages/cli/src/lib/update-check.ts) — new
   updateChannel field in the global-config Zod schema (stable | nightly
   | manual; defaults to manual so existing users see no surprise installs).
   fetchLatestVersion now reads dist-tags[channel] from the registry;
   isVersionOutdated compares prerelease segments numerically + lexically
   so SHA-suffixed nightlies sort correctly. maybeShowUpdateNotice and
   scheduleBackgroundRefresh skip entirely on manual.

C. Active-session guard (packages/cli/src/commands/update.ts) — before
   any handle*Update proceeds, sm.list() filters for working/idle/
   needs_input/stuck and refuses with `N session(s) active. Run
   `ao stop` first.` instead of auto-stopping (per the design doc:
   surprise-killing user work is worse than refusing).

D. Soft auto-install + onboarding — handleNpmUpdate skips the confirm
   prompt on stable/nightly. New packages/cli/src/lib/update-channel-
   onboarding.ts prompts once on the first `ao start` after this lands;
   ask-once gate keyed on the absence of updateChannel in the global
   config; dismissal persists `manual`. New `ao config set updateChannel
   <value>` command (also handles installMethod).

E. Dashboard banner — packages/web/src/app/api/version/route.ts reads
   the same cache file the CLI writes (~/.cache/ao/update-check.json,
   XDG-aware) and rejects cache entries from a different channel.
   packages/web/src/app/api/update/route.ts duplicates the active-session
   guard so the dashboard can return a structured 409. New UpdateBanner
   component wired into Dashboard.tsx — Tailwind only, var(--color-*)
   tokens, dismissible per-version via localStorage, deferred fetch so
   it doesn't shift the call order in existing dashboard tests.

F. Bun + Homebrew detection (update-check.ts) — new classifiers for
   ~/.bun/install/global/ (auto-installs `bun add -g @aoagents/ao@<channel>`)
   and /Cellar/ao/ (notice-only — `brew upgrade ao`, never auto-install
   because brew owns the symlinks). New installMethod override field in
   the global config to pin detection when path heuristics fail.

Tests: +155 (B/C/F unit, onboarding ask-once gate, /api/version + /api/update,
UpdateBanner visibility/dismiss/click). pnpm test, pnpm typecheck, pnpm lint
all green for the changes; the same 10 pre-existing test failures observed
on main are still present (all environment-dependent: ~/.cache/ao state, codex
binary install, /private path canonicalization on macOS).

Closes #1525

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(release-train): CI failures + Greptile review feedback

CI fixes:
- Web /api/update spawn ENOENT — attach `child.on("error", ...)` so the
  asynchronous spawn-error event from a missing `ao` binary doesn't bubble
  up as an unhandled error and crash vitest. The route already returns 202
  before the error fires; on real installs the user sees "no version change"
  if the install fails.
- start.test.ts pollution — runStartup calls `maybePromptForUpdateChannel`,
  which (with isHumanCaller mocked to true) writes to the real
  ~/.agent-orchestrator/config.yaml on the CI runner via persistUpdateChannel.
  Subsequent tests then load that newly-created (empty-projects) config and
  report "No projects configured" instead of the expected "project not found".
  Fix: stub `update-channel-onboarding.js` in start.test.ts so runStartup
  is a no-op for the channel prompt.

Review feedback:
- (P1) `runtime: "tmux"` hardcoded default in `persistUpdateChannel` and
  `loadOrInit` would lock Windows users into a non-functional tmux config
  when they dismiss the channel prompt. Both now use `getDefaultRuntime()`,
  matching `makeEmptyGlobalConfig` in core's global-config.ts.
- (P2) `hasChosenUpdateChannel` JSDoc inverted — the second "True when"
  bullet actually described the False case. Rewritten with separate
  True/False sections that match the implementation.
- (P2) `isVersionOutdated` was duplicated between the CLI and the dashboard
  /api/version route. Moved to a new shared module
  `packages/core/src/version-compare.ts`, exported from `@aoagents/ao-core`,
  consumed by both CLI (re-exports as `isVersionOutdated`) and the web route
  directly. Added 14 unit tests in core for the canonical implementation.

Defensive: `maybePromptForUpdateChannel` now validates the prompt result via
`UpdateChannelSchema.safeParse` before persisting — never writes `undefined`
or an unrecognized string to disk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(release-train): Windows spawn + dismiss-while-blocked review feedback

- (P1) `ao update` silently never ran on Windows because `spawn("ao", ...)`
  doesn't consult PATHEXT, so npm's `ao.cmd` shim wasn't found and the
  async ENOENT was swallowed by the error handler. Add `shell: isWindows()`
  + `windowsHide: true` per the cross-platform guide.
- (P1) Dismiss button was inert when the banner was in the `blocked` (409)
  or `error` phase — `setDismissedFor` set the localStorage flag but the
  hide condition required `phase === "idle"`, so the banner stayed pinned
  until reload. `handleDismiss` now resets phase to idle (and clears the
  error message) so the existing condition fires. Added a regression test
  covering dismiss from the 409 path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(release-train): runNpmInstall on Windows — shell:true so PATHEXT resolves npm.cmd

(P1) The dashboard /api/update spawn got `shell: isWindows()` + `windowsHide:
true` in 9f29131d, but `runNpmInstall` in the CLI's `ao update` command was
still missing the same fix. On Windows, `spawn("npm", ...)` without a shell
wrapper doesn't consult PATHEXT, so npm/pnpm/bun's `*.cmd` shims never
resolve and the install silently ENOENTs.

Mirror the fix into runNpmInstall — it's the single spawn site behind every
non-git, non-homebrew install path (npm-global, pnpm-global, bun-global,
unknown), so this one change covers all four install methods.

Tests:
- Mock `isWindows` from @aoagents/ao-core so the spawn options can be
  inspected per-platform.
- Assert `shell: true, windowsHide: true, stdio: "inherit"` on Windows.
- Assert `shell: false` on macOS / Linux.
- Parametrize over pnpm-global / bun-global to confirm the same options flow
  through every npm-style install command.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(release-train): /api/version reads cached.isOutdated for git installs

(P1) The dashboard banner never appeared for git-installed users because
`/api/version` ran `isVersionOutdated(current, "origin/main")`, and
`parseVersion("origin/main")` produces NaN parts that the early-exit guard
catches with `return false`. Git installs cache `latestVersion` as a git
ref (not a semver) and a precomputed `isOutdated` flag from `git fetch +
merge-base`; the CLI special-cases this in `update-check.ts`. Mirror the
same pattern here:

  cached.installMethod === "git"
    ? cached.isOutdated === true
    : isVersionOutdated(current, latest)

Also extend the local CacheData with `installMethod?: string` and
`isOutdated?: boolean` so the new branch type-checks. Kept as `string`
rather than importing the CLI's `InstallMethod` type — the literal "git"
compare is the only thing that matters here, and the web package shouldn't
take a dep on @aoagents/ao-cli.

Two new tests cover the git-install path: one asserts isOutdated=true is
trusted from the cache, the other asserts isOutdated=false (current with
origin) is trusted too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(release-train): must-fix #3+#4 — global-config layout + git-only flag guard

#3 — ensureNoActiveSessions now consults loadGlobalConfig() first as a quick
"any projects registered?" check, then routes through loadConfig(globalPath)
only when the registry actually has projects (loadConfig dispatches to
buildEffectiveConfigFromGlobalConfigPath when given the canonical global path
— see packages/core/src/config.ts). Defends against AO_GLOBAL_CONFIG override
to a non-canonical path. Three new tests cover: registered-projects path
fires the guard correctly; empty registry returns early without building a
SessionManager; missing global file returns early without even reading it.

#4 — Restored the rejection of git-only flags on non-git installs. Users
copy/pasting `ao update --skip-smoke` from older docs would silently no-op
on npm/pnpm/bun installs. Now exits non-zero with:
"--skip-smoke only applies to git installs (current install: npm-global)."
Test it.each across npm/pnpm/bun/homebrew/unknown plus a positive test that
git installs still accept the flag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(release-train): #2 should-fix — channel-switch prompt

When a stable user runs `ao config set updateChannel nightly` and then
`ao update`, isVersionOutdated(0.5.0, 0.5.0-nightly-abc) returns false (per
semver, prerelease < stable on equal base). The old code printed "Already
on latest nightly" and exited without installing — confusing, because the
install command we'd run is genuinely a different dist-tag.

Fix: snapshot the previously-cached channel BEFORE forcing a refresh, then
detect a switch via `previousChannel !== activeChannel && !info.isOutdated`.
On switch:
  - Don't take the "already on latest" early-return.
  - Print a yellow "Channel switch detected: was X, now Y." notice.
  - Force a confirm prompt regardless of stable/nightly soft-install,
    defaulting to "no" (channel-switch should be explicit). Manual users
    still see their normal prompt.

Onboarding copy now includes one line about channel switches: "switching
later prompts before installing the other channel's build."

4 new tests: explicit switch fires the prompt + installs on yes; declines
on no; same-channel doesn't fire (back to "Already on latest"); first-ever
update with no previous cache doesn't fire either.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(release-train): polish — drop setTimeout, dedup defaults, share cache, dedup export

#5 — UpdateBanner no longer wraps its mount fetch in setTimeout(0). Production
code shouldn't bend to test mock ordering. Instead, the two brittle Dashboard
tests that relied on `mockImplementationOnce` queue ordering now route by URL
via `mockImplementation`, and the cadence test asserts "no other endpoints
were touched" instead of "no fetch was touched at all". Also added a
deliberate "no interval / re-fetch" comment per #6.

#7 — Promoted core's `makeEmptyGlobalConfig` to the public
`createDefaultGlobalConfig` (kept the internal alias for back-compat). Both
the CLI's `persistUpdateChannel` and `loadOrInit` (in `ao config`) now call
it instead of inlining the same defaults block. Single source of truth.

#8 — New `packages/core/src/update-cache.ts` exports
`getUpdateCheckCachePath`, `readUpdateCheckCacheRaw`, and
`getInstalledAoVersion`. The CLI's `update-check.ts` keeps its richer
install-method/channel/git-rev validation but now delegates path resolution
and version lookup to core. The dashboard's `/api/version` route drops its
duplicated `getCachePath`/`readCache`/`getCurrentVersion` and consumes from
core directly. Cache layout is one file, not two.

#9 — Removed the duplicate `export { isManualOnlyInstall }` from
`update.ts` (also dropped the unused import). The canonical export lives in
`update-check.ts`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(release-train): cosmetic — workflow rename note, design tokens, changeset trim

#1  release.yml: added a comment above `workflows: [CI]` warning that GitHub
    matches by name (not filename) and silently no-ops on mismatch — so a
    rename of ci.yml's `name:` field would mean releases stop triggering.

#10 UpdateBanner: replaced text-[13px] / text-[12px] with text-sm / text-xs
    to match the dashboard's chrome scale.

#6  Banner refresh: noted in the existing useEffect comment that we don't
    re-fetch — re-evaluate if "user kept tab open for days, missed an
    update" becomes a real complaint.

#11 .changeset/release-train.md: dropped @aoagents/ao-web from the version
    bump list. The package is `private: true` and in changeset's ignore[],
    so listing it was cosmetic and would just clutter the eventual release
    notes with a non-published artifact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(release-train): illegalcall review — global guard, channel scoping, publishable web

(#1, PRRT_kwDORPZUAc6BHFDf) — `ensureNoActiveSessions` now ALWAYS loads
from the canonical global config, never from project-local. The previous
code preferred `loadConfig()` (local search-upward) when run inside a repo,
which made `sm.list()` enumerate only that project's sessions — active work
in other registered projects would be missed and the install would proceed.
New regression test asserts that a session in `other-project` blocks the
update even when invoked from `this-project`'s cwd. Existing global-config
tests retained.

(#2, PRRT_kwDORPZUAc6BHFOl) + (#4, PRRT_kwDORPZUAc6BHFon) — Reverted the
`private: true` on @aoagents/ao-web. Because @aoagents/ao-cli has a
workspace:* runtime dep on it (for `findWebDir()`/dashboard files), pnpm
rewrites the dep on publish to a literal version — keeping ao-web private
would make `npm install -g @aoagents/ao` fail. Restored ao-web to the
changeset linked group, removed it from `ignore[]`, restored the release-
train changeset entry, added publishConfig + repository metadata.

New `scripts/check-publishable-deps.mjs` walks every package and asserts
that no publishable package has a workspace:* runtime dep on a `private:
true` package. Wired into both release.yml and canary.yml before the
publish step so any future regression is caught at CI rather than at the
user's `npm install`. Verified the script catches the inverse condition.

(#3, PRRT_kwDORPZUAc6BHFaV) — `readCachedUpdateInfo` now treats a missing
`data.channel` as a miss when an explicit channel is provided. Previous
logic only rejected when both data.channel and channel were set AND
differed, so a legacy cache entry (pre-channel-scoping) could keep returning
stale stable state to a user who had since switched to nightly until the
24h TTL expired. Existing fixtures bumped to include `channel` where the
test exercises the checkForUpdate / maybeShowUpdateNotice path; new
regression test exercises the legacy-no-channel case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(release-train): SHA-suffix nightly compare — never miss a banner

(P1, PRRT_kwDORPZUAc6BJLrW) Git SHAs are uniformly-random hex, so the old
`comparePrereleaseSegments` lexical fallback gave the wrong answer ~50% of
the time on snapshot tags. Concretely: user installs `0.5.0-nightly-f00d123`,
CI publishes `0.5.0-nightly-0dead01`, and `'f' < '0'` returns false → banner
never shows.

Fix: when two prerelease segments are both non-numeric and differ, treat the
left side as older (return -1). The cache layer always carries the registry's
CURRENT dist-tag, so any non-numeric mismatch on the same base means the
installed copy is behind by construction. Numeric ordering (`rc.1 < rc.2`)
and numeric-vs-non-numeric (`0.5.0-1 < 0.5.0-alpha`) are unchanged.

Tradeoff: a user who manually installed `0.5.0-beta` while the registry only
publishes `0.5.0-alpha` would see a spurious banner. AO's release pipeline
only emits SHA-suffixed nightly prereleases, so the scenario doesn't occur in
practice — documented in the function's JSDoc.

Updated two misleadingly-named tests ("orders SHA-suffixed nightlies
lexically") that had been asserting the buggy behavior; new tests cover the
specific case from the review (`nightly-f00d123` vs `nightly-0dead01`) and
preserve the numeric-ordering invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(release-train): multi-project active-session proof + changeset note

(#1, PRRT_kwDORPZUAc6BHFDf follow-up) Dhruv asked for proof — not a comment —
that loadConfig(globalPath) actually enumerates across all registered
projects, not just the cwd's. New test in update.test.ts seeds proj-a and
proj-b in the global config, places one active session in each (one
"working", one "needs_input"), and asserts the refusal stderr lists BOTH
session ids AND the total count says "2 sessions active". The test is
specifically named so it shows up in `vitest run -t "Dhruv proof"`.

Verified `pnpm changeset version` locally — @aoagents/ao-web, ao-cli,
and ao all bump to 0.7.0 together via the linked group, confirming the
install-404 class of bug is gone.

Also updated the release-train changeset to drop the stale "moves the
private @aoagents/ao-web to ignore" line — that contradicts the current
state (ao-web is publishable and in the linked group).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(release-train): Ashish P1+P2 — dashboard banner now actually installs

P1 — Dashboard banner click was a no-op for npm users.
POST /api/update spawns `ao update` with `stdio: "ignore"`, which makes
`isTTY()` return false in the child. The old handleNpmUpdate hit the
non-TTY branch ("Run: ...") and exited without installing. Banner returned
202 "started"; nothing actually happened.

Fix (Ashish's option c, with an env-var bridge):
- /api/update spawns with `AO_NON_INTERACTIVE_INSTALL=1` on the env.
- handleNpmUpdate computes `interactive = isTTY() && !isApiInvoked()`.
- Restructured so the early-return only fires for non-TTY + non-API
  (piped output): we still print "Run: ..." for that case, matching the
  old contract. API-invoked path now actually runs runNpmInstall, skipping
  the confirm prompt (would hang the detached child forever).

Three new CLI tests:
- AO_NON_INTERACTIVE_INSTALL=1 → spawn invoked even with isTTY=false.
- The piped-output case (no env var, no TTY) still prints "Run: ...".
- Active-session guard still fires in the API-invoked path (defense in
  depth — the route's own guard isn't single point of trust).

P2 — First nightly opt-in stuck on "Already on latest stable".
Repro: user on stable 0.5.0, runs `ao config set updateChannel nightly`,
runs `ao update`. previousChannel was undefined, isOutdated was false
(semver: prerelease < stable on equal base), so the early return fired
and the install never ran.

Fix: new `isFirstChannelOptIn` branch — `previousChannel === undefined
&& info.currentVersion !== info.latestVersion && !info.isOutdated`. Force
the same prompt path the channel-switch case uses (default=no, explicit
consent). Confirmed install path covered by a new test that mirrors the
repro exactly.

The pre-existing "no previous cache → no prompt" test asserted the OLD
buggy behavior; rewritten to assert the canonical case (no prior cache
AND versions match → still "Already on latest", no prompt).

P2 — Dashboard /api/version legacy cache.
Same class as Dhruv #3, this time on the web side. Old code:
  const cacheMatchesChannel = !cache?.channel || cache.channel === channel;
A legacy entry without `channel` would short-circuit `!cache?.channel`
and serve stale latestVersion. Fixed to require `cache.channel === channel`
explicitly. New regression test seeds a no-channel entry and asserts
{ latest: null, isOutdated: false, checkedAt: null }.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(release-train): Dhruv edge-case — running.json is the live source of truth

(PRRT_kwDORPZUAc6BUIUK) The active-session guard previously short-circuited
on empty global config, which missed the case where:

  - User runs `ao start` from a repo with a local agent-orchestrator.yaml
    and no global registration.
  - running.json lists that project as currently being polled.
  - Sessions live on disk under ~/.agent-orchestrator/{hash}-{projectId}/.

In that state, `loadGlobalConfig().projects` is empty so the old early
return fired and `ao update` would proceed while a daemon was actively
supervising the user's in-flight work.

Fix: consult `getRunning()` BEFORE falling back to the global registry.
When running.json reports projects, trust its configPath (could be a local
project yaml OR the canonical global path — `loadConfig` dispatches on
shape) and build the SessionManager from there. The global fallback is now
the no-daemon-running case, where on-disk sessions get reconciled by
SessionManager enrichment.

Three new tests in update.test.ts:
- `refuses when sessions exist in a locally-registered project not in
  global config (Dhruv edge-case)` — seeds running.json with a local-only
  project + working session, asserts refusal + that loadConfig was called
  with running.configPath (NOT the global path).
- `returns true (allows update) when running.json is gone and global is
  empty` — covers the genuinely-safe case.
- `trusts running.json over an inconsistent global config` — when both
  signals exist, the live one (running.json) wins and loadGlobalConfig is
  never consulted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(release-train): shift canary cron 23:00 → 23:30 IST

Schedule moves to 23:30 IST = 18:00 UTC. Cron expression changes from
`30 17 * * 5,6,0,1,2` to `0 18 * * 5,6,0,1,2`. Same DOW window
(Fri,Sat,Sun,Mon,Tue) so the bake window (Wed–Thu) is unaffected.

Files touched (all consistent):
- .github/workflows/canary.yml — cron expression + comment block
- .changeset/release-train.md — schedule string in feature description
- CONTRIBUTING.md — "Testing your changes" callout

Verified `grep -rn '23:00 IST|17:30 UTC|"30 17'` returns zero matches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
suraj_markup 2026-05-12 23:11:09 +05:30 committed by GitHub
parent 0133bcdc88
commit 7c46dc92a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 3614 additions and 191 deletions

View File

@ -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",

View File

@ -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 FriTue) plus `workflow_dispatch`.
Bake window (WedThu) 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 <value>` (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@<channel>`)
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).

65
.github/workflows/canary.yml vendored Normal file
View File

@ -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"

64
.github/workflows/release.yml vendored Normal file
View File

@ -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"

View File

@ -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 (FriTue). The bake window (WedThu) pauses scheduled nightlies; release captains can re-cut a nightly via `workflow_dispatch` if a fix lands during bake.
---
## Building a Plugin

View File

@ -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 FriTue): `npm install -g @aoagents/ao@nightly`
> Back to stable: `npm install -g @aoagents/ao@latest`
<details>
<summary>Permission denied? Install from source?</summary>

View File

@ -27,5 +27,9 @@
},
"dependencies": {
"@aoagents/ao-cli": "workspace:*"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}

View File

@ -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

View File

@ -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<unknown>>(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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
expect(opts.shell).toBe(true);
});
});
});

View File

@ -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");
});
});
});

View File

@ -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-<sha>. 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;
});
});
});

View File

@ -68,5 +68,9 @@
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}

View File

@ -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 <key> <value>")
.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 <key>")
.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);
});
}

View File

@ -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<number> {
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`));

View File

@ -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<Session["status"]>([
"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<void> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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<number> {
const [cmd, ...args] = command.split(" ");
return new Promise<number>((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<number> {
});
}
// ---------------------------------------------------------------------------
// homebrew install (notice only)
// ---------------------------------------------------------------------------
async function handleHomebrewUpdate(): Promise<void> {
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<void> {
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`,
),
);
}

View File

@ -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<UpdateChannel | "skip">;
/** 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<void> {
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 FriTue. Manual stays put.",
),
);
console.log(
chalk.dim(
" You can switch later with `ao config set updateChannel <value>` —\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 <stable|nightly|manual>\n`),
);
}
async function defaultPrompt(
message: string,
options: { value: UpdateChannel | "skip"; label: string; hint?: string }[],
): Promise<UpdateChannel | "skip"> {
return promptSelect<UpdateChannel | "skip">(message, options);
}

View File

@ -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/<version>/.
// 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<string | null> {
/**
* 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<string | null> {
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<string, unknown> };
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<string | null> {
// 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 <remote> <branch>` + `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<UpdateInfo> {
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;

View File

@ -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")

View File

@ -103,5 +103,9 @@
"tslib": "^2.8.1",
"typescript": "^5.7.0",
"vitest": "^4.0.18"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}

View File

@ -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-<sha>. 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);
});
});
});

View File

@ -153,6 +153,34 @@ export type GlobalProjectEntry = z.infer<typeof GlobalProjectEntrySchema>;
* 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 FriTue 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<typeof UpdateChannelSchema>;
/**
* 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<typeof InstallMethodOverrideSchema>;
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 <stable|nightly|manual>`.
*/
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<string, unknown>): RawGlobalConfigSanitization {
const projects = raw["projects"];
if (!projects || typeof projects !== "object") {

View File

@ -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";

View File

@ -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";
}

View File

@ -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-<sha>`.
* 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;
}

View File

@ -40,5 +40,9 @@
"@types/node": "^25.2.3",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}

View File

@ -40,5 +40,9 @@
"@types/node": "^25.2.3",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}

View File

@ -40,5 +40,9 @@
"@types/node": "^25.2.3",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}

View File

@ -40,5 +40,9 @@
"@types/node": "^25.2.3",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}

View File

@ -40,5 +40,9 @@
"@types/node": "^25.2.3",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}

View File

@ -40,5 +40,9 @@
"@types/node": "^25.2.3",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}

View File

@ -48,5 +48,9 @@
"@types/node": "^25.2.3",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}

View File

@ -40,5 +40,9 @@
"@types/node": "^25.2.3",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}

View File

@ -41,5 +41,9 @@
"rimraf": "^6.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}

View File

@ -40,5 +40,9 @@
"@types/node": "^25.2.3",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}

View File

@ -40,5 +40,9 @@
"@types/node": "^25.2.3",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}

View File

@ -40,5 +40,9 @@
"@types/node": "^25.2.3",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}

View File

@ -42,5 +42,9 @@
"@types/node": "^25.2.3",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}

View File

@ -41,5 +41,9 @@
"@types/node": "^25.2.3",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}

View File

@ -41,5 +41,9 @@
"@types/node": "^25.2.3",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}

View File

@ -44,5 +44,9 @@
"@types/node": "^25.2.3",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}

View File

@ -40,5 +40,9 @@
"@types/node": "^25.2.3",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}

View File

@ -40,5 +40,9 @@
"@types/node": "^25.2.3",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}

View File

@ -40,5 +40,9 @@
"@types/node": "^25.2.3",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}

View File

@ -41,5 +41,9 @@
"@types/node": "^25.2.3",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}

View File

@ -40,5 +40,9 @@
"@types/node": "^25.2.3",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}

View File

@ -41,5 +41,9 @@
"@types/node": "^25.2.3",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}

View File

@ -41,5 +41,9 @@
"@types/node": "^25.2.3",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}

View File

@ -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",

View File

@ -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/);
});
});

View File

@ -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<UpdateResponse>(
{
ok: false,
message: `Failed to check active sessions: ${err instanceof Error ? err.message : String(err)}`,
},
{ status: 500 },
);
}
if (activeCount > 0) {
return NextResponse.json<UpdateResponse>(
{
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<UpdateResponse>(
{
ok: false,
message: `Failed to spawn 'ao update': ${err instanceof Error ? err.message : String(err)}`,
},
{ status: 500 },
);
}
return NextResponse.json<UpdateResponse>(
{
ok: true,
message:
"Update started. The dashboard will restart once the new version is installed; re-run `ao start` to resume.",
},
{ status: 202 },
);
}

View File

@ -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);
}

View File

@ -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 }}
>
<>
<UpdateBanner />
<ConnectionBar status={connectionStatus} />
<div className="dashboard-app-shell">
<header className="dashboard-app-header">

View File

@ -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<VersionResponse | null>(null);
const [phase, setPhase] = useState<Phase>("idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [dismissedFor, setDismissedFor] = useState<string | null>(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 `<Dashboard>` 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<VersionResponse>) : 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 (
<div
role="status"
aria-live="polite"
className="flex w-full items-center justify-between gap-3 border-b border-[var(--color-border-default)] bg-[var(--color-accent-amber-dim)] px-4 py-2 text-sm text-[var(--color-text-primary)]"
>
<div className="flex flex-1 items-center gap-3">
<span
aria-hidden="true"
className="inline-block h-2 w-2 flex-shrink-0 rounded-full bg-[var(--color-accent-amber)]"
/>
<div className="flex flex-col gap-0.5 sm:flex-row sm:items-baseline sm:gap-2">
<span className="font-medium">
Update available{channelLabel}: {info.current} {info.latest}
</span>
{phase === "blocked" && errorMessage ? (
<span className="text-xs text-[var(--color-status-error)]">{errorMessage}</span>
) : phase === "error" && errorMessage ? (
<span className="text-xs text-[var(--color-status-error)]">
{errorMessage}
</span>
) : phase === "starting" ? (
<span className="text-xs text-[var(--color-text-secondary)]">Starting</span>
) : (
<span className="text-xs text-[var(--color-text-secondary)]">
Click Update to install. The dashboard will restart.
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void handleUpdate()}
disabled={phase === "starting"}
className="rounded-sm border border-[var(--color-accent-amber-border)] bg-[var(--color-accent-amber)] px-3 py-1 text-xs font-medium text-[var(--color-text-inverse)] hover:bg-[color-mix(in_srgb,var(--color-accent-amber)_85%,black)] disabled:cursor-wait disabled:opacity-60"
>
{phase === "starting" ? "Updating…" : "Update"}
</button>
<button
type="button"
onClick={handleDismiss}
aria-label="Dismiss"
className="rounded-sm px-2 py-1 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]"
>
Dismiss
</button>
</div>
</div>
);
}

View File

@ -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<Response>((resolve) => {
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input.toString();
if (url.includes("/api/orchestrators")) {
return new Promise<Response>((resolve) => {
resolveSpawn = resolve;
}),
);
});
}
return Promise.resolve({
ok: false,
status: 404,
json: async () => ({}),
} as Response);
});
render(
<Dashboard
@ -175,10 +194,20 @@ describe("Dashboard project overview cards", () => {
});
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(
<Dashboard

View File

@ -56,7 +56,15 @@ describe("Dashboard render cadence", () => {
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 () => {

View File

@ -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<typeof vi.fn>;
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(<UpdateBanner />);
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(<UpdateBanner />);
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(<UpdateBanner />);
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(<UpdateBanner />);
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(<UpdateBanner />);
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(<UpdateBanner />);
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(<UpdateBanner />);
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(<UpdateBanner />);
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());
});
});

View File

@ -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).`);