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:
parent
0133bcdc88
commit
7c46dc92a4
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
---
|
||||
"@aoagents/ao-core": minor
|
||||
"@aoagents/ao-cli": minor
|
||||
"@aoagents/ao": minor
|
||||
"@aoagents/ao-web": minor
|
||||
---
|
||||
|
||||
feat(release): weekly release train — channels, onboarding, dashboard banner, cron
|
||||
|
||||
Ships the full release pipeline described in `release-process.html`:
|
||||
|
||||
- **Cron-driven nightly canary.** `.github/workflows/canary.yml` triggers via
|
||||
`schedule: '0 18 * * 5,6,0,1,2'` (23:30 IST Fri–Tue) plus `workflow_dispatch`.
|
||||
Bake window (Wed–Thu) pauses scheduled nightlies; the captain re-cuts via
|
||||
workflow_dispatch when a fix lands. Stable `release.yml` publishes via
|
||||
`changesets/action`. `.changeset/config.json` adds the snapshot template
|
||||
(`{tag}-{commit}`). `@aoagents/ao-web` stays in the linked group and ships
|
||||
alongside `@aoagents/ao-cli` (it's a workspace:* runtime dep, so marking it
|
||||
private would 404 every `npm install -g @aoagents/ao` after publish).
|
||||
`scripts/check-publishable-deps.mjs` runs in both release.yml and canary.yml
|
||||
before the publish step and fails CI if a publishable package depends on a
|
||||
`private: true` package via workspace:*.
|
||||
|
||||
- **Update channels.** New `updateChannel` field in the global config schema
|
||||
(`stable | nightly | manual`, default `manual` so existing users see no
|
||||
surprise installs). `update-check.ts` reads `dist-tags[channel]` from the
|
||||
npm registry, compares prerelease versions segment-by-segment so SHA-suffixed
|
||||
nightlies sort correctly, and skips notices entirely on `manual`.
|
||||
|
||||
- **Soft auto-install + active-session guard.** On stable/nightly, `ao update`
|
||||
skips the confirm prompt and just installs. Before installing it lists
|
||||
sessions and refuses with `N session(s) active. Run \`ao stop\` first.` if
|
||||
any are in `working`/`idle`/`needs_input`/`stuck`. Same guard duplicated
|
||||
in `POST /api/update` so the dashboard returns a structured 409.
|
||||
|
||||
- **Onboarding question.** `ao start` prompts once for the channel if unset;
|
||||
dismissal persists `manual`. `ao config set updateChannel <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).
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -70,6 +70,24 @@ ao update
|
|||
|
||||
`ao update` fast-forwards the local install repo, reinstalls dependencies, clean-rebuilds `@aoagents/ao-core`, `@aoagents/ao-cli`, and `@aoagents/ao-web`, refreshes the global launcher with `npm link`, and finishes with CLI smoke tests. Use `ao update --skip-smoke` when you only need the rebuild step, or `ao update --smoke-only` when validating an existing install.
|
||||
|
||||
## Release Setup (maintainers only)
|
||||
|
||||
The canary and stable release workflows require one secret configured in GitHub repo settings:
|
||||
|
||||
- `NPM_TOKEN` — an npm automation token with publish access to the `@aoagents` org. Add it at **Settings → Secrets and variables → Actions → New repository secret**.
|
||||
|
||||
Without this secret, both `release.yml` and `canary.yml` will fail at the publish step.
|
||||
|
||||
## Testing your changes
|
||||
|
||||
### Latest main at any time
|
||||
|
||||
```bash
|
||||
npm install -g @aoagents/ao@nightly
|
||||
```
|
||||
|
||||
The nightly cron publishes from `main` daily at 23:30 IST (Fri–Tue). The bake window (Wed–Thu) pauses scheduled nightlies; release captains can re-cut a nightly via `workflow_dispatch` if a fix lands during bake.
|
||||
|
||||
---
|
||||
|
||||
## Building a Plugin
|
||||
|
|
|
|||
|
|
@ -55,6 +55,9 @@ Agent Orchestrator manages fleets of AI coding agents working in parallel on you
|
|||
npm install -g @aoagents/ao
|
||||
```
|
||||
|
||||
> **Nightly builds** (latest `main`, daily Fri–Tue): `npm install -g @aoagents/ao@nightly`
|
||||
> Back to stable: `npm install -g @aoagents/ao@latest`
|
||||
|
||||
<details>
|
||||
<summary>Permission denied? Install from source?</summary>
|
||||
|
||||
|
|
|
|||
|
|
@ -27,5 +27,9 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@aoagents/ao-cli": "workspace:*"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -68,5 +68,9 @@
|
|||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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`));
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 Fri–Tue. 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -103,5 +103,9 @@
|
|||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 Fri–Tue cron). Auto-installs when run.
|
||||
* manual — no checks, no notice, no install. User runs `ao update` manually.
|
||||
*/
|
||||
export const UpdateChannelSchema = z.enum(["stable", "nightly", "manual"]);
|
||||
export type UpdateChannel = z.infer<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") {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -40,5 +40,9 @@
|
|||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,5 +40,9 @@
|
|||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,5 +40,9 @@
|
|||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,5 +40,9 @@
|
|||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,5 +40,9 @@
|
|||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,5 +40,9 @@
|
|||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,5 +48,9 @@
|
|||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,5 +40,9 @@
|
|||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,5 +41,9 @@
|
|||
"rimraf": "^6.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,5 +40,9 @@
|
|||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,5 +40,9 @@
|
|||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,5 +40,9 @@
|
|||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,5 +42,9 @@
|
|||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,5 +41,9 @@
|
|||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,5 +41,9 @@
|
|||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,5 +44,9 @@
|
|||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,5 +40,9 @@
|
|||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,5 +40,9 @@
|
|||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,5 +40,9 @@
|
|||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,5 +41,9 @@
|
|||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,5 +40,9 @@
|
|||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,5 +41,9 @@
|
|||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,5 +41,9 @@
|
|||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
@ -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).`);
|
||||
Loading…
Reference in New Issue