Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions packages/@expo/agent-cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### 🛠 Breaking changes

- The `package.json` config key is `expo.agent-cli`, not `expo.agentCli`. Nothing inside it changed: `target`, `buildBackend`, `ios` and `android` are the same. A project still on the old key gets an error naming the new one rather than having its settings read as absent. ([#85](https://github.com/expo/expo-agent-cli/pull/85) by [@kudo](https://github.com/kudo))
- `status --explain` is removed, with no alias. Every `status` now reports the changed sources, the OTA verdict and the EAS build lookup, cached under `.expo`, and `--build`, `--device` and `--device-timeout` work on their own; `builds.askedEas` in `--json` now means EAS was called on this run. ([#71](https://github.com/expo/expo-agent-cli/pull/71) by [@kudo](https://github.com/kudo))
- `dev` and `smoke` no longer build on EAS just because this machine cannot build locally. That route is still described (`--plan`, `status`), with a reason saying an EAS build and an EAS Simulator session use EAS credits, but a bare run stops: `dev` exits 1 with `EAS_ROUTE_NOT_CHOSEN` naming `--eas` and `--local`, and `smoke` fails its start phase the same way before spawning anything. `--eas` on the command line, or `buildBackend: "eas"` under `expo.agent-cli` in `package.json`, takes the route with no stop, as before. ([#41](https://github.com/expo/expo-agent-cli/pull/41) by [@kudo](https://github.com/kudo))
- `--cloud` is `--eas` on `smoke`, `navigate`, `runtime:reload` and `runtime:stop`, the flag `dev` already used for the same service. One name now means "on EAS", for the build and for the device. `--cloud` is removed, with no alias: it is an unknown option now. Every command line this CLI prints says `--eas`. ([#34](https://github.com/expo/expo-agent-cli/pull/34) by [@kudo](https://github.com/kudo))
- `dev` now requires a platform flag (`--ios`, `--android`, or `--web`), like `smoke`. The plan always acts on the named platform: a fresh plan runs `expo start --<platform>`, which boots a simulator or an emulator and opens the app, and a stale plan prebuilds and builds through `expo run:<platform>`. A run with no platform exits 1 with a one-line usage error. Every suggestion the CLI prints now states a platform too. ([#7](https://github.com/expo/expo-agent-cli/pull/7) by [@kudo](https://github.com/kudo))
Expand All @@ -18,12 +19,12 @@
- `smoke --ios` and `smoke --android` release the device they bound when the run ends: a child dev server's device through `dev:stop --release`, a device bound next to a reused dev server through the boot phase's cleanup. `smoke --no-start` binds and releases nothing. The report names that device, how the run got it, and what its release did: a `device` line, and `environment.boundDevice` in `--json`. A run that binds nothing and uses the worktree's bound device names it as `already bound · kept` (action `existing`). When no app attached and the run put its dev server and device back, the follow-ups start them again with `dev --<platform> --detach --wait-ready` before `navigate`, and a repeated `smoke` keeps `--port`. ([#TBD](https://github.com/expo/expo-agent-cli/pull/TBD) by [@vonovak](https://github.com/vonovak))
- `dev:stop --release` parks the worktree's created simulator and ends its spawned emulator instance once this worktree's dev server is stopped; plain `dev:stop` keeps them and reports them under `devices` with `released: null`; `false` means a release failed. ([#TBD](https://github.com/expo/expo-agent-cli/pull/TBD) by [@vonovak](https://github.com/vonovak))
- A running dev server (`dev`, its detached child, and `start`) and each build step of `dev` renew the worktree's device lease every five minutes, so a session longer than the 60-minute lease keeps its device. A lost lease is reported once each time it is lost, naming `dev --<platform> --detach --wait-ready`, with `--eas` for an EAS Simulator session, to bind the device again. ([#TBD](https://github.com/expo/expo-agent-cli/pull/TBD) by [@vonovak](https://github.com/vonovak))
- `dev --android` binds an Android emulator instance of its own to the git worktree it runs in, and every verb of that worktree uses it. The instance is a read-only emulator of the first AVD, started by this CLI with `-avd <avd> -ports <port>,<port+1> -no-snapshot-save -read-only` on the first free even console port from 5554 to 5584, so two worktrees run two instances of one AVD side by side and neither writes the AVD's image; the binding records its serial and pid, the boot is verified with `sys.boot_completed`, and the build and install steps carry `--device <serial>` (`expo run:android` matches a serial since SDK 58). `navigate`, `runtime:reload`, `runtime:stop` and `status --explain` read the binding through `adb get-state` and never pick the first `adb` device: with no binding they refuse with `NO_BOUND_DEVICE` (exit 20) and name `dev --android --detach --wait-ready`. `smoke --android` gets its instance from the binding, installs the app onto it, and leaves it bound; nothing runs `adb emu kill` any more. When every console port is taken, `dev` refuses `DEVICE_UNAVAILABLE` (`no-free-port`) naming each serial and the worktree it is bound to. An `adb` call that exits non-zero fails with `ADB_FAILED`, quoting adb's stderr. On a host with no Android SDK, `dev --android` binds nothing and serves for a device elsewhere, as before. ([#TBD](https://github.com/expo/expo-agent-cli/pull/TBD) by [@vonovak](https://github.com/vonovak))
- `dev --ios` binds an iOS simulator of its own to the git worktree it runs in, and every verb of that worktree uses it. The simulator is one this CLI created, named `agent-cli <digest>`, or the one the worktree bound before; it is booted with `simctl bootstatus -b`, the build and install steps carry `--device <udid>`, and the open deep-links to it. `navigate`, `runtime:reload`, `runtime:stop` and `status --explain` read the binding, extend its 60-minute lease, and never pick the first booted simulator: with no binding they refuse with `NO_BOUND_DEVICE` (exit 20) and name `dev --ios --detach --wait-ready`. `dev --detach` on a running server boots the worktree's own simulator and refuses `not-reusable` when it has none; a device refusal in the detached child reaches the caller with its own code, exit and `Try:` line. `smoke --ios` gets its simulator from the binding too and leaves it bound. The bindings live under `<expo home>/agent-cli/bindings`, written under one machine-wide lock. ([#134](https://github.com/expo/expo-agent-cli/pull/134) by [@vonovak](https://github.com/vonovak))
- `status --explain` reports what the app installed on a device was built from. It reads the fingerprint a debug build embeds (`expo-constants`, SDK 58+) out of the app on an Android device or a booted iOS simulator, compares it with the project's fingerprint for that platform, and says `up to date`, `rebuild required` or `unknown`, with the commands that fix a stale one. On an older SDK it says the app cannot be checked and reads no device. Right about a build somebody else made, which the freshness line cannot be. Android reads a few hundred KB of the APK through `adb` instead of pulling it; `--device <name>` narrows the read to one device. ([#59](https://github.com/expo/expo-agent-cli/pull/59) by [@vonovak](https://github.com/vonovak))
- The OTA verdict of `status --explain` no longer runs `expo config` for a project with no app config file. ([#70](https://github.com/expo/expo-agent-cli/pull/70) by [@kudo](https://github.com/kudo))
- `dev --android` binds an Android emulator instance of its own to the git worktree it runs in, and every verb of that worktree uses it. The instance is a read-only emulator of the first AVD, started by this CLI with `-avd <avd> -ports <port>,<port+1> -no-snapshot-save -read-only` on the first free even console port from 5554 to 5584, so two worktrees run two instances of one AVD side by side and neither writes the AVD's image; the binding records its serial and pid, the boot is verified with `sys.boot_completed`, and the build and install steps carry `--device <serial>` (`expo run:android` matches a serial since SDK 58). `navigate`, `runtime:reload`, `runtime:stop` and `status` read the binding through `adb get-state` and never pick the first `adb` device: with no binding they refuse with `NO_BOUND_DEVICE` (exit 20) and name `dev --android --detach --wait-ready`. `smoke --android` gets its instance from the binding, installs the app onto it, and leaves it bound; nothing runs `adb emu kill` any more. When every console port is taken, `dev` refuses `DEVICE_UNAVAILABLE` (`no-free-port`) naming each serial and the worktree it is bound to. An `adb` call that exits non-zero fails with `ADB_FAILED`, quoting adb's stderr. On a host with no Android SDK, `dev --android` binds nothing and serves for a device elsewhere, as before. ([#TBD](https://github.com/expo/expo-agent-cli/pull/TBD) by [@vonovak](https://github.com/vonovak))
- `dev --ios` binds an iOS simulator of its own to the git worktree it runs in, and every verb of that worktree uses it. The simulator is one this CLI created, named `agent-cli <digest>`, or the one the worktree bound before; it is booted with `simctl bootstatus -b`, the build and install steps carry `--device <udid>`, and the open deep-links to it. `navigate`, `runtime:reload`, `runtime:stop` and `status` read the binding, extend its 60-minute lease, and never pick the first booted simulator: with no binding they refuse with `NO_BOUND_DEVICE` (exit 20) and name `dev --ios --detach --wait-ready`. `dev --detach` on a running server boots the worktree's own simulator and refuses `not-reusable` when it has none; a device refusal in the detached child reaches the caller with its own code, exit and `Try:` line. `smoke --ios` gets its simulator from the binding too and leaves it bound. The bindings live under `<expo home>/agent-cli/bindings`, written under one machine-wide lock. ([#134](https://github.com/expo/expo-agent-cli/pull/134) by [@vonovak](https://github.com/vonovak))
- `status` reports what the app installed on a device was built from. It reads the fingerprint a debug build embeds (`expo-constants`, SDK 58+) out of the app on an Android device or a booted iOS simulator, compares it with the project's fingerprint for that platform, and says `up to date`, `rebuild required` or `unknown`, with the commands that fix a stale one. On an older SDK it says the app cannot be checked and reads no device. Right about a build somebody else made, which the freshness line cannot be. Android reads a few hundred KB of the APK through `adb` instead of pulling it; `--device <name>` narrows the read to one device. ([#59](https://github.com/expo/expo-agent-cli/pull/59) by [@vonovak](https://github.com/vonovak))
- The OTA verdict of `status` no longer runs `expo config` for a project with no app config file. ([#70](https://github.com/expo/expo-agent-cli/pull/70) by [@kudo](https://github.com/kudo))
- `status` remembers an EAS "no build" answer for five minutes and shows its age (`none (as of 2m ago)`); `--no-fingerprint-cache` asks EAS again. A project whose static app config names no `extra.eas.projectId` is not sent to EAS; `status` reports `unknown` with the `eas init` command. ([#69](https://github.com/expo/expo-agent-cli/pull/69) by [@kudo](https://github.com/kudo))
- `status --explain --device <phone>` reads the app on a physical iPhone. `devicectl` exposes no app container, so the app is launched with a one-time URL and posts its embedded fingerprint back over the LAN; it needs a debug build whose `expo-dev-client` answers the check. Naming the phone is the consent, because the probe launches the app and `status` otherwise starts nothing. `--device-timeout <secs>` bounds how long the phone gets to answer. A phone that does not answer in time reads as unknown. ([#90](https://github.com/expo/expo-agent-cli/pull/90) by [@vonovak](https://github.com/vonovak))
- `status --device <phone>` reads the app on a physical iPhone. `devicectl` exposes no app container, so the app is launched with a one-time URL and posts its embedded fingerprint back over the LAN; it needs a debug build whose `expo-dev-client` answers the check. Naming the phone is the consent, because the probe launches the app and `status` otherwise starts nothing. `--device-timeout <secs>` bounds how long the phone gets to answer. A phone that does not answer in time reads as unknown. ([#90](https://github.com/expo/expo-agent-cli/pull/90) by [@vonovak](https://github.com/vonovak))
- `smoke --eas` brings its own EAS Simulator session the way `smoke` boots a simulator: its dev server starts through `dev --eas` (tunnelled; a build the plan needs is the simulator profile on EAS), the session in progress on the platform is reused, and otherwise one is started with Expo Go or with the finished EAS build of this fingerprint — and stopped again when the run ends. A development build with no finished build on EAS is a failed `start-session` phase naming `dev --eas`; the gate never compiles. `dev:stop --eas` also ends this project's session, by id, and reports it under `session`. ([#37](https://github.com/expo/expo-agent-cli/pull/37) by [@kudo](https://github.com/kudo))
- `dev --eas` runs everything on EAS: the dev server is tunnelled, a native build (when the plan needs one) is the `development-simulator` profile on EAS Build — added to `eas.json` when it is missing, and skipped when EAS already has a finished build of this fingerprint — and the app is opened on this project's EAS Simulator session, which is started with Expo Go or with that build when none is up. `--eas` refuses `--web`, `--lan` and `--localhost`, and runs no `eas build:configure` — the profile it needs is the whole of what it writes. Before, `--eas` moved only the build, and did nothing at all for an Expo Go project. ([#36](https://github.com/expo/expo-agent-cli/pull/36) by [@kudo](https://github.com/kudo))
- `dev` opens the app itself once its dev server is up, through the same device tools `smoke` and `navigate` use: it boots a simulator or an emulator when none is up, installs the Expo Go release the project's SDK ships when it is missing, and deep-links through `simctl`/`adb` — never AppleScript, so it works headless and needs no macOS Automation grant. The platform flag no longer reaches `expo start` at all. ([#7](https://github.com/expo/expo-agent-cli/pull/7) by [@kudo](https://github.com/kudo))
Expand Down
24 changes: 12 additions & 12 deletions packages/@expo/agent-cli/e2e-live/__tests__/live-eas-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
//
// The budget this suite spends:
//
// - **Reads are free and repeated.** `whoami`, `status --explain`, `inspect:build-log`.
// - **Reads are free and repeated.** `whoami`, `status`, `inspect:build-log`.
// - **One write per run, and it is idempotent.** `deploy --web`. EAS Hosting gives each deploy its
// own preview URL, so re-running adds a deployment and changes nothing that existed.
// - **No native build.** The suite reads builds; it does not make them (that is the
Expand Down Expand Up @@ -112,9 +112,9 @@ describeLive('live-eas', gate)('live-eas: the real service, on expo-ci', () => {

// --- the build read side ------------------------------------------------------------------------

it('status --explain asks EAS, and every row it comes back with keeps its contract', async () => {
const result = await runLiveEasAsync(run, readProjectRoot, ['status', '--explain', '--json'], {
label: 'status-explain',
it('status asks EAS, and every row it comes back with keeps its contract', async () => {
const result = await runLiveEasAsync(run, readProjectRoot, ['status', '--json'], {
label: 'status-eas',
});
// Never fails a command: every way of not getting an answer is an `unknown` with a reason, and
// the section costs one line of the report rather than the exit code.
Expand Down Expand Up @@ -143,8 +143,8 @@ describeLive('live-eas', gate)('live-eas: the real service, on expo-ci', () => {
it('the lookup does find the real build this project was made from', async () => {
// The claim the row in llp/0022-live-tier.plan.md was missing. One run and no retry: the retry that used to be here
// was scaffolding for F93, and it went with the fix.
const result = await runLiveEasAsync(run, readProjectRoot, ['status', '--explain', '--json'], {
label: 'status-explain-found',
const result = await runLiveEasAsync(run, readProjectRoot, ['status', '--json'], {
label: 'status-eas-found',
});
expectExit(result, 0);
const found = parseJson(result).builds.platforms.find((p: any) => p.state === 'found') ?? null;
Expand All @@ -160,7 +160,7 @@ describeLive('live-eas', gate)('live-eas: the real service, on expo-ci', () => {

// F93 — MAJOR, found by this suite on 2026-08-27, **fixed in wave 22**.
//
// What it was: `status --explain` runs its two per-platform lookups concurrently (`Promise.all` in
// What it was: `status` runs its two per-platform lookups concurrently (`Promise.all` in
// `src/status/easBuilds.ts` §readEasBuildsStatusAsync). In a project that does not pin `eas-cli` —
// the common case, and the case wave 18 made the only rung — each lookup spawns
// `bunx eas-cli@latest`, and both shared one per-spec scratch directory
Expand Down Expand Up @@ -192,8 +192,8 @@ describeLive('live-eas', gate)('live-eas: the real service, on expo-ci', () => {
// started milliseconds apart, which is the state F93 was found in.
fs.rmSync(path.join(readProjectRoot, '.expo', 'agent-cli-eas-builds.json'), { force: true });

const result = await runLiveEasAsync(run, readProjectRoot, ['status', '--explain', '--json'], {
label: 'f93-status-explain',
const result = await runLiveEasAsync(run, readProjectRoot, ['status', '--json'], {
label: 'f93-status-eas',
});
expectExit(result, 0);
const report = parseJson(result);
Expand All @@ -210,12 +210,12 @@ describeLive('live-eas', gate)('live-eas: the real service, on expo-ci', () => {
expect(report.builds.platforms.every((p: any) => p.state !== 'unknown')).toBe(true);
});

it('status --explain --build against a build that does not exist says so, in text and in JSON', async () => {
it('status --build against a build that does not exist says so, in text and in JSON', async () => {
const result = await runLiveEasAsync(
run,
readProjectRoot,
['status', '--explain', '--build', 'not-a-real-build-id', '--json'],
{ label: 'status-explain-build' }
['status', '--build', 'not-a-real-build-id', '--json'],
{ label: 'status-build' }
);
expectExit(result, 0);
const report = parseJson(result);
Expand Down
Loading
Loading